Add product request validation and upload limits

This commit is contained in:
2026-05-29 17:35:15 +07:00
parent 76fb4f342d
commit f090ba7bc2
12 changed files with 367 additions and 26 deletions

View File

@ -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

View File

@ -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>}

View File

@ -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">
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline">
{d.features}
</label>
<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

View File

@ -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>

View File

@ -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>}

View File

@ -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) {

View File

@ -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"];

View File

@ -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);

View 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,
};
}

View 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
View 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);
}
}

View File

@ -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 },