feat(meal-plan): weekly planner, pantry, shopping lists, nutrition tracking
AI-generated weekly meal plans with pantry-awareness. Manual entry per slot. Pantry inventory management. Auto-generated shopping lists from meal plan. Weekly nutrition bar chart vs daily goals. Nutrition goals settings.
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Meal Plan</h1>
|
||||
<p className="text-muted-foreground mt-1">{label}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Shopping lists
|
||||
</Link>
|
||||
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WeeklyNutritionBar weekStart={weekStart} />
|
||||
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PantryPageHeader />
|
||||
<PantryManager initialItems={items.map((i) => ({
|
||||
id: i.id,
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
expiresAt: i.expiresAt?.toISOString() ?? null,
|
||||
}))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ShoppingListView
|
||||
listId={id}
|
||||
initialItems={list.items.map((i) => ({
|
||||
id: i.id,
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
aisle: i.aisle,
|
||||
checked: i.checked,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ShoppingListsPageContent
|
||||
lists={lists.map((list) => ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, { rawName: string; quantity?: string; unit?: string }>();
|
||||
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 });
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addTo", { day: dayLabel, meal: mealLabel })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder={t("searchRecipes")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={servings}
|
||||
onChange={(e) => setServings(e.target.value)}
|
||||
placeholder={t("servings")}
|
||||
/>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("noRecipes")}</p>
|
||||
) : (
|
||||
filtered.map((recipe) => (
|
||||
<button
|
||||
key={recipe.id}
|
||||
onClick={() => add(recipe)}
|
||||
disabled={saving}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
{recipe.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function MealPlanner({
|
||||
weekStart,
|
||||
initialEntries,
|
||||
userRecipes,
|
||||
}: {
|
||||
weekStart: string;
|
||||
initialEntries: Entry[];
|
||||
userRecipes: UserRecipe[];
|
||||
}) {
|
||||
const [entries, setEntries] = useState<Entry[]>(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 */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("generateWithAi")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] border-collapse table-fixed">
|
||||
<colgroup>
|
||||
<col className="w-24" />
|
||||
{DAYS.map(({ key }) => (
|
||||
<col key={key} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
|
||||
{DAYS.map(({ key, label }) => (
|
||||
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MEAL_TYPES.map(({ key: mealType, label }) => (
|
||||
<tr key={mealType} className="border-t">
|
||||
<td className="py-3 pr-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</span>
|
||||
</td>
|
||||
{DAYS.map(({ key: day }) => {
|
||||
const entry = getEntry(day, mealType);
|
||||
return (
|
||||
<td key={day} className="py-2 px-1 align-top">
|
||||
{entry?.recipe ? (
|
||||
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
|
||||
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
|
||||
<div className="text-muted-foreground mt-0.5">{entry.servings} srv</div>
|
||||
<button
|
||||
onClick={() => removeEntry(entry)}
|
||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAdding({ day, mealType })}
|
||||
className="w-full min-h-[52px] rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground/40" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{adding && addingDay && addingMeal && (
|
||||
<AddEntryModal
|
||||
weekStart={weekStart}
|
||||
day={adding.day}
|
||||
mealType={adding.mealType}
|
||||
userRecipes={userRecipes}
|
||||
onAdded={handleAdded}
|
||||
onClose={() => setAdding(null)}
|
||||
dayLabel={addingDay.label}
|
||||
mealLabel={addingMeal.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI generation modal */}
|
||||
{showAiModal && (
|
||||
<Dialog open onOpenChange={(open) => { if (!aiGenerating) setShowAiModal(open); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("generateWithAi")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("aiModalDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("dietaryPrefs")}</label>
|
||||
<Input
|
||||
placeholder={t("dietaryPrefsPlaceholder")}
|
||||
value={dietaryPrefs}
|
||||
onChange={(e) => setDietaryPrefs(e.target.value)}
|
||||
disabled={aiGenerating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("servings")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={aiServings}
|
||||
onChange={(e) => setAiServings(e.target.value)}
|
||||
disabled={aiGenerating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={usePantry}
|
||||
onChange={(e) => {
|
||||
setUsePantry(e.target.checked);
|
||||
if (!e.target.checked) setPantryMode(false);
|
||||
}}
|
||||
disabled={aiGenerating}
|
||||
/>
|
||||
<span className="text-sm font-medium">{t("includePantryItems")}</span>
|
||||
</label>
|
||||
{usePantry && (
|
||||
<label className="flex items-start gap-2 cursor-pointer select-none pl-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 mt-0.5"
|
||||
checked={pantryMode}
|
||||
onChange={(e) => setPantryMode(e.target.checked)}
|
||||
disabled={aiGenerating}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="text-sm font-medium block">{t("pantryMode")}</span>
|
||||
<span className="text-sm text-muted-foreground block">{t("pantryModeDescription")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<FakeProgressBar active={aiGenerating} durationMs={20000} label={aiPhase} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowAiModal(false)} disabled={aiGenerating}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => { void generateWithAi(); }} disabled={aiGenerating} className="gap-2">
|
||||
{aiGenerating ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block h-3.5 w-3.5 border-2 border-current border-t-transparent rounded-full" />
|
||||
{t("generating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t("generate")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> New list
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader><DialogTitle>New shopping list</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Weekly groceries" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Generate from meal plan week (optional)</Label>
|
||||
<Input type="date" value={weekStart} onChange={(e) => setWeekStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">Picks Monday of the selected week</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<PantryItem[]>(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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Add form */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Item name" className="flex-1 min-w-[160px]" onKeyDown={(e) => e.key === "Enter" && add()} />
|
||||
<Input value={quantity} onChange={(e) => setQuantity(e.target.value)} placeholder="Qty" className="w-20" />
|
||||
<Input value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="Unit" className="w-20" />
|
||||
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} className="w-36" />
|
||||
<Button onClick={add} disabled={!name.trim() || adding} size="sm">
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No pantry items yet.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border divide-y">
|
||||
{sorted.map((item) => {
|
||||
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
|
||||
const expiring = days !== null && days <= 3;
|
||||
const expired = days !== null && days < 0;
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{item.rawName}</span>
|
||||
{item.quantity && <span className="text-xs text-muted-foreground">{item.quantity}{item.unit ? ` ${item.unit}` : ""}</span>}
|
||||
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />Expired</span>}
|
||||
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />Expires in {days}d</span>}
|
||||
</div>
|
||||
{item.expiresAt && !expired && !expiring && (
|
||||
<p className="text-xs text-muted-foreground">Expires {new Date(item.expiresAt).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => remove(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Item[]>(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<Record<string, Item[]>>((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 <p className="text-muted-foreground text-sm">This list is empty.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{checkedCount}/{items.length} checked</p>
|
||||
{checkedCount > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={moveToPantry} disabled={movingToPantry}>
|
||||
{movingToPantry ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
|
||||
Move {checkedCount} to pantry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
|
||||
<div key={aisle} className="space-y-2">
|
||||
{Object.keys(grouped).length > 1 && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisle}</h2>
|
||||
)}
|
||||
<div className="rounded-xl border divide-y">
|
||||
{aisleItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => toggleItem(item)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 text-left transition-colors"
|
||||
>
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
item.checked ? "bg-primary border-primary" : "border-input"
|
||||
)}>
|
||||
{item.checked && <Check className="h-3 w-3 text-primary-foreground" />}
|
||||
</div>
|
||||
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
|
||||
{item.rawName}
|
||||
</span>
|
||||
{(item.quantity || item.unit) && (
|
||||
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
|
||||
{item.quantity}{item.unit ? ` ${item.unit}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>(
|
||||
initialGoals?.caloriesKcal != null ? String(initialGoals.caloriesKcal) : ""
|
||||
);
|
||||
const [proteinG, setProteinG] = useState<string>(
|
||||
initialGoals?.proteinG != null ? String(initialGoals.proteinG) : ""
|
||||
);
|
||||
const [carbsG, setCarbsG] = useState<string>(
|
||||
initialGoals?.carbsG != null ? String(initialGoals.carbsG) : ""
|
||||
);
|
||||
const [fatG, setFatG] = useState<string>(
|
||||
initialGoals?.fatG != null ? String(initialGoals.fatG) : ""
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
const body: Record<string, number> = {};
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="caloriesKcal">Calories (kcal/day)</Label>
|
||||
<Input
|
||||
id="caloriesKcal"
|
||||
type="number"
|
||||
min={0}
|
||||
value={caloriesKcal}
|
||||
onChange={(e) => setCaloriesKcal(e.target.value)}
|
||||
placeholder="e.g. 2000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proteinG">Protein (g/day)</Label>
|
||||
<Input
|
||||
id="proteinG"
|
||||
type="number"
|
||||
min={0}
|
||||
value={proteinG}
|
||||
onChange={(e) => setProteinG(e.target.value)}
|
||||
placeholder="e.g. 50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="carbsG">Carbs (g/day)</Label>
|
||||
<Input
|
||||
id="carbsG"
|
||||
type="number"
|
||||
min={0}
|
||||
value={carbsG}
|
||||
onChange={(e) => setCarbsG(e.target.value)}
|
||||
placeholder="e.g. 250"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fatG">Fat (g/day)</Label>
|
||||
<Input
|
||||
id="fatG"
|
||||
type="number"
|
||||
min={0}
|
||||
value={fatG}
|
||||
onChange={(e) => setFatG(e.target.value)}
|
||||
placeholder="e.g. 70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save goals"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<NutritionResponse | null>(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 (
|
||||
<div className="space-y-3">
|
||||
{bars.map((bar) => {
|
||||
if (bar.goal == null) return null;
|
||||
const width = Math.min(bar.percentage, 100);
|
||||
return (
|
||||
<div key={bar.label} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{bar.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{bar.value} / {bar.goal} {bar.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${bar.colorClass}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Link href="/recipes/can-cook" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ChefHat className="h-4 w-4" />
|
||||
{t("canCook")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
||||
</div>
|
||||
<NewShoppingListButton />
|
||||
</div>
|
||||
|
||||
{lists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
|
||||
<ShoppingCart className="h-12 w-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground text-sm">{t("empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-w-lg">
|
||||
{lists.map((list) => (
|
||||
<Link
|
||||
key={list.id}
|
||||
href={`/shopping-lists/${list.id}`}
|
||||
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold">{list.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{list.totalItems === 0
|
||||
? t("listEmpty")
|
||||
: t("items", { checked: list.checkedItems, total: list.totalItems })}
|
||||
{list.generatedAt && ` · ${t("generated")}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-bold">
|
||||
{list.totalItems === 0 ? "—" : `${Math.round((list.checkedItems / list.totalItems) * 100)}%`}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user