Add edit product media action payloads

This commit is contained in:
2026-06-03 13:08:14 +07:00
parent 84c81d4a2b
commit 6ef6dcc5e8
3 changed files with 167 additions and 16 deletions

View File

@ -2,11 +2,11 @@
Project: `ina-trading-web` Project: `ina-trading-web`
Current branch: `main` Current branch: `main`
Latest verified commit: `f090ba7` Latest verified commit: current `HEAD` after the product edit media-action payload update
## Summary ## 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: Latest local verification before the newest push:
@ -26,6 +26,46 @@ Latest TypeScript verification after the validation/upload changes:
npx tsc --noEmit 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` ## Latest Codex Changes After `4e28ccd`
### Admin review image dedupe ### Admin review image dedupe

View File

@ -217,6 +217,14 @@ interface ApiProduct {
} | null; } | null;
} }
type MediaAction = "ADD" | "UPDATE" | "DELETE";
interface OriginalMediaState {
productFiles: string[];
productImages: string[];
complianceFileId: string;
}
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function getToken() { function getToken() {
@ -468,6 +476,93 @@ function apiToEditState(data: ApiProduct): EditState {
const MAX_IMAGES = 8; 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"; 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 ─────────────────────────────────────────────────────────── // ─── Sub-components ───────────────────────────────────────────────────────────
function SectionHeader({ step, title }: { step: string; title: string }) { function SectionHeader({ step, title }: { step: string; title: string }) {
@ -1190,6 +1285,7 @@ function EditProductPageInner() {
const [docError, setDocError] = useState(""); const [docError, setDocError] = useState("");
const msdsInputRef = useRef<HTMLInputElement | null>(null); const msdsInputRef = useRef<HTMLInputElement | null>(null);
const docInputRef = useRef<HTMLInputElement | null>(null); const docInputRef = useRef<HTMLInputElement | null>(null);
const originalMediaRef = useRef<OriginalMediaState | null>(null);
// Load product, categories (with subcategory resolution), and warehouses // Load product, categories (with subcategory resolution), and warehouses
useEffect(() => { useEffect(() => {
@ -1243,6 +1339,7 @@ function EditProductPageInner() {
setProductState(isDraftParam ? "DRAFT" : toStr(rawProduct?.state)); setProductState(isDraftParam ? "DRAFT" : toStr(rawProduct?.state));
setCategories(cats); setCategories(cats);
originalMediaRef.current = captureOriginalMedia(editState);
setForm(editState); setForm(editState);
setWarehouses( setWarehouses(
Array.isArray(whJson?.rows) ? whJson.rows : Array.isArray(whJson?.rows) ? whJson.rows :
@ -1337,14 +1434,15 @@ function EditProductPageInner() {
function buildPayload(state?: "DRAFT" | "REVIEW") { function buildPayload(state?: "DRAFT" | "REVIEW") {
if (!form) return null; if (!form) return null;
const resolvedState = state ?? "DRAFT"; const resolvedState = state ?? "DRAFT";
const productImageIds = form.productImages.filter(Boolean); const productImagesWithMain = normalizedProductImageIds(form);
const hasMainImageInProductImages = Boolean( const originalMedia = originalMediaRef.current;
form.imageId && productImageIds.includes(form.imageId) const shouldUseMediaActions = Boolean(originalMedia && !isDraftParam);
); const complianceAction = shouldUseMediaActions
const productImagesWithMain = [ ? complianceFileAction(
...(hasMainImageInProductImages ? [] : [form.imageId]), originalMedia?.complianceFileId || "",
...productImageIds, form.complianceInformation.fileId
].filter(Boolean); )
: undefined;
const base = { const base = {
subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined, subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined,
name: form.name, name: form.name,
@ -1354,8 +1452,12 @@ function EditProductPageInner() {
isNew: form.isNew, isNew: form.isNew,
isEligibleToExport: form.isEligibleToExport, isEligibleToExport: form.isEligibleToExport,
imageId: form.imageId || null, imageId: form.imageId || null,
productFiles: form.productFiles.map((file) => file.id).filter(Boolean), productFiles: shouldUseMediaActions && originalMedia
productImages: productImagesWithMain.map((imageId, i) => ({ imageId, sequence: i + 1 })), ? 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), productKeyWords: form.keywords.filter(Boolean).slice(0, 3),
productFeatures: form.features.filter(Boolean).slice(0, 5), productFeatures: form.features.filter(Boolean).slice(0, 5),
productModels: form.models.map((m) => ({ productModels: form.models.map((m) => ({
@ -1410,7 +1512,10 @@ function EditProductPageInner() {
})), })),
productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue), productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue),
categoryInformations: form.categoryInformations.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 warrantyInformation: form.warrantyInformation.enabled
? { ? {
type: form.warrantyInformation.type, type: form.warrantyInformation.type,

View File

@ -64,9 +64,15 @@ export function validateAddProductPayload(payload: Record<string, unknown>) {
pushMinNumber(issues, payload.preOrderDay, 1, "Hari pre-order"); 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}`); pushMaxLength(issues, fileId, 100, `File produk ${index + 1}`);
}); });
}
const keywords = strings(payload.productKeyWords); const keywords = strings(payload.productKeyWords);
if (keywords.length > 3) issues.push("Kata kunci pencarian maksimal 3 item."); if (keywords.length > 3) issues.push("Kata kunci pencarian maksimal 3 item.");