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:
parent
c4304615ec
commit
1994725101
28 changed files with 9375 additions and 0 deletions
39
drizzle/0001_wardrobe_tables.sql
Normal file
39
drizzle/0001_wardrobe_tables.sql
Normal 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");
|
||||
14
drizzle/0002_outfits_table.sql
Normal file
14
drizzle/0002_outfits_table.sql
Normal 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");
|
||||
21
drizzle/manual/06-wardrobe-rls.sql
Normal file
21
drizzle/manual/06-wardrobe-rls.sql
Normal 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);
|
||||
3215
drizzle/meta/0001_snapshot.json
Normal file
3215
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
3332
drizzle/meta/0002_snapshot.json
Normal file
3332
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
110
src/app/api/garments/[id]/route.ts
Normal file
110
src/app/api/garments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
35
src/app/api/garments/[id]/wear/route.ts
Normal file
35
src/app/api/garments/[id]/wear/route.ts
Normal 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 });
|
||||
}
|
||||
72
src/app/api/garments/outfit/route.ts
Normal file
72
src/app/api/garments/outfit/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
17
src/app/api/garments/outfits/[id]/route.ts
Normal file
17
src/app/api/garments/outfits/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
51
src/app/api/garments/outfits/route.ts
Normal file
51
src/app/api/garments/outfits/route.ts
Normal 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 });
|
||||
}
|
||||
66
src/app/api/garments/outgrowth/route.ts
Normal file
66
src/app/api/garments/outgrowth/route.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
63
src/app/api/garments/packing/route.ts
Normal file
63
src/app/api/garments/packing/route.ts
Normal 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 });
|
||||
}
|
||||
160
src/app/api/garments/route.ts
Normal file
160
src/app/api/garments/route.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
src/app/api/garments/tag/route.ts
Normal file
31
src/app/api/garments/tag/route.ts
Normal 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 });
|
||||
}
|
||||
99
src/app/api/garments/upload/route.ts
Normal file
99
src/app/api/garments/upload/route.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
411
src/app/wardrobe/[id]/page.tsx
Normal file
411
src/app/wardrobe/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
467
src/app/wardrobe/add/page.tsx
Normal file
467
src/app/wardrobe/add/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/app/wardrobe/outfit/page.tsx
Normal file
198
src/app/wardrobe/outfit/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
164
src/app/wardrobe/packing/page.tsx
Normal file
164
src/app/wardrobe/packing/page.tsx
Normal 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
248
src/app/wardrobe/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/app/wardrobe/saved-outfits/page.tsx
Normal file
146
src/app/wardrobe/saved-outfits/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
131
src/db/schema/wardrobe.ts
Normal 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;
|
||||
74
src/lib/wardrobe/outfit.ts
Normal file
74
src/lib/wardrobe/outfit.ts
Normal 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
152
src/lib/wardrobe/vision.ts
Normal 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)
|
||||
),
|
||||
};
|
||||
}
|
||||
43
src/lib/wardrobe/weather.ts
Normal file
43
src/lib/wardrobe/weather.ts
Normal 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
|
||||
// 15–28°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}¤t_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" };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue