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 (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.
++ Maksimal 150 karakter. +
+ Minimal 1 hari jika pre-order aktif. +
- {d.keywordLimit} + {d.keywordLimit} Tiap kata kunci maksimal 200 karakter.
{draft.keywords.length > 0 && (+ Maksimal 5 fitur, tiap fitur maksimal 200 karakter. +
+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.
+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.
+{uploadingMsds ? t.dashboard.productEdit.uploading : s.msds}
-PDF, DOCX up to 15MB
+PDF, DOCX up to 10MB
{uploadingDoc ? "Uploading..." : "Upload Supporting Documents"}
-PDF, DOCX, XLSX, ZIP · Multiple files allowed
+PDF, DOCX, XLSX, ZIP · Max 10MB per file
{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