feat: §6 Gruppo B residuo — pannello macros, esclusioni granulari, achievement

- Plan: pannello macros laterale "oggi" con consumati/pianificati vs target (kcal/P/F/C). Sticky su lg, in-flow su mobile. Profile/derived ora include kcalTarget e i macro target (BMR aggiustato per condizioni e diet history).
- Preferenze: nuova sezione "Ingredienti specifici da evitare" con ricerca debounced sul catalogo (cap 50). Schema Preferences.bannedIngredientIds + matchmaking che scarta ricette contenenti ingredient bandito (test unit).
- Achievement: tabella Achievement (userId+key unique), 4 traguardi iniziali (first_weigh_in, first_plan, first_fast_complete, ten_meals_consumed). Service idempotente eval+persist agganciato a 4 trigger (weight POST, plan POST, fast PATCH→COMPLETED, slot consumed). Push notification best-effort all'unlock. Pannello badge nel profilo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-06 15:45:30 +02:00
1 parent 3b895191f5
commit d17c5ef869
29 files changed
+1048 -86

No files matched your search

+39
View File
@@ -334,6 +334,13 @@
"subtitle": "Le esclusioni filtrano gli ingredienti, le cucine influenzano il matchmaking, il tempo decide quante ricette elaborate proporre.",
"exclusionsTitle": "Esclusioni alimentari",
"exclusionsHint": "Tutte le ricette che contengono almeno uno di questi gruppi verranno escluse dal piano.",
"bannedIngredientsTitle": "Ingredienti specifici da evitare",
"bannedIngredientsHint": "Cerca un ingrediente per nome (es. salmone, tofu, peperone). Le ricette che lo contengono verranno escluse dal piano. Massimo 50.",
"bannedIngredientsSearchPlaceholder": "Cerca un ingrediente…",
"bannedIngredientsSearching": "Sto cercando…",
"bannedIngredientsEmpty": "Nessun ingrediente bandito per ora.",
"bannedIngredientsRemove": "Rimuovi",
"bannedIngredientsCap": "Hai raggiunto il massimo di 50 ingredienti.",
"cuisinesTitle": "Cucine preferite",
"cuisinesHint": "Indicaci le tradizioni che preferisci. È un suggerimento, non un vincolo.",
"cookingTimeTitle": "Tempo in cucina",
@@ -538,6 +545,9 @@
"avgProtein": "Proteine medie",
"avgFat": "Grassi medi",
"avgNetCarb": "Carb. netti medi",
"todayPanelTitle": "Oggi · macros del giorno",
"todayPanelTargetHint": "Consumati / target — pianificati in chiaro",
"todayPanelFasting": "Niente pasti pianificati per oggi: è un giorno di digiuno completo.",
"fastingDay": "Giorno di digiuno",
"fastingDayHint": "Acqua, tè e tisane. Niente cibo solido.",
"lightDay": "Giorno leggero",
@@ -566,6 +576,35 @@
"sunday": "Domenica"
}
},
"Achievements": {
"eyebrow": "Traguardi",
"title": "Le tappe del tuo percorso",
"subtitle": "Hai sbloccato {got} traguardi su {total}. Continua così.",
"locked": "Da sbloccare",
"category": {
"tracking": "Tracking",
"plan": "Piano",
"fasting": "Digiuno"
},
"key": {
"first_weigh_in": {
"title": "Prima pesata",
"hint": "Hai registrato il tuo peso per la prima volta."
},
"first_plan": {
"title": "Primo piano",
"hint": "Hai generato il tuo primo piano settimanale."
},
"first_fast_complete": {
"title": "Primo digiuno completato",
"hint": "Hai portato a termine la tua prima sessione di digiuno."
},
"ten_meals_consumed": {
"title": "Dieci pasti spuntati",
"hint": "Hai marcato come consumati 10 pasti dal tuo piano."
}
}
},
"Profile": {
"title": "Il tuo profilo",
"subtitle": "I dati che ci servono per calcolare BMR, TDEE e i target di macros della settimana.",
@@ -740,5 +740,6 @@ function toFormValues(view: PreferencesView): PreferencesValues {
trainingType: view.trainingType as PreferencesValues['trainingType'],
sessionMinutes: view.sessionMinutes,
mealsPerDay: view.mealsPerDay,
bannedIngredientIds: view.bannedIngredientIds ?? [],
};
}
+33
View File
@@ -44,6 +44,13 @@ export interface CurrentPlan {
phase2Week: number | null;
}
export interface DailyTarget {
kcal: number;
proteinG: number;
fatG: number;
netCarbG: number;
}
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
@@ -59,6 +66,32 @@ export async function fetchCurrentPlan(): Promise<CurrentPlan | null> {
return data.plan;
}
export async function fetchDailyTarget(): Promise<DailyTarget | null> {
const res = await fetch(`${API_URL}/me/profile`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return null;
const data = (await res.json()) as {
profile?: {
derived?: {
kcalTarget?: number;
proteinTargetG?: number;
fatTargetG?: number;
netCarbTargetG?: number;
};
};
};
const d = data.profile?.derived;
if (!d || d.kcalTarget == null || d.proteinTargetG == null) return null;
return {
kcal: d.kcalTarget,
proteinG: d.proteinTargetG,
fatG: d.fatTargetG ?? 0,
netCarbG: d.netCarbTargetG ?? 0,
};
}
// Server action invocata via `<form action={regeneratePlan}>`. La firma è
// `(FormData) => Promise<void>` per allinearsi al type richiesto da React 18.
export async function regeneratePlan(_formData?: FormData): Promise<void> {
+11 -5
View File
@@ -8,7 +8,7 @@ import { Masthead } from '@/components/masthead';
import { Button } from '@/components/ui/button';
import { getServerSession } from '@/lib/auth';
import { fetchCurrentPlan, regeneratePlan } from './actions';
import { fetchCurrentPlan, fetchDailyTarget, regeneratePlan } from './actions';
import { PlanWeek } from './plan-week';
export default async function PlanPage({ params: { locale } }: { params: { locale: string } }) {
@@ -23,9 +23,9 @@ export default async function PlanPage({ params: { locale } }: { params: { local
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
if (!user.profile) redirect('/onboarding');
const plan = await fetchCurrentPlan();
const [plan, dailyTarget] = await Promise.all([fetchCurrentPlan(), fetchDailyTarget()]);
return <PlanPageContent plan={plan} />;
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} />;
}
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
@@ -45,7 +45,13 @@ function formatWeek(weekStart: string): string {
return `${startStr} – ${endStr}`;
}
function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurrentPlan>> }) {
function PlanPageContent({
plan,
dailyTarget,
}: {
plan: Awaited<ReturnType<typeof fetchCurrentPlan>>;
dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>;
}) {
const t = useTranslations('Plan');
return (
@@ -114,7 +120,7 @@ function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurren
{plan ? (
<div className="animate-fade-up [animation-delay:480ms]">
<PlanWeek plan={plan} />
<PlanWeek plan={plan} dailyTarget={dailyTarget} />
</div>
) : null}
</main>
+220 -69
View File
@@ -13,6 +13,7 @@ import {
toggleConsumed,
toggleFreeMeal,
type CurrentPlan,
type DailyTarget,
type PlanSlot,
} from './actions';
@@ -61,7 +62,19 @@ function aggregateMacros(slots: PlanSlot[]): {
const FREE_MEAL_DEFAULT_KCAL = 750;
export function PlanWeek({ plan }: { plan: CurrentPlan }) {
// PRD §6 — il pannello macros laterale ancora oggi (lun=0…dom=6).
function todayDayOfWeek(): number {
const d = new Date().getDay(); // 0 = Sunday
return d === 0 ? 6 : d - 1;
}
export function PlanWeek({
plan,
dailyTarget,
}: {
plan: CurrentPlan;
dailyTarget: DailyTarget | null;
}) {
const t = useTranslations('Plan');
const byDay = new Map<number, PlanSlot[]>();
@@ -70,6 +83,11 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
byDay.get(slot.dayOfWeek)!.push(slot);
}
const today = todayDayOfWeek();
const todaySlots = byDay.get(today) ?? [];
const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed));
const todayPlanned = aggregateMacros(todaySlots);
const isPhase3 = plan.currentPhase === 'MAINTENANCE';
const weekTotals = aggregateMacros(plan.slots);
const activeDays = Array.from(byDay.values()).filter((s) => s.length > 0).length;
@@ -93,76 +111,87 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
);
return (
<div className="space-y-14">
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-5">
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
<div className="flex flex-col gap-1">
<span className="editorial-eyebrow">{t('adherence')}</span>
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
</span>
{adherence.pastSlots > 0 ? (
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
{adherence.consumedSlots}/{adherence.pastSlots}
<div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
<div className="space-y-14 lg:col-start-1">
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-5">
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
<div className="flex flex-col gap-1">
<span className="editorial-eyebrow">{t('adherence')}</span>
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
</span>
) : null}
</div>
</section>
{DAY_KEYS.map((dayKey, idx) => {
const slots = (byDay.get(idx) ?? []).sort(
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
);
const dayMacros = aggregateMacros(slots);
// Nessuno slot → giorno di digiuno completo (ESE_24).
const isFastingDay = slots.length === 0;
// Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2.
const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA';
return (
<section key={dayKey} aria-labelledby={`day-${dayKey}`} 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">
{DAY_NUMERAL[idx]}
{adherence.pastSlots > 0 ? (
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
{adherence.consumedSlots}/{adherence.pastSlots}
</span>
<h2
id={`day-${dayKey}`}
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
>
{t(`days.${dayKey}`)}
{isFastingDay ? (
<span className="font-display text-ink-dim ml-3 text-base italic">
· {t('fastingDay')}
</span>
) : isLightDay ? (
<span className="font-display text-ink-dim ml-3 text-base italic">
· {t('lightDay')}
</span>
) : null}
</h2>
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{isFastingDay
? '— kcal'
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
</span>
</header>
{isFastingDay ? (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('fastingDayHint')}
</p>
) : (
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
{slots.map((slot, slotIdx) => (
<SlotCard key={slot.id} slot={slot} index={slotIdx} canFreeMeal={isPhase3} />
))}
</div>
)}
</section>
);
})}
) : null}
</div>
</section>
{DAY_KEYS.map((dayKey, idx) => {
const slots = (byDay.get(idx) ?? []).sort(
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
);
const dayMacros = aggregateMacros(slots);
// Nessuno slot → giorno di digiuno completo (ESE_24).
const isFastingDay = slots.length === 0;
// Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2.
const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA';
return (
<section key={dayKey} aria-labelledby={`day-${dayKey}`} 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">
{DAY_NUMERAL[idx]}
</span>
<h2
id={`day-${dayKey}`}
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
>
{t(`days.${dayKey}`)}
{isFastingDay ? (
<span className="font-display text-ink-dim ml-3 text-base italic">
· {t('fastingDay')}
</span>
) : isLightDay ? (
<span className="font-display text-ink-dim ml-3 text-base italic">
· {t('lightDay')}
</span>
) : null}
</h2>
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{isFastingDay
? '— kcal'
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
</span>
</header>
{isFastingDay ? (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('fastingDayHint')}
</p>
) : (
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
{slots.map((slot, slotIdx) => (
<SlotCard key={slot.id} slot={slot} index={slotIdx} canFreeMeal={isPhase3} />
))}
</div>
)}
</section>
);
})}
</div>
<aside className="lg:col-start-2 lg:row-start-1">
<TodayMacrosPanel
consumed={todayConsumed}
planned={todayPlanned}
target={dailyTarget}
hasSlots={todaySlots.length > 0}
/>
</aside>
</div>
);
}
@@ -327,6 +356,128 @@ function SlotCard({
);
}
interface MacroBucket {
kcal: number;
proteinG: number;
fatG: number;
netCarbG: number;
}
function TodayMacrosPanel({
consumed,
planned,
target,
hasSlots,
}: {
consumed: MacroBucket;
planned: MacroBucket;
target: DailyTarget | null;
hasSlots: boolean;
}) {
const t = useTranslations('Plan');
if (!hasSlots) {
return (
<div className="border-ink/15 bg-carta-light/40 border p-5 lg:sticky lg:top-6">
<p className="editorial-eyebrow">{t('todayPanelTitle')}</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t('todayPanelFasting')}
</p>
</div>
);
}
return (
<div className="border-ink/15 bg-carta-light/40 space-y-5 border p-5 lg:sticky lg:top-6">
<div>
<p className="editorial-eyebrow">{t('todayPanelTitle')}</p>
{target ? (
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
{t('todayPanelTargetHint')}
</p>
) : null}
</div>
<MacroBar
label={t('avgKcal')}
consumed={consumed.kcal}
planned={planned.kcal}
target={target?.kcal}
unit="kcal"
/>
<MacroBar
label={t('avgProtein')}
consumed={consumed.proteinG}
planned={planned.proteinG}
target={target?.proteinG}
unit="g"
/>
<MacroBar
label={t('avgFat')}
consumed={consumed.fatG}
planned={planned.fatG}
target={target?.fatG}
unit="g"
/>
<MacroBar
label={t('avgNetCarb')}
consumed={consumed.netCarbG}
planned={planned.netCarbG}
target={target?.netCarbG}
unit="g"
/>
</div>
);
}
function MacroBar({
label,
consumed,
planned,
target,
unit,
}: {
label: string;
consumed: number;
planned: number;
target: number | undefined;
unit: string;
}) {
const ref = target && target > 0 ? target : Math.max(planned, 1);
const consumedPct = Math.max(0, Math.min(100, (consumed / ref) * 100));
const plannedPct = Math.max(0, Math.min(100, (planned / ref) * 100));
const overTarget = target != null && consumed > target;
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="editorial-eyebrow">{label}</span>
<span className="text-ink font-mono text-xs tabular-nums">
{Math.round(consumed)}
{target != null ? (
<span className="text-ink-soft"> / {Math.round(target)}</span>
) : (
<span className="text-ink-soft"> / {Math.round(planned)}</span>
)}
<span className="font-display text-ink-soft ml-1 text-[10px] italic">{unit}</span>
</span>
</div>
<div className="bg-ink/10 relative h-1.5 w-full overflow-hidden">
<div
className="bg-ink/30 absolute inset-y-0 left-0"
style={{ width: `${plannedPct}%` }}
aria-hidden
/>
<div
className={`absolute inset-y-0 left-0 ${overTarget ? 'bg-pomodoro' : 'bg-oliva'}`}
style={{ width: `${consumedPct}%` }}
aria-hidden
/>
</div>
</div>
);
}
function MacroSummary({
label,
value,
@@ -0,0 +1,21 @@
'use server';
import { type AchievementKey } from '@ketopath/shared';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
export interface UnlockedAchievement {
key: AchievementKey;
unlockedAt: string;
}
export async function fetchAchievements(): Promise<UnlockedAchievement[]> {
const res = await fetch(`${API_URL}/me/achievements`, {
headers: { cookie: headers().get('cookie') ?? '' },
cache: 'no-store',
});
if (!res.ok) return [];
const body = (await res.json()) as { unlocked: UnlockedAchievement[] };
return body.unlocked;
}
@@ -0,0 +1,81 @@
'use client';
import { ACHIEVEMENTS } from '@ketopath/shared';
import { useTranslations } from 'next-intl';
import type { UnlockedAchievement } from './achievements-actions';
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
export function AchievementsPanel({ unlocked }: { unlocked: UnlockedAchievement[] }) {
const t = useTranslations('Achievements');
const map = new Map(unlocked.map((u) => [u.key, u.unlockedAt]));
const total = ACHIEVEMENTS.length;
const got = unlocked.length;
return (
<section className="space-y-8">
<header className="space-y-3">
<p className="editorial-eyebrow">{t('eyebrow')}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight tracking-tight">
{t('title')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('subtitle', { got, total })}
</p>
</header>
<ul className="grid gap-4 sm:grid-cols-2">
{ACHIEVEMENTS.map((a) => {
const at = map.get(a.key);
const unlockedDate = at ? ITALIAN_DATE.format(new Date(at)) : null;
return (
<li
key={a.key}
className={`group flex flex-col gap-2 border p-5 transition-colors ${
at ? 'border-pomodoro/40 bg-carta-light' : 'border-ink/15 bg-transparent'
}`}
>
<div className="flex items-baseline justify-between gap-2">
<span
className={`font-mono text-[10px] uppercase tracking-widest ${
at ? 'text-pomodoro' : 'text-ink-dim'
}`}
>
{t(`category.${a.category}`)}
</span>
{at ? (
<span className="text-ink-soft font-mono text-[10px] tracking-widest">
{unlockedDate}
</span>
) : (
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
{t('locked')}
</span>
)}
</div>
<p
className={`font-display text-xl font-medium leading-tight ${
at ? 'text-ink' : 'text-ink-dim'
}`}
>
{t(`key.${a.key}.title`)}
</p>
<p
className={`font-display text-sm italic leading-snug ${
at ? 'text-ink-soft' : 'text-ink-dim'
}`}
>
{t(`key.${a.key}.hint`)}
</p>
</li>
);
})}
</ul>
</section>
);
}
+17 -1
View File
@@ -6,6 +6,8 @@ import { setRequestLocale } from 'next-intl/server';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchAchievements } from './achievements-actions';
import { AchievementsPanel } from './achievements-panel';
import { fetchProfile } from './actions';
import { DataPanel } from './data-panel';
import { fetchNotificationConfig } from './notifications-actions';
@@ -28,9 +30,15 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
const notifications = await fetchNotificationConfig();
const preferences = await fetchPreferences();
const achievements = await fetchAchievements();
return (
<ProfilePageContent initial={profile} notifications={notifications} preferences={preferences} />
<ProfilePageContent
initial={profile}
notifications={notifications}
preferences={preferences}
achievements={achievements}
/>
);
}
@@ -38,10 +46,12 @@ function ProfilePageContent({
initial,
notifications,
preferences,
achievements,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
preferences: Awaited<ReturnType<typeof fetchPreferences>>;
achievements: Awaited<ReturnType<typeof fetchAchievements>>;
}) {
const t = useTranslations('Profile');
@@ -79,6 +89,12 @@ function ProfilePageContent({
<div className="rule animate-rule-in my-16 [animation-delay:780ms]" />
<div className="animate-fade-up [animation-delay:840ms]">
<AchievementsPanel unlocked={achievements} />
</div>
<div className="rule animate-rule-in my-16 [animation-delay:900ms]" />
<div className="animate-fade-up [animation-delay:960ms]">
<DataPanel />
</div>
</main>
@@ -19,6 +19,39 @@ export interface PreferencesView {
trainingType: string | null;
sessionMinutes: number | null;
mealsPerDay: number | null;
bannedIngredientIds: string[];
}
export interface IngredientView {
id: string;
name: string;
category: string | null;
exclusionGroups: string[];
}
export async function searchIngredients(query: string): Promise<IngredientView[]> {
const url = new URL(`${API_URL}/me/ingredients`);
if (query.trim()) url.searchParams.set('q', query.trim());
const res = await fetch(url.toString(), {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return [];
const body = (await res.json()) as { ingredients: IngredientView[] };
return body.ingredients;
}
export async function fetchIngredientsByIds(ids: string[]): Promise<IngredientView[]> {
if (ids.length === 0) return [];
const url = new URL(`${API_URL}/me/ingredients`);
url.searchParams.set('ids', ids.join(','));
const res = await fetch(url.toString(), {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return [];
const body = (await res.json()) as { ingredients: IngredientView[] };
return body.ingredients;
}
export async function fetchPreferences(): Promise<PreferencesView | null> {
@@ -54,5 +54,6 @@ function toFormValues(view: PreferencesView): PreferencesValues {
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
cookingTime: view.cookingTime as CookingTimeLevel,
fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'],
bannedIngredientIds: view.bannedIngredientIds ?? [],
};
}
@@ -13,8 +13,13 @@ import {
type TrainingType,
} from '@ketopath/shared';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
fetchIngredientsByIds,
searchIngredients,
type IngredientView,
} from '@/app/[locale]/profile/preferences-actions';
import { Button } from '@/components/ui/button';
import {
Select,
@@ -33,6 +38,7 @@ export interface PreferencesValues {
trainingType: TrainingType | null;
sessionMinutes: number | null;
mealsPerDay: number | null;
bannedIngredientIds: string[];
}
export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
@@ -44,6 +50,7 @@ export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
trainingType: null,
sessionMinutes: null,
mealsPerDay: null,
bannedIngredientIds: [],
};
const DAY_LABELS = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
@@ -102,6 +109,7 @@ export function PreferencesForm({
trainingType: values.trainingType,
sessionMinutes: values.sessionMinutes,
mealsPerDay: values.mealsPerDay,
bannedIngredientIds: values.bannedIngredientIds,
});
setPending(false);
if (!result.ok) {
@@ -132,6 +140,11 @@ export function PreferencesForm({
</div>
</fieldset>
<BannedIngredientsField
value={values.bannedIngredientIds}
onChange={(next) => setValues((prev) => ({ ...prev, bannedIngredientIds: next }))}
/>
<fieldset className="space-y-5">
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
{t('cuisinesTitle')}
@@ -380,3 +393,142 @@ function Chip({
</button>
);
}
function BannedIngredientsField({
value,
onChange,
}: {
value: string[];
onChange: (next: string[]) => void;
}) {
const t = useTranslations('Preferences');
const [query, setQuery] = useState('');
const [results, setResults] = useState<IngredientView[]>([]);
const [resolved, setResolved] = useState<Map<string, IngredientView>>(new Map());
const [searching, setSearching] = useState(false);
// Risolvi i nomi degli ingredient già bannati al mount (per i chip).
useEffect(() => {
const missing = value.filter((id) => !resolved.has(id));
if (missing.length === 0) return;
let cancelled = false;
void fetchIngredientsByIds(missing).then((list) => {
if (cancelled) return;
setResolved((prev) => {
const next = new Map(prev);
for (const ing of list) next.set(ing.id, ing);
return next;
});
});
return () => {
cancelled = true;
};
// resolved è intenzionalmente fuori: vogliamo solo reagire ai bannati cambiati
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
// Debounce ricerca per nome.
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults([]);
return;
}
setSearching(true);
const timer = setTimeout(() => {
void searchIngredients(q).then((list) => {
setResults(list);
setSearching(false);
});
}, 250);
return () => clearTimeout(timer);
}, [query]);
function add(ing: IngredientView): void {
if (value.includes(ing.id)) return;
if (value.length >= 50) return;
setResolved((prev) => new Map(prev).set(ing.id, ing));
onChange([...value, ing.id]);
setQuery('');
setResults([]);
}
function remove(id: string): void {
onChange(value.filter((x) => x !== id));
}
return (
<fieldset className="space-y-5">
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
{t('bannedIngredientsTitle')}
</legend>
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('bannedIngredientsHint')}
</p>
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('bannedIngredientsSearchPlaceholder')}
maxLength={80}
className="border-ink/30 focus:border-ink font-display text-ink w-full border-b bg-transparent py-2 outline-none"
/>
{results.length > 0 ? (
<ul className="border-ink/15 bg-carta-light absolute z-10 mt-1 max-h-60 w-full overflow-auto border shadow-sm">
{results.map((ing) => (
<li key={ing.id}>
<button
type="button"
onClick={() => add(ing)}
disabled={value.includes(ing.id) || value.length >= 50}
className="hover:bg-ink/5 font-display text-ink flex w-full items-baseline justify-between gap-3 px-3 py-2 text-left text-sm leading-tight disabled:opacity-40"
>
<span>{ing.name}</span>
{ing.category ? (
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
{ing.category}
</span>
) : null}
</button>
</li>
))}
</ul>
) : null}
{searching && results.length === 0 ? (
<p className="text-ink-dim mt-1 font-mono text-[10px] uppercase tracking-widest">
{t('bannedIngredientsSearching')}
</p>
) : null}
</div>
{value.length > 0 ? (
<div className="flex flex-wrap gap-2">
{value.map((id) => {
const ing = resolved.get(id);
return (
<span
key={id}
className="border-pomodoro/40 text-pomodoro inline-flex items-center gap-2 rounded-full border px-4 py-2"
>
<span className="font-display text-sm leading-tight">{ing?.name ?? id}</span>
<button
type="button"
onClick={() => remove(id)}
aria-label={t('bannedIngredientsRemove')}
title={t('bannedIngredientsRemove')}
className="hover:text-ink font-mono text-xs leading-none"
>
×
</button>
</span>
);
})}
</div>
) : (
<p className="font-display text-ink-dim text-sm italic">{t('bannedIngredientsEmpty')}</p>
)}
{value.length >= 50 ? (
<p className="font-display text-ink-soft text-xs italic">{t('bannedIngredientsCap')}</p>
) : null}
</fieldset>
);
}