feat: recipe detail + weekly shopping list (PRD §5.1, §5.4)
Recipe detail - Seed catalog of 33 italian ingredients with macros, allergens, avg price. - Each seed recipe now declares its RecipeIngredient links (qty + unit). - /recipes/[id] page: editorial layout with prep meta, ingredients list, macros block, preparation, chef's note. Plan-week meal names linkable. Shopping list - GET /me/shopping-list aggregates RecipeIngredient quantities of the ACTIVE plan, grouped by ingredient category, sorted alphabetically. - /shopping page: total items, estimated cost, per-category checklist with check-off persisted in localStorage per plan id. - Home gets a "Spesa" nav item; /plan links the list when a plan exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b79340efb7
commit
ad1e4ecaeb
13 files changed
+1331
-14
No files matched your search
@@ -11,6 +11,7 @@ import { healthRoutes } from './modules/health/health.routes.js';
|
||||
import { meRoutes } from './modules/me/me.routes.js';
|
||||
import { planRoutes } from './modules/plan/plan.routes.js';
|
||||
import { profileRoutes } from './modules/profile/profile.routes.js';
|
||||
import { shoppingRoutes } from './modules/shopping/shopping.routes.js';
|
||||
import { fastRoutes } from './modules/tracking/fast.routes.js';
|
||||
import { weightRoutes } from './modules/tracking/weight.routes.js';
|
||||
import { authPlugin } from './plugins/auth.js';
|
||||
@@ -52,6 +53,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(weightRoutes);
|
||||
await app.register(fastRoutes);
|
||||
await app.register(planRoutes);
|
||||
await app.register(shoppingRoutes);
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
app.setErrorHandler((err, request, reply) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// PRD §5.4 — lista della spesa generata aggregando i RecipeIngredient
|
||||
// del piano settimanale ACTIVE dell'utente. Aggregazione per (ingrediente, unità):
|
||||
// se la stessa ricetta usa lo stesso ingrediente con unità diverse (raro ma
|
||||
// possibile), restano righe separate.
|
||||
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
|
||||
interface ShoppingLine {
|
||||
ingredientId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
recipes: string[]; // nomi delle ricette che la richiedono
|
||||
priceAvgEur: number | null;
|
||||
}
|
||||
|
||||
interface ShoppingGroup {
|
||||
category: string;
|
||||
items: ShoppingLine[];
|
||||
}
|
||||
|
||||
export const shoppingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/shopping-list', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
const plan = await fastify.prisma.mealPlan.findFirst({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { weekStart: 'desc' },
|
||||
include: {
|
||||
slots: {
|
||||
where: { recipeId: { not: null } },
|
||||
include: {
|
||||
selected: {
|
||||
include: {
|
||||
ingredients: {
|
||||
include: { ingredient: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) return reply.code(404).send({ error: 'plan_not_found' });
|
||||
|
||||
type AggKey = string; // `${ingredientId}::${unit}`
|
||||
const aggregated = new Map<AggKey, ShoppingLine>();
|
||||
|
||||
for (const slot of plan.slots) {
|
||||
const recipe = slot.selected;
|
||||
if (!recipe) continue;
|
||||
for (const ri of recipe.ingredients) {
|
||||
const key: AggKey = `${ri.ingredientId}::${ri.unit}`;
|
||||
const existing = aggregated.get(key);
|
||||
if (existing) {
|
||||
existing.quantity += ri.quantity;
|
||||
if (!existing.recipes.includes(recipe.name)) {
|
||||
existing.recipes.push(recipe.name);
|
||||
}
|
||||
} else {
|
||||
aggregated.set(key, {
|
||||
ingredientId: ri.ingredientId,
|
||||
name: ri.ingredient.name,
|
||||
category: ri.ingredient.category,
|
||||
unit: ri.unit,
|
||||
quantity: ri.quantity,
|
||||
recipes: [recipe.name],
|
||||
priceAvgEur: ri.ingredient.priceAvgEur ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byCategory = new Map<string, ShoppingLine[]>();
|
||||
for (const line of aggregated.values()) {
|
||||
if (!byCategory.has(line.category)) byCategory.set(line.category, []);
|
||||
byCategory.get(line.category)!.push(line);
|
||||
}
|
||||
|
||||
const groups: ShoppingGroup[] = Array.from(byCategory.entries())
|
||||
.map(([category, items]) => ({
|
||||
category,
|
||||
items: items.sort((a, b) => a.name.localeCompare(b.name, 'it')),
|
||||
}))
|
||||
.sort((a, b) => a.category.localeCompare(b.category, 'it'));
|
||||
|
||||
return {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
weekStart: plan.weekStart.toISOString().slice(0, 10),
|
||||
},
|
||||
groups,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
"signOut": "Esci",
|
||||
"completeProfile": "Profilo & calcoli",
|
||||
"viewPlan": "Piano della settimana",
|
||||
"viewShopping": "Lista della spesa",
|
||||
"viewTracking": "Pesata & misure",
|
||||
"viewFasting": "Digiuno intermittente",
|
||||
"lettura": "Continua la lettura",
|
||||
@@ -164,6 +165,64 @@
|
||||
"delta": "Variazione",
|
||||
"emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate."
|
||||
},
|
||||
"Shopping": {
|
||||
"title": "La spesa della settimana",
|
||||
"subtitle": "Tutto ciò che ti serve per il piano in corso, raggruppato per reparto.",
|
||||
"noPlan": "Genera prima un piano della settimana: la lista comparirà qui.",
|
||||
"viewPlan": "Vai al piano",
|
||||
"groupSize": "{n} articoli",
|
||||
"totalLabel": "Totale articoli",
|
||||
"estimatedCost": "Spesa stimata",
|
||||
"checked": "{n} di {total}",
|
||||
"fromRecipes": "Per: {names}",
|
||||
"categoryLabel": {
|
||||
"verdura": "Verdura",
|
||||
"frutta": "Frutta",
|
||||
"carne": "Carne",
|
||||
"pesce": "Pesce",
|
||||
"latticini": "Latticini",
|
||||
"uova": "Uova",
|
||||
"condimenti": "Condimenti",
|
||||
"panetteria": "Panetteria",
|
||||
"frutta-secca": "Frutta secca",
|
||||
"erbe": "Erbe e spezie",
|
||||
"salumi": "Salumi"
|
||||
},
|
||||
"viewCart": "Lista spesa"
|
||||
},
|
||||
"Recipe": {
|
||||
"title": "Ricetta",
|
||||
"back": "← Torna al piano",
|
||||
"ingredientsTitle": "Ingredienti",
|
||||
"ingredientsSubtitle": "Per una porzione singola",
|
||||
"preparationTitle": "Preparazione",
|
||||
"preparationFallback": "Mescola gli ingredienti seguendo l'ordine indicato e cuoci come preferisci.",
|
||||
"macrosTitle": "Valori per porzione",
|
||||
"kcal": "Calorie",
|
||||
"protein": "Proteine",
|
||||
"fat": "Grassi",
|
||||
"netCarb": "Carb. netti",
|
||||
"metaTitle": "Scheda ricetta",
|
||||
"prepTime": "Preparazione",
|
||||
"minutes": "{n} min",
|
||||
"difficulty": "Difficoltà",
|
||||
"phase": "Fase",
|
||||
"phasesAll": "Tutte le fasi",
|
||||
"phasesSome": "Fase {phases}",
|
||||
"notesChef": "Nota dello chef",
|
||||
"difficultyLabel": {
|
||||
"FACILE": "Facile",
|
||||
"MEDIA": "Media",
|
||||
"ELABORATA": "Elaborata"
|
||||
},
|
||||
"categoryLabel": {
|
||||
"COLAZIONE": "Colazione",
|
||||
"PRANZO": "Pranzo",
|
||||
"SPUNTINO": "Spuntino",
|
||||
"CENA": "Cena"
|
||||
},
|
||||
"notFound": "Ricetta non trovata."
|
||||
},
|
||||
"Plan": {
|
||||
"title": "Il tuo piano",
|
||||
"weekOf": "Settimana del {date}",
|
||||
|
||||
@@ -104,6 +104,7 @@ function SignedInBlock({
|
||||
</div>
|
||||
<ul className="space-y-px">
|
||||
<NavItem href="/plan" label={t('viewPlan')} eyebrow="Cucina" />
|
||||
<NavItem href="/shopping" label={t('viewShopping')} eyebrow="Spesa" />
|
||||
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" />
|
||||
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" />
|
||||
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
@@ -62,12 +63,20 @@ function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurren
|
||||
{plan ? formatWeek(plan.weekStart) : t('noPlan')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
|
||||
<div className="animate-fade-up flex flex-col items-stretch gap-3 [animation-delay:360ms] md:col-span-4 md:items-end">
|
||||
<form action={regeneratePlan}>
|
||||
<Button type="submit" variant={plan ? 'outline' : 'default'} size="lg">
|
||||
{plan ? t('regenerate') : t('generate')}
|
||||
</Button>
|
||||
</form>
|
||||
{plan ? (
|
||||
<Link
|
||||
href="/shopping"
|
||||
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
|
||||
>
|
||||
Lista della spesa →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
@@ -87,9 +88,16 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
|
||||
<span>{t(`meals.${slot.meal}`)}</span>
|
||||
</p>
|
||||
</header>
|
||||
<p className="font-display text-ink text-lg font-medium leading-tight">
|
||||
{slot.selected?.name ?? t('noRecipe')}
|
||||
</p>
|
||||
{slot.selected ? (
|
||||
<Link
|
||||
href={`/recipes/${slot.selected.id}`}
|
||||
className="font-display text-ink decoration-pomodoro hover:decoration-ink text-lg font-medium leading-tight underline decoration-[1.5px] underline-offset-[5px] transition-colors"
|
||||
>
|
||||
{slot.selected.name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-display text-ink-soft text-lg italic leading-tight">{t('noRecipe')}</p>
|
||||
)}
|
||||
{slot.selected ? (
|
||||
<p className="text-ink-soft font-mono text-xs tracking-tight">
|
||||
{Math.round(slot.selected.kcal)}{' '}
|
||||
@@ -109,12 +117,15 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
|
||||
<ul className="border-ink/15 mt-3 space-y-2 border-t pt-3">
|
||||
{slot.alternatives.map((alt) => (
|
||||
<li key={alt.id} className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-display text-ink text-sm leading-snug">
|
||||
<Link
|
||||
href={`/recipes/${alt.id}`}
|
||||
className="font-display text-ink hover:text-pomodoro text-sm leading-snug transition-colors"
|
||||
>
|
||||
{alt.name}{' '}
|
||||
<span className="text-ink-dim font-mono text-[10px]">
|
||||
{Math.round(alt.kcal)} kcal
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
interface RecipeIngredientView {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface RecipeView {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
|
||||
description: string | null;
|
||||
prepMinutes: number;
|
||||
difficulty: 'FACILE' | 'MEDIA' | 'ELABORATA';
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
fatG: number;
|
||||
netCarbG: number;
|
||||
phases: number[];
|
||||
notesChef: string | null;
|
||||
ingredients: RecipeIngredientView[];
|
||||
}
|
||||
|
||||
export default async function RecipePage({
|
||||
params: { locale, id },
|
||||
}: {
|
||||
params: { locale: string; id: string };
|
||||
}) {
|
||||
setRequestLocale(locale);
|
||||
const session = await getServerSession();
|
||||
if (!session?.user) redirect('/sign-in');
|
||||
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: { select: { id: true, name: true, category: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const view: RecipeView = {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
category: recipe.category,
|
||||
description: recipe.description,
|
||||
prepMinutes: recipe.prepMinutes,
|
||||
difficulty: recipe.difficulty,
|
||||
kcal: recipe.kcal,
|
||||
proteinG: recipe.proteinG,
|
||||
fatG: recipe.fatG,
|
||||
netCarbG: recipe.netCarbG,
|
||||
phases: recipe.phases,
|
||||
notesChef: recipe.notesChef,
|
||||
ingredients: recipe.ingredients.map((ri) => ({
|
||||
id: ri.id,
|
||||
name: ri.ingredient.name,
|
||||
category: ri.ingredient.category,
|
||||
quantity: ri.quantity,
|
||||
unit: ri.unit,
|
||||
})),
|
||||
};
|
||||
|
||||
return <RecipeContent recipe={view} />;
|
||||
}
|
||||
|
||||
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'];
|
||||
|
||||
function RecipeContent({ recipe }: { recipe: RecipeView }) {
|
||||
const t = useTranslations('Recipe');
|
||||
const phasesAll = recipe.phases.length === 3;
|
||||
|
||||
return (
|
||||
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||
<Masthead issueLabel={`N. 08 — ${t(`categoryLabel.${recipe.category}`)}`} />
|
||||
<main className="py-10 sm:py-14">
|
||||
<Link
|
||||
href="/plan"
|
||||
className="editorial-eyebrow hover:text-ink text-ink-soft animate-fade-up inline-flex items-center transition-colors"
|
||||
>
|
||||
{t('back')}
|
||||
</Link>
|
||||
|
||||
<header className="mt-6 grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-8">
|
||||
<p className="editorial-eyebrow animate-fade-up [animation-delay:120ms]">
|
||||
{t(`categoryLabel.${recipe.category}`)}
|
||||
</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:180ms]">
|
||||
{recipe.name}
|
||||
<span className="text-pomodoro">.</span>
|
||||
</h1>
|
||||
{recipe.description ? (
|
||||
<p className="font-display text-ink-soft animate-fade-up mt-5 max-w-2xl text-xl italic leading-snug [animation-delay:300ms]">
|
||||
{recipe.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-4 md:border-l md:pl-8">
|
||||
<p className="editorial-eyebrow">{t('metaTitle')}</p>
|
||||
<dl className="mt-5 space-y-4">
|
||||
<MetaRow label={t('prepTime')} value={t('minutes', { n: recipe.prepMinutes })} />
|
||||
<MetaRow label={t('difficulty')} value={t(`difficultyLabel.${recipe.difficulty}`)} />
|
||||
<MetaRow
|
||||
label={t('phase')}
|
||||
value={
|
||||
phasesAll
|
||||
? t('phasesAll')
|
||||
: t('phasesSome', { phases: recipe.phases.join(' · ') })
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<div className="rule animate-rule-in my-10 [animation-delay:480ms]" />
|
||||
|
||||
<section className="grid gap-12 md:grid-cols-12">
|
||||
<div className="animate-fade-up [animation-delay:540ms] md:col-span-7">
|
||||
<p className="editorial-eyebrow">{t('ingredientsTitle')}</p>
|
||||
<p className="font-display text-ink-soft mt-1 text-sm italic">
|
||||
{t('ingredientsSubtitle')}
|
||||
</p>
|
||||
<ol className="mt-7 space-y-5">
|
||||
{recipe.ingredients.map((ing, idx) => (
|
||||
<li
|
||||
key={ing.id}
|
||||
className="border-ink/10 grid grid-cols-[2.5rem_1fr_auto] items-baseline gap-4 border-b pb-4"
|
||||
>
|
||||
<span className="text-ink-dim font-mono text-xs uppercase tracking-widest">
|
||||
{ROMAN[idx] ?? idx + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-ink text-lg leading-tight">{ing.name}</p>
|
||||
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
|
||||
{ing.category}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-ink font-mono text-sm tabular-nums">
|
||||
{formatQuantity(ing.quantity)} {ing.unit}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<aside className="md:border-ink/15 animate-fade-up [animation-delay:660ms] md:col-span-5 md:border-l md:pl-10">
|
||||
<p className="editorial-eyebrow">{t('macrosTitle')}</p>
|
||||
<dl className="mt-7 space-y-6">
|
||||
<MacroRow label={t('kcal')} value={Math.round(recipe.kcal)} unit="kcal" big />
|
||||
<MacroRow label={t('protein')} value={Math.round(recipe.proteinG)} unit="g" />
|
||||
<MacroRow label={t('fat')} value={Math.round(recipe.fatG)} unit="g" />
|
||||
<MacroRow label={t('netCarb')} value={Math.round(recipe.netCarbG)} unit="g" />
|
||||
</dl>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<div className="rule animate-rule-in my-12 [animation-delay:780ms]" />
|
||||
|
||||
<section className="animate-fade-up grid gap-12 [animation-delay:840ms] md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<p className="editorial-eyebrow">{t('preparationTitle')}</p>
|
||||
<p className="font-display text-ink mt-6 text-lg leading-relaxed">
|
||||
<span className="drop-cap">
|
||||
{(recipe.description ?? t('preparationFallback'))[0]}
|
||||
</span>
|
||||
{(recipe.description ?? t('preparationFallback')).slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
{recipe.notesChef ? (
|
||||
<aside className="md:border-ink/15 md:col-span-5 md:border-l md:pl-10">
|
||||
<p className="editorial-eyebrow">{t('notesChef')}</p>
|
||||
<p className="font-display text-ink-soft mt-6 text-base italic leading-relaxed">
|
||||
“{recipe.notesChef}”
|
||||
</p>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div className="rule-thick mt-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 items-baseline gap-3">
|
||||
<dt className="editorial-eyebrow">{label}</dt>
|
||||
<dd className="font-display text-ink text-base leading-tight">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacroRow({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
big = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
big?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-ink/10 grid grid-cols-2 items-baseline gap-3 border-b pb-4 last:border-b-0">
|
||||
<dt className="editorial-eyebrow">{label}</dt>
|
||||
<dd
|
||||
className={`text-ink font-mono tabular-nums ${
|
||||
big ? 'text-3xl font-medium' : 'text-xl'
|
||||
} leading-none`}
|
||||
>
|
||||
{value}
|
||||
<span className="font-display text-ink-soft ml-1 text-xs italic">{unit}</span>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatQuantity(q: number): string {
|
||||
if (Number.isInteger(q)) return String(q);
|
||||
if (q < 1) {
|
||||
if (q === 0.25) return '¼';
|
||||
if (q === 0.5) return '½';
|
||||
if (q === 0.75) return '¾';
|
||||
}
|
||||
return q.toFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
export interface ShoppingLine {
|
||||
ingredientId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
recipes: string[];
|
||||
priceAvgEur: number | null;
|
||||
}
|
||||
|
||||
export interface ShoppingGroup {
|
||||
category: string;
|
||||
items: ShoppingLine[];
|
||||
}
|
||||
|
||||
export interface ShoppingList {
|
||||
plan: { id: string; weekStart: string };
|
||||
groups: ShoppingGroup[];
|
||||
}
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
|
||||
export async function fetchShoppingList(): Promise<ShoppingList | null> {
|
||||
const res = await fetch(`${API_URL}/me/shopping-list`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`api_error_${res.status}`);
|
||||
return (await res.json()) as ShoppingList;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchShoppingList, type ShoppingList } from './actions';
|
||||
import { ShoppingChecklist } from './shopping-checklist';
|
||||
|
||||
export default async function ShoppingPage({ params: { locale } }: { params: { locale: string } }) {
|
||||
setRequestLocale(locale);
|
||||
const session = await getServerSession();
|
||||
if (!session?.user) redirect('/sign-in');
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
||||
});
|
||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||
if (!user.profile) redirect('/profile');
|
||||
|
||||
const list = await fetchShoppingList();
|
||||
|
||||
return <ShoppingPageContent list={list} />;
|
||||
}
|
||||
|
||||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
function formatWeek(weekStart: string): string {
|
||||
const start = new Date(`${weekStart}T00:00:00`);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
return `${ITALIAN_DATE.format(start)} – ${ITALIAN_DATE.format(end)}`;
|
||||
}
|
||||
|
||||
function ShoppingPageContent({ list }: { list: ShoppingList | null }) {
|
||||
const t = useTranslations('Shopping');
|
||||
|
||||
const totalItems = list?.groups.reduce((sum, g) => sum + g.items.length, 0) ?? 0;
|
||||
const estimatedCost = list?.groups.reduce(
|
||||
(sum, g) =>
|
||||
sum + g.items.reduce((s, it) => s + (it.priceAvgEur != null ? estimateLineCost(it) : 0), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||
<Masthead issueLabel="N. 09 — La spesa" />
|
||||
<main className="py-12 sm:py-16">
|
||||
<div className="grid items-end gap-6 md:grid-cols-12">
|
||||
<div className="md:col-span-8">
|
||||
<p className="editorial-eyebrow animate-fade-up">Capitolo IX</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||
{t('title')}
|
||||
<span className="text-pomodoro">.</span>
|
||||
</h1>
|
||||
<p className="font-display text-ink-soft animate-fade-up mt-5 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
|
||||
{list ? formatWeek(list.plan.weekStart) : t('noPlan')}
|
||||
</p>
|
||||
</div>
|
||||
{list ? (
|
||||
<aside className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
|
||||
<p className="editorial-eyebrow">{t('totalLabel')}</p>
|
||||
<p className="font-display text-ink mt-2 font-mono text-3xl tabular-nums">
|
||||
{totalItems}
|
||||
</p>
|
||||
{typeof estimatedCost === 'number' && estimatedCost > 0 ? (
|
||||
<p className="text-ink-soft mt-3 font-mono text-xs uppercase tracking-widest">
|
||||
{t('estimatedCost')}{' '}
|
||||
<span className="text-ink not-italic">€ {estimatedCost.toFixed(2)}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
) : (
|
||||
<div className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/plan">{t('viewPlan')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rule animate-rule-in my-10 [animation-delay:420ms]" />
|
||||
|
||||
{list ? (
|
||||
<div className="animate-fade-up [animation-delay:480ms]">
|
||||
<ShoppingChecklist list={list} />
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<div className="rule-thick mt-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function estimateLineCost(line: {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
priceAvgEur: number | null;
|
||||
}): number {
|
||||
if (line.priceAvgEur == null) return 0;
|
||||
// Prezzo medio è espresso al kg/litro/cad (vedi seed-ingredients).
|
||||
// Heuristica semplice: g/ml → kg/litro (÷1000); pz / cucchiaio / cucchiaino → conta uno.
|
||||
if (line.unit === 'g' || line.unit === 'ml') {
|
||||
return (line.quantity / 1000) * line.priceAvgEur;
|
||||
}
|
||||
return line.quantity * line.priceAvgEur;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { ShoppingLine, ShoppingList } from './actions';
|
||||
|
||||
const ROMAN = [
|
||||
'I',
|
||||
'II',
|
||||
'III',
|
||||
'IV',
|
||||
'V',
|
||||
'VI',
|
||||
'VII',
|
||||
'VIII',
|
||||
'IX',
|
||||
'X',
|
||||
'XI',
|
||||
'XII',
|
||||
'XIII',
|
||||
'XIV',
|
||||
'XV',
|
||||
'XVI',
|
||||
'XVII',
|
||||
'XVIII',
|
||||
'XIX',
|
||||
'XX',
|
||||
];
|
||||
|
||||
function storageKey(planId: string): string {
|
||||
return `ketopath:shopping:${planId}`;
|
||||
}
|
||||
|
||||
function lineKey(line: ShoppingLine): string {
|
||||
return `${line.ingredientId}::${line.unit}`;
|
||||
}
|
||||
|
||||
export function ShoppingChecklist({ list }: { list: ShoppingList }) {
|
||||
const t = useTranslations('Shopping');
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Load locally persisted check state per piano.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(list.plan.id));
|
||||
if (raw) setChecked(JSON.parse(raw) as Record<string, boolean>);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}, [list.plan.id]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(list.plan.id), JSON.stringify(checked));
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}, [checked, list.plan.id]);
|
||||
|
||||
const totalCount = useMemo(
|
||||
() => list.groups.reduce((s, g) => s + g.items.length, 0),
|
||||
[list.groups],
|
||||
);
|
||||
const checkedCount = useMemo(() => Object.values(checked).filter(Boolean).length, [checked]);
|
||||
|
||||
function toggle(line: ShoppingLine): void {
|
||||
const key = lineKey(line);
|
||||
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('checked', { n: checkedCount, total: totalCount })}
|
||||
</p>
|
||||
|
||||
{list.groups.map((group, groupIdx) => (
|
||||
<section
|
||||
key={group.category}
|
||||
aria-labelledby={`group-${group.category}`}
|
||||
className="space-y-5"
|
||||
>
|
||||
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
|
||||
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
|
||||
{ROMAN[groupIdx] ?? groupIdx + 1}
|
||||
</span>
|
||||
<h2
|
||||
id={`group-${group.category}`}
|
||||
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t.has(`categoryLabel.${group.category}`)
|
||||
? t(`categoryLabel.${group.category}`)
|
||||
: group.category}
|
||||
</h2>
|
||||
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
||||
{t('groupSize', { n: group.items.length })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul className="divide-ink/10 divide-y">
|
||||
{group.items.map((line) => {
|
||||
const key = lineKey(line);
|
||||
const isChecked = !!checked[key];
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(line)}
|
||||
aria-pressed={isChecked}
|
||||
className="hover:bg-ink/5 group grid w-full grid-cols-[1.25rem_1fr_auto] items-baseline gap-4 py-4 pr-2 text-left transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`border-ink h-3 w-3 translate-y-1 border-[1.5px] transition-colors ${
|
||||
isChecked ? 'bg-ink' : 'bg-transparent'
|
||||
}`}
|
||||
/>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`font-display text-ink text-lg leading-tight transition-colors ${
|
||||
isChecked ? 'text-ink-dim line-through' : ''
|
||||
}`}
|
||||
>
|
||||
{line.name}
|
||||
</span>
|
||||
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||
{t('fromRecipes', { names: line.recipes.join(' · ') })}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`text-ink font-mono text-sm tabular-nums transition-colors ${
|
||||
isChecked ? 'text-ink-dim' : ''
|
||||
}`}
|
||||
>
|
||||
{formatQuantity(line.quantity)} {line.unit}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatQuantity(q: number): string {
|
||||
if (Number.isInteger(q)) return String(q);
|
||||
if (q === 0.25) return '¼';
|
||||
if (q === 0.5) return '½';
|
||||
if (q === 0.75) return '¾';
|
||||
return q.toFixed(1);
|
||||
}
|
||||
Reference in new issue
Block a user