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` ## 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 ### AddProductRequest validation
Files: Files:

View File

@ -171,6 +171,7 @@ export default function ProductDetailsPage() {
const { submit, submitting } = useProductSubmit(); const { submit, submitting } = useProductSubmit();
const { t } = useLanguage(); const { t } = useLanguage();
const d = t.dashboard.productNew.details; const d = t.dashboard.productNew.details;
const [preOrderError, setPreOrderError] = useState("");
const [keywordInput, setKeywordInput] = useState(""); const [keywordInput, setKeywordInput] = useState("");
const keywordLimitReached = draft.keywords.filter(Boolean).length >= MAX_KEYWORDS; const keywordLimitReached = draft.keywords.filter(Boolean).length >= MAX_KEYWORDS;
const featureLimitReached = draft.features.length >= MAX_FEATURES; 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 ( return (
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8"> <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="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> </label>
<input <input
value={draft.preOrderDay} value={draft.preOrderDay}
onChange={(e) => setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }))} onChange={(e) => {
placeholder="14" setPreOrderError("");
setDraft((prev) => ({ ...prev, preOrderDay: e.target.value }));
}}
placeholder="0"
type="number" type="number"
min="1" min="1"
className="w-full bg-surface-container-low border-none rounded-xl p-4 font-semibold focus:ring-2 focus:ring-primary/10" 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"> <p className={`mt-1.5 text-[11px] font-semibold ${preOrderError ? "text-error" : "text-on-surface-variant"}`}>
Minimal 1 hari jika pre-order aktif. {preOrderError || "Minimal 1 hari jika pre-order aktif."}
</p> </p>
</div> </div>
</div> </div>
@ -517,7 +532,7 @@ export default function ProductDetailsPage() {
</button> </button>
<button <button
type="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" 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} {d.next}

View File

@ -448,7 +448,7 @@ export default function ProductReviewPage() {
</div> </div>
)} )}
</div> </div>
{(draft.warrantyInformation.type || draft.warrantyInformation.duration) && ( {draft.warrantyInformation.enabled && (
<div> <div>
<p className="text-xs font-bold text-on-surface-variant mb-2">{r.warranty}</p> <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} /> <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 msdsInputRef = useRef<HTMLInputElement>(null);
const docInputRef = useRef<HTMLInputElement>(null); const docInputRef = useRef<HTMLInputElement>(null);
const [msdsName, setMsdsName] = useState(""); const [msdsName, setMsdsName] = useState("");
const [warrantyError, setWarrantyError] = useState("");
// --- productInformations helpers --- // --- productInformations helpers ---
function getProductInfo(key: string) { function getProductInfo(key: string) {
@ -140,6 +141,22 @@ export default function ProductSpecificationsPage() {
setDraft((prev) => ({ ...prev, productFiles: (prev.productFiles ?? []).filter((f) => f.id !== id) })); 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 ?? []; const subCategoryAttributes = draft.subCategoryAttributes ?? [];
return ( return (
@ -157,7 +174,7 @@ export default function ProductSpecificationsPage() {
<p>Safety warning maksimal 100 karakter.</p> <p>Safety warning maksimal 100 karakter.</p>
<p>Country of origin maksimal 100 karakter.</p> <p>Country of origin maksimal 100 karakter.</p>
<p>Compliance file ID 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>Dokumen pendukung maksimal 100 karakter per file ID.</p>
<p>Upload dokumen maksimal 10 MB per file.</p> <p>Upload dokumen maksimal 10 MB per file.</p>
</div> </div>
@ -362,24 +379,47 @@ export default function ProductSpecificationsPage() {
{/* 4. Warranty */} {/* 4. Warranty */}
<section className={sectionClass}> <section className={sectionClass}>
<div className="flex items-center gap-3 mb-5"> <div className="flex flex-col gap-4 mb-5 sm:flex-row sm:items-center sm:justify-between">
<div className={sectionIconClass}> <div className="flex items-center gap-3">
<span className="material-symbols-outlined">verified</span> <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> </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>
<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> <div>
<label className={labelClass}>{s.warrantyType}</label> <label className={labelClass}>{s.warrantyType}</label>
<input <input
value={draft.warrantyInformation.type} value={draft.warrantyInformation.type}
onChange={(e) => onChange={(e) => {
setWarrantyError("");
setDraft((prev) => ({ setDraft((prev) => ({
...prev, ...prev,
warrantyInformation: { ...prev.warrantyInformation, type: e.target.value }, warrantyInformation: { ...prev.warrantyInformation, type: e.target.value },
})) }));
} }}
placeholder="e.g. Global Manufacturers Warranty" placeholder="e.g. Global Manufacturers Warranty"
disabled={!draft.warrantyInformation.enabled}
className={inputClass} className={inputClass}
/> />
</div> </div>
@ -388,15 +428,17 @@ export default function ProductSpecificationsPage() {
<div className="flex gap-3"> <div className="flex gap-3">
<input <input
value={draft.warrantyInformation.duration} value={draft.warrantyInformation.duration}
onChange={(e) => onChange={(e) => {
setWarrantyError("");
setDraft((prev) => ({ setDraft((prev) => ({
...prev, ...prev,
warrantyInformation: { ...prev.warrantyInformation, duration: e.target.value }, warrantyInformation: { ...prev.warrantyInformation, duration: e.target.value },
})) }));
} }}
placeholder="0" placeholder="0"
type="number" type="number"
min="0" 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" 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 <select
@ -407,6 +449,7 @@ export default function ProductSpecificationsPage() {
warrantyInformation: { ...prev.warrantyInformation, durationType: e.target.value }, 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" 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> <option value="DAY">Days</option>
@ -416,6 +459,7 @@ export default function ProductSpecificationsPage() {
</div> </div>
</div> </div>
</div> </div>
{warrantyError && <p className="mt-3 text-[11px] font-semibold text-error">{warrantyError}</p>}
</section> </section>
{/* 5. International Export */} {/* 5. International Export */}
@ -550,7 +594,7 @@ export default function ProductSpecificationsPage() {
</button> </button>
<button <button
type="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" 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} {s.next}

View File

@ -102,6 +102,7 @@ export interface ProductDraftState {
fileId: string; fileId: string;
}; };
warrantyInformation: { warrantyInformation: {
enabled: boolean;
type: string; type: string;
duration: string; duration: string;
durationType: string; durationType: string;
@ -151,6 +152,7 @@ const defaultDraft: ProductDraftState = {
fileId: "", fileId: "",
}, },
warrantyInformation: { warrantyInformation: {
enabled: false,
type: "", type: "",
duration: "", duration: "",
durationType: "MONTH", durationType: "MONTH",
@ -199,6 +201,10 @@ function normalizeDraft(stored: Partial<ProductDraftState>): ProductDraftState {
features: Array.isArray(stored.features) features: Array.isArray(stored.features)
? stored.features.filter(Boolean).slice(0, MAX_PRODUCT_FEATURES) ? stored.features.filter(Boolean).slice(0, MAX_PRODUCT_FEATURES)
: defaultDraft.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; const warrantyInformation = payload.warrantyInformation as Record<string, unknown> | undefined;
pushMaxLength(issues, warrantyInformation?.type, 50, "Tipe garansi"); 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"); 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") { export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "REVIEW") {
return { const payload = {
subCategory: draft.subCategoryId ? { id: draft.subCategoryId } : undefined, subCategory: draft.subCategoryId ? { id: draft.subCategoryId } : undefined,
name: draft.name, name: draft.name,
description: draft.description, description: draft.description,
@ -102,11 +102,25 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "
), ),
complianceInformation: { ...draft.complianceInformation }, complianceInformation: { ...draft.complianceInformation },
warrantyInformation: { warrantyInformation: {
...draft.warrantyInformation, type: null,
duration: toNumber(draft.warrantyInformation.duration), duration: null,
durationType: "MONTH",
}, },
state, 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() { export function useProductSubmit() {