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,76 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
interface SlotRecipe {
|
||||
id: string;
|
||||
name: string;
|
||||
kcal: number;
|
||||
}
|
||||
|
||||
export interface PlanSlot {
|
||||
id: string;
|
||||
dayOfWeek: number;
|
||||
meal: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
|
||||
recipeId: string | null;
|
||||
status: string;
|
||||
selected: SlotRecipe | null;
|
||||
alternatives: SlotRecipe[];
|
||||
}
|
||||
|
||||
export interface CurrentPlan {
|
||||
id: string;
|
||||
weekStart: string;
|
||||
slots: PlanSlot[];
|
||||
}
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
|
||||
export async function fetchCurrentPlan(): Promise<CurrentPlan | null> {
|
||||
const res = await fetch(`${API_URL}/me/meal-plans/current`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`api_error_${res.status}`);
|
||||
const data = (await res.json()) as { plan: CurrentPlan };
|
||||
return data.plan;
|
||||
}
|
||||
|
||||
export type RegenerateResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function regeneratePlan(): Promise<RegenerateResult> {
|
||||
const res = await fetch(`${API_URL}/me/meal-plans`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/plan');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type SwapResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function swapSlotRecipe(slotId: string, recipeId: string): Promise<SwapResult> {
|
||||
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
|
||||
body: JSON.stringify({ recipeId }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/plan');
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in new issue
Block a user