feat: preferences, exclusion-aware planner, /onboarding (PRD §5.1, §3)

Preferences (PRD §5.1)
- @ketopath/shared: zod schemas + EXCLUSION_GROUPS, CUISINE_TAGS,
  COOKING_TIMES, FASTING_PROTOCOL_VALUES
- apps/api: GET/PATCH /me/preferences (exclusions, cuisinePreferences,
  cookingTime, fastingProtocol)
- apps/web: PreferencesForm chip-based + PreferencesSection wired into
  /profile so existing users can edit
- Plan generation now aggregates Ingredient.exclusionGroups → Recipe
  exclusionTags so matchMeals filters out excluded recipes

Onboarding (PRD §3)
- /onboarding multi-step (profile → preferences → done) with smart-jump:
  starts at the first incomplete step
- ProfileForm gains optional onSaved/saveLabel for reuse outside /profile
- Redirect chain updated: /welcome accept → /onboarding (was /profile);
  /plan, /tracking, /shopping redirect missing profiles to /onboarding
- /profile remains the editing surface for returning users

i18n
- New Onboarding + Preferences namespaces (it)

Smoke tested end-to-end: selected fish exclusion, regenerated the plan,
verified Salmone/Branzino/Tonno are filtered out and unaffected recipes
(Frittata, Pollo, Mandorle, Polpette) remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 22:14:50 +02:00
1 parent cf7361f735
commit 624152b29b
18 files changed
+864 -22

No files matched your search

@@ -0,0 +1,204 @@
'use client';
import {
type CookingTimeLevel,
type CuisineTag,
type ExclusionGroup,
type PreferencesPatch,
} from '@ketopath/shared';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
DEFAULT_PREFERENCES_VALUES,
PreferencesForm,
type PreferencesValues,
} from '@/components/preferences/preferences-form';
import { Button } from '@/components/ui/button';
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
import type { ProfileForm } from '../profile/profile-form';
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
const STEPS = ['profile', 'preferences', 'done'] as const;
type StepKey = (typeof STEPS)[number];
interface OnboardingFlowProps {
initialProfile: Parameters<typeof ProfileForm>[0]['initial'];
initialPreferences: PreferencesView | null;
}
export function OnboardingFlow({ initialProfile, initialPreferences }: OnboardingFlowProps) {
const t = useTranslations('Onboarding');
const router = useRouter();
// Lo step di partenza salta i form già completati: comodo per chi torna a metà.
const [step, setStep] = useState<StepKey>(() => {
if (!initialProfile) return 'profile';
if (!initialPreferences || initialPreferences.exclusions.length === 0) return 'preferences';
return 'done';
});
const stepIndex = STEPS.indexOf(step);
return (
<section className="grid gap-12 md:grid-cols-12">
<aside className="md:border-ink/15 md:col-span-4 md:border-r md:pr-8">
<p className="editorial-eyebrow">{t('stepsTitle')}</p>
<ol className="mt-6 space-y-5">
{STEPS.map((s, i) => (
<li key={s} className="flex items-baseline gap-3">
<span
className={`font-mono text-xs uppercase tracking-widest ${
i < stepIndex ? 'text-oliva' : i === stepIndex ? 'text-pomodoro' : 'text-ink-dim'
}`}
>
{['I', 'II', 'III'][i]}
</span>
<span
className={`font-display text-lg leading-tight ${
i === stepIndex ? 'text-ink font-medium' : 'text-ink-soft'
}`}
>
{t(`steps.${s}`)}
</span>
</li>
))}
</ol>
</aside>
<div className="md:col-span-8">
{step === 'profile' ? (
<StepProfile
initial={initialProfile}
onSaved={() => setStep('preferences')}
label={t('next')}
/>
) : null}
{step === 'preferences' ? (
<StepPreferences
initial={
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
}
onSaved={() => setStep('done')}
label={t('next')}
onBack={() => setStep('profile')}
backLabel={t('back')}
/>
) : null}
{step === 'done' ? <StepDone onFinish={() => router.push('/plan')} /> : null}
</div>
</section>
);
}
function StepProfile({
initial,
onSaved,
label,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
onSaved: () => void;
label: string;
}) {
const t = useTranslations('Onboarding');
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 3 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('profileHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('profileHint')}
</p>
</header>
<ProfileFormComponent initial={initial} onSaved={onSaved} saveLabel={label} />
</div>
);
}
function StepPreferences({
initial,
onSaved,
label,
onBack,
backLabel,
}: {
initial: PreferencesValues;
onSaved: () => void;
label: string;
onBack: () => void;
backLabel: string;
}) {
const t = useTranslations('Onboarding');
async function handleSubmit(patch: PreferencesPatch) {
const result = await savePreferences(patch);
if (result.ok) onSaved();
return result;
}
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 3 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('preferencesHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('preferencesHint')}
</p>
</header>
<PreferencesForm initial={initial} onSubmit={handleSubmit} submitLabel={label} />
<button
type="button"
onClick={onBack}
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"
>
{backLabel}
</button>
</div>
);
}
function StepDone({ onFinish }: { onFinish: () => void }) {
const t = useTranslations('Onboarding');
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 3 })}</p>
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
{t('doneHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-xl italic leading-snug">
{t('doneHint')}
</p>
</header>
<div className="flex flex-wrap items-center gap-4">
<Button type="button" size="lg" onClick={onFinish}>
{t('goToPlan')}
</Button>
<Link
href="/"
className="text-ink-soft hover:text-ink font-mono text-[11px] uppercase tracking-widest"
>
{t('skipToHome')}
</Link>
</div>
</div>
);
}
function toFormValues(view: PreferencesView): PreferencesValues {
return {
exclusions: view.exclusions as ExclusionGroup[],
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
cookingTime: view.cookingTime as CookingTimeLevel,
fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'],
};
}
@@ -0,0 +1,70 @@
import { prisma } from '@ketopath/db';
import { 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';
import { fetchProfile } from '../profile/actions';
import { fetchPreferences } from '../profile/preferences-actions';
import type { ProfileForm } from '../profile/profile-form';
import { OnboardingFlow } from './onboarding-flow';
export default async function OnboardingPage({
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 },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
const [profile, preferences] = await Promise.all([fetchProfile(), fetchPreferences()]);
return (
<OnboardingPageContent
profile={profile as Parameters<typeof ProfileForm>[0]['initial']}
preferences={preferences}
/>
);
}
function OnboardingPageContent({
profile,
preferences,
}: {
profile: Parameters<typeof ProfileForm>[0]['initial'];
preferences: Awaited<ReturnType<typeof fetchPreferences>>;
}) {
const t = useTranslations('Onboarding');
return (
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
<Masthead issueLabel="N. 03 — Inizio" />
<main className="py-12 sm:py-16">
<p className="editorial-eyebrow animate-fade-up">Capitolo III</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-6 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
{t('subtitle')}
</p>
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
<OnboardingFlow initialProfile={profile} initialPreferences={preferences} />
</main>
<div className="rule-thick mt-16" />
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ export default async function PlanPage({ params: { locale } }: { params: { local
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
if (!user.profile) redirect('/profile');
if (!user.profile) redirect('/onboarding');
const plan = await fetchCurrentPlan();
+16 -3
View File
@@ -9,6 +9,8 @@ import { getServerSession } from '@/lib/auth';
import { fetchProfile } from './actions';
import { fetchNotificationConfig } from './notifications-actions';
import { NotificationsPanel } from './notifications-panel';
import { fetchPreferences } from './preferences-actions';
import { PreferencesSection } from './preferences-section';
import { ProfileForm } from './profile-form';
export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) {
@@ -24,16 +26,21 @@ 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();
return <ProfilePageContent initial={profile} notifications={notifications} />;
return (
<ProfilePageContent initial={profile} notifications={notifications} preferences={preferences} />
);
}
function ProfilePageContent({
initial,
notifications,
preferences,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
preferences: Awaited<ReturnType<typeof fetchPreferences>>;
}) {
const t = useTranslations('Profile');
@@ -56,9 +63,15 @@ function ProfilePageContent({
<ProfileForm initial={initial} />
</div>
<div className="rule animate-rule-in my-16 [animation-delay:480ms]" />
<div className="rule animate-rule-in my-16 [animation-delay:420ms]" />
<div className="animate-fade-up [animation-delay:540ms]">
<div className="animate-fade-up [animation-delay:480ms]">
<PreferencesSection initial={preferences} />
</div>
<div className="rule animate-rule-in my-16 [animation-delay:600ms]" />
<div className="animate-fade-up [animation-delay:660ms]">
<NotificationsPanel initial={notifications} />
</div>
</main>
@@ -0,0 +1,46 @@
'use server';
import { type PreferencesPatch } from '@ketopath/shared';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export interface PreferencesView {
exclusions: string[];
cuisinePreferences: string[];
cookingTime: 'LOW' | 'MEDIUM' | 'HIGH';
fastingProtocol: string | null;
}
export async function fetchPreferences(): Promise<PreferencesView | null> {
const res = await fetch(`${API_URL}/me/preferences`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return null;
const body = (await res.json()) as { preferences: PreferencesView };
return body.preferences;
}
export type SavePrefsResult = { ok: true } | { ok: false; error: string };
export async function savePreferences(patch: PreferencesPatch): Promise<SavePrefsResult> {
const res = await fetch(`${API_URL}/me/preferences`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify(patch),
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('/profile');
revalidatePath('/onboarding');
return { ok: true };
}
@@ -0,0 +1,54 @@
'use client';
import {
type CookingTimeLevel,
type CuisineTag,
type ExclusionGroup,
type PreferencesPatch,
} from '@ketopath/shared';
import { useTranslations } from 'next-intl';
import {
DEFAULT_PREFERENCES_VALUES,
PreferencesForm,
type PreferencesValues,
} from '@/components/preferences/preferences-form';
import { savePreferences, type PreferencesView } from './preferences-actions';
export function PreferencesSection({ initial }: { initial: PreferencesView | null }) {
const t = useTranslations('Preferences');
const initialValues: PreferencesValues = initial
? toFormValues(initial)
: DEFAULT_PREFERENCES_VALUES;
async function handleSubmit(patch: PreferencesPatch) {
return savePreferences(patch);
}
return (
<section className="space-y-10">
<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')}
</p>
</header>
<div className="border-ink/15 border-t" />
<PreferencesForm initial={initialValues} onSubmit={handleSubmit} />
</section>
);
}
function toFormValues(view: PreferencesView): PreferencesValues {
return {
exclusions: view.exclusions as ExclusionGroup[],
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
cookingTime: view.cookingTime as CookingTimeLevel,
fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'],
};
}
@@ -30,7 +30,15 @@ import { saveProfile } from './actions';
type Derived = { bmr: number; tdee: number; activityMultiplier: number };
type Profile = ProfileInput & { derived?: Derived };
export function ProfileForm({ initial }: { initial: Profile | null }) {
export function ProfileForm({
initial,
onSaved,
saveLabel,
}: {
initial: Profile | null;
onSaved?: (profile: Profile) => void;
saveLabel?: string;
}) {
const t = useTranslations('Profile');
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
@@ -62,7 +70,11 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
}
const profile = result.profile as Profile;
if (profile.derived) setSaved(profile.derived);
router.refresh();
if (onSaved) {
onSaved(profile);
} else {
router.refresh();
}
});
return (
@@ -191,7 +203,7 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
size="lg"
className="w-full sm:w-auto"
>
{form.formState.isSubmitting ? t('saving') : t('save')}
{form.formState.isSubmitting ? t('saving') : (saveLabel ?? t('save'))}
</Button>
</div>
</fieldset>
+1 -1
View File
@@ -21,7 +21,7 @@ export default async function ShoppingPage({ params: { locale } }: { params: { l
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
if (!user.profile) redirect('/profile');
if (!user.profile) redirect('/onboarding');
const list = await fetchShoppingList();
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function TrackingPage({ params: { locale } }: { params: { l
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
if (!user.profile) redirect('/profile');
if (!user.profile) redirect('/onboarding');
const entries = await fetchWeightEntries();
+1 -1
View File
@@ -16,5 +16,5 @@ export async function acceptDisclaimer(): Promise<void> {
});
revalidatePath('/', 'layout');
redirect('/profile');
redirect('/onboarding');
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function WelcomePage({ params: { locale } }: { params: { lo
where: { id: session.user.id },
select: { disclaimerAcceptedAt: true },
});
if (user?.disclaimerAcceptedAt) redirect('/profile');
if (user?.disclaimerAcceptedAt) redirect('/onboarding');
return <WelcomePageContent />;
}
@@ -0,0 +1,242 @@
'use client';
import {
COOKING_TIMES,
CUISINE_TAGS,
EXCLUSION_GROUPS,
FASTING_PROTOCOL_VALUES,
type CookingTimeLevel,
type CuisineTag,
type ExclusionGroup,
type PreferencesPatch,
} from '@ketopath/shared';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface PreferencesValues {
exclusions: ExclusionGroup[];
cuisinePreferences: CuisineTag[];
cookingTime: CookingTimeLevel;
fastingProtocol: (typeof FASTING_PROTOCOL_VALUES)[number] | null;
}
export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
exclusions: [],
cuisinePreferences: [],
cookingTime: 'MEDIUM',
fastingProtocol: null,
};
interface PreferencesFormProps {
initial: PreferencesValues;
onSubmit: (patch: PreferencesPatch) => Promise<{ ok: boolean; error?: string }>;
submitLabel?: string;
showFasting?: boolean;
}
export function PreferencesForm({
initial,
onSubmit,
submitLabel,
showFasting = true,
}: PreferencesFormProps) {
const t = useTranslations('Preferences');
const [values, setValues] = useState<PreferencesValues>(initial);
const [pending, setPending] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
function toggleExclusion(group: ExclusionGroup): void {
setValues((prev) => {
const has = prev.exclusions.includes(group);
return {
...prev,
exclusions: has ? prev.exclusions.filter((g) => g !== group) : [...prev.exclusions, group],
};
});
}
function toggleCuisine(tag: CuisineTag): void {
setValues((prev) => {
const has = prev.cuisinePreferences.includes(tag);
return {
...prev,
cuisinePreferences: has
? prev.cuisinePreferences.filter((g) => g !== tag)
: [...prev.cuisinePreferences, tag],
};
});
}
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setError(null);
setFeedback(null);
setPending(true);
const result = await onSubmit({
exclusions: values.exclusions,
cuisinePreferences: values.cuisinePreferences,
cookingTime: values.cookingTime,
fastingProtocol: values.fastingProtocol,
});
setPending(false);
if (!result.ok) {
setError(result.error ?? t('errorGeneric'));
return;
}
setFeedback(t('saved'));
}
return (
<form onSubmit={handleSubmit} className="space-y-12">
<fieldset className="space-y-5">
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
{t('exclusionsTitle')}
</legend>
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('exclusionsHint')}
</p>
<div className="flex flex-wrap gap-2">
{EXCLUSION_GROUPS.map((g) => (
<Chip
key={g}
label={t(`exclusionLabel.${g}`)}
active={values.exclusions.includes(g)}
onToggle={() => toggleExclusion(g)}
/>
))}
</div>
</fieldset>
<fieldset className="space-y-5">
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
{t('cuisinesTitle')}
</legend>
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('cuisinesHint')}
</p>
<div className="flex flex-wrap gap-2">
{CUISINE_TAGS.map((tag) => (
<Chip
key={tag}
label={t(`cuisineLabel.${tag}`)}
active={values.cuisinePreferences.includes(tag)}
onToggle={() => toggleCuisine(tag)}
/>
))}
</div>
</fieldset>
<fieldset className="space-y-5">
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
{t('cookingTimeTitle')}
</legend>
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('cookingTimeHint')}
</p>
<div className="max-w-xs">
<Select
value={values.cookingTime}
onValueChange={(v) =>
setValues((prev) => ({ ...prev, cookingTime: v as CookingTimeLevel }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COOKING_TIMES.map((c) => (
<SelectItem key={c} value={c}>
{t(`cookingTimeLabel.${c}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</fieldset>
{showFasting ? (
<fieldset className="space-y-5">
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
{t('fastingTitle')}
</legend>
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('fastingHint')}
</p>
<div className="max-w-xs">
<Select
value={values.fastingProtocol ?? 'NONE'}
onValueChange={(v) =>
setValues((prev) => ({
...prev,
fastingProtocol:
v === 'NONE' ? null : (v as (typeof FASTING_PROTOCOL_VALUES)[number]),
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">{t('fastingNone')}</SelectItem>
{FASTING_PROTOCOL_VALUES.map((p) => (
<SelectItem key={p} value={p}>
{t(`fastingProtocolLabel.${p}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</fieldset>
) : null}
{error ? (
<p className="font-display text-pomodoro text-base italic" role="alert">
{error}
</p>
) : null}
{feedback ? (
<p className="font-display text-oliva text-base italic" role="status">
{feedback}
</p>
) : null}
<div>
<Button type="submit" size="lg" disabled={pending}>
{pending ? t('saving') : (submitLabel ?? t('save'))}
</Button>
</div>
</form>
);
}
function Chip({
label,
active,
onToggle,
}: {
label: string;
active: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onToggle}
className={`font-display border-ink/30 hover:border-ink rounded-full border px-4 py-2 text-sm leading-tight transition-colors ${
active ? 'bg-ink text-carta-light border-ink' : 'text-ink hover:bg-ink/5 bg-transparent'
}`}
>
{label}
</button>
);
}