diff --git a/HANDOFF.md b/HANDOFF.md index 742ec63..80b15f0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,11 +2,11 @@ Project: `ina-trading-web` Current branch: `main` -Latest verified commit: `aa406f5` +Latest verified commit: `9b4e5d7` ## 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, and sanitized backend error display. +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. The latest build was verified successfully with: @@ -14,13 +14,62 @@ The latest build was verified successfully with: npm run build ``` -Latest TypeScript verification after `aa406f5`: +Latest TypeScript verification after the validation/upload changes: ```bash npx tsc --noEmit ``` -## Latest Codex Changes After `aa406f5` +## Latest Codex Changes After `76fb4f3` + +### AddProductRequest validation + +Files: +- `src/lib/product-request-validation.ts` +- `src/lib/use-product-submit.ts` +- `src/app/(dashboard)/products/[productId]/edit/page.tsx` +- `src/app/(dashboard)/products/new/details/page.tsx` +- `src/app/(dashboard)/products/new/pricing/page.tsx` +- `src/app/(dashboard)/products/new/specifications/page.tsx` +- `src/lib/product-draft.tsx` + +Behavior: +- Added a shared validator for create/edit product payloads before requests are sent to backend. +- Validates the backend `AddProductRequest` limits for: + - basic product fields + - image/file IDs + - keywords max 3 and features max 5 + - model fields, currencies, SKU, package fields + - numeric weight/dimension/package fields max 9 digits plus 1 decimal + - warehouse IDs max 50 and stock minimal 1 + - measurement fields, measurement warehouses, and measurement stock + - product/category information param name/value limits + - compliance information limits + - warranty type max 50 and duration minimal 1 when warranty is filled +- Existing wizard drafts normalize keywords to max 3 and features to max 5. +- Create-product UI now shows visible validation-limit panels on: + - Details + - Pricing + - Specifications +- Relevant inputs also received direct hints/limits such as max product name length, keyword/feature limits, pre-order minimum, warranty minimum, and upload limit text. + +### Upload file size limit + +Files: +- `src/lib/upload-limits.ts` +- `src/app/api/upload/route.ts` +- `src/components/upload-field.tsx` +- `src/app/(dashboard)/products/new/specifications/page.tsx` +- `src/app/(dashboard)/products/[productId]/edit/page.tsx` +- `src/app/(onboarding)/onboarding/business/page.tsx` + +Behavior: +- Uploads are limited to 10 MB per file. +- Client-side checks reject oversized files before upload in shared upload fields, product create/edit document uploads, and onboarding legal document upload. +- Server-side `/api/upload` rejects oversized files with HTTP `413` and a safe `responseDesc`. +- User-facing helper text now mentions the 10 MB limit for document uploads. + +## Previous Codex Changes After `aa406f5` ### Backend URL diff --git a/src/app/(dashboard)/products/[productId]/edit/page.tsx b/src/app/(dashboard)/products/[productId]/edit/page.tsx index e1360dd..ba2efa5 100644 --- a/src/app/(dashboard)/products/[productId]/edit/page.tsx +++ b/src/app/(dashboard)/products/[productId]/edit/page.tsx @@ -5,6 +5,8 @@ import { Suspense, useEffect, useRef, useState } from "react"; import { WEIGHT_TYPES, DIMENSION_TYPES, WORLD_CURRENCIES } from "@/lib/product-options"; import { useLanguage } from "@/lib/i18n-context"; import { getBackendErrorMessage } from "@/lib/error-message"; +import { assertValidAddProductPayload } from "@/lib/product-request-validation"; +import { assertUploadFileSize } from "@/lib/upload-limits"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -265,6 +267,7 @@ function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; } async function uploadFile(file: File) { + assertUploadFileSize(file); const fd = new FormData(); fd.append("file", file); @@ -1285,8 +1288,8 @@ function EditProductPageInner() { imageId: form.imageId || undefined, productFiles: form.productFiles.map((file) => file.id).filter(Boolean), productImages: form.productImages.filter(Boolean).map((imageId, i) => ({ imageId, sequence: i + 1 })), - productKeyWords: form.keywords.filter(Boolean), - productFeatures: form.features.filter(Boolean), + productKeyWords: form.keywords.filter(Boolean).slice(0, 3), + productFeatures: form.features.filter(Boolean).slice(0, 5), productModels: form.models.map((m) => ({ name: m.name, sku: m.sku, @@ -1354,6 +1357,8 @@ function EditProductPageInner() { try { const payload = buildPayload(); + if (!payload) return; + assertValidAddProductPayload(payload); const res = await fetch(`/api/products/${params.productId}?draft=1`, { method: "PUT", @@ -1382,6 +1387,8 @@ function EditProductPageInner() { try { const payload = buildPayload("REVIEW"); + if (!payload) return; + assertValidAddProductPayload(payload); const res = await fetch("/api/products/create", { method: "POST", @@ -1410,6 +1417,8 @@ function EditProductPageInner() { try { const payload = buildPayload("REVIEW"); + if (!payload) return; + assertValidAddProductPayload(payload); const res = await fetch(`/api/products/${params.productId}`, { method: "PUT", @@ -1823,7 +1832,7 @@ function EditProductPageInner() {

{uploadingMsds ? e.uploading : "Upload MSDS / Compliance File"}

-

PDF, DOC, DOCX

+

PDF, DOC, DOCX · Max 10MB

)} @@ -1907,7 +1916,7 @@ function EditProductPageInner() {

{uploadingDoc ? e.uploading : "Upload Supporting Documents"}

-

PDF, DOCX, XLSX, PPTX, ZIP

+

PDF, DOCX, XLSX, PPTX, ZIP · Max 10MB per file

{docError &&

{docError}

} diff --git a/src/app/(dashboard)/products/new/details/page.tsx b/src/app/(dashboard)/products/new/details/page.tsx index 9b2983c..82f3345 100644 --- a/src/app/(dashboard)/products/new/details/page.tsx +++ b/src/app/(dashboard)/products/new/details/page.tsx @@ -9,6 +9,7 @@ import { getBackendErrorMessage } from "@/lib/error-message"; const MAX_IMAGES = 8; const MAX_KEYWORDS = 3; +const MAX_FEATURES = 5; function getToken() { if (typeof window === "undefined") return ""; @@ -172,6 +173,7 @@ export default function ProductDetailsPage() { const d = t.dashboard.productNew.details; const [keywordInput, setKeywordInput] = useState(""); const keywordLimitReached = draft.keywords.filter(Boolean).length >= MAX_KEYWORDS; + const featureLimitReached = draft.features.length >= MAX_FEATURES; // slotsCount tracks how many image slots to show (starts from existing draft state) const [slotsCount, setSlotsCount] = useState(() => { @@ -249,6 +251,24 @@ export default function ProductDetailsPage() { return (
+
+
+ rule +
+

Batas validasi produk

+
+

Sub kategori ID maksimal 50 karakter.

+

Nama produk maksimal 150 karakter.

+

Hari pre-order minimal 1 jika pre-order aktif.

+

File produk maksimal 100 karakter per ID.

+

Kata kunci maksimal 3 item, tiap item maksimal 200 karakter.

+

Fitur produk maksimal 5 item, tiap item maksimal 200 karakter.

+

Gambar utama dan gambar tambahan maksimal 100 karakter per file ID.

+

Upload file/dokumen maksimal 10 MB per file.

+
+
+
+
@@ -259,8 +279,12 @@ export default function ProductDetailsPage() { value={draft.name} onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))} placeholder={d.officialNamePlaceholder} + maxLength={150} className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10" /> +

+ Maksimal 150 karakter. +

@@ -296,8 +320,13 @@ export default function ProductDetailsPage() { value={draft.preOrderDay} onChange={(e) => setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }))} placeholder="14" + type="number" + min="1" className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10" /> +

+ Minimal 1 hari jika pre-order aktif. +

@@ -323,6 +352,7 @@ export default function ProductDetailsPage() { value={keywordInput} onChange={(e) => setKeywordInput(e.target.value)} disabled={keywordLimitReached} + maxLength={200} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -342,7 +372,7 @@ export default function ProductDetailsPage() {

- {d.keywordLimit} + {d.keywordLimit} Tiap kata kunci maksimal 200 karakter.

{draft.keywords.length > 0 && (
@@ -368,13 +398,24 @@ export default function ProductDetailsPage() {
- +
+ +

+ Maksimal 5 fitur, tiap fitur maksimal 200 karakter. +

+
@@ -390,6 +431,7 @@ export default function ProductDetailsPage() { value={feature} onChange={(e) => updateFeature(index, e.target.value)} placeholder="e.g. Premium finishing" + maxLength={200} className="flex-1 bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10" />
+
+
+ rule +
+

Batas validasi model, stok, dan measurement

+
+

Nama model dan SKU maksimal 50 karakter.

+

Currency dan promotion currency maksimal 20 karakter.

+

Weight type, dimension type, packaging weight type, dan packaging dimension type maksimal 50 karakter.

+

Model image ID maksimal 100 karakter.

+

Berat, dimensi, dan dimensi kemasan maksimal 9 digit angka + 1 digit desimal.

+

Warehouse ID maksimal 50 karakter, stok minimal 1.

+

Measurement type dan measurement value maksimal 100 karakter.

+

Rule measurement mengikuti batas currency, berat, dimensi, packaging, warehouse ID, dan stok yang sama.

+
+
+
+
+ {validationError && (
error diff --git a/src/app/(dashboard)/products/new/specifications/page.tsx b/src/app/(dashboard)/products/new/specifications/page.tsx index e29ff7b..60e1fff 100644 --- a/src/app/(dashboard)/products/new/specifications/page.tsx +++ b/src/app/(dashboard)/products/new/specifications/page.tsx @@ -6,6 +6,7 @@ import { useProductDraft } from "@/lib/product-draft"; import { useProductSubmit } from "@/lib/use-product-submit"; import { useLanguage } from "@/lib/i18n-context"; import { getBackendErrorMessage } from "@/lib/error-message"; +import { assertUploadFileSize } from "@/lib/upload-limits"; function getToken() { if (typeof window === "undefined") return ""; @@ -27,6 +28,7 @@ function useFileUpload(onSuccess: (fileId: string, fileName: string) => void) { setUploading(true); setError(""); try { + assertUploadFileSize(file); const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/upload", { @@ -142,6 +144,26 @@ export default function ProductSpecificationsPage() { return (
+
+
+ rule +
+

Batas validasi informasi, compliance, dan garansi

+
+

Product information param name maksimal 100 karakter.

+

Product information param value maksimal 200 karakter.

+

Category information param name maksimal 100 karakter.

+

Category information param value maksimal 200 karakter.

+

Safety warning maksimal 100 karakter.

+

Country of origin maksimal 100 karakter.

+

Compliance file ID maksimal 100 karakter.

+

Warranty type maksimal 50 karakter, durasi minimal 1 jika garansi diisi.

+

Dokumen pendukung maksimal 100 karakter per file ID.

+

Upload dokumen maksimal 10 MB per file.

+
+
+
+
{/* 1. General Information */}
@@ -329,7 +351,7 @@ export default function ProductSpecificationsPage() {

{uploadingMsds ? t.dashboard.productEdit.uploading : s.msds}

-

PDF, DOCX up to 15MB

+

PDF, DOCX up to 10MB

)} @@ -493,7 +515,7 @@ export default function ProductSpecificationsPage() {

{uploadingDoc ? "Uploading..." : "Upload Supporting Documents"}

-

PDF, DOCX, XLSX, ZIP · Multiple files allowed

+

PDF, DOCX, XLSX, ZIP · Max 10MB per file

{docError &&

{docError}

} diff --git a/src/app/(onboarding)/onboarding/business/page.tsx b/src/app/(onboarding)/onboarding/business/page.tsx index 5533e8a..9971c60 100644 --- a/src/app/(onboarding)/onboarding/business/page.tsx +++ b/src/app/(onboarding)/onboarding/business/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useLanguage } from "@/lib/i18n-context"; import { getBackendErrorMessage } from "@/lib/error-message"; +import { assertUploadFileSize } from "@/lib/upload-limits"; type DocType = "NPWP" | "NIB" | "AKTA" | "OTHER"; @@ -392,15 +393,21 @@ export default function BusinessPage() { function handleFileSelect(file: File) { setError(""); setDocError(""); - setPendingFile(file); - setDocForm({ - type: "NPWP", - customType: "", - documentNumber: "", - publishedDate: "", - validDate: "", - }); - setShowDocModal(true); + try { + assertUploadFileSize(file); + setPendingFile(file); + setDocForm({ + type: "NPWP", + customType: "", + documentNumber: "", + publishedDate: "", + validDate: "", + }); + setShowDocModal(true); + } catch (err) { + setPendingFile(null); + setDocError(err instanceof Error ? err.message : b.uploadFail); + } } function handleDrop(e: React.DragEvent) { diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 8e39de2..df3ac68 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { API_URL, makeHeaders } from "@/lib/api"; +import { isFileTooLarge, MAX_UPLOAD_FILE_SIZE_MESSAGE } from "@/lib/upload-limits"; export async function POST(req: NextRequest) { try { @@ -10,6 +11,14 @@ export async function POST(req: NextRequest) { ? `Bearer ${rawToken}` : ""; const formData = await req.formData(); + const file = formData.get("file"); + + if (file instanceof File && isFileTooLarge(file)) { + return NextResponse.json( + { responseDesc: MAX_UPLOAD_FILE_SIZE_MESSAGE }, + { status: 413 } + ); + } const headers = makeHeaders(token); delete headers["Content-Type"]; diff --git a/src/components/upload-field.tsx b/src/components/upload-field.tsx index ee14fd3..0733d2d 100644 --- a/src/components/upload-field.tsx +++ b/src/components/upload-field.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { getBackendErrorMessage } from "@/lib/error-message"; +import { assertUploadFileSize } from "@/lib/upload-limits"; interface UploadFieldProps { label: string; @@ -61,6 +62,7 @@ export function UploadField({ setError(""); try { + assertUploadFileSize(file); const formData = new FormData(); formData.append("file", file); diff --git a/src/lib/product-draft.tsx b/src/lib/product-draft.tsx index fa30c8c..5382c8b 100644 --- a/src/lib/product-draft.tsx +++ b/src/lib/product-draft.tsx @@ -117,6 +117,7 @@ interface ProductDraftContextValue { const STORAGE_KEY = "productWizardDraft"; const MAX_PRODUCT_KEYWORDS = 3; +const MAX_PRODUCT_FEATURES = 5; const defaultDraft: ProductDraftState = { categoryId: "", @@ -195,6 +196,9 @@ function normalizeDraft(stored: Partial): ProductDraftState { keywords: Array.isArray(stored.keywords) ? stored.keywords.filter(Boolean).slice(0, MAX_PRODUCT_KEYWORDS) : defaultDraft.keywords, + features: Array.isArray(stored.features) + ? stored.features.filter(Boolean).slice(0, MAX_PRODUCT_FEATURES) + : defaultDraft.features, }; } diff --git a/src/lib/product-request-validation.ts b/src/lib/product-request-validation.ts new file mode 100644 index 0000000..c7e3e60 --- /dev/null +++ b/src/lib/product-request-validation.ts @@ -0,0 +1,163 @@ +const MAX_DECIMAL_VALUE = 999999999.9; +const DECIMAL_EPSILON = 0.0000001; + +type ValidationIssue = string; + +function text(value: unknown) { + return typeof value === "string" ? value : value == null ? "" : String(value); +} + +function numberValue(value: unknown) { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function pushMaxLength( + issues: ValidationIssue[], + value: unknown, + maxLength: number, + label: string +) { + if (text(value).length > maxLength) { + issues.push(`${label} maksimal ${maxLength} karakter.`); + } +} + +function pushMinNumber( + issues: ValidationIssue[], + value: unknown, + min: number, + label: string +) { + if (numberValue(value) < min) { + issues.push(`${label} minimal ${min}.`); + } +} + +function pushDecimalLimit(issues: ValidationIssue[], value: unknown, label: string) { + const numeric = numberValue(value); + const hasTooManyDecimals = Math.abs(numeric * 10 - Math.round(numeric * 10)) > DECIMAL_EPSILON; + + if (numeric > MAX_DECIMAL_VALUE || hasTooManyDecimals) { + issues.push(`${label} maksimal 9 digit angka dan 1 digit desimal.`); + } +} + +function values(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter((item): item is Record => Boolean(item) && typeof item === "object") + : []; +} + +function strings(value: unknown): string[] { + return Array.isArray(value) ? value.map(text).filter(Boolean) : []; +} + +export function validateAddProductPayload(payload: Record) { + const issues: ValidationIssue[] = []; + + const subCategory = payload.subCategory as Record | undefined; + pushMaxLength(issues, subCategory?.id, 50, "Sub kategori"); + pushMaxLength(issues, payload.name, 150, "Nama produk"); + + if (payload.isPreOrder) { + pushMinNumber(issues, payload.preOrderDay, 1, "Hari pre-order"); + } + + strings(payload.productFiles).forEach((fileId, index) => { + 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."); + keywords.forEach((keyword, index) => { + pushMaxLength(issues, keyword, 200, `Kata kunci ${index + 1}`); + }); + + const features = strings(payload.productFeatures); + if (features.length > 5) issues.push("Fitur produk maksimal 5 item."); + features.forEach((feature, index) => { + pushMaxLength(issues, feature, 200, `Fitur produk ${index + 1}`); + }); + + pushMaxLength(issues, payload.imageId, 100, "Gambar utama"); + values(payload.productImages).forEach((image, index) => { + pushMaxLength(issues, image.imageId, 100, `Gambar produk ${index + 1}`); + }); + + values(payload.productModels).forEach((model, modelIndex) => { + const prefix = `Model ${modelIndex + 1}`; + + pushMaxLength(issues, model.name, 50, `${prefix} nama`); + pushMaxLength(issues, model.currency, 20, `${prefix} currency`); + pushMaxLength(issues, model.weightType, 50, `${prefix} tipe berat`); + pushMaxLength(issues, model.dimensionType, 50, `${prefix} tipe dimensi`); + pushMaxLength(issues, model.imageId, 100, `${prefix} gambar`); + pushMaxLength(issues, model.sku, 50, `${prefix} SKU`); + pushMaxLength(issues, model.promotionCurrency, 20, `${prefix} currency promo`); + pushMaxLength(issues, model.packagingWeightType, 50, `${prefix} tipe berat kemasan`); + pushMaxLength(issues, model.packagingDimensionType, 50, `${prefix} tipe dimensi kemasan`); + + ["weight", "length", "width", "height", "packagingWeight", "packagingLength", "packagingWidth", "packagingHeight"].forEach( + (field) => pushDecimalLimit(issues, model[field], `${prefix} ${field}`) + ); + + values(model.warehouses).forEach((warehouse, warehouseIndex) => { + pushMaxLength(issues, warehouse.id, 50, `${prefix} warehouse ${warehouseIndex + 1}`); + pushMinNumber(issues, warehouse.stock, 1, `${prefix} stok warehouse ${warehouseIndex + 1}`); + }); + + values(model.productMeasurements).forEach((measurement, measurementIndex) => { + const measurementPrefix = `${prefix} measurement ${measurementIndex + 1}`; + + pushMaxLength(issues, measurement.currency, 20, `${measurementPrefix} currency`); + pushMaxLength(issues, measurement.weightType, 50, `${measurementPrefix} tipe berat`); + pushMaxLength(issues, measurement.dimensionType, 50, `${measurementPrefix} tipe dimensi`); + pushMaxLength(issues, measurement.measurementType, 100, `${measurementPrefix} tipe`); + pushMaxLength(issues, measurement.measurementValue, 100, `${measurementPrefix} nilai`); + pushMaxLength(issues, measurement.promotionCurrency, 20, `${measurementPrefix} currency promo`); + pushMaxLength(issues, measurement.packagingWeightType, 50, `${measurementPrefix} tipe berat kemasan`); + pushMaxLength(issues, measurement.packagingDimensionType, 50, `${measurementPrefix} tipe dimensi kemasan`); + + ["weight", "length", "width", "height", "packagingWeight", "packagingLength", "packagingWidth", "packagingHeight"].forEach( + (field) => pushDecimalLimit(issues, measurement[field], `${measurementPrefix} ${field}`) + ); + + values(measurement.warehouses).forEach((warehouse, warehouseIndex) => { + pushMaxLength(issues, warehouse.id, 50, `${measurementPrefix} warehouse ${warehouseIndex + 1}`); + pushMinNumber(issues, warehouse.stock, 1, `${measurementPrefix} stok warehouse ${warehouseIndex + 1}`); + }); + }); + }); + + values(payload.productInformations).forEach((info, index) => { + pushMaxLength(issues, info.paramName, 100, `Informasi produk ${index + 1} nama`); + pushMaxLength(issues, info.paramValue, 200, `Informasi produk ${index + 1} nilai`); + }); + + values(payload.categoryInformations).forEach((info, index) => { + pushMaxLength(issues, info.paramName, 100, `Informasi kategori ${index + 1} nama`); + pushMaxLength(issues, info.paramValue, 200, `Informasi kategori ${index + 1} nilai`); + }); + + const complianceInformation = payload.complianceInformation as Record | undefined; + pushMaxLength(issues, complianceInformation?.safetyWarning, 100, "Peringatan keamanan"); + pushMaxLength(issues, complianceInformation?.countryOfOrigin, 100, "Negara asal"); + pushMaxLength(issues, complianceInformation?.fileId, 100, "File compliance"); + + const warrantyInformation = payload.warrantyInformation as Record | undefined; + pushMaxLength(issues, warrantyInformation?.type, 50, "Tipe garansi"); + if (text(warrantyInformation?.type) || numberValue(warrantyInformation?.duration) > 0) { + pushMinNumber(issues, warrantyInformation?.duration, 1, "Durasi garansi"); + } + + return issues; +} + +export function assertValidAddProductPayload(payload: Record) { + const issues = validateAddProductPayload(payload); + + if (issues.length > 0) { + throw new Error(issues.slice(0, 3).join(" ")); + } +} diff --git a/src/lib/upload-limits.ts b/src/lib/upload-limits.ts new file mode 100644 index 0000000..dd817f1 --- /dev/null +++ b/src/lib/upload-limits.ts @@ -0,0 +1,12 @@ +export const MAX_UPLOAD_FILE_SIZE_BYTES = 10 * 1024 * 1024; +export const MAX_UPLOAD_FILE_SIZE_MESSAGE = "Ukuran file maksimal 10 MB."; + +export function isFileTooLarge(file: { size: number }) { + return file.size > MAX_UPLOAD_FILE_SIZE_BYTES; +} + +export function assertUploadFileSize(file: { size: number }) { + if (isFileTooLarge(file)) { + throw new Error(MAX_UPLOAD_FILE_SIZE_MESSAGE); + } +} diff --git a/src/lib/use-product-submit.ts b/src/lib/use-product-submit.ts index 6ae14c4..18960e2 100644 --- a/src/lib/use-product-submit.ts +++ b/src/lib/use-product-submit.ts @@ -3,8 +3,10 @@ import { useState } from "react"; import { useProductDraft, type ProductDraftState } from "./product-draft"; import { getBackendErrorMessage } from "./error-message"; +import { assertValidAddProductPayload } from "./product-request-validation"; const MAX_PRODUCT_KEYWORDS = 3; +const MAX_PRODUCT_FEATURES = 5; const FALLBACK_SAVE_ERROR = "Gagal menyimpan produk"; function getToken() { @@ -33,7 +35,7 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | " .filter(Boolean) .map((imageId, index) => ({ imageId, sequence: index + 1 })), productKeyWords: draft.keywords.filter(Boolean).slice(0, MAX_PRODUCT_KEYWORDS), - productFeatures: draft.features.filter(Boolean), + productFeatures: draft.features.filter(Boolean).slice(0, MAX_PRODUCT_FEATURES), productModels: draft.models.map((model) => ({ name: model.name, sku: model.sku, @@ -118,6 +120,7 @@ export function useProductSubmit() { try { const token = getToken(); const payload = buildProductPayload(draft, state); + assertValidAddProductPayload(payload); const res = await fetch("/api/products/create", { method: "POST", headers: { "Content-Type": "application/json", "x-auth-token": token },