feat(web): weekly meal plan UI with regenerate + per-slot swap
apps/api:
- New PATCH /me/meal-plans/slots/:slotId — accepts { recipeId } and validates
ownership + that the new recipe is one of the slot's existing alternatives
(or already selected). Returns the updated slot with the new selected recipe.
apps/web — /plan route:
- Server component runs the same auth+disclaimer+profile guard chain as
/profile, then redirects to /profile if no profile exists yet (you can't
generate a plan without macros)
- actions.ts exposes three server actions: fetchCurrentPlan, regeneratePlan
and swapSlotRecipe; all proxy to api with the user's session cookie and
revalidatePath('/plan') after writes
- plan-week.tsx renders day-by-day sections (Lun→Dom) with a 4-card meal
row (1/2/4 columns at sm/lg breakpoints), recipe name + kcal, daily kcal
totals, expandable list of alternatives with one-click "Scegli"
- "Genera piano" CTA when there's no plan yet, "Rigenera" when there is one
- Italian copy under Plan.* with day, meal and kcal-total interpolations
- Home gains a "Vedi il piano settimanale" CTA next to "Completa profilo"
Verified end-to-end in Chrome:
- generate plan → 7 sections (Lun-Dom), each with 4 meal cards and per-day
kcal sum (e.g. Lunedì 1510 kcal = 360+540+150+460)
- recently-consumed penalty visible: Mon/Tue/Wed have different breakfasts
- swap: clicked "Vedi 3 alternative" on Mon Colazione → "Scegli" on
"Uova strapazzate" → card refreshed to that recipe and 410 kcal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e7158abbad
commit
f8eaf2f493
6 files changed
+311
-1
No files matched your search
@@ -0,0 +1,53 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchCurrentPlan, regeneratePlan } from './actions';
|
||||
import { PlanWeek } from './plan-week';
|
||||
|
||||
export default async function PlanPage({ 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 plan = await fetchCurrentPlan();
|
||||
|
||||
return <PlanPageContent plan={plan} />;
|
||||
}
|
||||
|
||||
function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurrentPlan>> }) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
return (
|
||||
<main className="mx-auto min-h-screen max-w-5xl px-4 py-10 sm:px-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{plan ? t('weekOf', { date: plan.weekStart }) : t('noPlan')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<form action={regeneratePlan}>
|
||||
<Button type="submit" variant={plan ? 'outline' : 'default'} size="sm">
|
||||
{plan ? t('regenerate') : t('generate')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardHeader>
|
||||
<CardContent>{plan ? <PlanWeek plan={plan} /> : null}</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user