feat(wardrobe): add complete wardrobe feature (W0–W9)

Schema (W0):
- Add garments, garment_wears, outfits tables with Drizzle migrations
- Drizzle migrations 0001 (garments/wears) and 0002 (outfits) auto-apply on deploy
- RLS policies in drizzle/manual/06-wardrobe-rls.sql (apply via superuser in prod)

API (W1–W9):
- POST /api/garments/upload — direct upload to R2 garments/ prefix with sharp thumbnail
- POST /api/garments/tag — vision tagging via LiteLLM, defensive parse, category validated
- GET/POST /api/garments — list with composable filters, create
- GET/PATCH/DELETE /api/garments/[id] — detail, edit, delete
- POST /api/garments/[id]/wear — log worn date
- GET /api/garments/outgrowth — pure SQL, explicit size ordering (no lexicographic sort)
- GET /api/garments/packing — active garments grouped by category
- GET /api/garments/outfit — Open-Meteo weather + deterministic outfit pairing, no LLM
- GET/POST /api/garments/outfits + DELETE [id] — saved outfits

Pages:
- /wardrobe — grid with status/category/size/season filters + outgrowth nudge
- /wardrobe/add — 3-step capture→vision→form, size required, batch-friendly
- /wardrobe/[id] — detail/edit/status lifecycle + wear history
- /wardrobe/packing — packing checklist by category
- /wardrobe/outfit — weather-aware suggestions with shown basis
- /wardrobe/saved-outfits — view/delete saved combinations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-23 18:09:22 +05:30
parent c4304615ec
commit 1994725101
28 changed files with 9375 additions and 0 deletions

View file

@ -0,0 +1,39 @@
CREATE TABLE "garment_wears" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"garment_id" uuid NOT NULL,
"worn_on" date NOT NULL,
"memory_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "garments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"child_id" uuid NOT NULL,
"name" text,
"category" text NOT NULL,
"size_label" text NOT NULL,
"colors" text[] DEFAULT '{}',
"seasons" text[] DEFAULT '{}',
"occasion_tags" text[] DEFAULT '{}',
"image_key" text NOT NULL,
"thumb_key" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"acquired_via" text,
"gift_from" text,
"vision_metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_garment_id_garments_id_fk" FOREIGN KEY ("garment_id") REFERENCES "public"."garments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garment_wears" ADD CONSTRAINT "garment_wears_memory_id_memories_id_fk" FOREIGN KEY ("memory_id") REFERENCES "public"."memories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garments" ADD CONSTRAINT "garments_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "garments" ADD CONSTRAINT "garments_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "garment_wears_garment_idx" ON "garment_wears" USING btree ("garment_id");--> statement-breakpoint
CREATE INDEX "garment_wears_family_idx" ON "garment_wears" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "garments_family_idx" ON "garments" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "garments_child_idx" ON "garments" USING btree ("child_id");--> statement-breakpoint
CREATE INDEX "garments_status_idx" ON "garments" USING btree ("status");

View file

@ -0,0 +1,14 @@
CREATE TABLE "outfits" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"family_id" uuid NOT NULL,
"child_id" uuid NOT NULL,
"name" text NOT NULL,
"garment_ids" uuid[] DEFAULT '{}' NOT NULL,
"occasion_tags" text[] DEFAULT '{}',
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "outfits" ADD CONSTRAINT "outfits_family_id_families_id_fk" FOREIGN KEY ("family_id") REFERENCES "public"."families"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "outfits" ADD CONSTRAINT "outfits_child_id_children_id_fk" FOREIGN KEY ("child_id") REFERENCES "public"."children"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "outfits_family_idx" ON "outfits" USING btree ("family_id");--> statement-breakpoint
CREATE INDEX "outfits_child_idx" ON "outfits" USING btree ("child_id");

View file

@ -0,0 +1,21 @@
-- RLS for garments and garment_wears
-- Run AFTER 0001_wardrobe_tables.sql has been applied
-- Apply as superuser: psql $DATABASE_URL_SUPERUSER -f drizzle/manual/06-wardrobe-rls.sql
ALTER TABLE garments ENABLE ROW LEVEL SECURITY;
ALTER TABLE garment_wears ENABLE ROW LEVEL SECURITY;
-- Both tables carry family_id directly, so we use the same direct comparison
-- pattern as family_invites rather than the child_id subquery pattern.
-- FOR ALL with USING also enforces WITH CHECK on INSERT (prevents cross-family writes).
CREATE POLICY family_isolation ON garments
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
CREATE POLICY family_isolation ON garment_wears
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);
-- W9: Saved outfits
ALTER TABLE outfits ENABLE ROW LEVEL SECURITY;
CREATE POLICY family_isolation ON outfits
FOR ALL USING (family_id = current_setting('app.current_family_id', true)::uuid);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,20 @@
"when": 1779518962214,
"tag": "0000_baseline_prod_2026_05_19",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779538936553,
"tag": "0001_wardrobe_tables",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779539431897,
"tag": "0002_outfits_table",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
function toDto(g: Record<string, unknown>, baseUrl: string) {
return {
id: g.id,
familyId: g.family_id,
childId: g.child_id,
name: g.name,
category: g.category,
sizeLabel: g.size_label,
colors: g.colors || [],
seasons: g.seasons || [],
occasionTags: g.occasion_tags || [],
imageKey: g.image_key,
thumbKey: g.thumb_key,
imageUrl: `${baseUrl}/${g.image_key}`,
thumbUrl: `${baseUrl}/${g.thumb_key}`,
status: g.status,
acquiredVia: g.acquired_via,
giftFrom: g.gift_from,
visionMetadata: g.vision_metadata,
createdAt: g.created_at,
updatedAt: g.updated_at,
};
}
function getBaseUrl() {
return process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
}
// GET /api/garments/[id]
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
const rows = await sql`SELECT * FROM garments WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`;
if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
const wears = await sql`
SELECT id, worn_on, memory_id, created_at FROM garment_wears
WHERE garment_id = ${id} AND family_id = ${familyId}
ORDER BY worn_on DESC`;
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()), wears });
}
// PATCH /api/garments/[id]
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
const existing = await sql`SELECT id FROM garments WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`;
if (!existing[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const VALID_STATUSES = ["active", "stored", "outgrown", "donated"];
if (body.status && !VALID_STATUSES.includes(body.status as string)) {
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
}
if (body.category && !GARMENT_CATEGORIES.includes(body.category as never)) {
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
}
const rows = await sql`
UPDATE garments SET
name = COALESCE(${body.name as string | null ?? null}, name),
category = COALESCE(${body.category as string | null ?? null}, category),
size_label = COALESCE(${body.sizeLabel as string | null ?? null}, size_label),
colors = COALESCE(${body.colors as string[] | null ?? null}, colors),
seasons = COALESCE(${body.seasons as string[] | null ?? null}, seasons),
occasion_tags = COALESCE(${body.occasionTags as string[] | null ?? null}, occasion_tags),
status = COALESCE(${body.status as string | null ?? null}, status),
acquired_via = COALESCE(${body.acquiredVia as string | null ?? null}, acquired_via),
gift_from = COALESCE(${body.giftFrom as string | null ?? null}, gift_from),
vision_metadata = COALESCE(${body.visionMetadata ? JSON.stringify(body.visionMetadata) : null}, vision_metadata),
updated_at = now()
WHERE id = ${id} AND family_id = ${familyId}
RETURNING *`;
return NextResponse.json({ success: true, item: toDto(rows[0], getBaseUrl()) });
}
// DELETE /api/garments/[id]
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
const rows = await sql`DELETE FROM garments WHERE id = ${id} AND family_id = ${familyId} RETURNING id`;
if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
// POST /api/garments/[id]/wear
// Logs that a garment was worn today (or a specific date)
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
// Confirm garment belongs to family
const garment = await sql`SELECT id FROM garments WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`;
if (!garment[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
let wornOn: string = new Date().toISOString().slice(0, 10); // default today
let memoryId: string | null = null;
try {
const body = await req.json();
if (body.wornOn) wornOn = body.wornOn;
if (body.memoryId) memoryId = body.memoryId;
} catch {
// body is optional
}
const rows = await sql`
INSERT INTO garment_wears (family_id, garment_id, worn_on, memory_id)
VALUES (${familyId}, ${id}, ${wornOn}, ${memoryId})
RETURNING *`;
return NextResponse.json({ success: true, wear: rows[0] }, { status: 201 });
}

View file

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { getGurgaonWeather } from "@/lib/wardrobe/weather";
import { buildOutfits, type GarmentRow } from "@/lib/wardrobe/outfit";
// GET /api/garments/outfit?childId=&occasion=
// Returns deterministic outfit suggestions based on today's weather.
// No LLM — pure filter + rule logic.
export async function GET(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
const occasion = searchParams.get("occasion");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
const weather = await getGurgaonWeather();
let rows;
if (occasion) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active'
AND ${weather.season} = ANY(seasons)
AND ${occasion} = ANY(occasion_tags)
AND category IN ('top','bottom','dress','onesie')
ORDER BY RANDOM()`;
} else {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active'
AND ${weather.season} = ANY(seasons)
AND category IN ('top','bottom','dress','onesie')
ORDER BY RANDOM()`;
}
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
const garments: GarmentRow[] = rows.map(g => ({
id: g.id as string,
name: g.name as string | null,
category: g.category as string,
sizeLabel: g.size_label as string,
colors: (g.colors as string[]) || [],
seasons: (g.seasons as string[]) || [],
occasionTags: (g.occasion_tags as string[]) || [],
thumbKey: g.thumb_key as string,
imageKey: g.image_key as string,
}));
const outfits = buildOutfits(garments, 3);
const outfitsWithUrls = outfits.map(o => ({
...o,
items: o.items.map(item => ({
...item,
thumbUrl: `${baseUrl}/${item.thumbKey}`,
imageUrl: `${baseUrl}/${item.imageKey}`,
})),
}));
return NextResponse.json({
success: true,
weather,
weatherBasis: `${weather.description}${Math.round(weather.tempC)}°C today in Gurugram`,
outfits: outfitsWithUrls,
});
}

View file

@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
// DELETE /api/garments/outfits/[id]
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
const rows = await sql`DELETE FROM outfits WHERE id = ${id} AND family_id = ${familyId} RETURNING id`;
if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
// GET /api/garments/outfits?childId=
export async function GET(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
const rows = await sql`
SELECT * FROM outfits
WHERE family_id = ${familyId} AND child_id = ${childId}
ORDER BY created_at DESC`;
return NextResponse.json({ success: true, items: rows });
}
// POST /api/garments/outfits
export async function POST(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let body: { childId?: string; name?: string; garmentIds?: string[]; occasionTags?: string[] };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { childId, name, garmentIds = [], occasionTags = [] } = body;
if (!childId || !name) return NextResponse.json({ error: "childId and name required" }, { status: 400 });
// Verify child belongs to family
const childCheck = await sql`SELECT id FROM children WHERE id = ${childId} AND family_id = ${familyId} LIMIT 1`;
if (!childCheck[0]) return NextResponse.json({ error: "Child not found" }, { status: 404 });
const rows = await sql`
INSERT INTO outfits (family_id, child_id, name, garment_ids, occasion_tags)
VALUES (${familyId}, ${childId}, ${name}, ${garmentIds}, ${occasionTags})
RETURNING *`;
return NextResponse.json({ success: true, item: rows[0] }, { status: 201 });
}

View file

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
// GET /api/garments/outgrowth?childId=
// Returns active garments that are likely outgrown:
// - size_label sorts BELOW the most recently-added active garment's size_label
// - AND no garment_wears in the last 45 days
//
// Size ordering uses an explicit ordered list to avoid lexicographic errors
// (e.g. '9-12m' < '12-18m' by size but '9' > '1' lexicographically).
export async function GET(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
// Fetch all active garments for this child
const active = await sql`
SELECT g.id, g.name, g.category, g.size_label, g.thumb_key, g.created_at,
MAX(gw.worn_on) AS last_worn
FROM garments g
LEFT JOIN garment_wears gw ON gw.garment_id = g.id
WHERE g.family_id = ${familyId} AND g.child_id = ${childId} AND g.status = 'active'
GROUP BY g.id
ORDER BY g.created_at DESC`;
if (active.length === 0) {
return NextResponse.json({ success: true, candidates: [], currentSizeLabel: null });
}
// Find the most recently-added active garment's size_label
const currentSizeLabel = active[0].size_label as string;
const currentSizeIdx = GARMENT_SIZE_ORDER.indexOf(currentSizeLabel as never);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 45);
const candidates = active.filter(g => {
const sizeIdx = GARMENT_SIZE_ORDER.indexOf(g.size_label as never);
const isSmaller = sizeIdx >= 0 && currentSizeIdx >= 0 && sizeIdx < currentSizeIdx;
const lastWorn = g.last_worn ? new Date(g.last_worn as string) : null;
const notWornRecently = !lastWorn || lastWorn < cutoff;
return isSmaller && notWornRecently;
});
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
return NextResponse.json({
success: true,
currentSizeLabel,
candidates: candidates.map(g => ({
id: g.id,
name: g.name,
category: g.category,
sizeLabel: g.size_label,
thumbUrl: `${baseUrl}/${g.thumb_key}`,
lastWorn: g.last_worn,
})),
});
}

View file

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
// GET /api/garments/packing?childId=&season=&occasion=&days=3
// Returns active garments matching size + season + occasion, grouped by category.
// Pure SQL — no AI.
export async function GET(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
const season = searchParams.get("season");
const occasion = searchParams.get("occasion");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
let rows;
if (season && occasion) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active'
AND ${season} = ANY(seasons) AND ${occasion} = ANY(occasion_tags)
ORDER BY category, name`;
} else if (season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active'
AND ${season} = ANY(seasons)
ORDER BY category, name`;
} else if (occasion) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active'
AND ${occasion} = ANY(occasion_tags)
ORDER BY category, name`;
} else {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = 'active'
ORDER BY category, name`;
}
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
// Group by category
const grouped: Record<string, { id: string; name: string; sizeLabel: string; thumbUrl: string; checked: boolean }[]> = {};
for (const g of rows) {
const cat = g.category as string;
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({
id: g.id as string,
name: (g.name as string) || cat,
sizeLabel: g.size_label as string,
thumbUrl: `${baseUrl}/${g.thumb_key}`,
checked: false,
});
}
return NextResponse.json({ success: true, groups: grouped, total: rows.length });
}

View file

@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { sql } from "@/db";
import { GARMENT_CATEGORIES } from "@/db/schema/wardrobe";
// GET /api/garments?childId=&status=active&category=&sizeLabel=&season=
export async function GET(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
const status = searchParams.get("status") || "active";
const category = searchParams.get("category");
const sizeLabel = searchParams.get("sizeLabel");
const season = searchParams.get("season");
if (!childId) return NextResponse.json({ error: "childId required" }, { status: 400 });
// Build filter conditions using raw SQL for array contains
let rows;
if (category && sizeLabel && season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category} AND size_label = ${sizeLabel}
AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else if (category && sizeLabel) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category} AND size_label = ${sizeLabel}
ORDER BY created_at DESC`;
} else if (category && season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category} AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else if (sizeLabel && season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND size_label = ${sizeLabel} AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else if (category) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND category = ${category}
ORDER BY created_at DESC`;
} else if (sizeLabel) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND size_label = ${sizeLabel}
ORDER BY created_at DESC`;
} else if (season) {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
AND ${season} = ANY(seasons)
ORDER BY created_at DESC`;
} else {
rows = await sql`
SELECT * FROM garments
WHERE family_id = ${familyId} AND child_id = ${childId} AND status = ${status}
ORDER BY created_at DESC`;
}
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
return NextResponse.json({
success: true,
items: rows.map(g => toDto(g, baseUrl)),
});
}
// POST /api/garments
export async function POST(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let body: {
childId?: string;
name?: string;
category?: string;
sizeLabel?: string;
colors?: string[];
seasons?: string[];
occasionTags?: string[];
imageKey?: string;
thumbKey?: string;
status?: string;
acquiredVia?: string;
giftFrom?: string;
visionMetadata?: unknown;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { childId, name, category, sizeLabel, colors = [], seasons = [],
occasionTags = [], imageKey, thumbKey, acquiredVia, giftFrom, visionMetadata } = body;
if (!childId || !category || !sizeLabel || !imageKey || !thumbKey) {
return NextResponse.json({ error: "childId, category, sizeLabel, imageKey, thumbKey required" }, { status: 400 });
}
if (!GARMENT_CATEGORIES.includes(category as never)) {
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
}
// Verify child belongs to family
const childCheck = await sql`SELECT id FROM children WHERE id = ${childId} AND family_id = ${familyId} LIMIT 1`;
if (!childCheck[0]) return NextResponse.json({ error: "Child not found" }, { status: 404 });
const rows = await sql`
INSERT INTO garments
(family_id, child_id, name, category, size_label, colors, seasons,
occasion_tags, image_key, thumb_key, status, acquired_via, gift_from, vision_metadata)
VALUES
(${familyId}, ${childId}, ${name || null}, ${category}, ${sizeLabel},
${colors}, ${seasons}, ${occasionTags}, ${imageKey}, ${thumbKey},
'active', ${acquiredVia || null}, ${giftFrom || null},
${visionMetadata ? JSON.stringify(visionMetadata) : null})
RETURNING *`;
const baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev`;
return NextResponse.json({ success: true, item: toDto(rows[0], baseUrl) }, { status: 201 });
}
function toDto(g: Record<string, unknown>, baseUrl: string) {
return {
id: g.id,
familyId: g.family_id,
childId: g.child_id,
name: g.name,
category: g.category,
sizeLabel: g.size_label,
colors: g.colors || [],
seasons: g.seasons || [],
occasionTags: g.occasion_tags || [],
imageKey: g.image_key,
thumbKey: g.thumb_key,
imageUrl: `${baseUrl}/${g.image_key}`,
thumbUrl: `${baseUrl}/${g.thumb_key}`,
status: g.status,
acquiredVia: g.acquired_via,
giftFrom: g.gift_from,
createdAt: g.created_at,
updatedAt: g.updated_at,
};
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import { tagGarment } from "@/lib/wardrobe/vision";
// POST /api/garments/tag
// Body: { imageKey: string }
// Returns vision-tagged garment metadata
export async function POST(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let body: { imageKey?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { imageKey } = body;
if (!imageKey) return NextResponse.json({ error: "imageKey required" }, { status: 400 });
// Verify the key belongs to this family (key contains family_id by construction)
if (!imageKey.startsWith(`garments/${familyId}/`)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const result = await tagGarment(imageKey);
return NextResponse.json({ success: true, ...result });
}

View file

@ -0,0 +1,99 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { NextRequest, NextResponse } from "next/server";
import { requireFamily } from "@/lib/auth";
import sharp from "sharp";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
const MAX_BYTES = 8 * 1024 * 1024; // 8MB
function getR2Config() {
return {
accountId: process.env.R2_ACCOUNT_ID!,
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretKey: process.env.R2_SECRET_ACCESS_KEY!,
bucket: process.env.R2_BUCKET_NAME!,
publicUrl: process.env.R2_PUBLIC_URL,
};
}
function makeClient(R2: ReturnType<typeof getR2Config>) {
return new S3Client({
region: "auto",
endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2.accessKeyId, secretAccessKey: R2.secretKey },
});
}
// POST /api/garments/upload
// Accepts multipart/form-data with a single "file" field.
// Returns { imageKey, thumbKey, imageUrl, thumbUrl }
export async function POST(req: NextRequest) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
// family_id comes from session — never from request params
const familyId = auth.session!.familyId!;
const R2 = getR2Config();
if (!R2.accountId || !R2.accessKeyId || !R2.secretKey || !R2.bucket) {
return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "File too large (max 8MB)" }, { status: 400 });
}
const ext = file.name.split(".").pop()?.toLowerCase() || "jpg";
const id = randomUUID();
const imageKey = `garments/${familyId}/${id}-original.${ext}`;
const thumbKey = `garments/${familyId}/${id}-thumb.webp`;
const arrayBuffer = await file.arrayBuffer();
const originalBuffer = Buffer.from(arrayBuffer);
const thumbBuffer = await sharp(originalBuffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 70 })
.toBuffer();
const client = makeClient(R2);
await Promise.all([
client.send(new PutObjectCommand({
Bucket: R2.bucket,
Key: imageKey,
Body: originalBuffer,
ContentType: file.type,
})),
client.send(new PutObjectCommand({
Bucket: R2.bucket,
Key: thumbKey,
Body: thumbBuffer,
ContentType: "image/webp",
})),
]);
const baseUrl = R2.publicUrl || `https://pub-${R2.accountId}.r2.dev`;
return NextResponse.json({
imageKey,
thumbKey,
imageUrl: `${baseUrl}/${imageKey}`,
thumbUrl: `${baseUrl}/${thumbKey}`,
});
}

View file

@ -16,6 +16,7 @@ export default function MenuPage() {
{ icon: "📸", label: "Memories", href: "/memories" },
{ icon: "🤖", label: "AI Chat", href: "/ai" },
{ icon: "🌟", label: "Milestones", href: "/milestones" },
{ icon: "👗", label: "Wardrobe", href: "/wardrobe" },
];
const handleSignOut = async () => {

View file

@ -0,0 +1,411 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
interface Garment {
id: string;
name: string | null;
category: string;
sizeLabel: string;
colors: string[];
seasons: string[];
occasionTags: string[];
imageKey: string;
thumbKey: string;
imageUrl: string;
thumbUrl: string;
status: string;
acquiredVia: string | null;
giftFrom: string | null;
visionMetadata: unknown;
createdAt: string;
updatedAt: string;
}
interface Wear {
id: string;
worn_on: string;
memory_id: string | null;
}
const STATUS_FLOW = ["active", "stored", "outgrown", "donated"] as const;
const STATUS_COLORS: Record<string, string> = {
active: "bg-green-100 text-green-700",
stored: "bg-blue-100 text-blue-700",
outgrown: "bg-amber-100 text-amber-700",
donated: "bg-gray-100 text-gray-600",
};
const CATEGORY_LABELS: Record<string, string> = {
onesie: "Onesie", top: "Top", bottom: "Bottom", dress: "Dress",
outerwear: "Jacket", sleepwear: "Sleepwear", accessory: "Accessory",
};
function Chip({ label, color }: { label: string; color?: string }) {
return (
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${color || "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"}`}>
{label}
</span>
);
}
export default function GarmentDetailPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [garment, setGarment] = useState<Garment | null>(null);
const [wears, setWears] = useState<Wear[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [loggingWear, setLoggingWear] = useState(false);
const [error, setError] = useState("");
// Edit state
const [editName, setEditName] = useState("");
const [editCategory, setEditCategory] = useState("");
const [editSize, setEditSize] = useState("");
const [editStatus, setEditStatus] = useState("");
const [editColors, setEditColors] = useState<string[]>([]);
const [editSeasons, setEditSeasons] = useState<string[]>([]);
const [editOccasions, setEditOccasions] = useState<string[]>([]);
const [editColorInput, setEditColorInput] = useState("");
const [editAcquiredVia, setEditAcquiredVia] = useState<string | null>(null);
const [editGiftFrom, setEditGiftFrom] = useState("");
const load = async () => {
setLoading(true);
try {
const res = await fetch(`/api/garments/${id}`);
if (!res.ok) { router.replace("/wardrobe"); return; }
const data = await res.json();
setGarment(data.item);
setWears(data.wears || []);
} catch {
router.replace("/wardrobe");
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [id]);
const startEdit = () => {
if (!garment) return;
setEditName(garment.name || "");
setEditCategory(garment.category);
setEditSize(garment.sizeLabel);
setEditStatus(garment.status);
setEditColors([...garment.colors]);
setEditSeasons([...garment.seasons]);
setEditOccasions([...garment.occasionTags]);
setEditAcquiredVia(garment.acquiredVia);
setEditGiftFrom(garment.giftFrom || "");
setEditing(true);
};
const saveEdit = async () => {
setSaving(true);
setError("");
try {
const res = await fetch(`/api/garments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: editName || null,
category: editCategory,
sizeLabel: editSize,
status: editStatus,
colors: editColors,
seasons: editSeasons,
occasionTags: editOccasions,
acquiredVia: editAcquiredVia,
giftFrom: editGiftFrom || null,
}),
});
if (!res.ok) throw new Error((await res.json()).error);
const data = await res.json();
setGarment(data.item);
setEditing(false);
} catch (err) {
setError(`Save failed: ${err}`);
} finally {
setSaving(false);
}
};
const logWear = async () => {
setLoggingWear(true);
try {
const res = await fetch(`/api/garments/${id}/wear`, { method: "POST" });
if (res.ok) {
const data = await res.json();
setWears(prev => [data.wear, ...prev]);
}
} catch {}
setLoggingWear(false);
};
const updateStatus = async (newStatus: string) => {
try {
const res = await fetch(`/api/garments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
const data = await res.json();
setGarment(data.item);
}
} catch {}
};
const toggleArray = (arr: string[], val: string) =>
arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val];
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="flex gap-1">
{[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>
</div>
);
}
if (!garment) return null;
return (
<div className="min-h-screen bg-gradient-to-br from-pink-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-lg font-bold truncate">{garment.name || CATEGORY_LABELS[garment.category] || garment.category}</h1>
<button
onClick={editing ? () => setEditing(false) : startEdit}
className="ml-auto text-sm px-3 py-1.5 bg-white dark:bg-gray-800 rounded-xl shadow-sm"
>
{editing ? "Cancel" : "Edit"}
</button>
</div>
{error && (
<div className="mx-4 mb-3 p-3 bg-red-50 rounded-xl text-sm text-red-700">{error}</div>
)}
{!editing ? (
/* ─── View mode ─── */
<div className="mx-4 space-y-3">
{/* Full-res image */}
<div className="rounded-3xl overflow-hidden shadow-lg">
<img src={garment.imageUrl} alt={garment.name || garment.category} className="w-full object-contain max-h-80 bg-white dark:bg-gray-800" />
</div>
{/* Status + quick actions */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${STATUS_COLORS[garment.status] || "bg-gray-100"}`}>
{garment.status}
</span>
<Button size="sm" loading={loggingWear} onClick={logWear} variant="secondary">
👕 Worn today
</Button>
</div>
<div className="flex gap-2 flex-wrap">
{STATUS_FLOW.filter(s => s !== garment.status).map(s => (
<button
key={s}
onClick={() => updateStatus(s)}
className="text-xs px-2.5 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600"
>
Move to {s}
</button>
))}
</div>
</div>
{/* Details */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-gray-400">Category</p>
<p className="font-medium text-sm">{CATEGORY_LABELS[garment.category] || garment.category}</p>
</div>
<div>
<p className="text-xs text-gray-400">Size</p>
<p className="font-medium text-sm">{garment.sizeLabel}</p>
</div>
{garment.acquiredVia && (
<div>
<p className="text-xs text-gray-400">Acquired via</p>
<p className="font-medium text-sm">{garment.acquiredVia}</p>
</div>
)}
{garment.giftFrom && (
<div>
<p className="text-xs text-gray-400">Gift from</p>
<p className="font-medium text-sm">💝 {garment.giftFrom}</p>
</div>
)}
</div>
{garment.colors.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Colors</p>
<div className="flex flex-wrap gap-1.5">
{garment.colors.map(c => <Chip key={c} label={c} color="bg-pink-50 text-pink-700 dark:bg-pink-900/20 dark:text-pink-300" />)}
</div>
</div>
)}
{garment.seasons.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Seasons</p>
<div className="flex flex-wrap gap-1.5">
{garment.seasons.map(s => <Chip key={s} label={s} color="bg-orange-50 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300" />)}
</div>
</div>
)}
{garment.occasionTags.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1.5">Occasions</p>
<div className="flex flex-wrap gap-1.5">
{garment.occasionTags.map(t => <Chip key={t} label={t} color="bg-purple-50 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300" />)}
</div>
</div>
)}
</div>
{/* Wear history */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="font-semibold text-sm mb-3">
Wear history
<span className="ml-2 text-xs font-normal text-gray-400">({wears.length} times)</span>
</p>
{wears.length === 0 ? (
<p className="text-sm text-gray-400">Not worn yet tap "Worn today" to start tracking</p>
) : (
<div className="space-y-1 max-h-40 overflow-y-auto">
{wears.map(w => (
<div key={w.id} className="flex items-center text-sm text-gray-600 dark:text-gray-300 gap-2">
<span className="text-gray-300">📅</span>
{new Date(w.worn_on).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
</div>
))}
</div>
)}
</div>
</div>
) : (
/* ─── Edit mode ─── */
<div className="mx-4 space-y-3">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-500">Name</label>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className="mt-1 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>
{/* Category chips */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Category</p>
<div className="flex flex-wrap gap-2">
{GARMENT_CATEGORIES.map(c => (
<button key={c} type="button" onClick={() => setEditCategory(c)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editCategory === c ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{CATEGORY_LABELS[c] || c}
</button>
))}
</div>
</div>
{/* Size chips */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Size</p>
<div className="flex flex-wrap gap-2">
{GARMENT_SIZE_ORDER.map(s => (
<button key={s} type="button" onClick={() => setEditSize(s)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editSize === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Status</p>
<div className="flex flex-wrap gap-2">
{STATUS_FLOW.map(s => (
<button key={s} type="button" onClick={() => setEditStatus(s)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editStatus === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
{/* Colors */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Colors</p>
<div className="flex flex-wrap gap-2 mb-2">
{editColors.map(c => (
<button key={c} type="button" onClick={() => setEditColors(editColors.filter(x => x !== c))}
className="px-2.5 py-1 rounded-full text-xs bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300"
>
{c} ×
</button>
))}
</div>
<div className="flex gap-2">
<input value={editColorInput} onChange={e => setEditColorInput(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}}
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={() => { const c = editColorInput.trim().toLowerCase(); if (c && !editColors.includes(c)) setEditColors([...editColors, c]); setEditColorInput(""); }}
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">
<p className="text-xs text-gray-500 mb-2">Seasons</p>
<div className="flex gap-2">
{["summer", "monsoon", "winter"].map(s => (
<button key={s} type="button" onClick={() => setEditSeasons(toggleArray(editSeasons, s))}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editSeasons.includes(s) ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
{/* Occasions */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
<p className="text-xs text-gray-500 mb-2">Occasions</p>
<div className="flex flex-wrap gap-2">
{["everyday", "daycare", "festive", "photoshoot"].map(o => (
<button key={o} type="button" onClick={() => setEditOccasions(toggleArray(editOccasions, o))}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${editOccasions.includes(o) ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{o}
</button>
))}
</div>
</div>
<Button fullWidth onClick={saveEdit} loading={saving} size="lg">Save Changes</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,467 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { useFamily } from "../../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>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../../FamilyProvider";
import { Button } from "@/components/ui";
interface OutfitItem {
id: string;
name: string | null;
category: string;
sizeLabel: string;
thumbUrl: string;
imageUrl: string;
colors: string[];
}
interface OutfitSuggestion {
label: string;
items: OutfitItem[];
}
interface OutfitResponse {
weatherBasis: string;
weather: { tempC: number; season: string };
outfits: OutfitSuggestion[];
}
const OCCASION_OPTS = ["", "everyday", "daycare", "festive", "photoshoot"] as const;
const SEASON_ICON: Record<string, string> = {
summer: "☀️", monsoon: "🌧️", winter: "❄️",
};
export default function OutfitSuggestionPage() {
const { childId } = useFamily();
const router = useRouter();
const [occasion, setOccasion] = useState("");
const [result, setResult] = useState<OutfitResponse | null>(null);
const [loading, setLoading] = useState(false);
const [savedOutfitName, setSavedOutfitName] = useState<Record<number, string>>({});
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set());
const fetchSuggestions = async () => {
if (!childId) return;
setLoading(true);
try {
const params = new URLSearchParams({ childId });
if (occasion) params.set("occasion", occasion);
const res = await fetch(`/api/garments/outfit?${params}`);
const data = await res.json();
setResult(data);
} catch {}
setLoading(false);
};
useEffect(() => { fetchSuggestions(); }, [childId]);
const saveOutfit = async (idx: number, outfit: OutfitSuggestion) => {
const name = savedOutfitName[idx]?.trim() || outfit.label;
if (!childId) return;
try {
const res = await fetch("/api/garments/outfits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId,
name,
garmentIds: outfit.items.map(i => i.id),
occasionTags: occasion ? [occasion] : [],
}),
});
if (res.ok) setSavedIdx(prev => new Set([...prev, idx]));
} catch {}
};
return (
<div className="min-h-screen bg-gradient-to-br from-yellow-50 via-orange-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<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"> Today&apos;s Outfit</h1>
<button
onClick={fetchSuggestions}
className="ml-auto p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg"
title="Refresh"
>
🔄
</button>
</div>
{/* Weather basis */}
{result && (
<div className="mx-4 mb-4 p-3 bg-white dark:bg-gray-800 rounded-2xl shadow-sm flex items-center gap-3">
<span className="text-2xl">{SEASON_ICON[result.weather.season] || "🌤️"}</span>
<div>
<p className="text-sm font-semibold text-gray-700 dark:text-gray-200">{result.weatherBasis}</p>
<p className="text-xs text-gray-400">Suggestions filtered for {result.weather.season} garments</p>
</div>
</div>
)}
{/* Occasion filter */}
<div className="px-4 mb-4 flex gap-2 overflow-x-auto no-scrollbar">
{OCCASION_OPTS.map(o => (
<button
key={o || "all"}
onClick={() => { setOccasion(o); }}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap flex-shrink-0 transition-all ${
occasion === o ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300"
}`}
>
{o || "Any occasion"}
</button>
))}
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="flex gap-1">
{[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="text-sm text-gray-500">Fetching weather + building outfits</p>
</div>
) : result && result.outfits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-8 text-center">
<span className="text-5xl mb-4">🌂</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">No outfits for today&apos;s weather</p>
<p className="text-sm text-gray-400 mt-1">
Add more {result.weather.season} garments to your wardrobe
</p>
<button
onClick={() => router.push("/wardrobe/add")}
className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>
+ Add Garment
</button>
</div>
) : result ? (
<div className="mx-4 space-y-4">
{result.outfits.map((outfit, idx) => (
<div key={idx} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<div className="px-4 pt-3 pb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
Outfit {idx + 1} {outfit.label}
</span>
{outfit.items[0]?.colors?.length > 0 && (
<span className="text-xs text-gray-400">{outfit.items.flatMap(i => i.colors).slice(0, 3).join(", ")}</span>
)}
</div>
<div className={`flex gap-3 px-4 pb-3 ${outfit.items.length === 1 ? "justify-center" : ""}`}>
{outfit.items.map(item => (
<div key={item.id} className="flex flex-col items-center gap-1.5">
<div className="w-28 h-28 rounded-xl overflow-hidden bg-gray-50">
<img src={item.thumbUrl} alt={item.name || item.category} className="w-full h-full object-cover" />
</div>
<p className="text-xs text-center text-gray-600 dark:text-gray-400 max-w-24 truncate">
{item.name || item.category}
</p>
<span className="text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 px-1.5 py-0.5 rounded-full">
{item.sizeLabel}
</span>
</div>
))}
</div>
{/* Save outfit */}
{savedIdx.has(idx) ? (
<div className="px-4 pb-3 text-sm text-green-600 dark:text-green-400"> Saved to wardrobe</div>
) : (
<div className="px-4 pb-3 flex gap-2">
<input
value={savedOutfitName[idx] ?? ""}
onChange={e => setSavedOutfitName(prev => ({ ...prev, [idx]: e.target.value }))}
placeholder={outfit.label}
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
onClick={() => saveOutfit(idx, outfit)}
className="px-3 py-1.5 bg-indigo-400 text-white rounded-xl text-sm font-medium"
>
💾 Save
</button>
</div>
)}
</div>
))}
<Button fullWidth variant="secondary" onClick={fetchSuggestions} size="lg">
🔄 Suggest again
</Button>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../../FamilyProvider";
import { Button } from "@/components/ui";
interface PackingItem {
id: string;
name: string;
sizeLabel: string;
thumbUrl: string;
checked: boolean;
}
type PackingGroups = Record<string, PackingItem[]>;
const SEASON_OPTS = ["summer", "monsoon", "winter"] as const;
const OCCASION_OPTS = ["everyday", "daycare", "festive", "photoshoot"] as const;
const CATEGORY_EMOJI: Record<string, string> = {
onesie: "👶", top: "👕", bottom: "👖", dress: "👗",
outerwear: "🧥", sleepwear: "😴", accessory: "🎀",
};
export default function PackingListPage() {
const { childId } = useFamily();
const router = useRouter();
const [season, setSeason] = useState<string>("");
const [occasion, setOccasion] = useState<string>("");
const [groups, setGroups] = useState<PackingGroups | null>(null);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const generate = async () => {
if (!childId) return;
setLoading(true);
try {
const params = new URLSearchParams({ childId });
if (season) params.set("season", season);
if (occasion) params.set("occasion", occasion);
const res = await fetch(`/api/garments/packing?${params}`);
const data = await res.json();
setGroups(data.groups || {});
setTotal(data.total || 0);
} catch {}
setLoading(false);
};
const toggleItem = (cat: string, itemId: string) => {
if (!groups) return;
setGroups(prev => ({
...prev!,
[cat]: prev![cat].map(i => i.id === itemId ? { ...i, checked: !i.checked } : i),
}));
};
const checkedCount = groups
? Object.values(groups).flat().filter(i => i.checked).length
: 0;
return (
<div className="min-h-screen bg-gradient-to-br from-sky-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<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">🧳 Packing List</h1>
</div>
{/* Filters */}
<div className="mx-4 bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-4 space-y-4">
<div>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Season</p>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setSeason("")}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${!season ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
Any
</button>
{SEASON_OPTS.map(s => (
<button
key={s}
onClick={() => setSeason(season === s ? "" : s)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${season === s ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-2">Occasion</p>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setOccasion("")}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${!occasion ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
Any
</button>
{OCCASION_OPTS.map(o => (
<button
key={o}
onClick={() => setOccasion(occasion === o ? "" : o)}
className={`px-3 py-1.5 rounded-full text-sm font-medium ${occasion === o ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{o}
</button>
))}
</div>
</div>
<Button fullWidth onClick={generate} loading={loading} size="lg">
Generate List
</Button>
</div>
{groups && (
<>
<div className="mx-4 mb-3 flex items-center justify-between">
<p className="text-sm text-gray-500">{total} garments found · {checkedCount} packed</p>
{total === 0 && (
<p className="text-sm text-amber-600">No matching garments try broader filters</p>
)}
</div>
<div className="mx-4 space-y-3">
{Object.entries(groups).map(([cat, items]) => (
<div key={cat} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<div className="px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border-b border-gray-100 dark:border-gray-600 flex items-center gap-2">
<span className="text-lg">{CATEGORY_EMOJI[cat] || "👗"}</span>
<span className="font-semibold text-sm capitalize">{cat}</span>
<span className="ml-auto text-xs text-gray-400">{items.length}</span>
</div>
{items.map(item => (
<button
key={item.id}
onClick={() => toggleItem(cat, item.id)}
className="w-full flex items-center gap-3 px-4 py-3 border-b border-gray-50 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
item.checked ? "border-rose-400 bg-rose-400" : "border-gray-300 dark:border-gray-500"
}`}>
{item.checked && <span className="text-white text-xs"></span>}
</div>
<div className="w-10 h-10 rounded-xl overflow-hidden flex-shrink-0">
<img src={item.thumbUrl} alt={item.name} className="w-full h-full object-cover" loading="lazy" />
</div>
<div className="flex-1 text-left">
<p className={`text-sm font-medium ${item.checked ? "line-through text-gray-400" : "text-gray-700 dark:text-gray-200"}`}>
{item.name}
</p>
<p className="text-xs text-gray-400">{item.sizeLabel}</p>
</div>
</button>
))}
</div>
))}
</div>
</>
)}
</div>
);
}

248
src/app/wardrobe/page.tsx Normal file
View file

@ -0,0 +1,248 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useFamily } from "../FamilyProvider";
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
interface Garment {
id: string;
name: string | null;
category: string;
sizeLabel: string;
thumbUrl: string;
status: string;
seasons: string[];
occasionTags: string[];
colors: string[];
}
interface OutgrowthNudge {
candidates: { id: string; sizeLabel: string }[];
currentSizeLabel: string | null;
}
const STATUS_OPTS = [
{ value: "active", label: "Active" },
{ value: "stored", label: "Stored" },
{ value: "outgrown", label: "Outgrown" },
{ value: "donated", label: "Donated" },
];
const CATEGORY_EMOJI: Record<string, string> = {
onesie: "👶", top: "👕", bottom: "👖", dress: "👗",
outerwear: "🧥", sleepwear: "😴", accessory: "🎀",
};
export default function WardrobePage() {
const { childId } = useFamily();
const router = useRouter();
const [garments, setGarments] = useState<Garment[]>([]);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("active");
const [filterCategory, setFilterCategory] = useState("");
const [filterSize, setFilterSize] = useState("");
const [filterSeason, setFilterSeason] = useState("");
const [nudge, setNudge] = useState<OutgrowthNudge | null>(null);
const [nudgeDismissed, setNudgeDismissed] = useState(false);
const fetchGarments = useCallback(async () => {
if (!childId) return;
setLoading(true);
try {
const params = new URLSearchParams({ childId, status });
if (filterCategory) params.set("category", filterCategory);
if (filterSize) params.set("sizeLabel", filterSize);
if (filterSeason) params.set("season", filterSeason);
const res = await fetch(`/api/garments?${params}`);
const data = await res.json();
setGarments(data.items || []);
} catch {
setGarments([]);
} finally {
setLoading(false);
}
}, [childId, status, filterCategory, filterSize, filterSeason]);
const fetchNudge = useCallback(async () => {
if (!childId || nudgeDismissed) return;
try {
const res = await fetch(`/api/garments/outgrowth?childId=${childId}`);
const data = await res.json();
if (data.candidates?.length > 0) setNudge(data);
} catch {}
}, [childId, nudgeDismissed]);
useEffect(() => { fetchGarments(); }, [fetchGarments]);
useEffect(() => { fetchNudge(); }, [fetchNudge]);
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">Wardrobe 👗</h1>
<div className="ml-auto flex gap-2">
<Link href="/wardrobe/outfit" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Today's outfit"></Link>
<Link href="/wardrobe/packing" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Packing list">🧳</Link>
<Link href="/wardrobe/saved-outfits" className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-lg" title="Saved outfits">💾</Link>
<Link href="/wardrobe/add" className="px-3 py-2 rounded-xl bg-rose-400 text-white text-sm font-medium shadow-sm">+ Add</Link>
</div>
</div>
{/* Outgrowth nudge */}
{nudge && !nudgeDismissed && (
<div className="mx-4 mb-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-2xl flex items-start gap-3">
<span className="text-2xl mt-0.5">📦</span>
<div className="flex-1">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200">
{nudge.candidates.length} item{nudge.candidates.length !== 1 ? "s" : ""} not worn in 45+ days and may be outgrown
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">
Current size: <strong>{nudge.currentSizeLabel}</strong>
</p>
<div className="flex gap-2 mt-2">
<button
onClick={() => { setFilterSize(nudge.candidates[0].sizeLabel); setNudgeDismissed(true); }}
className="text-xs px-2 py-1 bg-amber-400 text-white rounded-lg"
>
View items
</button>
<button onClick={() => setNudgeDismissed(true)} className="text-xs px-2 py-1 text-amber-600 dark:text-amber-400">
Dismiss
</button>
</div>
</div>
</div>
)}
{/* Filters */}
<div className="px-4 space-y-2 mb-4">
{/* Status row */}
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
{STATUS_OPTS.map(s => (
<button
key={s.value}
onClick={() => setStatus(s.value)}
className={`px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap flex-shrink-0 transition-all ${
status === s.value
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300"
}`}
>
{s.label}
</button>
))}
</div>
{/* Category row */}
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
<button
onClick={() => setFilterCategory("")}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-all ${
!filterCategory ? "bg-indigo-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
All
</button>
{GARMENT_CATEGORIES.map(c => (
<button
key={c}
onClick={() => setFilterCategory(filterCategory === c ? "" : c)}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-all ${
filterCategory === c ? "bg-indigo-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
{CATEGORY_EMOJI[c]} {c}
</button>
))}
</div>
{/* Size row */}
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
<button
onClick={() => setFilterSize("")}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap flex-shrink-0 transition-all ${
!filterSize ? "bg-teal-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
Any size
</button>
{GARMENT_SIZE_ORDER.map(s => (
<button
key={s}
onClick={() => setFilterSize(filterSize === s ? "" : s)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap flex-shrink-0 transition-all ${
filterSize === s ? "bg-teal-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
{s}
</button>
))}
</div>
{/* Season row */}
<div className="flex gap-2 pb-1">
{["", "summer", "monsoon", "winter"].map(s => (
<button
key={s || "all"}
onClick={() => setFilterSeason(s)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-all ${
filterSeason === s ? "bg-orange-400 text-white" : "bg-white dark:bg-gray-800 text-gray-500"
}`}
>
{s || "All seasons"}
</button>
))}
</div>
</div>
{/* Grid */}
{loading ? (
<div className="grid grid-cols-3 gap-3 px-4">
{[...Array(9)].map((_, i) => (
<div key={i} className="aspect-square rounded-2xl bg-white dark:bg-gray-800 animate-pulse" />
))}
</div>
) : garments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
<span className="text-5xl mb-4">👚</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">No garments yet</p>
<p className="text-sm text-gray-400 mt-1">Add your first garment to build the wardrobe catalogue</p>
<Link href="/wardrobe/add" className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium">
+ Add Garment
</Link>
</div>
) : (
<div className="grid grid-cols-3 gap-3 px-4">
{garments.map(g => (
<Link
key={g.id}
href={`/wardrobe/${g.id}`}
className="flex flex-col bg-white dark:bg-gray-800 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
<div className="aspect-square relative">
<img
src={g.thumbUrl}
alt={g.name || g.category}
className="w-full h-full object-cover"
loading="lazy"
/>
<span className="absolute top-1 right-1 text-xs bg-white/90 dark:bg-gray-900/90 px-1.5 py-0.5 rounded-full font-medium text-gray-700 dark:text-gray-200">
{g.sizeLabel}
</span>
</div>
<div className="px-2 py-1.5">
<p className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate">
{g.name || `${CATEGORY_EMOJI[g.category] || ""} ${g.category}`}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,146 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useFamily } from "../../FamilyProvider";
interface SavedOutfit {
id: string;
name: string;
garment_ids: string[];
occasion_tags: string[];
created_at: string;
}
interface GarmentThumb {
id: string;
thumbUrl: string;
name: string | null;
category: string;
}
export default function SavedOutfitsPage() {
const { childId } = useFamily();
const router = useRouter();
const [outfits, setOutfits] = useState<SavedOutfit[]>([]);
const [thumbs, setThumbs] = useState<Record<string, GarmentThumb>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!childId) return;
(async () => {
try {
const res = await fetch(`/api/garments/outfits?childId=${childId}`);
const data = await res.json();
setOutfits(data.items || []);
// Fetch thumbnails for garment IDs referenced by outfits
const allIds = [...new Set((data.items || []).flatMap((o: SavedOutfit) => o.garment_ids))] as string[];
if (allIds.length > 0) {
const garmentRes = await fetch(`/api/garments?childId=${childId}&status=active`);
const garmentData = await garmentRes.json();
const map: Record<string, GarmentThumb> = {};
for (const g of garmentData.items || []) {
map[g.id] = { id: g.id, thumbUrl: g.thumbUrl, name: g.name, category: g.category };
}
setThumbs(map);
}
} catch {}
setLoading(false);
})();
}, [childId]);
const deleteOutfit = async (id: string) => {
try {
const res = await fetch(`/api/garments/outfits/${id}`, { method: "DELETE" });
if (res.ok) setOutfits(prev => prev.filter(o => o.id !== id));
} catch {}
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 dark:from-gray-900 dark:to-gray-800 pb-24">
<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">💾 Saved Outfits</h1>
<Link href="/wardrobe/outfit" className="ml-auto text-sm px-3 py-1.5 bg-rose-400 text-white rounded-xl font-medium shadow-sm">
+ New
</Link>
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="flex gap-1">
{[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>
</div>
) : outfits.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 px-8 text-center">
<span className="text-5xl mb-4">👗</span>
<p className="font-semibold text-gray-600 dark:text-gray-300">No saved outfits yet</p>
<p className="text-sm text-gray-400 mt-1">Save combinations from the outfit suggestion screen</p>
<Link href="/wardrobe/outfit" className="mt-4 px-5 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium">
Get suggestions
</Link>
</div>
) : (
<div className="mx-4 space-y-3">
{outfits.map(outfit => (
<div key={outfit.id} className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-semibold text-gray-700 dark:text-gray-200">{outfit.name}</p>
{outfit.occasion_tags?.length > 0 && (
<div className="flex gap-1 mt-1">
{outfit.occasion_tags.map(t => (
<span key={t} className="text-xs px-2 py-0.5 bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-300 rounded-full">
{t}
</span>
))}
</div>
)}
</div>
<button
onClick={() => deleteOutfit(outfit.id)}
className="text-gray-300 hover:text-red-400 text-lg p-1 transition-colors"
>
×
</button>
</div>
<div className="flex gap-2">
{outfit.garment_ids.slice(0, 4).map(gid => {
const g = thumbs[gid];
if (!g) return (
<div key={gid} className="w-16 h-16 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-300 text-xl">
👗
</div>
);
return (
<div key={gid} className="flex flex-col items-center gap-1">
<div className="w-16 h-16 rounded-xl overflow-hidden">
<img src={g.thumbUrl} alt={g.name || g.category} className="w-full h-full object-cover" loading="lazy" />
</div>
</div>
);
})}
{outfit.garment_ids.length > 4 && (
<div className="w-16 h-16 rounded-xl bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm text-gray-500">
+{outfit.garment_ids.length - 4}
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-2">
{new Date(outfit.created_at).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" })}
</p>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -15,3 +15,4 @@ export * from "./admin";
export * from "./support";
export * from "./ai";
export * from "./affiliate";
export * from "./wardrobe";

131
src/db/schema/wardrobe.ts Normal file
View file

@ -0,0 +1,131 @@
import {
pgTable,
text,
timestamp,
uuid,
date,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { families } from "./family";
import { children } from "./family";
import { memories } from "./media";
// ---------------------------------------------------------------------------
// Wardrobe schema
//
// RLS NOTE: garments and garment_wears carry a `family_isolation` policy in
// prod (see drizzle/manual/06-wardrobe-rls.sql). Policies are not modelled
// in these pgTable definitions — they live in the DB and are managed
// separately. Do not assume Drizzle will recreate them.
// ---------------------------------------------------------------------------
export const garments = pgTable(
"garments",
{
id: uuid("id").primaryKey().defaultRandom(),
familyId: uuid("family_id")
.notNull()
.references(() => families.id),
childId: uuid("child_id")
.notNull()
.references(() => children.id),
name: text("name"),
category: text("category").notNull(),
sizeLabel: text("size_label").notNull(),
colors: text("colors").array().default([]),
seasons: text("seasons").array().default([]),
occasionTags: text("occasion_tags").array().default([]),
imageKey: text("image_key").notNull(),
thumbKey: text("thumb_key").notNull(),
status: text("status").notNull().default("active"),
acquiredVia: text("acquired_via"),
giftFrom: text("gift_from"),
visionMetadata: jsonb("vision_metadata"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("garments_family_idx").on(table.familyId),
index("garments_child_idx").on(table.childId),
index("garments_status_idx").on(table.status),
]
);
export const garmentWears = pgTable(
"garment_wears",
{
id: uuid("id").primaryKey().defaultRandom(),
familyId: uuid("family_id")
.notNull()
.references(() => families.id),
garmentId: uuid("garment_id")
.notNull()
.references(() => garments.id, { onDelete: "cascade" }),
wornOn: date("worn_on").notNull(),
memoryId: uuid("memory_id").references(() => memories.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("garment_wears_garment_idx").on(table.garmentId),
index("garment_wears_family_idx").on(table.familyId),
]
);
export type Garment = typeof garments.$inferSelect;
export type NewGarment = typeof garments.$inferInsert;
export type GarmentWear = typeof garmentWears.$inferSelect;
export type NewGarmentWear = typeof garmentWears.$inferInsert;
export const GARMENT_CATEGORIES = [
"onesie",
"top",
"bottom",
"dress",
"outerwear",
"sleepwear",
"accessory",
] as const;
export type GarmentCategory = (typeof GARMENT_CATEGORIES)[number];
export const GARMENT_SIZE_ORDER = [
"preemie",
"newborn",
"0-3m",
"3-6m",
"6-9m",
"9-12m",
"12-18m",
"18-24m",
"2-3y",
"3-4y",
"4-5y",
] as const;
export type GarmentSizeLabel = (typeof GARMENT_SIZE_ORDER)[number];
// W9: Saved outfits
export const outfits = pgTable(
"outfits",
{
id: uuid("id").primaryKey().defaultRandom(),
familyId: uuid("family_id")
.notNull()
.references(() => families.id),
childId: uuid("child_id")
.notNull()
.references(() => children.id),
name: text("name").notNull(),
garmentIds: uuid("garment_ids").array().notNull().default([]),
occasionTags: text("occasion_tags").array().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("outfits_family_idx").on(table.familyId),
index("outfits_child_idx").on(table.childId),
]
);
export type Outfit = typeof outfits.$inferSelect;
export type NewOutfit = typeof outfits.$inferInsert;

View file

@ -0,0 +1,74 @@
// Neutral colors that pair well with anything
const NEUTRALS = new Set(["white", "cream", "beige", "grey", "gray", "black", "off-white", "ivory", "nude"]);
export function colorsCompatible(a: string[], b: string[]): boolean {
if (!a.length || !b.length) return true; // no color info → assume compatible
const hasNeutral = (arr: string[]) => arr.some(c => NEUTRALS.has(c.toLowerCase()));
if (hasNeutral(a) || hasNeutral(b)) return true;
// Same hue family → monochromatic, fine
const normalize = (c: string) => c.toLowerCase().replace(/light |dark |pale |deep /, "");
const aHues = a.map(normalize);
const bHues = b.map(normalize);
return aHues.some(h => bHues.includes(h));
}
export interface GarmentRow {
id: string;
name: string | null;
category: string;
sizeLabel: string;
colors: string[];
seasons: string[];
occasionTags: string[];
thumbKey: string;
imageKey: string;
}
export interface OutfitSuggestion {
items: GarmentRow[];
label: string;
}
export function buildOutfits(garments: GarmentRow[], max = 3): OutfitSuggestion[] {
const tops = garments.filter(g => g.category === "top");
const bottoms = garments.filter(g => g.category === "bottom");
const dresses = garments.filter(g => g.category === "dress");
const onesies = garments.filter(g => g.category === "onesie");
const results: OutfitSuggestion[] = [];
// Dresses and onesies are standalone complete outfits
for (const item of [...dresses, ...onesies]) {
if (results.length >= max) break;
results.push({ items: [item], label: item.category === "dress" ? "Dress" : "Onesie" });
}
// Pair tops + bottoms
for (const top of tops) {
if (results.length >= max) break;
for (const bottom of bottoms) {
if (results.length >= max) break;
if (colorsCompatible(top.colors, bottom.colors)) {
results.push({ items: [top, bottom], label: "Top + Bottom" });
break; // one bottom per top
}
}
}
// If not enough pairs, add mismatched ones anyway
if (results.length < max) {
for (const top of tops) {
if (results.length >= max) break;
for (const bottom of bottoms) {
if (results.length >= max) break;
const alreadyAdded = results.some(r => r.items.some(i => i.id === top.id || i.id === bottom.id));
if (!alreadyAdded) {
results.push({ items: [top, bottom], label: "Top + Bottom" });
break;
}
}
}
}
return results;
}

152
src/lib/wardrobe/vision.ts Normal file
View file

@ -0,0 +1,152 @@
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { GARMENT_CATEGORIES, type GarmentCategory } from "@/db/schema/wardrobe";
const VISION_MODEL = process.env.VISION_MODEL || "gemini-flash";
const LITELLM_URL = process.env.LITELLM_BASE_URL;
const LITELLM_KEY = process.env.LITELLM_API_KEY;
interface GarmentVisionResult {
name: string;
category: GarmentCategory;
colors: string[];
seasons: string[];
occasion_tags: string[];
}
const SAFE_DEFAULT: GarmentVisionResult = {
name: "",
category: "top",
colors: [],
seasons: [],
occasion_tags: [],
};
function getR2Config() {
return {
accountId: process.env.R2_ACCOUNT_ID!,
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretKey: process.env.R2_SECRET_ACCESS_KEY!,
bucket: process.env.R2_BUCKET_NAME!,
};
}
async function fetchFromR2(key: string): Promise<Buffer> {
const R2 = getR2Config();
const client = new S3Client({
region: "auto",
endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2.accessKeyId, secretAccessKey: R2.secretKey },
});
const res = await client.send(new GetObjectCommand({ Bucket: R2.bucket, Key: key }));
const chunks: Buffer[] = [];
for await (const chunk of res.Body as AsyncIterable<Buffer>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
export async function tagGarment(imageKey: string): Promise<GarmentVisionResult> {
if (!LITELLM_URL || !LITELLM_KEY) {
console.warn("[wardrobe/vision] LiteLLM not configured — returning defaults");
return { ...SAFE_DEFAULT };
}
let imageBuffer: Buffer;
try {
imageBuffer = await fetchFromR2(imageKey);
} catch (err) {
console.error("[wardrobe/vision] R2 fetch failed:", err);
return { ...SAFE_DEFAULT };
}
const base64 = imageBuffer.toString("base64");
const systemPrompt = `You are a baby clothing tagger. Respond ONLY with a JSON object — no prose, no markdown fences.
The JSON must have exactly these keys:
- name: a short descriptive name for the garment (string, e.g. "Striped blue onesie")
- category: exactly one of: onesie|top|bottom|dress|outerwear|sleepwear|accessory
- colors: array of color names visible in the garment (e.g. ["blue","white"])
- seasons: subset of: summer|monsoon|winter (based on fabric weight and style)
- occasion_tags: subset of: everyday|daycare|festive|photoshoot
Do NOT attempt to guess the garment size. Do NOT include any other keys.`;
let raw = "";
try {
const response = await fetch(`${LITELLM_URL}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LITELLM_KEY}`,
},
body: JSON.stringify({
model: VISION_MODEL,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: `data:image/jpeg;base64,${base64}` },
},
{
type: "text",
text: "Tag this baby garment.",
},
],
},
],
max_tokens: 200,
temperature: 0.1,
}),
});
if (!response.ok) {
console.error("[wardrobe/vision] API error:", response.status);
return { ...SAFE_DEFAULT };
}
const data = await response.json();
raw = data.choices?.[0]?.message?.content ?? "";
} catch (err) {
console.error("[wardrobe/vision] fetch failed:", err);
return { ...SAFE_DEFAULT };
}
// Strip accidental markdown fences
const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(cleaned);
} catch {
console.warn("[wardrobe/vision] parse failed, raw:", raw);
return { ...SAFE_DEFAULT };
}
// Validate category against allowed enum — treat hallucinated values as default
const rawCategory = String(parsed.category ?? "").toLowerCase() as GarmentCategory;
const safeCategory: GarmentCategory = GARMENT_CATEGORIES.includes(rawCategory)
? rawCategory
: "top";
const toStringArray = (v: unknown): string[] => {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
};
return {
name: typeof parsed.name === "string" ? parsed.name : "",
category: safeCategory,
colors: toStringArray(parsed.colors),
seasons: toStringArray(parsed.seasons).filter(s =>
["summer", "monsoon", "winter"].includes(s)
),
occasion_tags: toStringArray(parsed.occasion_tags).filter(t =>
["everyday", "daycare", "festive", "photoshoot"].includes(t)
),
};
}

View file

@ -0,0 +1,43 @@
// Gurugram coordinates (product is explicitly Gurugram-targeted)
const LAT = 28.4595;
const LON = 77.0266;
export interface WeatherSnapshot {
tempC: number;
description: string;
season: "summer" | "monsoon" | "winter";
}
// Maps temperature band to Gurugram season label.
// < 15°C → winter
// 1528°C → monsoon (shoulder / rain season)
// > 28°C → summer
function tempToSeason(tempC: number): "summer" | "monsoon" | "winter" {
if (tempC < 15) return "winter";
if (tempC <= 28) return "monsoon";
return "summer";
}
export async function getGurgaonWeather(): Promise<WeatherSnapshot> {
try {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LON}&current_weather=true`;
const res = await fetch(url, { next: { revalidate: 1800 } }); // cache 30 min
if (!res.ok) throw new Error(`weather API ${res.status}`);
const data = await res.json();
const tempC: number = data.current_weather?.temperature ?? 30;
const season = tempToSeason(tempC);
const descriptions: Record<string, string> = {
summer: "hot",
monsoon: "warm",
winter: "cool",
};
return {
tempC,
description: descriptions[season],
season,
};
} catch {
// Fallback to summer (Gurugram default)
return { tempC: 35, description: "hot", season: "summer" };
}
}