Add product request validation and upload limits
This commit is contained in:
57
HANDOFF.md
57
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
|
||||
|
||||
|
||||
@ -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() {
|
||||
<p className="text-sm font-bold text-on-surface">
|
||||
{uploadingMsds ? e.uploading : "Upload MSDS / Compliance File"}
|
||||
</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOC, DOCX</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOC, DOCX · Max 10MB</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@ -1907,7 +1916,7 @@ function EditProductPageInner() {
|
||||
<p className="text-sm font-bold text-on-surface">
|
||||
{uploadingDoc ? e.uploading : "Upload Supporting Documents"}
|
||||
</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, PPTX, ZIP</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, PPTX, ZIP · Max 10MB per file</p>
|
||||
</div>
|
||||
</button>
|
||||
{docError && <p className="text-[11px] text-error mt-1.5 font-semibold">{docError}</p>}
|
||||
|
||||
@ -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 (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||
<div className="xl:col-span-12 rounded-xl border border-outline-variant/15 bg-surface-container-lowest p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">rule</span>
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.18em] text-outline">Batas validasi produk</p>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-[11px] font-semibold text-on-surface-variant">
|
||||
<p>Sub kategori ID maksimal 50 karakter.</p>
|
||||
<p>Nama produk maksimal 150 karakter.</p>
|
||||
<p>Hari pre-order minimal 1 jika pre-order aktif.</p>
|
||||
<p>File produk maksimal 100 karakter per ID.</p>
|
||||
<p>Kata kunci maksimal 3 item, tiap item maksimal 200 karakter.</p>
|
||||
<p>Fitur produk maksimal 5 item, tiap item maksimal 200 karakter.</p>
|
||||
<p>Gambar utama dan gambar tambahan maksimal 100 karakter per file ID.</p>
|
||||
<p>Upload file/dokumen maksimal 10 MB per file.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xl:col-span-7 space-y-8">
|
||||
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-6">
|
||||
<div>
|
||||
@ -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"
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] font-semibold text-on-surface-variant">
|
||||
Maksimal 150 karakter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@ -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"
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] font-semibold text-on-surface-variant">
|
||||
Minimal 1 hari jika pre-order aktif.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] font-semibold text-on-surface-variant">
|
||||
{d.keywordLimit}
|
||||
{d.keywordLimit} Tiap kata kunci maksimal 200 karakter.
|
||||
</p>
|
||||
{draft.keywords.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@ -368,13 +398,24 @@ export default function ProductDetailsPage() {
|
||||
|
||||
<div className="bg-surface-container-lowest p-8 rounded-2xl border border-outline-variant/10 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline">
|
||||
{d.features}
|
||||
</label>
|
||||
<p className="mt-1 text-[11px] font-semibold text-on-surface-variant">
|
||||
Maksimal 5 fitur, tiap fitur maksimal 200 karakter.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, features: [...prev.features, ""] }))}
|
||||
className="text-sm font-black text-primary"
|
||||
disabled={featureLimitReached}
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
features: [...prev.features, ""].slice(0, MAX_FEATURES),
|
||||
}))
|
||||
}
|
||||
className="text-sm font-black text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{d.addFeature}
|
||||
</button>
|
||||
@ -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"
|
||||
/>
|
||||
<button
|
||||
|
||||
@ -1160,6 +1160,25 @@ export default function ProductPricingPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-lowest p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">rule</span>
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.18em] text-outline">Batas validasi model, stok, dan measurement</p>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-[11px] font-semibold text-on-surface-variant">
|
||||
<p>Nama model dan SKU maksimal 50 karakter.</p>
|
||||
<p>Currency dan promotion currency maksimal 20 karakter.</p>
|
||||
<p>Weight type, dimension type, packaging weight type, dan packaging dimension type maksimal 50 karakter.</p>
|
||||
<p>Model image ID maksimal 100 karakter.</p>
|
||||
<p>Berat, dimensi, dan dimensi kemasan maksimal 9 digit angka + 1 digit desimal.</p>
|
||||
<p>Warehouse ID maksimal 50 karakter, stok minimal 1.</p>
|
||||
<p>Measurement type dan measurement value maksimal 100 karakter.</p>
|
||||
<p>Rule measurement mengikuti batas currency, berat, dimensi, packaging, warehouse ID, dan stok yang sama.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="p-4 rounded-xl bg-error-container text-on-error-container text-sm font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-5 pb-32">
|
||||
<section className="rounded-xl border border-outline-variant/15 bg-surface-container-lowest p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">rule</span>
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.18em] text-outline">Batas validasi informasi, compliance, dan garansi</p>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 text-[11px] font-semibold text-on-surface-variant">
|
||||
<p>Product information param name maksimal 100 karakter.</p>
|
||||
<p>Product information param value maksimal 200 karakter.</p>
|
||||
<p>Category information param name maksimal 100 karakter.</p>
|
||||
<p>Category information param value maksimal 200 karakter.</p>
|
||||
<p>Safety warning maksimal 100 karakter.</p>
|
||||
<p>Country of origin maksimal 100 karakter.</p>
|
||||
<p>Compliance file ID maksimal 100 karakter.</p>
|
||||
<p>Warranty type maksimal 50 karakter, durasi minimal 1 jika garansi diisi.</p>
|
||||
<p>Dokumen pendukung maksimal 100 karakter per file ID.</p>
|
||||
<p>Upload dokumen maksimal 10 MB per file.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 1. General Information */}
|
||||
<section className={sectionClass}>
|
||||
@ -329,7 +351,7 @@ export default function ProductSpecificationsPage() {
|
||||
<p className="text-sm font-bold text-on-surface">
|
||||
{uploadingMsds ? t.dashboard.productEdit.uploading : s.msds}
|
||||
</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX up to 15MB</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX up to 10MB</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@ -493,7 +515,7 @@ export default function ProductSpecificationsPage() {
|
||||
<p className="text-sm font-bold text-on-surface">
|
||||
{uploadingDoc ? "Uploading..." : "Upload Supporting Documents"}
|
||||
</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, ZIP · Multiple files allowed</p>
|
||||
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, ZIP · Max 10MB per file</p>
|
||||
</div>
|
||||
</button>
|
||||
{docError && <p className="text-[11px] text-error mt-1.5 font-semibold">{docError}</p>}
|
||||
|
||||
@ -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,6 +393,8 @@ export default function BusinessPage() {
|
||||
function handleFileSelect(file: File) {
|
||||
setError("");
|
||||
setDocError("");
|
||||
try {
|
||||
assertUploadFileSize(file);
|
||||
setPendingFile(file);
|
||||
setDocForm({
|
||||
type: "NPWP",
|
||||
@ -401,6 +404,10 @@ export default function BusinessPage() {
|
||||
validDate: "",
|
||||
});
|
||||
setShowDocModal(true);
|
||||
} catch (err) {
|
||||
setPendingFile(null);
|
||||
setDocError(err instanceof Error ? err.message : b.uploadFail);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>): 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
163
src/lib/product-request-validation.ts
Normal file
163
src/lib/product-request-validation.ts
Normal file
@ -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<string, unknown>[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
|
||||
: [];
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(text).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
export function validateAddProductPayload(payload: Record<string, unknown>) {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
const subCategory = payload.subCategory as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>) {
|
||||
const issues = validateAddProductPayload(payload);
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues.slice(0, 3).join(" "));
|
||||
}
|
||||
}
|
||||
12
src/lib/upload-limits.ts
Normal file
12
src/lib/upload-limits.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user