diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx
new file mode 100644
index 0000000..ddb4352
--- /dev/null
+++ b/apps/web/app/(app)/meal-plan/page.tsx
@@ -0,0 +1,102 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+import Link from "next/link";
+import { ChevronLeft, ChevronRight, ShoppingCart } from "lucide-react";
+import { auth } from "@/lib/auth/server";
+import { db, mealPlans, recipes, eq, and, desc } from "@epicure/db";
+import { buttonVariants } from "@/components/ui/button";
+import { MealPlanner } from "@/components/meal-plan/meal-planner";
+import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
+import { cn } from "@/lib/utils";
+
+export const metadata: Metadata = { title: "Meal Plan" };
+
+function getMonday(dateStr?: string): Date {
+ const d = dateStr ? new Date(dateStr) : new Date();
+ const day = d.getDay();
+ const diff = (day === 0 ? -6 : 1 - day);
+ d.setDate(d.getDate() + diff);
+ d.setHours(0, 0, 0, 0);
+ return d;
+}
+
+function toDateStr(d: Date): string {
+ return d.toISOString().slice(0, 10);
+}
+
+function addWeeks(d: Date, n: number): Date {
+ const copy = new Date(d);
+ copy.setDate(copy.getDate() + n * 7);
+ return copy;
+}
+
+export default async function MealPlanPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ week?: string }>;
+}) {
+ const { week } = await searchParams;
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const monday = getMonday(week);
+ const weekStart = toDateStr(monday);
+ const prevWeek = toDateStr(addWeeks(monday, -1));
+ const nextWeek = toDateStr(addWeeks(monday, 1));
+
+ const sunday = addWeeks(monday, 1);
+ sunday.setDate(sunday.getDate() - 1);
+
+ const [plan, userRecipes] = await Promise.all([
+ db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
+ with: {
+ entries: {
+ with: { recipe: { with: { photos: true } } },
+ },
+ },
+ }),
+ db.query.recipes.findMany({
+ where: eq(recipes.authorId, session.user.id),
+ orderBy: desc(recipes.updatedAt),
+ columns: { id: true, title: true },
+ }),
+ ]);
+
+ const entries = (plan?.entries ?? []).map((e) => ({
+ id: e.id,
+ day: e.day,
+ mealType: e.mealType,
+ servings: e.servings,
+ note: e.note,
+ recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
+ }));
+
+ const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
+
+ return (
+
+
+
+
+
+
+ Shopping lists
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx
new file mode 100644
index 0000000..2c1cbc0
--- /dev/null
+++ b/apps/web/app/(app)/pantry/page.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, pantryItems, eq, asc } from "@epicure/db";
+import { PantryManager } from "@/components/meal-plan/pantry-manager";
+import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
+
+export const metadata: Metadata = { title: "Pantry" };
+
+export default async function PantryPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const items = await db.query.pantryItems.findMany({
+ where: eq(pantryItems.userId, session.user.id),
+ orderBy: asc(pantryItems.rawName),
+ });
+
+ return (
+
+
+
({
+ id: i.id,
+ rawName: i.rawName,
+ quantity: i.quantity,
+ unit: i.unit,
+ expiresAt: i.expiresAt?.toISOString() ?? null,
+ }))} />
+
+ );
+}
diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx
new file mode 100644
index 0000000..541b879
--- /dev/null
+++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx
@@ -0,0 +1,45 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, shoppingLists, eq, and } from "@epicure/db";
+import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
+
+type Params = { params: Promise<{ id: string }> };
+
+export const metadata: Metadata = { title: "Shopping List" };
+
+export default async function ShoppingListPage({ params }: Params) {
+ const { id } = await params;
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const list = await db.query.shoppingLists.findFirst({
+ where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)),
+ with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
+ });
+
+ if (!list) notFound();
+
+ return (
+
+
+
{list.name}
+
+ {list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
+
+
+
({
+ id: i.id,
+ rawName: i.rawName,
+ quantity: i.quantity,
+ unit: i.unit,
+ aisle: i.aisle,
+ checked: i.checked,
+ }))}
+ />
+
+ );
+}
diff --git a/apps/web/app/(app)/shopping-lists/page.tsx b/apps/web/app/(app)/shopping-lists/page.tsx
new file mode 100644
index 0000000..57e13f2
--- /dev/null
+++ b/apps/web/app/(app)/shopping-lists/page.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, shoppingLists, eq, desc } from "@epicure/db";
+import { ShoppingListsPageContent } from "@/components/shopping-lists/shopping-lists-page-content";
+
+export const metadata: Metadata = { title: "Shopping Lists" };
+
+export default async function ShoppingListsPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const lists = await db.query.shoppingLists.findMany({
+ where: eq(shoppingLists.userId, session.user.id),
+ orderBy: desc(shoppingLists.createdAt),
+ with: { items: { columns: { id: true, checked: true } } },
+ });
+
+ return (
+ ({
+ id: list.id,
+ name: list.name,
+ generatedAt: list.generatedAt ? list.generatedAt.toISOString() : null,
+ totalItems: list.items.length,
+ checkedItems: list.items.filter((i) => i.checked).length,
+ }))}
+ />
+ );
+}
diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts
new file mode 100644
index 0000000..595024a
--- /dev/null
+++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/[entryId]/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ weekStart: string; entryId: string }> };
+
+export async function DELETE(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { weekStart, entryId } = await params;
+
+ const plan = await db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
+ });
+ if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ await db.delete(mealPlanEntries).where(
+ and(eq(mealPlanEntries.id, entryId), eq(mealPlanEntries.mealPlanId, plan.id))
+ );
+
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts
new file mode 100644
index 0000000..01fd683
--- /dev/null
+++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts
@@ -0,0 +1,61 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+import { dispatchWebhook } from "@/lib/webhooks";
+
+type Params = { params: Promise<{ weekStart: string }> };
+
+const Schema = z.object({
+ day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
+ mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
+ recipeId: z.string().optional(),
+ servings: z.number().int().min(1).max(100).default(2),
+ note: z.string().max(500).optional(),
+});
+
+async function getOrCreatePlan(userId: string, weekStart: string) {
+ const existing = await db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
+ });
+ if (existing) return existing;
+
+ const id = crypto.randomUUID();
+ await db.insert(mealPlans).values({ id, userId, weekStart });
+ return { id, userId, weekStart };
+}
+
+export async function POST(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { weekStart } = await params;
+
+ const body = await req.json() as unknown;
+ const parsed = Schema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const plan = await getOrCreatePlan(session!.user.id, weekStart);
+
+ // Remove existing entry for same day+mealType before inserting
+ await db.delete(mealPlanEntries).where(
+ and(
+ eq(mealPlanEntries.mealPlanId, plan.id),
+ eq(mealPlanEntries.day, parsed.data.day),
+ eq(mealPlanEntries.mealType, parsed.data.mealType)
+ )
+ );
+
+ const entryId = crypto.randomUUID();
+ await db.insert(mealPlanEntries).values({
+ id: entryId,
+ mealPlanId: plan.id,
+ day: parsed.data.day,
+ mealType: parsed.data.mealType,
+ recipeId: parsed.data.recipeId,
+ servings: parsed.data.servings,
+ note: parsed.data.note,
+ });
+
+ void dispatchWebhook(session!.user.id, "meal_plan.updated", { weekStart, day: parsed.data.day, mealType: parsed.data.mealType });
+ return NextResponse.json({ id: entryId }, { status: 201 });
+}
diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts
new file mode 100644
index 0000000..0aadb78
--- /dev/null
+++ b/apps/web/app/api/v1/meal-plans/[weekStart]/nutrition/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, mealPlans, mealPlanEntries, recipes, userNutritionGoals, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ weekStart: string }> };
+
+export async function GET(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const { weekStart } = await params;
+ const userId = session!.user.id;
+
+ // Find the meal plan for this week
+ const plan = await db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
+ });
+
+ const totals = { calories: 0, protein: 0, carbs: 0, fat: 0 };
+
+ if (plan) {
+ // Fetch all entries with their recipes
+ const entries = await db.query.mealPlanEntries.findMany({
+ where: eq(mealPlanEntries.mealPlanId, plan.id),
+ with: {
+ recipe: {
+ columns: {
+ id: true,
+ baseServings: true,
+ nutritionData: true,
+ },
+ },
+ },
+ });
+
+ for (const entry of entries) {
+ const recipe = entry.recipe;
+ if (!recipe || !recipe.nutritionData?.perServing) continue;
+
+ const { calories, proteinG, carbsG, fatG } = recipe.nutritionData.perServing;
+ const servings = entry.servings ?? recipe.baseServings;
+
+ totals.calories += Math.round(calories * servings);
+ totals.protein += Math.round(proteinG * servings);
+ totals.carbs += Math.round(carbsG * servings);
+ totals.fat += Math.round(fatG * servings);
+ }
+ }
+
+ // Fetch user's nutrition goals
+ const goals = await db.query.userNutritionGoals.findFirst({
+ where: eq(userNutritionGoals.userId, userId),
+ });
+
+ const goalsData = goals
+ ? {
+ caloriesKcal: goals.caloriesKcal,
+ proteinG: goals.proteinG,
+ carbsG: goals.carbsG,
+ fatG: goals.fatG,
+ }
+ : null;
+
+ // Calculate coverage percentages
+ const coverage = {
+ calories:
+ goalsData?.caloriesKcal
+ ? Math.round((totals.calories / goalsData.caloriesKcal) * 100)
+ : 0,
+ protein:
+ goalsData?.proteinG
+ ? Math.round((totals.protein / goalsData.proteinG) * 100)
+ : 0,
+ carbs:
+ goalsData?.carbsG
+ ? Math.round((totals.carbs / goalsData.carbsG) * 100)
+ : 0,
+ fat:
+ goalsData?.fatG
+ ? Math.round((totals.fat / goalsData.fatG) * 100)
+ : 0,
+ };
+
+ return NextResponse.json({ totals, goals: goalsData, coverage });
+}
diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/route.ts
new file mode 100644
index 0000000..c32e630
--- /dev/null
+++ b/apps/web/app/api/v1/meal-plans/[weekStart]/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, mealPlans, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ weekStart: string }> };
+
+export async function GET(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { weekStart } = await params;
+
+ const plan = await db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
+ with: {
+ entries: {
+ with: {
+ recipe: {
+ with: { photos: true },
+ },
+ },
+ },
+ },
+ });
+
+ if (!plan) return NextResponse.json({ weekStart, entries: [] });
+ return NextResponse.json(plan);
+}
+
+export async function POST(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { weekStart } = await params;
+
+ const existing = await db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
+ });
+
+ if (existing) return NextResponse.json(existing);
+
+ const id = crypto.randomUUID();
+ await db.insert(mealPlans).values({ id, userId: session!.user.id, weekStart });
+ return NextResponse.json({ id, weekStart }, { status: 201 });
+}
diff --git a/apps/web/app/api/v1/pantry/[id]/route.ts b/apps/web/app/api/v1/pantry/[id]/route.ts
new file mode 100644
index 0000000..d31798d
--- /dev/null
+++ b/apps/web/app/api/v1/pantry/[id]/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, pantryItems, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function PUT(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const item = await db.query.pantryItems.findFirst({ where: and(eq(pantryItems.id, id), eq(pantryItems.userId, session!.user.id)) });
+ if (!item) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json() as unknown;
+ const parsed = z.object({
+ rawName: z.string().min(1).max(200).optional(),
+ quantity: z.string().nullable().optional(),
+ unit: z.string().nullable().optional(),
+ expiresAt: z.string().datetime().nullable().optional(),
+ }).safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const data = parsed.data;
+ await db.update(pantryItems).set({
+ ...(data.rawName && { rawName: data.rawName }),
+ ...(data.quantity !== undefined && { quantity: data.quantity ?? undefined }),
+ ...(data.unit !== undefined && { unit: data.unit ?? undefined }),
+ ...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined }),
+ }).where(eq(pantryItems.id, id));
+
+ return NextResponse.json({ updated: true });
+}
+
+export async function DELETE(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ await db.delete(pantryItems).where(and(eq(pantryItems.id, id), eq(pantryItems.userId, session!.user.id)));
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/apps/web/app/api/v1/pantry/bulk/route.ts b/apps/web/app/api/v1/pantry/bulk/route.ts
new file mode 100644
index 0000000..f495f9f
--- /dev/null
+++ b/apps/web/app/api/v1/pantry/bulk/route.ts
@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, pantryItems, eq } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+const Schema = z.object({
+ items: z.array(z.object({
+ rawName: z.string().min(1).max(200),
+ quantity: z.string().optional(),
+ unit: z.string().optional(),
+ })).min(1),
+});
+
+export async function POST(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = await req.json() as unknown;
+ const parsed = Schema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const userId = session!.user.id;
+ const existing = await db.query.pantryItems.findMany({
+ where: eq(pantryItems.userId, userId),
+ });
+
+ for (const incoming of parsed.data.items) {
+ const key = incoming.rawName.toLowerCase();
+ const match = existing.find(
+ (e) => e.rawName.toLowerCase() === key && (e.unit ?? "") === (incoming.unit ?? "")
+ );
+
+ if (match) {
+ const existingQty = match.quantity ? parseFloat(match.quantity) : null;
+ const incomingQty = incoming.quantity ? parseFloat(incoming.quantity) : null;
+ if (existingQty !== null && incomingQty !== null && !isNaN(existingQty) && !isNaN(incomingQty)) {
+ const merged = existingQty + incomingQty;
+ await db.update(pantryItems)
+ .set({ quantity: String(merged) })
+ .where(eq(pantryItems.id, match.id));
+ }
+ // if quantities aren't numeric, leave as-is (item already exists)
+ } else {
+ await db.insert(pantryItems).values({
+ id: crypto.randomUUID(),
+ userId,
+ rawName: incoming.rawName,
+ quantity: incoming.quantity,
+ unit: incoming.unit,
+ });
+ }
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/apps/web/app/api/v1/pantry/route.ts b/apps/web/app/api/v1/pantry/route.ts
new file mode 100644
index 0000000..ecb94ca
--- /dev/null
+++ b/apps/web/app/api/v1/pantry/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, pantryItems, eq, desc } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+const Schema = z.object({
+ rawName: z.string().min(1).max(200),
+ quantity: z.string().optional(),
+ unit: z.string().optional(),
+ expiresAt: z.string().datetime().optional(),
+});
+
+export async function GET(_req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const items = await db.query.pantryItems.findMany({
+ where: eq(pantryItems.userId, session!.user.id),
+ orderBy: desc(pantryItems.createdAt),
+ });
+
+ return NextResponse.json(items);
+}
+
+export async function POST(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = await req.json() as unknown;
+ const parsed = Schema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const id = crypto.randomUUID();
+ await db.insert(pantryItems).values({
+ id,
+ userId: session!.user.id,
+ rawName: parsed.data.rawName,
+ quantity: parsed.data.quantity,
+ unit: parsed.data.unit,
+ expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : undefined,
+ });
+
+ return NextResponse.json({ id }, { status: 201 });
+}
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts
new file mode 100644
index 0000000..95157a9
--- /dev/null
+++ b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+type Params = { params: Promise<{ id: string; itemId: string }> };
+
+export async function PUT(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id, itemId } = await params;
+
+ const list = await db.query.shoppingLists.findFirst({
+ where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
+ });
+ if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json() as { checked?: boolean };
+ await db.update(shoppingListItems)
+ .set({ checked: body.checked ?? false })
+ .where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
+
+ return NextResponse.json({ updated: true });
+}
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts
new file mode 100644
index 0000000..80e0119
--- /dev/null
+++ b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+const AddItemsSchema = z.object({
+ items: z.array(z.object({
+ rawName: z.string().min(1),
+ quantity: z.string().optional(),
+ unit: z.string().optional(),
+ aisle: z.string().optional(),
+ })).min(1),
+});
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function POST(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const list = await db.query.shoppingLists.findFirst({
+ where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
+ });
+ if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = await req.json() as unknown;
+ const parsed = AddItemsSchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ await db.insert(shoppingListItems).values(
+ parsed.data.items.map((item) => ({
+ id: crypto.randomUUID(),
+ listId: id,
+ rawName: item.rawName,
+ quantity: item.quantity,
+ unit: item.unit,
+ aisle: item.aisle,
+ checked: false,
+ }))
+ );
+
+ return NextResponse.json({ ok: true }, { status: 201 });
+}
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/route.ts
new file mode 100644
index 0000000..e643dbf
--- /dev/null
+++ b/apps/web/app/api/v1/shopping-lists/[id]/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+import { dispatchWebhook } from "@/lib/webhooks";
+
+type Params = { params: Promise<{ id: string }> };
+
+export async function GET(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const list = await db.query.shoppingLists.findFirst({
+ where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
+ with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
+ });
+
+ if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ return NextResponse.json(list);
+}
+
+const PatchSchema = z.object({ completed: z.boolean() });
+
+export async function PATCH(req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ const list = await db.query.shoppingLists.findFirst({
+ where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
+ });
+ if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const body = PatchSchema.safeParse(await req.json());
+ if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ if (body.data.completed) {
+ // Mark all items as checked
+ await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id));
+ void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: list.name });
+ }
+
+ return NextResponse.json({ updated: true });
+}
+
+export async function DELETE(_req: NextRequest, { params }: Params) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const { id } = await params;
+
+ await db.delete(shoppingLists).where(and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)));
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/apps/web/app/api/v1/shopping-lists/route.ts b/apps/web/app/api/v1/shopping-lists/route.ts
new file mode 100644
index 0000000..260a216
--- /dev/null
+++ b/apps/web/app/api/v1/shopping-lists/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, eq, and, desc, inArray } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+const CreateSchema = z.object({
+ name: z.string().min(1).max(100),
+ items: z.array(z.object({
+ rawName: z.string().min(1),
+ quantity: z.string().optional(),
+ unit: z.string().optional(),
+ aisle: z.string().optional(),
+ })).optional(),
+ fromMealPlanWeek: z.string().optional(),
+});
+
+export async function GET(_req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const lists = await db.query.shoppingLists.findMany({
+ where: eq(shoppingLists.userId, session!.user.id),
+ orderBy: desc(shoppingLists.createdAt),
+ with: { items: { orderBy: (t, { asc }) => asc(t.aisle) } },
+ });
+
+ return NextResponse.json(lists);
+}
+
+export async function POST(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = await req.json() as unknown;
+ const parsed = CreateSchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const data = parsed.data;
+ const listId = crypto.randomUUID();
+
+ let items: Array<{ rawName: string; quantity?: string; unit?: string; aisle?: string }> = data.items ?? [];
+
+ if (data.fromMealPlanWeek) {
+ const plan = await db.query.mealPlans.findFirst({
+ where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, data.fromMealPlanWeek)),
+ with: { entries: true },
+ });
+
+ if (plan) {
+ const recipeIds = plan.entries.map((e) => e.recipeId).filter(Boolean) as string[];
+ if (recipeIds.length > 0) {
+ const ings = await db.query.recipeIngredients.findMany({
+ where: inArray(recipeIngredients.recipeId, recipeIds),
+ });
+
+ // Simple merge by rawName (case-insensitive)
+ const merged = new Map();
+ for (const ing of ings) {
+ const key = ing.rawName.toLowerCase();
+ if (!merged.has(key)) {
+ merged.set(key, { rawName: ing.rawName, quantity: ing.quantity ?? undefined, unit: ing.unit ?? undefined });
+ }
+ }
+ items = Array.from(merged.values());
+ }
+ }
+ }
+
+ await db.insert(shoppingLists).values({
+ id: listId,
+ userId: session!.user.id,
+ name: data.name,
+ generatedAt: data.fromMealPlanWeek ? new Date() : undefined,
+ });
+
+ if (items.length > 0) {
+ await db.insert(shoppingListItems).values(
+ items.map((item) => ({
+ id: crypto.randomUUID(),
+ listId,
+ rawName: item.rawName,
+ quantity: item.quantity,
+ unit: item.unit,
+ aisle: item.aisle,
+ checked: false,
+ }))
+ );
+ }
+
+ return NextResponse.json({ id: listId }, { status: 201 });
+}
diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx
new file mode 100644
index 0000000..355000b
--- /dev/null
+++ b/apps/web/components/meal-plan/meal-planner.tsx
@@ -0,0 +1,398 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Plus, Trash2, Sparkles } from "lucide-react";
+import { toast } from "sonner";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "next-intl";
+
+type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
+type MealType = "breakfast" | "lunch" | "dinner" | "snack";
+
+type Recipe = { id: string; title: string };
+type Entry = {
+ id: string;
+ day: Day;
+ mealType: MealType;
+ servings: number;
+ recipe: Recipe | null;
+ note: string | null;
+};
+
+type UserRecipe = { id: string; title: string };
+
+function AddEntryModal({
+ weekStart,
+ day,
+ mealType,
+ userRecipes,
+ onAdded,
+ onClose,
+ dayLabel,
+ mealLabel,
+}: {
+ weekStart: string;
+ day: Day;
+ mealType: MealType;
+ userRecipes: UserRecipe[];
+ onAdded: (entry: Entry) => void;
+ onClose: () => void;
+ dayLabel: string;
+ mealLabel: string;
+}) {
+ const [search, setSearch] = useState("");
+ const [servings, setServings] = useState("2");
+ const [saving, setSaving] = useState(false);
+ const t = useTranslations("mealPlan");
+
+ const filtered = userRecipes.filter((r) =>
+ r.title.toLowerCase().includes(search.toLowerCase())
+ );
+
+ async function add(recipe: UserRecipe) {
+ setSaving(true);
+ try {
+ const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ day, mealType, recipeId: recipe.id, servings: parseInt(servings) || 2 }),
+ });
+ if (!res.ok) { toast.error("Failed to add"); return; }
+ const { id } = await res.json() as { id: string };
+ onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, note: null });
+ onClose();
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+export function MealPlanner({
+ weekStart,
+ initialEntries,
+ userRecipes,
+}: {
+ weekStart: string;
+ initialEntries: Entry[];
+ userRecipes: UserRecipe[];
+}) {
+ const [entries, setEntries] = useState(initialEntries);
+ const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
+ const [showAiModal, setShowAiModal] = useState(false);
+ const [aiGenerating, setAiGenerating] = useState(false);
+ const [aiPhase, setAiPhase] = useState("");
+ const [dietaryPrefs, setDietaryPrefs] = useState("");
+ const [aiServings, setAiServings] = useState("2");
+ const [usePantry, setUsePantry] = useState(true);
+ const [pantryMode, setPantryMode] = useState(false);
+ const t = useTranslations("mealPlan");
+
+ async function generateWithAi() {
+ setAiGenerating(true);
+ setAiPhase("Analyzing your preferences…");
+ const phases = [
+ [1500, "Planning breakfast, lunch & dinner…"],
+ [5000, "Selecting recipes for each day…"],
+ [10000, "Balancing nutrition across the week…"],
+ [16000, "Finalizing your meal plan…"],
+ ] as const;
+ const timers = phases.map(([delay, label]) =>
+ setTimeout(() => setAiPhase(label), delay)
+ );
+ try {
+ const res = await fetch("/api/v1/ai/meal-plan/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ weekStart,
+ dietaryPrefs: dietaryPrefs.trim() || undefined,
+ servings: parseInt(aiServings) || 2,
+ usePantry,
+ pantryMode,
+ }),
+ });
+ if (!res.ok) {
+ const data = await res.json() as { error?: string };
+ throw new Error(data.error ?? "Generation failed");
+ }
+ const data = await res.json() as {
+ entries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }>;
+ };
+ // Merge generated entries into state
+ setEntries((prev) => {
+ const updated = [...prev];
+ for (const e of data.entries) {
+ const idx = updated.findIndex((u) => u.day === e.day && u.mealType === e.mealType);
+ const newEntry: Entry = {
+ id: e.id,
+ day: e.day as Day,
+ mealType: e.mealType as MealType,
+ servings: parseInt(aiServings) || 2,
+ recipe: { id: e.recipeId, title: e.recipeTitle },
+ note: null,
+ };
+ if (idx >= 0) updated[idx] = newEntry;
+ else updated.push(newEntry);
+ }
+ return updated;
+ });
+ setShowAiModal(false);
+ toast.success(t("aiGenerated"));
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Generation failed");
+ } finally {
+ timers.forEach(clearTimeout);
+ setAiGenerating(false);
+ setAiPhase("");
+ }
+ }
+
+ const DAYS: { key: Day; label: string }[] = [
+ { key: "mon", label: t("days.mon") },
+ { key: "tue", label: t("days.tue") },
+ { key: "wed", label: t("days.wed") },
+ { key: "thu", label: t("days.thu") },
+ { key: "fri", label: t("days.fri") },
+ { key: "sat", label: t("days.sat") },
+ { key: "sun", label: t("days.sun") },
+ ];
+
+ const MEAL_TYPES: { key: MealType; label: string }[] = [
+ { key: "breakfast", label: t("meals.breakfast") },
+ { key: "lunch", label: t("meals.lunch") },
+ { key: "dinner", label: t("meals.dinner") },
+ { key: "snack", label: t("meals.snack") },
+ ];
+
+ const getEntry = (day: Day, mealType: MealType) =>
+ entries.find((e) => e.day === day && e.mealType === mealType);
+
+ const handleAdded = useCallback((entry: Entry) => {
+ setEntries((prev) => [
+ ...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)),
+ entry,
+ ]);
+ }, []);
+
+ async function removeEntry(entry: Entry) {
+ const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/${entry.id}`, { method: "DELETE" });
+ if (res.ok) {
+ setEntries((prev) => prev.filter((e) => e.id !== entry.id));
+ } else {
+ toast.error("Failed to remove");
+ }
+ }
+
+ const addingDay = adding ? DAYS.find((d) => d.key === adding.day) : null;
+ const addingMeal = adding ? MEAL_TYPES.find((m) => m.key === adding.mealType) : null;
+
+ return (
+ <>
+ {/* AI Generate button */}
+
+
+
+
+
+
+
+
+ {DAYS.map(({ key }) => (
+
+ ))}
+
+
+
+ |
+ {DAYS.map(({ key, label }) => (
+ {label} |
+ ))}
+
+
+
+ {MEAL_TYPES.map(({ key: mealType, label }) => (
+
+ |
+ {label}
+ |
+ {DAYS.map(({ key: day }) => {
+ const entry = getEntry(day, mealType);
+ return (
+
+ {entry?.recipe ? (
+
+ {entry.recipe.title}
+ {entry.servings} srv
+
+
+ ) : (
+
+ )}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+
+ {adding && addingDay && addingMeal && (
+ setAdding(null)}
+ dayLabel={addingDay.label}
+ mealLabel={addingMeal.label}
+ />
+ )}
+
+ {/* AI generation modal */}
+ {showAiModal && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/components/meal-plan/new-shopping-list-button.tsx b/apps/web/components/meal-plan/new-shopping-list-button.tsx
new file mode 100644
index 0000000..163b726
--- /dev/null
+++ b/apps/web/components/meal-plan/new-shopping-list-button.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { Plus, ShoppingCart } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export function NewShoppingListButton() {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [weekStart, setWeekStart] = useState("");
+ const [saving, setSaving] = useState(false);
+
+ async function create() {
+ if (!name.trim()) return;
+ setSaving(true);
+ try {
+ const res = await fetch("/api/v1/shopping-lists", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: name.trim(),
+ fromMealPlanWeek: weekStart || undefined,
+ }),
+ });
+ if (!res.ok) { toast.error("Failed to create"); return; }
+ const { id } = await res.json() as { id: string };
+ toast.success("List created");
+ setOpen(false);
+ setName(""); setWeekStart("");
+ router.push(`/shopping-lists/${id}`);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/web/components/meal-plan/pantry-manager.tsx b/apps/web/components/meal-plan/pantry-manager.tsx
new file mode 100644
index 0000000..e74492a
--- /dev/null
+++ b/apps/web/components/meal-plan/pantry-manager.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useState } from "react";
+import { Plus, Trash2, AlertTriangle } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+type PantryItem = {
+ id: string;
+ rawName: string;
+ quantity: string | null;
+ unit: string | null;
+ expiresAt: string | null;
+};
+
+function daysUntilExpiry(dateStr: string): number {
+ const diff = new Date(dateStr).getTime() - Date.now();
+ return Math.ceil(diff / (1000 * 60 * 60 * 24));
+}
+
+export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
+ const [items, setItems] = useState(initialItems);
+ const [name, setName] = useState("");
+ const [quantity, setQuantity] = useState("");
+ const [unit, setUnit] = useState("");
+ const [expiresAt, setExpiresAt] = useState("");
+ const [adding, setAdding] = useState(false);
+
+ async function add() {
+ if (!name.trim()) return;
+ setAdding(true);
+ try {
+ const res = await fetch("/api/v1/pantry", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ rawName: name.trim(),
+ quantity: quantity.trim() || undefined,
+ unit: unit.trim() || undefined,
+ expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
+ }),
+ });
+ if (!res.ok) { toast.error("Failed to add"); return; }
+ const { id } = await res.json() as { id: string };
+ setItems((prev) => [...prev, { id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }]);
+ setName(""); setQuantity(""); setUnit(""); setExpiresAt("");
+ } finally {
+ setAdding(false);
+ }
+ }
+
+ async function remove(id: string) {
+ const res = await fetch(`/api/v1/pantry/${id}`, { method: "DELETE" });
+ if (res.ok) setItems((prev) => prev.filter((i) => i.id !== id));
+ else toast.error("Failed to remove");
+ }
+
+ const sorted = [...items].sort((a, b) => {
+ if (a.expiresAt && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
+ if (a.expiresAt) return -1;
+ if (b.expiresAt) return 1;
+ return a.rawName.localeCompare(b.rawName);
+ });
+
+ return (
+
+ {/* Add form */}
+
+
+ {/* Item list */}
+ {items.length === 0 ? (
+
No pantry items yet.
+ ) : (
+
+ {sorted.map((item) => {
+ const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
+ const expiring = days !== null && days <= 3;
+ const expired = days !== null && days < 0;
+ return (
+
+
+
+
{item.rawName}
+ {item.quantity &&
{item.quantity}{item.unit ? ` ${item.unit}` : ""}}
+ {expired &&
Expired}
+ {expiring && !expired &&
Expires in {days}d}
+
+ {item.expiresAt && !expired && !expiring && (
+
Expires {new Date(item.expiresAt).toLocaleDateString()}
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx
new file mode 100644
index 0000000..2054317
--- /dev/null
+++ b/apps/web/components/meal-plan/shopping-list-view.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import { Check, Package, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+
+type Item = {
+ id: string;
+ rawName: string;
+ quantity: string | null;
+ unit: string | null;
+ aisle: string | null;
+ checked: boolean;
+};
+
+export function ShoppingListView({
+ listId,
+ initialItems,
+}: {
+ listId: string;
+ initialItems: Item[];
+}) {
+ const [items, setItems] = useState- (initialItems);
+ const [movingToPantry, setMovingToPantry] = useState(false);
+
+ const checkedItems = items.filter((i) => i.checked);
+
+ async function moveToPantry() {
+ if (checkedItems.length === 0) return;
+ setMovingToPantry(true);
+ try {
+ const res = await fetch("/api/v1/pantry/bulk", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ items: checkedItems.map((i) => ({
+ rawName: i.rawName,
+ quantity: i.quantity ?? undefined,
+ unit: i.unit ?? undefined,
+ })),
+ }),
+ });
+ if (!res.ok) { toast.error("Failed to move items to pantry"); return; }
+ toast.success(`${checkedItems.length} item${checkedItems.length !== 1 ? "s" : ""} added to pantry`);
+ } finally {
+ setMovingToPantry(false);
+ }
+ }
+
+ async function toggleItem(item: Item) {
+ const next = !item.checked;
+ setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
+ await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ checked: next }),
+ });
+ }
+
+ const grouped = items.reduce>((acc, item) => {
+ const key = item.aisle ?? "Other";
+ (acc[key] ??= []).push(item);
+ return acc;
+ }, {});
+
+ const checkedCount = items.filter((i) => i.checked).length;
+
+ if (items.length === 0) {
+ return
This list is empty.
;
+ }
+
+ return (
+
+
+
{checkedCount}/{items.length} checked
+ {checkedCount > 0 && (
+
+ )}
+
+
+ {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
+
+ {Object.keys(grouped).length > 1 && (
+
{aisle}
+ )}
+
+ {aisleItems.map((item) => (
+
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/components/nutrition/nutrition-goals-form.tsx b/apps/web/components/nutrition/nutrition-goals-form.tsx
new file mode 100644
index 0000000..ff31e14
--- /dev/null
+++ b/apps/web/components/nutrition/nutrition-goals-form.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+type NutritionGoals = {
+ caloriesKcal: number | null;
+ proteinG: number | null;
+ carbsG: number | null;
+ fatG: number | null;
+} | null;
+
+interface NutritionGoalsFormProps {
+ initialGoals: NutritionGoals;
+}
+
+export function NutritionGoalsForm({ initialGoals }: NutritionGoalsFormProps) {
+ const [caloriesKcal, setCaloriesKcal] = useState(
+ initialGoals?.caloriesKcal != null ? String(initialGoals.caloriesKcal) : ""
+ );
+ const [proteinG, setProteinG] = useState(
+ initialGoals?.proteinG != null ? String(initialGoals.proteinG) : ""
+ );
+ const [carbsG, setCarbsG] = useState(
+ initialGoals?.carbsG != null ? String(initialGoals.carbsG) : ""
+ );
+ const [fatG, setFatG] = useState(
+ initialGoals?.fatG != null ? String(initialGoals.fatG) : ""
+ );
+ const [saving, setSaving] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSaving(true);
+
+ const body: Record = {};
+ if (caloriesKcal !== "") body.caloriesKcal = parseInt(caloriesKcal, 10);
+ if (proteinG !== "") body.proteinG = parseInt(proteinG, 10);
+ if (carbsG !== "") body.carbsG = parseInt(carbsG, 10);
+ if (fatG !== "") body.fatG = parseInt(fatG, 10);
+
+ try {
+ const res = await fetch("/api/v1/users/me/nutrition-goals", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ toast.error("Failed to save nutrition goals");
+ return;
+ }
+
+ toast.success("Nutrition goals saved");
+ } catch {
+ toast.error("Failed to save nutrition goals");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/nutrition/weekly-nutrition-bar.tsx b/apps/web/components/nutrition/weekly-nutrition-bar.tsx
new file mode 100644
index 0000000..0353061
--- /dev/null
+++ b/apps/web/components/nutrition/weekly-nutrition-bar.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+type NutritionTotals = {
+ calories: number;
+ protein: number;
+ carbs: number;
+ fat: number;
+};
+
+type NutritionGoals = {
+ caloriesKcal: number | null;
+ proteinG: number | null;
+ carbsG: number | null;
+ fatG: number | null;
+} | null;
+
+type NutritionCoverage = {
+ calories: number;
+ protein: number;
+ carbs: number;
+ fat: number;
+};
+
+type NutritionResponse = {
+ totals: NutritionTotals;
+ goals: NutritionGoals;
+ coverage: NutritionCoverage;
+};
+
+interface WeeklyNutritionBarProps {
+ weekStart: string;
+}
+
+type BarItem = {
+ label: string;
+ value: number;
+ goal: number | null;
+ unit: string;
+ percentage: number;
+ colorClass: string;
+};
+
+export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
+ const [data, setData] = useState(null);
+
+ useEffect(() => {
+ fetch(`/api/v1/meal-plans/${weekStart}/nutrition`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((json) => setData(json))
+ .catch(() => setData(null));
+ }, [weekStart]);
+
+ if (!data || !data.goals) return null;
+
+ const { totals, goals, coverage } = data;
+
+ const bars: BarItem[] = [
+ {
+ label: "Calories",
+ value: totals.calories,
+ goal: goals.caloriesKcal,
+ unit: "kcal",
+ percentage: coverage.calories,
+ colorClass: "bg-orange-500",
+ },
+ {
+ label: "Protein",
+ value: totals.protein,
+ goal: goals.proteinG,
+ unit: "g",
+ percentage: coverage.protein,
+ colorClass: "bg-blue-500",
+ },
+ {
+ label: "Carbs",
+ value: totals.carbs,
+ goal: goals.carbsG,
+ unit: "g",
+ percentage: coverage.carbs,
+ colorClass: "bg-green-500",
+ },
+ {
+ label: "Fat",
+ value: totals.fat,
+ goal: goals.fatG,
+ unit: "g",
+ percentage: coverage.fat,
+ colorClass: "bg-yellow-500",
+ },
+ ];
+
+ return (
+
+ {bars.map((bar) => {
+ if (bar.goal == null) return null;
+ const width = Math.min(bar.percentage, 100);
+ return (
+
+
+ {bar.label}
+
+ {bar.value} / {bar.goal} {bar.unit}
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/components/pantry/pantry-page-header.tsx b/apps/web/components/pantry/pantry-page-header.tsx
new file mode 100644
index 0000000..d192249
--- /dev/null
+++ b/apps/web/components/pantry/pantry-page-header.tsx
@@ -0,0 +1,22 @@
+"use client";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { ChefHat } from "lucide-react";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export function PantryPageHeader() {
+ const t = useTranslations("pantry");
+ return (
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+ {t("canCook")}
+
+
+ );
+}
diff --git a/apps/web/components/shopping-lists/shopping-lists-page-content.tsx b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx
new file mode 100644
index 0000000..04d42af
--- /dev/null
+++ b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx
@@ -0,0 +1,63 @@
+"use client";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { ShoppingCart } from "lucide-react";
+import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button";
+
+type ShoppingListItem = {
+ id: string;
+ name: string;
+ generatedAt: string | null;
+ totalItems: number;
+ checkedItems: number;
+};
+
+type Props = {
+ lists: ShoppingListItem[];
+};
+
+export function ShoppingListsPageContent({ lists }: Props) {
+ const t = useTranslations("shoppingLists");
+
+ return (
+
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+
+ {lists.length === 0 ? (
+
+ ) : (
+
+ {lists.map((list) => (
+
+
+
{list.name}
+
+ {list.totalItems === 0
+ ? t("listEmpty")
+ : t("items", { checked: list.checkedItems, total: list.totalItems })}
+ {list.generatedAt && ` · ${t("generated")}`}
+
+
+
+ {list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
+
+
+ ))}
+
+ )}
+
+ );
+}