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.");