diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 85ca181..4c5514a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { healthRoutes } from './modules/health/health.routes.js'; import { meRoutes } from './modules/me/me.routes.js'; import { notificationsRoutes } from './modules/notifications/notifications.routes.js'; import { planRoutes } from './modules/plan/plan.routes.js'; +import { preferencesRoutes } from './modules/preferences/preferences.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; import { shoppingRoutes } from './modules/shopping/shopping.routes.js'; import { fastRoutes } from './modules/tracking/fast.routes.js'; @@ -51,6 +52,7 @@ export async function buildApp(): Promise { await app.register(dbRoutes); await app.register(meRoutes); await app.register(profileRoutes); + await app.register(preferencesRoutes); await app.register(weightRoutes); await app.register(fastRoutes); await app.register(planRoutes); diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 5d08daa..9eec1ef 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -47,23 +47,32 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { fatG: true, netCarbG: true, phases: true, + ingredients: { + select: { ingredient: { select: { exclusionGroups: true } } }, + }, }, }); if (recipes.length === 0) { return reply.code(409).send({ error: 'recipe_catalog_empty' }); } - const candidates: RecipeCandidate[] = recipes.map((r) => ({ - id: r.id, - name: r.name, - category: r.category, - kcal: r.kcal, - proteinG: r.proteinG, - fatG: r.fatG, - netCarbG: r.netCarbG, - exclusionTags: [], - phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), - })); + const candidates: RecipeCandidate[] = recipes.map((r) => { + const tags = new Set(); + for (const ri of r.ingredients) { + for (const g of ri.ingredient.exclusionGroups) tags.add(g); + } + return { + id: r.id, + name: r.name, + category: r.category, + kcal: r.kcal, + proteinG: r.proteinG, + fatG: r.fatG, + netCarbG: r.netCarbG, + exclusionTags: Array.from(tags), + phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), + }; + }); const weightCurrentKg = Number(profile.weightCurrentKg); const bmr = calculateBmr({ diff --git a/apps/api/src/modules/preferences/preferences.routes.ts b/apps/api/src/modules/preferences/preferences.routes.ts new file mode 100644 index 0000000..b3d6f17 --- /dev/null +++ b/apps/api/src/modules/preferences/preferences.routes.ts @@ -0,0 +1,68 @@ +// PRD §5.1 — preferences utente: esclusioni, cucine, tempo di cottura, +// protocollo digiuno default. Le esclusioni filtrano il matchmaking ricette. + +import { preferencesPatchSchema } from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +export const preferencesRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/preferences', { preHandler: requireAuth() }, async (request) => { + const userId = request.user!.id; + const prefs = await fastify.prisma.preferences.findUnique({ where: { userId } }); + return { + preferences: prefs + ? { + exclusions: prefs.exclusions, + cuisinePreferences: prefs.cuisinePreferences, + cookingTime: prefs.cookingTime, + fastingProtocol: prefs.fastingProtocol, + } + : { + exclusions: [], + cuisinePreferences: [], + cookingTime: 'MEDIUM', + fastingProtocol: null, + }, + }; + }); + + fastify.patch('/me/preferences', { preHandler: requireAuth() }, async (request, reply) => { + const parsed = preferencesPatchSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const userId = request.user!.id; + const data = parsed.data; + + // Costruiamo il diff: con `exactOptionalPropertyTypes` Prisma rifiuta i + // campi `undefined` espliciti. + const create: Record = { + userId, + exclusions: data.exclusions ?? [], + cuisinePreferences: data.cuisinePreferences ?? [], + cookingTime: data.cookingTime ?? 'MEDIUM', + fastingProtocol: data.fastingProtocol ?? null, + }; + const update: Record = {}; + if (data.exclusions !== undefined) update.exclusions = data.exclusions; + if (data.cuisinePreferences !== undefined) update.cuisinePreferences = data.cuisinePreferences; + if (data.cookingTime !== undefined) update.cookingTime = data.cookingTime; + if (data.fastingProtocol !== undefined) update.fastingProtocol = data.fastingProtocol; + + const prefs = await fastify.prisma.preferences.upsert({ + where: { userId }, + create: create as never, + update, + }); + + return { + preferences: { + exclusions: prefs.exclusions, + cuisinePreferences: prefs.cuisinePreferences, + cookingTime: prefs.cookingTime, + fastingProtocol: prefs.fastingProtocol, + }, + }; + }); +}; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 80a8a1e..8a0c690 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -165,6 +165,76 @@ "delta": "Variazione", "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate." }, + "Onboarding": { + "title": "Configura il tuo percorso", + "subtitle": "Tre passaggi: parametri personali, preferenze, e via al primo piano.", + "stepsTitle": "Tappe", + "stepEyebrow": "Tappa {n} di {total}", + "next": "Salva e continua", + "back": "← Indietro", + "steps": { + "profile": "Anagrafica e attività", + "preferences": "Esclusioni e cucine", + "done": "Pronti a partire" + }, + "profileHeading": "I numeri che ti riguardano", + "profileHint": "Età, sesso, altezza, peso e attività: bastano per calcolare BMR, TDEE e i target di macros.", + "preferencesHeading": "Cosa eviti, cosa ami", + "preferencesHint": "Le esclusioni filtrano davvero le ricette del piano. Le cucine sono un suggerimento al matchmaking.", + "doneHeading": "Tutto pronto.", + "doneHint": "Puoi generare il primo piano della settimana adesso, oppure esplorare l'app a tuo ritmo.", + "goToPlan": "Genera il primo piano", + "skipToHome": "Esplora l'app →" + }, + "Preferences": { + "eyebrow": "Preferenze", + "title": "Cucina su misura", + "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.", + "cuisinesTitle": "Cucine preferite", + "cuisinesHint": "Indicaci le tradizioni che preferisci. È un suggerimento, non un vincolo.", + "cookingTimeTitle": "Tempo in cucina", + "cookingTimeHint": "Quanto tempo vuoi dedicare alla preparazione, in media?", + "fastingTitle": "Protocollo digiuno predefinito", + "fastingHint": "Sarà selezionato in /fasting alla prossima sessione.", + "fastingNone": "Nessuno per ora", + "save": "Salva preferenze", + "saving": "Salvataggio…", + "saved": "Preferenze aggiornate.", + "errorGeneric": "Non è stato possibile salvare. Riprova.", + "exclusionLabel": { + "lactose": "Lattosio", + "egg": "Uova", + "fish": "Pesce", + "nuts": "Frutta secca", + "gluten": "Glutine", + "pork": "Maiale", + "beef": "Manzo" + }, + "cuisineLabel": { + "mediterranea": "Mediterranea", + "italiana": "Italiana classica", + "pugliese": "Pugliese", + "siciliana": "Siciliana", + "toscana": "Toscana", + "medio-orientale": "Medio-orientale", + "nordica": "Nordica" + }, + "cookingTimeLabel": { + "LOW": "Veloce · fino a 15 min", + "MEDIUM": "Medio · 15–30 min", + "HIGH": "Elaborato · oltre 30 min" + }, + "fastingProtocolLabel": { + "FOURTEEN_TEN": "14:10 — inizio dolce", + "SIXTEEN_EIGHT": "16:8 — lo standard", + "EIGHTEEN_SIX": "18:6 — più aggressivo", + "TWENTY_FOUR": "20:4 — avanzato", + "ESE_24": "ESE 24h", + "FIVE_TWO": "5:2" + } + }, "Notifications": { "eyebrow": "Notifiche", "title": "Promemoria al momento giusto", diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx new file mode 100644 index 0000000..9712736 --- /dev/null +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -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[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(() => { + if (!initialProfile) return 'profile'; + if (!initialPreferences || initialPreferences.exclusions.length === 0) return 'preferences'; + return 'done'; + }); + + const stepIndex = STEPS.indexOf(step); + + return ( +
+ + +
+ {step === 'profile' ? ( + setStep('preferences')} + label={t('next')} + /> + ) : null} + + {step === 'preferences' ? ( + setStep('done')} + label={t('next')} + onBack={() => setStep('profile')} + backLabel={t('back')} + /> + ) : null} + + {step === 'done' ? router.push('/plan')} /> : null} +
+
+ ); +} + +function StepProfile({ + initial, + onSaved, + label, +}: { + initial: Parameters[0]['initial']; + onSaved: () => void; + label: string; +}) { + const t = useTranslations('Onboarding'); + return ( +
+
+

{t('stepEyebrow', { n: 1, total: 3 })}

+

+ {t('profileHeading')} +

+

+ {t('profileHint')} +

+
+ +
+ ); +} + +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 ( +
+
+

{t('stepEyebrow', { n: 2, total: 3 })}

+

+ {t('preferencesHeading')} +

+

+ {t('preferencesHint')} +

+
+ + +
+ ); +} + +function StepDone({ onFinish }: { onFinish: () => void }) { + const t = useTranslations('Onboarding'); + return ( +
+
+

{t('stepEyebrow', { n: 3, total: 3 })}

+

+ {t('doneHeading')} +

+

+ {t('doneHint')} +

+
+
+ + + {t('skipToHome')} + +
+
+ ); +} + +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'], + }; +} diff --git a/apps/web/src/app/[locale]/onboarding/page.tsx b/apps/web/src/app/[locale]/onboarding/page.tsx new file mode 100644 index 0000000..37433d2 --- /dev/null +++ b/apps/web/src/app/[locale]/onboarding/page.tsx @@ -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 ( + [0]['initial']} + preferences={preferences} + /> + ); +} + +function OnboardingPageContent({ + profile, + preferences, +}: { + profile: Parameters[0]['initial']; + preferences: Awaited>; +}) { + const t = useTranslations('Onboarding'); + + return ( +
+ +
+

Capitolo III

+

+ {t('title')} + . +

+

+ {t('subtitle')} +

+ +
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index 3010666..bc1bfd7 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -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(); diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx index 1e0c7cf..aab763c 100644 --- a/apps/web/src/app/[locale]/profile/page.tsx +++ b/apps/web/src/app/[locale]/profile/page.tsx @@ -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[0]['initial']; const notifications = await fetchNotificationConfig(); + const preferences = await fetchPreferences(); - return ; + return ( + + ); } function ProfilePageContent({ initial, notifications, + preferences, }: { initial: Parameters[0]['initial']; notifications: Awaited>; + preferences: Awaited>; }) { const t = useTranslations('Profile'); @@ -56,9 +63,15 @@ function ProfilePageContent({
-
+
-
+
+ +
+ +
+ +
diff --git a/apps/web/src/app/[locale]/profile/preferences-actions.ts b/apps/web/src/app/[locale]/profile/preferences-actions.ts new file mode 100644 index 0000000..f651343 --- /dev/null +++ b/apps/web/src/app/[locale]/profile/preferences-actions.ts @@ -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 { + 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 { + 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 }; +} diff --git a/apps/web/src/app/[locale]/profile/preferences-section.tsx b/apps/web/src/app/[locale]/profile/preferences-section.tsx new file mode 100644 index 0000000..61462fc --- /dev/null +++ b/apps/web/src/app/[locale]/profile/preferences-section.tsx @@ -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 ( +
+
+

{t('eyebrow')}

+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+
+ +
+ ); +} + +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'], + }; +} diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 44c91cc..3f3a28c 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -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(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'))}
diff --git a/apps/web/src/app/[locale]/shopping/page.tsx b/apps/web/src/app/[locale]/shopping/page.tsx index 5d2bcec..ba3bf43 100644 --- a/apps/web/src/app/[locale]/shopping/page.tsx +++ b/apps/web/src/app/[locale]/shopping/page.tsx @@ -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(); diff --git a/apps/web/src/app/[locale]/tracking/page.tsx b/apps/web/src/app/[locale]/tracking/page.tsx index ea31554..d0c1417 100644 --- a/apps/web/src/app/[locale]/tracking/page.tsx +++ b/apps/web/src/app/[locale]/tracking/page.tsx @@ -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(); diff --git a/apps/web/src/app/[locale]/welcome/actions.ts b/apps/web/src/app/[locale]/welcome/actions.ts index 7edf075..6b8fa6c 100644 --- a/apps/web/src/app/[locale]/welcome/actions.ts +++ b/apps/web/src/app/[locale]/welcome/actions.ts @@ -16,5 +16,5 @@ export async function acceptDisclaimer(): Promise { }); revalidatePath('/', 'layout'); - redirect('/profile'); + redirect('/onboarding'); } diff --git a/apps/web/src/app/[locale]/welcome/page.tsx b/apps/web/src/app/[locale]/welcome/page.tsx index 7cebf20..8c10649 100644 --- a/apps/web/src/app/[locale]/welcome/page.tsx +++ b/apps/web/src/app/[locale]/welcome/page.tsx @@ -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 ; } diff --git a/apps/web/src/components/preferences/preferences-form.tsx b/apps/web/src/components/preferences/preferences-form.tsx new file mode 100644 index 0000000..8adb4e7 --- /dev/null +++ b/apps/web/src/components/preferences/preferences-form.tsx @@ -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(initial); + const [pending, setPending] = useState(false); + const [feedback, setFeedback] = useState(null); + const [error, setError] = useState(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 { + 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 ( +
+
+ + {t('exclusionsTitle')} + +

+ {t('exclusionsHint')} +

+
+ {EXCLUSION_GROUPS.map((g) => ( + toggleExclusion(g)} + /> + ))} +
+
+ +
+ + {t('cuisinesTitle')} + +

+ {t('cuisinesHint')} +

+
+ {CUISINE_TAGS.map((tag) => ( + toggleCuisine(tag)} + /> + ))} +
+
+ +
+ + {t('cookingTimeTitle')} + +

+ {t('cookingTimeHint')} +

+
+ +
+
+ + {showFasting ? ( +
+ + {t('fastingTitle')} + +

+ {t('fastingHint')} +

+
+ +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + {feedback ? ( +

+ {feedback} +

+ ) : null} + +
+ +
+
+ ); +} + +function Chip({ + label, + active, + onToggle, +}: { + label: string; + active: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bc67e02..da7f327 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './notifications/schema.js'; +export * from './preferences/schema.js'; export * from './nutrition/bmr.js'; export * from './nutrition/tdee.js'; export * from './nutrition/types.js'; diff --git a/packages/shared/src/preferences/schema.ts b/packages/shared/src/preferences/schema.ts new file mode 100644 index 0000000..0e051c9 --- /dev/null +++ b/packages/shared/src/preferences/schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +// PRD §5.1 — gruppi di esclusione legati agli ingredienti. +// Devono coincidere con i tag su `Ingredient.exclusionGroups` nel seed +// (vedi packages/db/prisma/seed-ingredients.ts). +export const EXCLUSION_GROUPS = [ + 'lactose', + 'egg', + 'fish', + 'nuts', + 'gluten', + 'pork', + 'beef', +] as const; +export type ExclusionGroup = (typeof EXCLUSION_GROUPS)[number]; + +// Tradizioni regionali italiane + low-carb internazionali. +// Per ora informative: il matchmaking ne tiene conto solo come bonus. +export const CUISINE_TAGS = [ + 'mediterranea', + 'italiana', + 'pugliese', + 'siciliana', + 'toscana', + 'medio-orientale', + 'nordica', +] as const; +export type CuisineTag = (typeof CUISINE_TAGS)[number]; + +export const COOKING_TIMES = ['LOW', 'MEDIUM', 'HIGH'] as const; +export type CookingTimeLevel = (typeof COOKING_TIMES)[number]; + +export const FASTING_PROTOCOL_VALUES = [ + 'FOURTEEN_TEN', + 'SIXTEEN_EIGHT', + 'EIGHTEEN_SIX', + 'TWENTY_FOUR', + 'ESE_24', + 'FIVE_TWO', +] as const; + +export const preferencesPatchSchema = z + .object({ + exclusions: z.array(z.enum(EXCLUSION_GROUPS)).max(EXCLUSION_GROUPS.length).optional(), + cuisinePreferences: z.array(z.enum(CUISINE_TAGS)).max(CUISINE_TAGS.length).optional(), + cookingTime: z.enum(COOKING_TIMES).optional(), + fastingProtocol: z.enum(FASTING_PROTOCOL_VALUES).nullable().optional(), + }) + .strict(); + +export type PreferencesPatch = z.infer;