diff --git a/HANDOFF.md b/HANDOFF.md index fab3946..28e8109 100644 --- a/HANDOFF.md +++ b/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 `` 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 diff --git a/src/app/(dashboard)/products/[productId]/edit/page.tsx b/src/app/(dashboard)/products/[productId]/edit/page.tsx index 5b875a1..dae9af2 100644 --- a/src/app/(dashboard)/products/[productId]/edit/page.tsx +++ b/src/app/(dashboard)/products/[productId]/edit/page.tsx @@ -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) { + 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(); + 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(null); const docInputRef = useRef(null); + const originalMediaRef = useRef(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, diff --git a/src/lib/product-request-validation.ts b/src/lib/product-request-validation.ts index d485fec..bb2c311 100644 --- a/src/lib/product-request-validation.ts +++ b/src/lib/product-request-validation.ts @@ -64,9 +64,15 @@ export function validateAddProductPayload(payload: Record) { pushMinNumber(issues, payload.preOrderDay, 1, "Hari pre-order"); } - strings(payload.productFiles).forEach((fileId, index) => { - pushMaxLength(issues, fileId, 100, `File produk ${index + 1}`); - }); + if (Array.isArray(payload.productFiles)) { + payload.productFiles.forEach((file, index) => { + const fileId = + file && typeof file === "object" + ? (file as Record).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.");