tia/src/app/(app)/wardrobe/add/page.tsx
Mannu 2a09c027fa feat(marketing): public homepage replacing / → /login redirect
- Add (marketing) route group: /, /pricing, /privacy, /terms
- Add (app) route group: moves all authenticated pages, app home → /home
- Root / is now a static marketing page (zero DB imports, zero auth)
- NavAuthButton client component: shows "Open Tia →" if logged in, else "Continue with Google"
- Plausible analytics hook in marketing layout
- Auto-generated OG image via opengraph-image.tsx
- Middleware updated to allowlist marketing routes
- All /-redirects updated to /home (login, onboarding, invite, circle join)
- BottomNav home tab updated: / → /home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:26:26 +05:30

467 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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" | "tagging" | "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<string, string> = {
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
};
const CATEGORY_COLORS: Record<string, string> = {
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<T extends string>({
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 (
<div className="mb-4">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
</p>
<div className="flex flex-wrap gap-2">
{options.map(o => (
<button
key={o}
type="button"
onClick={() => toggle(o)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
selected.includes(o)
? "bg-rose-400 text-white shadow-sm scale-105"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
}`}
>
{optionLabel ? optionLabel(o) : o}
</button>
))}
</div>
</div>
);
}
function SingleChipRow<T extends string>({
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 (
<div className="mb-4">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
</p>
<div className="flex flex-wrap gap-2">
{options.map(o => (
<button
key={o}
type="button"
onClick={() => onChange(o)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
selected === o
? "bg-rose-400 text-white shadow-sm scale-105"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
}`}
>
{optionLabel ? optionLabel(o) : o}
</button>
))}
</div>
</div>
);
}
export default function AddGarmentPage() {
const { childId } = useFamily();
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<Step>("capture");
const [preview, setPreview] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [imageKey, setImageKey] = useState("");
const [thumbKey, setThumbKey] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
// Form fields
const [name, setName] = useState("");
const [category, setCategory] = useState<string | null>(null);
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
const [colors, setColors] = useState<string[]>([]);
const [colorInput, setColorInput] = useState("");
const [seasons, setSeasons] = useState<string[]>([]);
const [occasionTags, setOccasionTags] = useState<string[]>([]);
const [acquiredVia, setAcquiredVia] = useState<string | null>(null);
const [giftFrom, setGiftFrom] = useState("");
const [visionMetadata, setVisionMetadata] = useState<VisionResult | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError("");
setStep("tagging");
// Show local preview immediately
const reader = new FileReader();
reader.onload = ev => setPreview(ev.target?.result as string);
reader.readAsDataURL(file);
// Upload to R2
const form = new FormData();
form.append("file", file);
let uploadRes;
try {
uploadRes = await fetch("/api/garments/upload", { method: "POST", body: form });
if (!uploadRes.ok) throw new Error((await uploadRes.json()).error);
} catch (err) {
setError(`Upload failed: ${err}`);
setStep("capture");
return;
}
const { imageKey: ik, thumbKey: tk, thumbUrl: tu } = await uploadRes.json();
setImageKey(ik);
setThumbKey(tk);
setThumbUrl(tu);
// Vision tagging
let vision: VisionResult = { name: "", category: "top", colors: [], seasons: [], occasion_tags: [] };
try {
const tagRes = await fetch("/api/garments/tag", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageKey: ik }),
});
if (tagRes.ok) {
const tagData = await tagRes.json();
vision = tagData;
}
} catch {
// Vision failure is non-fatal — proceed with empty defaults
}
setVisionMetadata(vision);
setName(vision.name || "");
setCategory(vision.category || null);
setColors(vision.colors || []);
setSeasons(vision.seasons || []);
setOccasionTags(vision.occasion_tags || []);
setStep("form");
};
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("");
if (fileRef.current) fileRef.current.value = "";
};
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="flex items-center gap-3 p-4">
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<h1 className="text-xl font-bold">Add Garment</h1>
{step === "form" && (
<span className="ml-auto text-xs text-gray-400 bg-white dark:bg-gray-800 px-2 py-1 rounded-full shadow-sm">
Vision pre-filled
</span>
)}
</div>
{error && (
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* Step 1: Capture */}
{step === "capture" && (
<div className="mx-4 mt-4">
<div
onClick={() => fileRef.current?.click()}
className="aspect-square rounded-3xl border-4 border-dashed border-rose-200 dark:border-gray-600 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-rose-400 transition-colors bg-white dark:bg-gray-800"
>
<span className="text-6xl">👚</span>
<div className="text-center px-4">
<p className="font-semibold text-gray-700 dark:text-gray-200">Take or upload a photo</p>
<p className="text-sm text-gray-400 mt-1">Tip: flat-lay on a light surface for best tagging</p>
</div>
<span className="px-4 py-2 bg-rose-400 text-white rounded-full text-sm font-medium">
📷 Add Photo
</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleFileChange}
/>
</div>
)}
{/* Step 2: Tagging spinner */}
{step === "tagging" && (
<div className="mx-4 mt-4 flex flex-col items-center gap-6">
{preview && (
<div className="w-48 h-48 rounded-2xl overflow-hidden shadow-lg">
<img src={preview} alt="preview" className="w-full h-full object-cover" />
</div>
)}
<div className="text-center">
<div className="flex gap-1 justify-center mb-3">
{[0, 150, 300].map(d => (
<span key={d} className="w-2.5 h-2.5 bg-rose-400 rounded-full animate-bounce" style={{ animationDelay: `${d}ms` }} />
))}
</div>
<p className="font-semibold text-gray-700 dark:text-gray-200">Analysing garment</p>
<p className="text-sm text-gray-400">Vision is detecting category & colors</p>
</div>
</div>
)}
{/* Step 3: Form */}
{step === "form" && (
<div className="mx-4 mt-2 space-y-2">
{/* Thumbnail */}
<div className="flex gap-4 items-start mb-4">
{(thumbUrl || preview) && (
<div className="w-24 h-24 rounded-2xl overflow-hidden shadow-md flex-shrink-0">
<img src={thumbUrl || preview!} alt="garment" className="w-full h-full object-cover" />
</div>
)}
<div className="flex-1">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300">Name</label>
<input
value={name}
onChange={e => 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"
/>
</div>
</div>
{/* Category */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<SingleChipRow
label="Category"
options={GARMENT_CATEGORIES}
selected={category as typeof GARMENT_CATEGORIES[number] | null}
onChange={v => setCategory(v)}
required
optionLabel={v => CATEGORY_LABELS[v] || v}
/>
</div>
{/* Size — required, not pre-filled */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<SingleChipRow
label="Size"
options={GARMENT_SIZE_ORDER}
selected={sizeLabel as typeof GARMENT_SIZE_ORDER[number] | null}
onChange={v => setSizeLabel(v)}
required
/>
{!sizeLabel && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Size must be set manually vision cannot guess it
</p>
)}
</div>
{/* Colors */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Colors</p>
<div className="flex flex-wrap gap-2 mb-3">
{colors.map(c => (
<button
key={c}
type="button"
onClick={() => setColors(colors.filter(x => x !== c))}
className="px-3 py-1 rounded-full text-sm bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 flex items-center gap-1"
>
{c} ×
</button>
))}
</div>
<div className="flex gap-2">
<input
value={colorInput}
onChange={e => 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"
/>
<button type="button" onClick={addColor} className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-sm">Add</button>
</div>
</div>
{/* Seasons */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<ChipRow
label="Seasons"
options={SEASON_OPTIONS as readonly string[]}
selected={seasons}
onChange={setSeasons}
/>
</div>
{/* Occasions */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<ChipRow
label="Occasions"
options={OCCASION_OPTIONS as readonly string[]}
selected={occasionTags}
onChange={setOccasionTags}
/>
</div>
{/* Optional fields */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-3">Optional</p>
<div className="mb-3">
<p className="text-xs text-gray-500 mb-1">Acquired via</p>
<div className="flex gap-2">
{ACQUIRED_OPTIONS.map(o => (
<button
key={o}
type="button"
onClick={() => setAcquiredVia(acquiredVia === o ? null : o)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
acquiredVia === o
? "bg-indigo-400 text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
}`}
>
{o}
</button>
))}
</div>
</div>
{acquiredVia === "gift" && (
<div>
<p className="text-xs text-gray-500 mb-1">Gift from</p>
<input
value={giftFrom}
onChange={e => 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"
/>
</div>
)}
</div>
{/* Action buttons */}
<div className="pt-2 space-y-3">
<Button
fullWidth
onClick={handleSave}
loading={saving}
disabled={!category || !sizeLabel || !childId}
size="lg"
>
Save Garment
</Button>
{!sizeLabel && (
<p className="text-center text-sm text-amber-600 dark:text-amber-400">
Please select a size to save
</p>
)}
<Button fullWidth variant="secondary" onClick={handleAddAnother} size="lg">
+ Add Another
</Button>
</div>
</div>
)}
</div>
);
}