diff --git a/apps/web/src/app/(store)/store/products/new/_components/ScreenFourSizeGuidePublish.tsx b/apps/web/src/app/(store)/store/products/new/_components/ScreenFourSizeGuidePublish.tsx new file mode 100644 index 0000000..ab65b28 --- /dev/null +++ b/apps/web/src/app/(store)/store/products/new/_components/ScreenFourSizeGuidePublish.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { useMemo } from "react"; +import { Footprints, Ruler, Send, Save, Shirt, X } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { cn } from "@/lib/utils"; +import { + type CustomSizeRow, + type ProductListingDraft, + type ProductListingSizeGuide, + SUBCATEGORY_OPTIONS, + parseWholeNumber, +} from "@/lib/product-listing"; + +interface ScreenFourSizeGuidePublishProps { + draft: ProductListingDraft; + sizeGuide: ProductListingSizeGuide; + onChange: (sizeGuide: ProductListingSizeGuide) => void; + onBack: () => void; + onSubmit: (publishNow: boolean) => void; + submitting: "publish" | "draft" | null; + submitErrors: string[]; +} + +export function ScreenFourSizeGuidePublish({ + draft, + sizeGuide, + onChange, + onBack, + onSubmit, + submitting, + submitErrors, +}: ScreenFourSizeGuidePublishProps) { + const selectedGuide = SUBCATEGORY_OPTIONS.find( + (option) => option.value === draft.details.subCategory, + ); + const isFootwear = draft.details.subCategory.startsWith("FOOTWEAR"); + const sizeOptions = useMemo(() => getSizeOptions(draft), [draft]); + const modelSizeOptions = + sizeOptions.length > 0 ? sizeOptions : ["S", "M", "L"]; + + const update = (patch: Partial) => { + onChange({ ...sizeGuide, ...patch }); + }; + + const toggleCustomSizes = (checked: boolean) => { + update({ + useCustomSizes: checked, + customSizes: + checked && sizeGuide.customSizes.length === 0 + ? modelSizeOptions.map(createCustomSizeRow) + : sizeGuide.customSizes, + }); + }; + + const updateCustomRow = (id: string, patch: Partial) => { + update({ + customSizes: sizeGuide.customSizes.map((row) => + row.id === id ? { ...row, ...patch } : row, + ), + }); + }; + + const addCustomRow = () => { + update({ + customSizes: [...sizeGuide.customSizes, createCustomSizeRow("")], + }); + }; + + const customSizeErrors = validateCustomSizes(sizeGuide, isFootwear); + const modelErrors = validateModelMeasurements(sizeGuide); + const allInlineErrors = [...customSizeErrors, ...modelErrors]; + + return ( +
+
+
+ + +
+

+ Size guide and publish +

+

+ Review sizing, model fit, and choose how to save the listing. +

+
+
+ + {selectedGuide ? ( +
+
+ + {isFootwear ? ( + +
+

+ {selectedGuide.label} +

+

+ Standard platform guide will be attached to this product. +

+
+
+ {draft.details.includesShoes && !isFootwear && ( +

+ Footwear is included in this bundle. +

+ )} +
+ ) : ( +
+ No platform size guide selected for this product. +
+ )} +
+ +
+ + + {sizeGuide.useCustomSizes && ( +
+ {sizeGuide.customSizes.map((row) => ( +
+ + updateCustomRow(row.id, { size: event.target.value }) + } + /> + {isFootwear ? ( + <> + + updateCustomRow(row.id, { + footLengthCm: event.target.value, + }) + } + /> + + updateCustomRow(row.id, { ukSize: event.target.value }) + } + /> + + updateCustomRow(row.id, { usSize: event.target.value }) + } + /> + + ) : ( + <> + + updateCustomRow(row.id, { bustCm: event.target.value }) + } + /> + + updateCustomRow(row.id, { waistCm: event.target.value }) + } + /> + + updateCustomRow(row.id, { hipsCm: event.target.value }) + } + /> + + updateCustomRow(row.id, { + heightCm: event.target.value, + }) + } + /> + + )} +
+ ))} + +
+ )} +
+ +
+

+ Model measurements +

+
+ update({ modelHeightCm: event.target.value })} + /> + update({ modelBustCm: event.target.value })} + /> + update({ modelWaistCm: event.target.value })} + /> + update({ modelHipsCm: event.target.value })} + /> +
+ + +
+
+
+ + {allInlineErrors.length > 0 && ( +
+

Fix these sizing fields:

+
    + {allInlineErrors.map((error) => ( +
  • {error}
  • + ))} +
+
+ )} + + {submitErrors.length > 0 && ( +
+

Unable to save product:

+
    + {submitErrors.map((error) => ( +
  • {error}
  • + ))} +
+
+ )} + +
+ + + +
+
+ ); +} + +function ToggleRow({ + checked, + label, + onChange, +}: { + checked: boolean; + label: string; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function getSizeOptions(draft: ProductListingDraft): string[] { + if ( + draft.pricing.hasVariants && + draft.pricing.dimensionTwoValues.length > 0 + ) { + return draft.pricing.dimensionTwoValues; + } + + if (draft.details.subCategory.startsWith("FOOTWEAR")) { + return ["36", "37", "38", "39", "40", "41", "42"]; + } + + if (draft.details.subCategory.startsWith("CLOTHING")) { + return ["XS", "S", "M", "L", "XL"]; + } + + return []; +} + +function createCustomSizeRow(size: string): CustomSizeRow { + return { + id: crypto.randomUUID(), + size, + bustCm: "", + waistCm: "", + hipsCm: "", + heightCm: "", + footLengthCm: "", + ukSize: "", + usSize: "", + }; +} + +function validateCustomSizes( + sizeGuide: ProductListingSizeGuide, + isFootwear: boolean, +): string[] { + if (!sizeGuide.useCustomSizes) return []; + + const errors: string[] = []; + sizeGuide.customSizes.forEach((row) => { + if (!row.size.trim()) return; + + if (isFootwear) { + if (!isOptionalNumber(row.footLengthCm)) { + errors.push(`${row.size}: foot length must be numeric.`); + } + return; + } + + if (!isOptionalNumber(row.bustCm)) { + errors.push(`${row.size}: bust must be numeric.`); + } + if (!isOptionalNumber(row.waistCm)) { + errors.push(`${row.size}: waist must be numeric.`); + } + if (!isOptionalNumber(row.hipsCm)) { + errors.push(`${row.size}: hips must be numeric.`); + } + if (!isOptionalNumber(row.heightCm)) { + errors.push(`${row.size}: height must be numeric.`); + } + }); + + return Array.from(new Set(errors)); +} + +function validateModelMeasurements( + sizeGuide: ProductListingSizeGuide, +): string[] { + const errors: string[] = []; + if (!isOptionalNumber(sizeGuide.modelHeightCm)) { + errors.push("Model height must be numeric."); + } + if (!isOptionalNumber(sizeGuide.modelBustCm)) { + errors.push("Model bust must be numeric."); + } + if (!isOptionalNumber(sizeGuide.modelWaistCm)) { + errors.push("Model waist must be numeric."); + } + if (!isOptionalNumber(sizeGuide.modelHipsCm)) { + errors.push("Model hips must be numeric."); + } + return errors; +} + +function isOptionalNumber(value: string): boolean { + if (!value.trim()) return true; + return parseWholeNumber(value) !== null; +} diff --git a/apps/web/src/app/(store)/store/products/new/_components/ScreenThreeVariantsPricing.tsx b/apps/web/src/app/(store)/store/products/new/_components/ScreenThreeVariantsPricing.tsx new file mode 100644 index 0000000..b2a0f9a --- /dev/null +++ b/apps/web/src/app/(store)/store/products/new/_components/ScreenThreeVariantsPricing.tsx @@ -0,0 +1,828 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Layers, Package, Plus, Store, X } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { cn } from "@/lib/utils"; +import { api } from "@/lib/api"; +import { + type DimensionOneType, + type DimensionTwoType, + type ProductListingPricing, + type VariantDraftRow, + calculateDiscountPercent, + nairaToKoboString, + parseWholeNumber, +} from "@/lib/product-listing"; + +const DIMENSION_ONE_OPTIONS: DimensionOneType[] = ["Color", "Pattern", "Style"]; +const DIMENSION_TWO_OPTIONS: DimensionTwoType[] = ["Size", "Weight", "Volume"]; +const MAX_DIMENSION_VALUES = 10; + +interface StoreContext { + storeType?: string; +} + +interface ScreenThreeVariantsPricingProps { + pricing: ProductListingPricing; + onChange: (pricing: ProductListingPricing) => void; + onBack: () => void; + onNext: () => void; +} + +export function ScreenThreeVariantsPricing({ + pricing, + onChange, + onBack, + onNext, +}: ScreenThreeVariantsPricingProps) { + const [dimensionOneInput, setDimensionOneInput] = useState(""); + const [dimensionTwoInput, setDimensionTwoInput] = useState(""); + const [errors, setErrors] = useState([]); + const [supportsDropship, setSupportsDropship] = useState(false); + + useEffect(() => { + let mounted = true; + + api + .get("/stores/me") + .then((store) => { + if (mounted) setSupportsDropship(store.storeType === "PHYSICAL"); + }) + .catch(() => { + if (mounted) setSupportsDropship(false); + }); + + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + if (!supportsDropship && pricing.allowDropship) { + onChange({ + ...pricing, + allowDropship: false, + dropshipperPriceNaira: "", + }); + } + }, [onChange, pricing, supportsDropship]); + + const discountPercent = useMemo( + () => + calculateDiscountPercent( + pricing.retailPriceNaira, + pricing.compareAtPriceNaira, + ), + [pricing.compareAtPriceNaira, pricing.retailPriceNaira], + ); + + const canShowVariantTable = + pricing.hasVariants && + pricing.dimensionOneValues.length > 0 && + pricing.dimensionTwoValues.length > 0; + + const updatePricing = (next: ProductListingPricing) => { + setErrors([]); + onChange(next); + }; + + const updateAndSyncVariants = (next: ProductListingPricing) => { + updatePricing(syncVariantRows(next)); + }; + + const handleContinue = () => { + const nextErrors = validatePricing(pricing, supportsDropship); + setErrors(nextErrors); + if (nextErrors.length === 0) onNext(); + }; + + const addDimensionValue = ( + side: "one" | "two", + rawValue: string, + clearInput: () => void, + ) => { + const value = rawValue.trim(); + if (!value) return; + + const current = + side === "one" ? pricing.dimensionOneValues : pricing.dimensionTwoValues; + const exists = current.some( + (entry) => entry.toLowerCase() === value.toLowerCase(), + ); + if (exists || current.length >= MAX_DIMENSION_VALUES) { + clearInput(); + return; + } + + updateAndSyncVariants({ + ...pricing, + [side === "one" ? "dimensionOneValues" : "dimensionTwoValues"]: [ + ...current, + value, + ], + }); + clearInput(); + }; + + const removeDimensionValue = (side: "one" | "two", value: string) => { + const key = side === "one" ? "dimensionOneValues" : "dimensionTwoValues"; + updateAndSyncVariants({ + ...pricing, + [key]: pricing[key].filter((entry) => entry !== value), + }); + }; + + const updateVariant = (id: string, patch: Partial) => { + updatePricing({ + ...pricing, + variants: pricing.variants.map((variant) => + variant.id === id ? { ...variant, ...patch } : variant, + ), + }); + }; + + const addTier = () => { + updatePricing({ + ...pricing, + volumePricingOpen: true, + volumeTiers: [ + ...pricing.volumeTiers, + { + id: crypto.randomUUID(), + minQuantity: "", + priceNaira: "", + }, + ], + }); + }; + + const fillAllVariantPrices = () => { + if (!nairaToKoboString(pricing.prefillPriceNaira)) return; + updatePricing({ + ...pricing, + variants: pricing.variants.map((variant) => ({ + ...variant, + priceNaira: pricing.prefillPriceNaira, + })), + }); + }; + + return ( +
+
+
+ + +
+

+ Variants and pricing +

+

+ Set the sellable combinations, price, and available stock. +

+
+
+ + + updateAndSyncVariants({ + ...pricing, + hasVariants: checked, + variants: checked ? pricing.variants : [], + }) + } + /> + +
+ + updatePricing({ + ...pricing, + retailPriceNaira: event.target.value, + }) + } + leftIcon={} + placeholder="24500" + required + /> +
+ + updatePricing({ + ...pricing, + compareAtPriceNaira: event.target.value, + }) + } + leftIcon={} + placeholder="30000" + /> + {discountPercent !== null && ( + + SAVE {discountPercent}% + + )} +
+
+ + {!pricing.hasVariants && ( +
+ + updatePricing({ ...pricing, singleStock: event.target.value }) + } + placeholder="0" + required + /> +
+ )} +
+ + {pricing.hasVariants && ( +
+
+ + updatePricing({ ...pricing, dimensionOneType: value }) + } + onInputChange={setDimensionOneInput} + onAdd={() => + addDimensionValue("one", dimensionOneInput, () => + setDimensionOneInput(""), + ) + } + onRemove={(value) => removeDimensionValue("one", value)} + /> + + updatePricing({ ...pricing, dimensionTwoType: value }) + } + onInputChange={setDimensionTwoInput} + onAdd={() => + addDimensionValue("two", dimensionTwoInput, () => + setDimensionTwoInput(""), + ) + } + onRemove={(value) => removeDimensionValue("two", value)} + /> +
+ + {canShowVariantTable && ( +
+
+ + updatePricing({ + ...pricing, + prefillPriceNaira: event.target.value, + }) + } + leftIcon={} + placeholder="24500" + /> + +
+ +
+ + + + + + + + + + + {pricing.variants.map((variant) => ( + + + + + + + ))} + +
VariantPrice ₦StockActive
+ {variant.label} + + + updateVariant(variant.id, { + priceNaira: event.target.value, + }) + } + aria-label={`${variant.label} price`} + className="h-11 w-full rounded-md border border-[var(--border)] bg-[var(--input)] px-3 text-base text-[var(--foreground)] outline-none focus:border-[var(--color-saffron)]" + /> + + + updateVariant(variant.id, { + stock: event.target.value, + }) + } + aria-label={`${variant.label} stock`} + className="h-11 w-full rounded-md border border-[var(--border)] bg-[var(--input)] px-3 text-base text-[var(--foreground)] outline-none focus:border-[var(--color-saffron)]" + /> + + +
+
+
+ )} +
+ )} + +
+ + updatePricing({ ...pricing, volumePricingOpen: checked }) + } + /> + {pricing.volumePricingOpen && ( +
+ {pricing.volumeTiers.map((tier) => ( +
+ + updatePricing({ + ...pricing, + volumeTiers: pricing.volumeTiers.map((item) => + item.id === tier.id + ? { ...item, minQuantity: event.target.value } + : item, + ), + }) + } + placeholder="3" + /> + + updatePricing({ + ...pricing, + volumeTiers: pricing.volumeTiers.map((item) => + item.id === tier.id + ? { ...item, priceNaira: event.target.value } + : item, + ), + }) + } + leftIcon={} + placeholder="22000" + /> +
+ ))} + +
+ )} + + + updatePricing({ ...pricing, notSoldIndividually: checked }) + } + /> + {pricing.notSoldIndividually && ( + + updatePricing({ + ...pricing, + minimumOrderQty: event.target.value, + }) + } + placeholder="2" + /> + )} +
+ + {supportsDropship && ( +
+
+ + +

+ Sourcing settings +

+
+ + updatePricing({ ...pricing, allowDropship: checked }) + } + /> + {pricing.allowDropship && ( +
+ + updatePricing({ + ...pricing, + dropshipperPriceNaira: event.target.value, + }) + } + leftIcon={} + placeholder="20000" + /> +
+ )} +
+ )} + + {errors.length > 0 && ( +
+

Fix these before continuing:

+
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+
+ )} + +
+ + +
+
+ ); +} + +function DimensionBuilder({ + label, + value, + options, + inputValue, + values, + examples, + onTypeChange, + onInputChange, + onAdd, + onRemove, +}: { + label: string; + value: T; + options: T[]; + inputValue: string; + values: string[]; + examples: string; + onTypeChange: (value: T) => void; + onInputChange: (value: string) => void; + onAdd: () => void; + onRemove: (value: string) => void; +}) { + return ( +
+ + +
+ onInputChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onAdd(); + } + }} + placeholder={examples} + /> +
+ {values.length > 0 && ( +
+ {values.map((entry) => ( + + ))} +
+ )} +
+ ); +} + +function ToggleRow({ + checked, + label, + onChange, +}: { + checked: boolean; + label: string; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function syncVariantRows( + pricing: ProductListingPricing, +): ProductListingPricing { + if ( + !pricing.hasVariants || + pricing.dimensionOneValues.length === 0 || + pricing.dimensionTwoValues.length === 0 + ) { + return { ...pricing, variants: [] }; + } + + const existing = new Map( + pricing.variants.map((variant) => [ + `${variant.dimensionOneValue}::${variant.dimensionTwoValue}`, + variant, + ]), + ); + + const variants = pricing.dimensionOneValues.flatMap((dimensionOneValue) => + pricing.dimensionTwoValues.map((dimensionTwoValue) => { + const key = `${dimensionOneValue}::${dimensionTwoValue}`; + const current = existing.get(key); + if (current) return current; + + return { + id: crypto.randomUUID(), + dimensionOneValue, + dimensionTwoValue, + label: `${dimensionOneValue} - ${dimensionTwoValue}`, + priceNaira: pricing.retailPriceNaira, + stock: "", + isActive: true, + }; + }), + ); + + return { ...pricing, variants }; +} + +function validatePricing( + pricing: ProductListingPricing, + supportsDropship: boolean, +): string[] { + const errors: string[] = []; + const retailPriceKobo = nairaToKoboString(pricing.retailPriceNaira); + const compareAtPriceKobo = nairaToKoboString(pricing.compareAtPriceNaira); + const dropshipperPriceKobo = nairaToKoboString(pricing.dropshipperPriceNaira); + + if (!retailPriceKobo || BigInt(retailPriceKobo) <= 0n) { + errors.push("Retail price is required."); + } + if ( + pricing.compareAtPriceNaira.trim() && + (!compareAtPriceKobo || + !retailPriceKobo || + BigInt(compareAtPriceKobo) <= BigInt(retailPriceKobo)) + ) { + errors.push("Compare-at price must be greater than retail price."); + } + + if (pricing.hasVariants) { + if ( + pricing.dimensionOneValues.length === 0 || + pricing.dimensionTwoValues.length === 0 + ) { + errors.push("Add values for both variant dimensions."); + } + if (pricing.variants.length === 0) { + errors.push("Generate at least one variant."); + } + pricing.variants.forEach((variant) => { + const priceKobo = nairaToKoboString(variant.priceNaira); + const stock = parseWholeNumber(variant.stock); + if (!priceKobo || BigInt(priceKobo) <= 0n) { + errors.push(`${variant.label}: enter a valid price.`); + } + if (stock === null) { + errors.push(`${variant.label}: stock must be a whole number.`); + } + }); + } else if (parseWholeNumber(pricing.singleStock) === null) { + errors.push("Stock must be a whole number."); + } + + validateVolumePricing(pricing, errors); + + if (pricing.notSoldIndividually) { + const minimumOrderQty = parseWholeNumber(pricing.minimumOrderQty); + if (minimumOrderQty === null || minimumOrderQty <= 1) { + errors.push("Minimum quantity must be greater than 1."); + } + } + + if (supportsDropship && pricing.allowDropship) { + if (!dropshipperPriceKobo || BigInt(dropshipperPriceKobo) < 0n) { + errors.push("Enter a valid floor price."); + } + if ( + retailPriceKobo && + dropshipperPriceKobo && + BigInt(retailPriceKobo) < BigInt(dropshipperPriceKobo) + ) { + errors.push("Selling price must be at least the floor price."); + } + } + + return Array.from(new Set(errors)); +} + +function validateVolumePricing( + pricing: ProductListingPricing, + errors: string[], +) { + const seen = new Set(); + + pricing.volumeTiers.forEach((tier) => { + if (!tier.minQuantity.trim() && !tier.priceNaira.trim()) return; + const minQuantity = parseWholeNumber(tier.minQuantity); + const priceKobo = nairaToKoboString(tier.priceNaira); + + if (minQuantity === null || minQuantity <= 1) { + errors.push("Volume tier quantity must be greater than 1."); + return; + } + if (seen.has(minQuantity)) { + errors.push("Volume tier quantities must be unique."); + return; + } + if (!priceKobo || BigInt(priceKobo) <= 0n) { + errors.push("Volume tier price must be greater than zero."); + return; + } + + seen.add(minQuantity); + }); +} diff --git a/apps/web/src/app/(store)/store/products/new/page.tsx b/apps/web/src/app/(store)/store/products/new/page.tsx index 98f37ab..a9ebec3 100644 --- a/apps/web/src/app/(store)/store/products/new/page.tsx +++ b/apps/web/src/app/(store)/store/products/new/page.tsx @@ -1,19 +1,26 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, Construction } from "lucide-react"; -import { Button } from "@/components/ui/Button"; +import { useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { type ApiError } from "@/lib/api"; import { type ProductListingDraft, + type ProductListingPricing, + type ProductListingSizeGuide, type UploadedPhoto, + buildProductCreatePayload, + createProduct, emptyDetails, + emptyPricing, + emptySizeGuide, } from "@/lib/product-listing"; import { StepProgress } from "./_components/StepProgress"; import { ScreenOnePhotos } from "./_components/ScreenOnePhotos"; import { ScreenTwoDetails } from "./_components/ScreenTwoDetails"; - -// ─── Wizard configuration ───────────────────────────────────────────────────── +import { ScreenThreeVariantsPricing } from "./_components/ScreenThreeVariantsPricing"; +import { ScreenFourSizeGuidePublish } from "./_components/ScreenFourSizeGuidePublish"; const STEP_LABELS = [ "Photos", @@ -24,40 +31,70 @@ const STEP_LABELS = [ const TOTAL_STEPS = STEP_LABELS.length; -// ─── Page ───────────────────────────────────────────────────────────────────── - export default function NewProductPage() { + const router = useRouter(); const [step, setStep] = useState(1); + const [submitting, setSubmitting] = useState<"publish" | "draft" | null>( + null, + ); + const [submitErrors, setSubmitErrors] = useState([]); const [draft, setDraft] = useState({ photos: [], details: emptyDetails(), + pricing: emptyPricing(), + sizeGuide: emptySizeGuide(), }); - // Keep a ref that always points to the current photos array. - // The cleanup effect reads from this ref so it sees the latest photos - // at unmount time, not the stale closure from when the effect first ran. const photosRef = useRef(draft.photos); photosRef.current = draft.photos; useEffect(() => { return () => { - photosRef.current.forEach((p) => URL.revokeObjectURL(p.previewUrl)); + photosRef.current.forEach((photo) => + URL.revokeObjectURL(photo.previewUrl), + ); }; - // photosRef is stable — no deps needed; ref is read at cleanup time }, []); const setPhotos = ( updater: UploadedPhoto[] | ((prev: UploadedPhoto[]) => UploadedPhoto[]), ) => { - setDraft((d) => ({ - ...d, - photos: typeof updater === "function" ? updater(d.photos) : updater, + setDraft((current) => ({ + ...current, + photos: typeof updater === "function" ? updater(current.photos) : updater, })); }; + const setPricing = (pricing: ProductListingPricing) => { + setDraft((current) => ({ ...current, pricing })); + }; + + const setSizeGuide = (sizeGuide: ProductListingSizeGuide) => { + setDraft((current) => ({ ...current, sizeGuide })); + }; + + const handleSubmit = async (publishNow: boolean) => { + setSubmitErrors([]); + const result = buildProductCreatePayload(draft, publishNow); + + if (!result.payload) { + setSubmitErrors(result.errors); + return; + } + + setSubmitting(publishNow ? "publish" : "draft"); + try { + await createProduct(result.payload); + router.push("/store/products"); + } catch (error) { + setSubmitErrors([getSubmitErrorMessage(error)]); + } finally { + setSubmitting(null); + } + }; + return (
- {/* Page header */}
- {/* Wizard content */}
- {step <= TOTAL_STEPS && ( - - )} + - {/* ── Step 1: Photos ──────────────────────────────────────────────── */} {step === 1 && ( )} - {/* ── Step 2: Product Details ─────────────────────────────────────── */} {step === 2 && ( setDraft((d) => ({ ...d, details }))} + onChange={(details) => + setDraft((current) => ({ ...current, details })) + } onBack={() => setStep(1)} onNext={() => setStep(3)} /> )} - {/* ── Step 3+ placeholder — W-15b continues here ─────────────────── */} - {step >= 3 && ( -
-
+ {step === 3 && ( + setStep(2)} + onNext={() => setStep(4)} + /> + )} + + {step === 4 && ( + setStep(3)} + onSubmit={handleSubmit} + submitting={submitting} + submitErrors={submitErrors} + /> )}
); } + +function getSubmitErrorMessage(error: unknown): string { + if (error && typeof error === "object" && "message" in error) { + const apiError = error as Partial; + if (apiError.message) return apiError.message; + } + return "Product could not be saved. Please review the form and try again."; +} diff --git a/apps/web/src/lib/product-listing.ts b/apps/web/src/lib/product-listing.ts index 97b2d45..297f977 100644 --- a/apps/web/src/lib/product-listing.ts +++ b/apps/web/src/lib/product-listing.ts @@ -12,11 +12,12 @@ * sent as-is — no backend enum change required. * * includesShoes note: - * - The "bundle includes shoes" toggle is tracked in local wizard state but - * has no corresponding field in CreateProductDto. Not submitted to API. - * Wire when the backend adds this field. + * - The "bundle includes shoes" toggle maps to + * sizeGuideConfig.includesFootwear when a size guide is present. */ +import { api } from "@/lib/api"; + // ─── Upload ──────────────────────────────────────────────────────────────────── const API_BASE = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); @@ -167,13 +168,146 @@ export interface ProductListingDetails { storeTags: string[]; specs: SpecRow[]; sku: string; - /** Local only — no matching field in CreateProductDto yet */ includesShoes: boolean; } export interface ProductListingDraft { photos: UploadedPhoto[]; details: ProductListingDetails; + pricing: ProductListingPricing; + sizeGuide: ProductListingSizeGuide; +} + +export type DimensionOneType = "Color" | "Pattern" | "Style"; +export type DimensionTwoType = "Size" | "Weight" | "Volume"; + +export interface VariantDraftRow { + id: string; + dimensionOneValue: string; + dimensionTwoValue: string; + label: string; + priceNaira: string; + stock: string; + isActive: boolean; +} + +export interface VolumePricingTier { + id: string; + minQuantity: string; + priceNaira: string; +} + +export interface ProductListingPricing { + hasVariants: boolean; + singleStock: string; + retailPriceNaira: string; + compareAtPriceNaira: string; + prefillPriceNaira: string; + dimensionOneType: DimensionOneType; + dimensionOneValues: string[]; + dimensionTwoType: DimensionTwoType; + dimensionTwoValues: string[]; + variants: VariantDraftRow[]; + volumePricingOpen: boolean; + volumeTiers: VolumePricingTier[]; + notSoldIndividually: boolean; + minimumOrderQty: string; + allowDropship: boolean; + dropshipperPriceNaira: string; +} + +export interface CustomSizeRow { + id: string; + size: string; + bustCm: string; + waistCm: string; + hipsCm: string; + heightCm: string; + footLengthCm: string; + ukSize: string; + usSize: string; +} + +export interface ProductListingSizeGuide { + useCustomSizes: boolean; + customSizes: CustomSizeRow[]; + modelHeightCm: string; + modelBustCm: string; + modelWaistCm: string; + modelHipsCm: string; + modelWearingSize: string; +} + +export interface ProductCreateResponse { + id: string; + productCode: string | null; +} + +interface ProductImagePayload { + url: string; + cloudinaryPublicId?: string; + order: number; + isDefault: boolean; + altText: string; + moderationStatus: ModerationStatus; +} + +interface ProductDetailPayload { + attribute: string; + value: string; +} + +interface ProductVariantOverridePayload { + colorName?: string; + sizeName?: string; + variantLabel: string; + priceOverrideKobo?: string; + isActive: boolean; + initialStock?: number; +} + +interface ProductSizeGuideConfigPayload { + includesFootwear?: boolean; + footwearGuideType?: string; + useCustomSizes?: boolean; + customSizes?: Record; + modelHeightCm?: number; + modelBustCm?: number; + modelWaistCm?: number; + modelHipsCm?: number; + modelWearingSize?: string; +} + +export interface ProductCreatePayload { + name: string; + description: string; + platformCategory: string; + productSubCategory?: string; + storeTags?: string[]; + productDetails?: ProductDetailPayload[]; + sku?: string; + retailPriceKobo: string; + compareAtPriceKobo?: string; + allowDropship?: boolean; + dropshipperPriceKobo?: string; + publishNow: boolean; + initialStock?: number; + notSoldIndividually?: boolean; + minimumOrderQty?: number; + hasVariants?: boolean; + variantOptions?: { + colors?: string[]; + sizes?: string[]; + overrides?: ProductVariantOverridePayload[]; + }; + images: ProductImagePayload[]; + sizeGuideConfig?: ProductSizeGuideConfigPayload; + attributes?: Record; +} + +export interface ProductPayloadBuildResult { + payload?: ProductCreatePayload; + errors: string[]; } export function emptyDetails(): ProductListingDetails { @@ -189,6 +323,39 @@ export function emptyDetails(): ProductListingDetails { }; } +export function emptyPricing(): ProductListingPricing { + return { + hasVariants: false, + singleStock: "", + retailPriceNaira: "", + compareAtPriceNaira: "", + prefillPriceNaira: "", + dimensionOneType: "Color", + dimensionOneValues: [], + dimensionTwoType: "Size", + dimensionTwoValues: [], + variants: [], + volumePricingOpen: false, + volumeTiers: [], + notSoldIndividually: false, + minimumOrderQty: "", + allowDropship: false, + dropshipperPriceNaira: "", + }; +} + +export function emptySizeGuide(): ProductListingSizeGuide { + return { + useCustomSizes: false, + customSizes: [], + modelHeightCm: "", + modelBustCm: "", + modelWaistCm: "", + modelHipsCm: "", + modelWearingSize: "", + }; +} + /** * Count photos that are usable for product listing. * SAFE ("approved") and SENSITIVE both uploaded successfully and count @@ -200,3 +367,338 @@ export function usableImageCount(photos: UploadedPhoto[]): number { (p) => p.status === "approved" || p.status === "sensitive", ).length; } + +export function nairaToKoboString(value: string): string | null { + const normalized = value.trim().replace(/[,\s_]/g, ""); + if (!normalized) return null; + if (!/^\d+(\.\d{1,2})?$/.test(normalized)) return null; + + const [nairaPart, koboPart = ""] = normalized.split("."); + const naira = BigInt(nairaPart); + const kobo = BigInt(koboPart.padEnd(2, "0")); + return (naira * 100n + kobo).toString(); +} + +export function parseWholeNumber(value: string): number | null { + const normalized = value.trim(); + if (!/^\d+$/.test(normalized)) return null; + const numberValue = Number(normalized); + if (!Number.isSafeInteger(numberValue)) return null; + return numberValue; +} + +export function calculateDiscountPercent( + retailNaira: string, + compareAtNaira: string, +): number | null { + const retailKobo = nairaToKoboString(retailNaira); + const compareKobo = nairaToKoboString(compareAtNaira); + if (!retailKobo || !compareKobo) return null; + + const retail = BigInt(retailKobo); + const compareAt = BigInt(compareKobo); + if (retail <= 0n || compareAt <= retail) return null; + + return Number(((compareAt - retail) * 100n) / compareAt); +} + +export function buildProductCreatePayload( + draft: ProductListingDraft, + publishNow: boolean, +): ProductPayloadBuildResult { + const errors: string[] = []; + const { details, photos, pricing, sizeGuide } = draft; + const retailPriceKobo = nairaToKoboString(pricing.retailPriceNaira); + const compareAtPriceKobo = nairaToKoboString(pricing.compareAtPriceNaira); + const dropshipperPriceKobo = nairaToKoboString(pricing.dropshipperPriceNaira); + + if (!details.name.trim()) errors.push("Product name is required."); + if (!details.description.trim()) errors.push("Description is required."); + if (!details.category) errors.push("Platform category is required."); + if (!retailPriceKobo || BigInt(retailPriceKobo) <= 0n) { + errors.push("Retail price must be greater than zero."); + } + if ( + pricing.compareAtPriceNaira.trim() && + (!compareAtPriceKobo || + !retailPriceKobo || + BigInt(compareAtPriceKobo) <= BigInt(retailPriceKobo)) + ) { + errors.push("Compare-at price must be greater than retail price."); + } + + const images = photos + .filter( + (photo) => + (photo.status === "approved" || photo.status === "sensitive") && + photo.cloudUrl, + ) + .map((photo, index) => ({ + url: photo.cloudUrl ?? "", + cloudinaryPublicId: photo.cloudinaryPublicId, + order: index, + isDefault: index === 0, + altText: details.name.trim() || `Product photo ${index + 1}`, + moderationStatus: photo.moderationStatus ?? "SAFE", + })); + + if (images.length === 0) { + errors.push("At least one uploaded product photo is required."); + } + + const attributes: Record = {}; + const volumePricingTiers = buildVolumePricingTiers(pricing, errors); + if (volumePricingTiers.length > 0) { + attributes.volumePricingTiers = volumePricingTiers; + } + + const payload: ProductCreatePayload = { + name: details.name.trim(), + description: details.description.trim(), + platformCategory: details.category, + retailPriceKobo: retailPriceKobo ?? "0", + publishNow, + images, + }; + + if (details.subCategory) payload.productSubCategory = details.subCategory; + if (details.storeTags.length > 0) payload.storeTags = details.storeTags; + + const productDetails = details.specs + .map((spec) => ({ + attribute: spec.attribute.trim(), + value: spec.value.trim(), + })) + .filter((spec) => spec.attribute && spec.value); + if (productDetails.length > 0) payload.productDetails = productDetails; + if (details.sku.trim()) payload.sku = details.sku.trim(); + if (compareAtPriceKobo) payload.compareAtPriceKobo = compareAtPriceKobo; + + if (pricing.hasVariants) { + const activeVariantErrors = buildVariantPayload(pricing, payload, errors); + if (activeVariantErrors.length > 0) { + errors.push(...activeVariantErrors); + } + attributes.variantDimensions = { + dimensionOne: pricing.dimensionOneType, + dimensionTwo: pricing.dimensionTwoType, + }; + } else { + const initialStock = parseWholeNumber(pricing.singleStock); + if (initialStock === null) { + errors.push("Stock must be a whole number."); + } else { + payload.initialStock = initialStock; + } + } + + if (pricing.notSoldIndividually) { + const minimumOrderQty = parseWholeNumber(pricing.minimumOrderQty); + if (minimumOrderQty === null || minimumOrderQty <= 1) { + errors.push("Minimum quantity must be greater than 1."); + } else { + payload.notSoldIndividually = true; + payload.minimumOrderQty = minimumOrderQty; + } + } + + if (pricing.allowDropship) { + payload.allowDropship = true; + if (!dropshipperPriceKobo) { + errors.push("Dropshipper price must be a valid amount."); + } else { + payload.dropshipperPriceKobo = dropshipperPriceKobo; + if ( + retailPriceKobo && + BigInt(retailPriceKobo) < BigInt(dropshipperPriceKobo) + ) { + errors.push( + "Retail price must be at least the dropshipper floor price.", + ); + } + } + } + + const sizeGuideConfig = buildSizeGuideConfig(details, sizeGuide, errors); + if (sizeGuideConfig) payload.sizeGuideConfig = sizeGuideConfig; + if (Object.keys(attributes).length > 0) payload.attributes = attributes; + + return errors.length > 0 ? { errors } : { payload, errors }; +} + +export async function createProduct( + payload: ProductCreatePayload, +): Promise { + return api.post("/products", payload); +} + +function buildVariantPayload( + pricing: ProductListingPricing, + payload: ProductCreatePayload, + errors: string[], +): string[] { + const variantErrors: string[] = []; + if ( + pricing.dimensionOneValues.length === 0 || + pricing.dimensionTwoValues.length === 0 + ) { + variantErrors.push("Add values for both variant dimensions."); + return variantErrors; + } + + payload.hasVariants = true; + payload.variantOptions = { + colors: pricing.dimensionOneValues, + sizes: pricing.dimensionTwoValues, + overrides: pricing.variants.map((variant) => { + const priceOverrideKobo = nairaToKoboString(variant.priceNaira); + const initialStock = parseWholeNumber(variant.stock); + + if (!priceOverrideKobo || BigInt(priceOverrideKobo) <= 0n) { + errors.push(`${variant.label}: price must be greater than zero.`); + } + if (initialStock === null) { + errors.push(`${variant.label}: stock must be a whole number.`); + } + + return { + colorName: variant.dimensionOneValue, + sizeName: variant.dimensionTwoValue, + variantLabel: variant.label, + priceOverrideKobo: priceOverrideKobo ?? undefined, + isActive: variant.isActive, + initialStock: initialStock ?? undefined, + }; + }), + }; + + return variantErrors; +} + +function buildVolumePricingTiers( + pricing: ProductListingPricing, + errors: string[], +): { minQuantity: number; priceKobo: string }[] { + const seenQuantities = new Set(); + const tiers: { minQuantity: number; priceKobo: string }[] = []; + + for (const tier of pricing.volumeTiers) { + const hasAnyValue = tier.minQuantity.trim() || tier.priceNaira.trim(); + if (!hasAnyValue) continue; + + const minQuantity = parseWholeNumber(tier.minQuantity); + const priceKobo = nairaToKoboString(tier.priceNaira); + + if (minQuantity === null || minQuantity <= 1) { + errors.push("Volume tier quantity must be greater than 1."); + continue; + } + if (seenQuantities.has(minQuantity)) { + errors.push("Volume tier quantities must be unique."); + continue; + } + if (!priceKobo || BigInt(priceKobo) <= 0n) { + errors.push("Volume tier price must be greater than zero."); + continue; + } + + seenQuantities.add(minQuantity); + tiers.push({ minQuantity, priceKobo }); + } + + return tiers; +} + +function buildSizeGuideConfig( + details: ProductListingDetails, + sizeGuide: ProductListingSizeGuide, + errors: string[], +): ProductSizeGuideConfigPayload | null { + if (!details.subCategory) return null; + + const config: ProductSizeGuideConfigPayload = { + includesFootwear: details.includesShoes, + useCustomSizes: sizeGuide.useCustomSizes, + }; + + if (details.subCategory.startsWith("FOOTWEAR")) { + config.footwearGuideType = details.subCategory; + } + + if (sizeGuide.useCustomSizes) { + config.customSizes = { + rows: sizeGuide.customSizes + .map((row) => normalizeCustomSizeRow(row, details.subCategory, errors)) + .filter((row) => row !== null), + }; + } + + const modelHeightCm = optionalPositiveInteger(sizeGuide.modelHeightCm); + const modelBustCm = optionalPositiveInteger(sizeGuide.modelBustCm); + const modelWaistCm = optionalPositiveInteger(sizeGuide.modelWaistCm); + const modelHipsCm = optionalPositiveInteger(sizeGuide.modelHipsCm); + + if (modelHeightCm.invalid) errors.push("Model height must be numeric."); + if (modelBustCm.invalid) errors.push("Model bust must be numeric."); + if (modelWaistCm.invalid) errors.push("Model waist must be numeric."); + if (modelHipsCm.invalid) errors.push("Model hips must be numeric."); + + if (modelHeightCm.value) config.modelHeightCm = modelHeightCm.value; + if (modelBustCm.value) config.modelBustCm = modelBustCm.value; + if (modelWaistCm.value) config.modelWaistCm = modelWaistCm.value; + if (modelHipsCm.value) config.modelHipsCm = modelHipsCm.value; + if (sizeGuide.modelWearingSize.trim()) { + config.modelWearingSize = sizeGuide.modelWearingSize.trim(); + } + + return config; +} + +function normalizeCustomSizeRow( + row: CustomSizeRow, + subCategory: string, + errors: string[], +): Record | null { + if (!row.size.trim()) return null; + + const output: Record = { size: row.size.trim() }; + const isFootwear = subCategory.startsWith("FOOTWEAR"); + + if (isFootwear) { + const footLengthCm = optionalPositiveInteger(row.footLengthCm); + if (footLengthCm.invalid || !footLengthCm.value) { + errors.push(`${row.size}: foot length must be numeric.`); + } else { + output.footLengthCm = footLengthCm.value; + } + if (row.ukSize.trim()) output.ukSize = row.ukSize.trim(); + if (row.usSize.trim()) output.usSize = row.usSize.trim(); + return output; + } + + const bustCm = optionalPositiveInteger(row.bustCm); + const waistCm = optionalPositiveInteger(row.waistCm); + const hipsCm = optionalPositiveInteger(row.hipsCm); + const heightCm = optionalPositiveInteger(row.heightCm); + + if (bustCm.invalid) errors.push(`${row.size}: bust must be numeric.`); + if (waistCm.invalid) errors.push(`${row.size}: waist must be numeric.`); + if (hipsCm.invalid) errors.push(`${row.size}: hips must be numeric.`); + if (heightCm.invalid) errors.push(`${row.size}: height must be numeric.`); + + if (bustCm.value) output.bustCm = bustCm.value; + if (waistCm.value) output.waistCm = waistCm.value; + if (hipsCm.value) output.hipsCm = hipsCm.value; + if (heightCm.value) output.heightCm = heightCm.value; + return output; +} + +function optionalPositiveInteger(value: string): { + value: number | null; + invalid: boolean; +} { + if (!value.trim()) return { value: null, invalid: false }; + const parsed = parseWholeNumber(value); + if (parsed === null || parsed <= 0) return { value: null, invalid: true }; + return { value: parsed, invalid: false }; +}