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`
|
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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)) {
|
||||||
pushMaxLength(issues, fileId, 100, `File produk ${index + 1}`);
|
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);
|
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.");
|
||||||
|
|||||||
Reference in New Issue
Block a user