Fix product preorder and warranty submit flow

This commit is contained in:
2026-05-30 10:07:54 +07:00
parent 73a0767418
commit d15bf885e2
7 changed files with 143 additions and 23 deletions

View File

@ -22,6 +22,41 @@ npx tsc --noEmit
## Latest Codex Changes After `76fb4f3`
### Product create preorder and warranty submit rules
Files:
- `src/app/(dashboard)/products/new/details/page.tsx`
- `src/app/(dashboard)/products/new/specifications/page.tsx`
- `src/app/(dashboard)/products/new/review/page.tsx`
- `src/lib/product-draft.tsx`
- `src/lib/use-product-submit.ts`
- `src/lib/product-request-validation.ts`
Behavior:
- Pre-order day placeholder is now `0`, not `14`, so an empty/unfilled value is visually obvious.
- If pre-order is checked, the Details step blocks Next unless `preOrderDay` is a valid number greater than or equal to 1.
- Warranty now has an On/Off switch in the Specifications step.
- When warranty is On:
- warranty type is required
- warranty duration must be greater than or equal to 1
- the review page shows warranty information
- submit payload sends the filled `warrantyInformation` object
- When warranty is Off:
- warranty inputs are disabled and dimmed
- the review page hides warranty information
- submit payload still sends `warrantyInformation` as an object required by the backend, with:
```json
{
"type": null,
"duration": null,
"durationType": "MONTH"
}
```
- Frontend payload validation treats the above null-valued warranty object as warranty Off, so it does not block submit with warranty type/duration errors.
- Local test server was rebuilt and restarted against `https://api-dev.inatrading.co.id` in `.env.local`; `.env.local` is not committed.
### AddProductRequest validation
Files:

View File

@ -171,6 +171,7 @@ export default function ProductDetailsPage() {
const { submit, submitting } = useProductSubmit();
const { t } = useLanguage();
const d = t.dashboard.productNew.details;
const [preOrderError, setPreOrderError] = useState("");
const [keywordInput, setKeywordInput] = useState("");
const keywordLimitReached = draft.keywords.filter(Boolean).length >= MAX_KEYWORDS;
const featureLimitReached = draft.features.length >= MAX_FEATURES;
@ -249,6 +250,17 @@ export default function ProductDetailsPage() {
}));
}
function goToPricing() {
const preOrderDay = Number(draft.preOrderDay);
if (draft.isPreOrder && (!Number.isFinite(preOrderDay) || preOrderDay < 1)) {
setPreOrderError("Hari pre-order wajib diisi minimal 1 hari.");
return;
}
setPreOrderError("");
router.push("/products/new/pricing");
}
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">
@ -318,14 +330,17 @@ export default function ProductDetailsPage() {
</label>
<input
value={draft.preOrderDay}
onChange={(e) => setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }))}
placeholder="14"
onChange={(e) => {
setPreOrderError("");
setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }));
}}
placeholder="0"
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 className={`mt-1.5 text-[11px] font-semibold ${preOrderError ? "text-error" : "text-on-surface-variant"}`}>
{preOrderError || "Minimal 1 hari jika pre-order aktif."}
</p>
</div>
</div>
@ -517,7 +532,7 @@ export default function ProductDetailsPage() {
</button>
<button
type="button"
onClick={() => router.push("/products/new/pricing")}
onClick={goToPricing}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
{d.next}

View File

@ -448,7 +448,7 @@ export default function ProductReviewPage() {
</div>
)}
</div>
{(draft.warrantyInformation.type || draft.warrantyInformation.duration) && (
{draft.warrantyInformation.enabled && (
<div>
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.warranty}</p>
<Row label={r.warrantyType} value={draft.warrantyInformation.type} yes={r.yes} no={r.no} />

View File

@ -70,6 +70,7 @@ export default function ProductSpecificationsPage() {
const msdsInputRef = useRef<HTMLInputElement>(null);
const docInputRef = useRef<HTMLInputElement>(null);
const [msdsName, setMsdsName] = useState("");
const [warrantyError, setWarrantyError] = useState("");
// --- productInformations helpers ---
function getProductInfo(key: string) {
@ -140,6 +141,22 @@ export default function ProductSpecificationsPage() {
setDraft((prev) => ({ ...prev, productFiles: (prev.productFiles ?? []).filter((f) => f.id !== id) }));
}
function goToReview() {
const warrantyDuration = Number(draft.warrantyInformation.duration);
if (
draft.warrantyInformation.enabled &&
(!draft.warrantyInformation.type.trim() ||
!Number.isFinite(warrantyDuration) ||
warrantyDuration < 1)
) {
setWarrantyError("Tipe garansi dan durasi minimal 1 wajib diisi jika garansi aktif.");
return;
}
setWarrantyError("");
router.push("/products/new/review");
}
const subCategoryAttributes = draft.subCategoryAttributes ?? [];
return (
@ -157,7 +174,7 @@ export default function ProductSpecificationsPage() {
<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>Warranty type maksimal 50 karakter, durasi minimal 1 jika garansi aktif.</p>
<p>Dokumen pendukung maksimal 100 karakter per file ID.</p>
<p>Upload dokumen maksimal 10 MB per file.</p>
</div>
@ -362,24 +379,47 @@ export default function ProductSpecificationsPage() {
{/* 4. Warranty */}
<section className={sectionClass}>
<div className="flex items-center gap-3 mb-5">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">verified</span>
<div className="flex flex-col gap-4 mb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className={sectionIconClass}>
<span className="material-symbols-outlined">verified</span>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.warranty}</h3>
</div>
<h3 className="text-sm font-black text-on-surface uppercase tracking-wider">{s.warranty}</h3>
<label className="inline-flex items-center gap-3 text-xs font-black uppercase tracking-[0.16em] text-on-surface-variant">
<span>{draft.warrantyInformation.enabled ? "On" : "Off"}</span>
<input
type="checkbox"
checked={draft.warrantyInformation.enabled}
onChange={(e) => {
setWarrantyError("");
setDraft((prev) => ({
...prev,
warrantyInformation: {
...prev.warrantyInformation,
enabled: e.target.checked,
},
}));
}}
className="peer sr-only"
/>
<span className="relative h-7 w-12 rounded-full bg-surface-container-high transition-colors peer-checked:bg-primary after:absolute after:left-1 after:top-1 after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-transform peer-checked:after:translate-x-5" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${draft.warrantyInformation.enabled ? "" : "opacity-50"}`}>
<div>
<label className={labelClass}>{s.warrantyType}</label>
<input
value={draft.warrantyInformation.type}
onChange={(e) =>
onChange={(e) => {
setWarrantyError("");
setDraft((prev) => ({
...prev,
warrantyInformation: { ...prev.warrantyInformation, type: e.target.value },
}))
}
}));
}}
placeholder="e.g. Global Manufacturers Warranty"
disabled={!draft.warrantyInformation.enabled}
className={inputClass}
/>
</div>
@ -388,15 +428,17 @@ export default function ProductSpecificationsPage() {
<div className="flex gap-3">
<input
value={draft.warrantyInformation.duration}
onChange={(e) =>
onChange={(e) => {
setWarrantyError("");
setDraft((prev) => ({
...prev,
warrantyInformation: { ...prev.warrantyInformation, duration: e.target.value },
}))
}
}));
}}
placeholder="0"
type="number"
min="0"
disabled={!draft.warrantyInformation.enabled}
className="w-24 bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-medium focus:ring-2 focus:ring-primary/10 outline-none"
/>
<select
@ -407,6 +449,7 @@ export default function ProductSpecificationsPage() {
warrantyInformation: { ...prev.warrantyInformation, durationType: e.target.value },
}))
}
disabled={!draft.warrantyInformation.enabled}
className="flex-1 bg-surface-container-low rounded-xl border-none px-4 py-2.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 outline-none"
>
<option value="DAY">Days</option>
@ -416,6 +459,7 @@ export default function ProductSpecificationsPage() {
</div>
</div>
</div>
{warrantyError && <p className="mt-3 text-[11px] font-semibold text-error">{warrantyError}</p>}
</section>
{/* 5. International Export */}
@ -550,7 +594,7 @@ export default function ProductSpecificationsPage() {
</button>
<button
type="button"
onClick={() => router.push("/products/new/review")}
onClick={goToReview}
className="editorial-gradient text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.18em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
{s.next}

View File

@ -102,6 +102,7 @@ export interface ProductDraftState {
fileId: string;
};
warrantyInformation: {
enabled: boolean;
type: string;
duration: string;
durationType: string;
@ -151,6 +152,7 @@ const defaultDraft: ProductDraftState = {
fileId: "",
},
warrantyInformation: {
enabled: false,
type: "",
duration: "",
durationType: "MONTH",
@ -199,6 +201,10 @@ function normalizeDraft(stored: Partial<ProductDraftState>): ProductDraftState {
features: Array.isArray(stored.features)
? stored.features.filter(Boolean).slice(0, MAX_PRODUCT_FEATURES)
: defaultDraft.features,
warrantyInformation: {
...defaultDraft.warrantyInformation,
...stored.warrantyInformation,
},
};
}

View File

@ -147,7 +147,13 @@ export function validateAddProductPayload(payload: Record<string, unknown>) {
const warrantyInformation = payload.warrantyInformation as Record<string, unknown> | undefined;
pushMaxLength(issues, warrantyInformation?.type, 50, "Tipe garansi");
if (text(warrantyInformation?.type) || numberValue(warrantyInformation?.duration) > 0) {
const hasWarranty =
Boolean(text(warrantyInformation?.type).trim()) ||
(warrantyInformation?.duration !== null && warrantyInformation?.duration !== undefined);
if (warrantyInformation && hasWarranty) {
if (!text(warrantyInformation.type).trim()) {
issues.push("Tipe garansi wajib diisi jika garansi aktif.");
}
pushMinNumber(issues, warrantyInformation?.duration, 1, "Durasi garansi");
}

View File

@ -21,7 +21,7 @@ function toNumber(value: string) {
}
export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "REVIEW") {
return {
const payload = {
subCategory: draft.subCategoryId ? { id: draft.subCategoryId } : undefined,
name: draft.name,
description: draft.description,
@ -102,11 +102,25 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "
),
complianceInformation: { ...draft.complianceInformation },
warrantyInformation: {
...draft.warrantyInformation,
duration: toNumber(draft.warrantyInformation.duration),
type: null,
duration: null,
durationType: "MONTH",
},
state,
};
if (draft.warrantyInformation.enabled) {
return {
...payload,
warrantyInformation: {
type: draft.warrantyInformation.type,
duration: toNumber(draft.warrantyInformation.duration),
durationType: draft.warrantyInformation.durationType,
},
};
}
return payload;
}
export function useProductSubmit() {