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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@ export async function acceptDisclaimer(): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
redirect('/profile');
|
||||
redirect('/onboarding');
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user