"use client"; import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { useFamily } from "@/app/FamilyProvider"; import { Button } from "@/components/ui"; import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe"; type Step = "capture" | "form"; interface VisionResult { name: string; category: string; colors: string[]; seasons: string[]; occasion_tags: string[]; } const SEASON_OPTIONS = ["summer", "monsoon", "winter"]; const OCCASION_OPTIONS = ["everyday", "daycare", "festive", "photoshoot"]; const ACQUIRED_OPTIONS = ["bought", "gift", "handmedown"]; const CATEGORY_LABELS: Record = { onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress", outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory", }; const CATEGORY_COLORS: Record = { onesie: "bg-pink-100 text-pink-700", top: "bg-blue-100 text-blue-700", bottom: "bg-indigo-100 text-indigo-700", dress: "bg-purple-100 text-purple-700", outerwear: "bg-teal-100 text-teal-700", sleepwear: "bg-amber-100 text-amber-700", accessory: "bg-rose-100 text-rose-700", }; function ChipRow({ label, options, selected, onChange, required, optionLabel, }: { label: string; options: readonly T[]; selected: T[]; onChange: (v: T[]) => void; required?: boolean; optionLabel?: (v: T) => string; }) { const toggle = (v: T) => { if (selected.includes(v)) onChange(selected.filter(x => x !== v)); else onChange([...selected, v]); }; return (

{label}{required && *}

{options.map(o => ( ))}
); } function SingleChipRow({ label, options, selected, onChange, required, optionLabel, }: { label: string; options: readonly T[]; selected: T | null; onChange: (v: T) => void; required?: boolean; optionLabel?: (v: T) => string; }) { return (

{label}{required && *}

{options.map(o => ( ))}
); } export default function AddGarmentPage() { const { childId } = useFamily(); const router = useRouter(); const fileRef = useRef(null); // gallery picker const cameraRef = useRef(null); // camera capture const [step, setStep] = useState("capture"); const [preview, setPreview] = useState(null); const [thumbUrl, setThumbUrl] = useState(null); const [imageKey, setImageKey] = useState(""); const [thumbKey, setThumbKey] = useState(""); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); // photo still uploading to R2 const [visionPending, setVisionPending] = useState(false); // AI analysis running in bg // Form fields const [name, setName] = useState(""); const [category, setCategory] = useState(null); const [sizeLabel, setSizeLabel] = useState(null); const [colors, setColors] = useState([]); const [colorInput, setColorInput] = useState(""); const [seasons, setSeasons] = useState([]); const [occasionTags, setOccasionTags] = useState([]); const [acquiredVia, setAcquiredVia] = useState(null); const [giftFrom, setGiftFrom] = useState(""); const [visionMetadata, setVisionMetadata] = useState(null); const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setError(""); // 1. Show local preview immediately and jump straight to the form. // The user can start filling in size/occasions while the upload runs. const reader = new FileReader(); reader.onload = ev => setPreview(ev.target?.result as string); reader.readAsDataURL(file); setStep("form"); setUploading(true); setVisionPending(false); // 2. Upload to R2 const form = new FormData(); form.append("file", file); let ik = "", tk = "", tu = ""; try { const uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form }); if (!uploadRes.ok) throw new Error((await uploadRes.json()).error); const uploadData = await uploadRes.json(); ik = uploadData.imageKey; tk = uploadData.thumbKey; tu = uploadData.thumbUrl; setImageKey(ik); setThumbKey(tk); setThumbUrl(tu); } catch (err) { setError(`Upload failed: ${err}`); setStep("capture"); setUploading(false); return; } setUploading(false); // 3. Fire vision AI in the background — do NOT await, form stays interactive. setVisionPending(true); fetch("/api/garments/tag", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ imageKey: ik }), }) .then(r => r.ok ? r.json() : null) .then((vision: VisionResult | null) => { if (vision) { setVisionMetadata(vision); // Only pre-fill fields the user hasn't touched yet setName(n => n || vision.name || ""); setCategory(c => c || vision.category || null); setColors(c => c.length ? c : (vision.colors || [])); setSeasons(s => s.length ? s : (vision.seasons || [])); setOccasionTags(t => t.length ? t : (vision.occasion_tags || [])); } }) .catch(() => {/* vision failure is non-fatal */}) .finally(() => setVisionPending(false)); }; const addColor = () => { const c = colorInput.trim().toLowerCase(); if (c && !colors.includes(c)) setColors([...colors, c]); setColorInput(""); }; const handleSave = async () => { if (!childId || !category || !sizeLabel) return; setSaving(true); setError(""); try { const res = await fetch("/api/garments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ childId, name: name || null, category, sizeLabel, colors, seasons, occasionTags, imageKey, thumbKey, acquiredVia: acquiredVia || null, giftFrom: giftFrom || null, visionMetadata, }), }); if (!res.ok) throw new Error((await res.json()).error); handleAddAnother(); } catch (err) { setError(`Save failed: ${err}`); } finally { setSaving(false); } }; const handleAddAnother = () => { setStep("capture"); setPreview(null); setThumbUrl(null); setImageKey(""); setThumbKey(""); setName(""); setCategory(null); setSizeLabel(null); setColors([]); setColorInput(""); setSeasons([]); setOccasionTags([]); setAcquiredVia(null); setGiftFrom(""); setVisionMetadata(null); setError(""); setUploading(false); setVisionPending(false); if (fileRef.current) fileRef.current.value = ""; if (cameraRef.current) cameraRef.current.value = ""; }; return (
{/* Header */}

Add Garment

{step === "form" && visionPending && ( 🔍 AI tagging… )} {step === "form" && !visionPending && visionMetadata && ( ✨ AI pre-filled )}
{error && (
{error}
)} {/* Step 1: Capture */} {step === "capture" && (
👚

Add a garment photo

Tip: flat-lay on a light surface for best AI tagging

{/* Gallery input — no capture attribute, Android shows full media picker */} {/* Camera input — capture="environment" opens rear camera directly */}
)} {/* Step 2: Form (shown immediately after photo selected) */} {step === "form" && (
{/* Thumbnail — shows upload spinner until R2 upload is done */}
{(thumbUrl || preview) && (
garment {uploading && (
)}
)}
setName(e.target.value)} placeholder="e.g. Striped blue onesie" className="mt-1 w-full px-3 py-2 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300" />
{/* Category */}
setCategory(v)} required optionLabel={v => CATEGORY_LABELS[v] || v} />
{/* Size — required, not pre-filled */}
setSizeLabel(v)} required /> {!sizeLabel && (

⚠ Size must be set manually — vision cannot guess it

)}
{/* Colors */}

Colors

{colors.map(c => ( ))}
setColorInput(e.target.value)} onKeyDown={e => e.key === "Enter" && addColor()} placeholder="Add color…" className="flex-1 px-3 py-1.5 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" />
{/* Seasons */}
{/* Occasions */}
{/* Optional fields */}

Optional

Acquired via

{ACQUIRED_OPTIONS.map(o => ( ))}
{acquiredVia === "gift" && (

Gift from

setGiftFrom(e.target.value)} placeholder="e.g. Nani" className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-sm focus:outline-none" />
)}
{/* Action buttons */}
{uploading && (

Photo uploading — you can pick size & tags while you wait

)} {!uploading && !sizeLabel && (

Please select a size to save

)}
)}
); }