Add edit product media action payloads
This commit is contained in:
44
HANDOFF.md
44
HANDOFF.md
@ -2,11 +2,11 @@
|
||||
|
||||
Project: `ina-trading-web`
|
||||
Current branch: `main`
|
||||
Latest verified commit: `f090ba7`
|
||||
Latest verified commit: current `HEAD` after the product edit media-action payload update
|
||||
|
||||
## Summary
|
||||
|
||||
This codebase has recent updates around auth/onboarding, help/privacy pages, dashboard search, product creation/edit/review/detail, admin review/detail, stock/price editing, seller in-review listing, backend request logging, expired-session redirects, product keyword limits, sanitized backend error display, AddProductRequest validation, and 10 MB upload limits.
|
||||
This codebase has recent updates around auth/onboarding, help/privacy pages, dashboard search, product creation/edit/review/detail, admin review/detail, stock/price editing, seller in-review listing, backend request logging, expired-session redirects, product keyword limits, sanitized backend error display, AddProductRequest validation, 10 MB upload limits, and edit-product media action payloads.
|
||||
|
||||
Latest local verification before the newest push:
|
||||
|
||||
@ -26,6 +26,46 @@ Latest TypeScript verification after the validation/upload changes:
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Latest Codex Changes After `84c81d4`
|
||||
|
||||
### Product edit media action payloads
|
||||
|
||||
Files:
|
||||
- `src/app/(dashboard)/products/[productId]/edit/page.tsx`
|
||||
- `src/lib/product-request-validation.ts`
|
||||
|
||||
Behavior:
|
||||
- Edit-product now captures the original product media snapshot when the product is loaded.
|
||||
- Existing product save (`PUT /api/products/:productId`) now sends media changes using backend action markers:
|
||||
- `ADD`
|
||||
- `UPDATE`
|
||||
- `DELETE`
|
||||
- `productFiles` now sends:
|
||||
- deleted original files as `{ fileId, action: "DELETE" }`
|
||||
- newly uploaded files as `{ fileId, action: "ADD" }`
|
||||
- `productImages` now sends changed image rows with `{ imageId, sequence, action }`.
|
||||
- replacing an image in an existing slot sends `UPDATE`
|
||||
- removing an original image sends `DELETE`
|
||||
- adding a new image sends `ADD`
|
||||
- `complianceInformation` now includes an `action` when the compliance/MSDS file changes:
|
||||
- no old file + new file: `ADD`
|
||||
- old file + no current file: `DELETE`
|
||||
- old file replaced by new file: `UPDATE`
|
||||
- Draft publish/create flow still keeps the previous create payload shape so `POST /api/products/create` is not changed.
|
||||
- Product payload validation now supports both old `productFiles: string[]` and new `productFiles: { fileId, action }[]` shapes.
|
||||
|
||||
Verification:
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npx eslint 'src/app/(dashboard)/products/[productId]/edit/page.tsx' src/lib/product-request-validation.ts
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Targeted lint passed with no errors.
|
||||
- The edit page still has existing `<img>` optimization warnings from Next.js.
|
||||
- Full `npm run lint` is still blocked by pre-existing lint errors in unrelated files noted before this handoff update.
|
||||
|
||||
## Latest Codex Changes After `4e28ccd`
|
||||
|
||||
### Admin review image dedupe
|
||||
|
||||
@ -217,6 +217,14 @@ interface ApiProduct {
|
||||
} | null;
|
||||
}
|
||||
|
||||
type MediaAction = "ADD" | "UPDATE" | "DELETE";
|
||||
|
||||
interface OriginalMediaState {
|
||||
productFiles: string[];
|
||||
productImages: string[];
|
||||
complianceFileId: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getToken() {
|
||||
@ -468,6 +476,93 @@ function apiToEditState(data: ApiProduct): EditState {
|
||||
const MAX_IMAGES = 8;
|
||||
const inputCls = "w-full bg-surface-container-low rounded-xl border border-outline-variant/10 p-3.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 focus:outline-none";
|
||||
|
||||
function normalizedProductImageIds(form: Pick<EditState, "imageId" | "productImages">) {
|
||||
const productImageIds = form.productImages.filter(Boolean);
|
||||
const hasMainImageInProductImages = Boolean(
|
||||
form.imageId && productImageIds.includes(form.imageId)
|
||||
);
|
||||
|
||||
return [
|
||||
...(hasMainImageInProductImages ? [] : [form.imageId]),
|
||||
...productImageIds,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function captureOriginalMedia(form: EditState): OriginalMediaState {
|
||||
return {
|
||||
productFiles: form.productFiles.map((file) => file.id).filter(Boolean),
|
||||
productImages: normalizedProductImageIds(form),
|
||||
complianceFileId: form.complianceInformation.fileId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProductFileActions(originalIds: string[], currentFiles: EditState["productFiles"]) {
|
||||
const currentIds = currentFiles.map((file) => file.id).filter(Boolean);
|
||||
const currentSet = new Set(currentIds);
|
||||
const originalSet = new Set(originalIds);
|
||||
|
||||
return [
|
||||
...originalIds
|
||||
.filter((fileId) => !currentSet.has(fileId))
|
||||
.map((fileId) => ({ fileId, action: "DELETE" as MediaAction })),
|
||||
...currentIds
|
||||
.filter((fileId) => !originalSet.has(fileId))
|
||||
.map((fileId) => ({ fileId, action: "ADD" as MediaAction })),
|
||||
];
|
||||
}
|
||||
|
||||
function buildProductImageActions(originalIds: string[], currentIds: string[]) {
|
||||
const originalSet = new Set(originalIds);
|
||||
const usedCurrentIndexes = new Set<number>();
|
||||
const actions: Array<{ imageId: string; sequence: number; action: MediaAction }> = [];
|
||||
|
||||
originalIds.forEach((originalImageId, index) => {
|
||||
const currentImageId = currentIds[index];
|
||||
|
||||
if (currentImageId === originalImageId) {
|
||||
usedCurrentIndexes.add(index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentImageId && !originalSet.has(currentImageId)) {
|
||||
actions.push({
|
||||
imageId: currentImageId,
|
||||
sequence: index + 1,
|
||||
action: "UPDATE",
|
||||
});
|
||||
usedCurrentIndexes.add(index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentIds.includes(originalImageId)) {
|
||||
actions.push({
|
||||
imageId: originalImageId,
|
||||
sequence: index + 1,
|
||||
action: "DELETE",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
currentIds.forEach((imageId, index) => {
|
||||
if (usedCurrentIndexes.has(index) || originalSet.has(imageId)) return;
|
||||
|
||||
actions.push({
|
||||
imageId,
|
||||
sequence: index + 1,
|
||||
action: "ADD",
|
||||
});
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function complianceFileAction(originalFileId: string, currentFileId: string): MediaAction | undefined {
|
||||
if (originalFileId === currentFileId) return undefined;
|
||||
if (!originalFileId && currentFileId) return "ADD";
|
||||
if (originalFileId && !currentFileId) return "DELETE";
|
||||
return "UPDATE";
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function SectionHeader({ step, title }: { step: string; title: string }) {
|
||||
@ -1190,6 +1285,7 @@ function EditProductPageInner() {
|
||||
const [docError, setDocError] = useState("");
|
||||
const msdsInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const docInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const originalMediaRef = useRef<OriginalMediaState | null>(null);
|
||||
|
||||
// Load product, categories (with subcategory resolution), and warehouses
|
||||
useEffect(() => {
|
||||
@ -1243,6 +1339,7 @@ function EditProductPageInner() {
|
||||
|
||||
setProductState(isDraftParam ? "DRAFT" : toStr(rawProduct?.state));
|
||||
setCategories(cats);
|
||||
originalMediaRef.current = captureOriginalMedia(editState);
|
||||
setForm(editState);
|
||||
setWarehouses(
|
||||
Array.isArray(whJson?.rows) ? whJson.rows :
|
||||
@ -1337,14 +1434,15 @@ function EditProductPageInner() {
|
||||
function buildPayload(state?: "DRAFT" | "REVIEW") {
|
||||
if (!form) return null;
|
||||
const resolvedState = state ?? "DRAFT";
|
||||
const productImageIds = form.productImages.filter(Boolean);
|
||||
const hasMainImageInProductImages = Boolean(
|
||||
form.imageId && productImageIds.includes(form.imageId)
|
||||
);
|
||||
const productImagesWithMain = [
|
||||
...(hasMainImageInProductImages ? [] : [form.imageId]),
|
||||
...productImageIds,
|
||||
].filter(Boolean);
|
||||
const productImagesWithMain = normalizedProductImageIds(form);
|
||||
const originalMedia = originalMediaRef.current;
|
||||
const shouldUseMediaActions = Boolean(originalMedia && !isDraftParam);
|
||||
const complianceAction = shouldUseMediaActions
|
||||
? complianceFileAction(
|
||||
originalMedia?.complianceFileId || "",
|
||||
form.complianceInformation.fileId
|
||||
)
|
||||
: undefined;
|
||||
const base = {
|
||||
subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined,
|
||||
name: form.name,
|
||||
@ -1354,8 +1452,12 @@ function EditProductPageInner() {
|
||||
isNew: form.isNew,
|
||||
isEligibleToExport: form.isEligibleToExport,
|
||||
imageId: form.imageId || null,
|
||||
productFiles: form.productFiles.map((file) => file.id).filter(Boolean),
|
||||
productImages: productImagesWithMain.map((imageId, i) => ({ imageId, sequence: i + 1 })),
|
||||
productFiles: shouldUseMediaActions && originalMedia
|
||||
? buildProductFileActions(originalMedia.productFiles, form.productFiles)
|
||||
: form.productFiles.map((file) => file.id).filter(Boolean),
|
||||
productImages: shouldUseMediaActions && originalMedia
|
||||
? buildProductImageActions(originalMedia.productImages, productImagesWithMain)
|
||||
: productImagesWithMain.map((imageId, i) => ({ imageId, sequence: i + 1 })),
|
||||
productKeyWords: form.keywords.filter(Boolean).slice(0, 3),
|
||||
productFeatures: form.features.filter(Boolean).slice(0, 5),
|
||||
productModels: form.models.map((m) => ({
|
||||
@ -1410,7 +1512,10 @@ function EditProductPageInner() {
|
||||
})),
|
||||
productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue),
|
||||
categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue),
|
||||
complianceInformation: { ...form.complianceInformation },
|
||||
complianceInformation: {
|
||||
...form.complianceInformation,
|
||||
...(complianceAction ? { action: complianceAction } : {}),
|
||||
},
|
||||
warrantyInformation: form.warrantyInformation.enabled
|
||||
? {
|
||||
type: form.warrantyInformation.type,
|
||||
|
||||
@ -64,9 +64,15 @@ export function validateAddProductPayload(payload: Record<string, unknown>) {
|
||||
pushMinNumber(issues, payload.preOrderDay, 1, "Hari pre-order");
|
||||
}
|
||||
|
||||
strings(payload.productFiles).forEach((fileId, index) => {
|
||||
if (Array.isArray(payload.productFiles)) {
|
||||
payload.productFiles.forEach((file, index) => {
|
||||
const fileId =
|
||||
file && typeof file === "object"
|
||||
? (file as Record<string, unknown>).fileId
|
||||
: file;
|
||||
pushMaxLength(issues, fileId, 100, `File produk ${index + 1}`);
|
||||
});
|
||||
}
|
||||
|
||||
const keywords = strings(payload.productKeyWords);
|
||||
if (keywords.length > 3) issues.push("Kata kunci pencarian maksimal 3 item.");
|
||||
|
||||
Reference in New Issue
Block a user