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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user