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
@@ -11,6 +11,7 @@ import { healthRoutes } from './modules/health/health.routes.js';
|
|||||||
import { meRoutes } from './modules/me/me.routes.js';
|
import { meRoutes } from './modules/me/me.routes.js';
|
||||||
import { notificationsRoutes } from './modules/notifications/notifications.routes.js';
|
import { notificationsRoutes } from './modules/notifications/notifications.routes.js';
|
||||||
import { planRoutes } from './modules/plan/plan.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 { profileRoutes } from './modules/profile/profile.routes.js';
|
||||||
import { shoppingRoutes } from './modules/shopping/shopping.routes.js';
|
import { shoppingRoutes } from './modules/shopping/shopping.routes.js';
|
||||||
import { fastRoutes } from './modules/tracking/fast.routes.js';
|
import { fastRoutes } from './modules/tracking/fast.routes.js';
|
||||||
@@ -51,6 +52,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(dbRoutes);
|
await app.register(dbRoutes);
|
||||||
await app.register(meRoutes);
|
await app.register(meRoutes);
|
||||||
await app.register(profileRoutes);
|
await app.register(profileRoutes);
|
||||||
|
await app.register(preferencesRoutes);
|
||||||
await app.register(weightRoutes);
|
await app.register(weightRoutes);
|
||||||
await app.register(fastRoutes);
|
await app.register(fastRoutes);
|
||||||
await app.register(planRoutes);
|
await app.register(planRoutes);
|
||||||
|
|||||||
@@ -47,23 +47,32 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
fatG: true,
|
fatG: true,
|
||||||
netCarbG: true,
|
netCarbG: true,
|
||||||
phases: true,
|
phases: true,
|
||||||
|
ingredients: {
|
||||||
|
select: { ingredient: { select: { exclusionGroups: true } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (recipes.length === 0) {
|
if (recipes.length === 0) {
|
||||||
return reply.code(409).send({ error: 'recipe_catalog_empty' });
|
return reply.code(409).send({ error: 'recipe_catalog_empty' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates: RecipeCandidate[] = recipes.map((r) => ({
|
const candidates: RecipeCandidate[] = recipes.map((r) => {
|
||||||
id: r.id,
|
const tags = new Set<string>();
|
||||||
name: r.name,
|
for (const ri of r.ingredients) {
|
||||||
category: r.category,
|
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||||
kcal: r.kcal,
|
}
|
||||||
proteinG: r.proteinG,
|
return {
|
||||||
fatG: r.fatG,
|
id: r.id,
|
||||||
netCarbG: r.netCarbG,
|
name: r.name,
|
||||||
exclusionTags: [],
|
category: r.category,
|
||||||
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
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 weightCurrentKg = Number(profile.weightCurrentKg);
|
||||||
const bmr = calculateBmr({
|
const bmr = calculateBmr({
|
||||||
|
|||||||
@@ -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<string, unknown> = {
|
||||||
|
userId,
|
||||||
|
exclusions: data.exclusions ?? [],
|
||||||
|
cuisinePreferences: data.cuisinePreferences ?? [],
|
||||||
|
cookingTime: data.cookingTime ?? 'MEDIUM',
|
||||||
|
fastingProtocol: data.fastingProtocol ?? null,
|
||||||
|
};
|
||||||
|
const update: Record<string, unknown> = {};
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -165,6 +165,76 @@
|
|||||||
"delta": "Variazione",
|
"delta": "Variazione",
|
||||||
"emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate."
|
"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": {
|
"Notifications": {
|
||||||
"eyebrow": "Notifiche",
|
"eyebrow": "Notifiche",
|
||||||
"title": "Promemoria al momento giusto",
|
"title": "Promemoria al momento giusto",
|
||||||
|
|||||||
@@ -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 } } },
|
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
||||||
});
|
});
|
||||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||||
if (!user.profile) redirect('/profile');
|
if (!user.profile) redirect('/onboarding');
|
||||||
|
|
||||||
const plan = await fetchCurrentPlan();
|
const plan = await fetchCurrentPlan();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { getServerSession } from '@/lib/auth';
|
|||||||
import { fetchProfile } from './actions';
|
import { fetchProfile } from './actions';
|
||||||
import { fetchNotificationConfig } from './notifications-actions';
|
import { fetchNotificationConfig } from './notifications-actions';
|
||||||
import { NotificationsPanel } from './notifications-panel';
|
import { NotificationsPanel } from './notifications-panel';
|
||||||
|
import { fetchPreferences } from './preferences-actions';
|
||||||
|
import { PreferencesSection } from './preferences-section';
|
||||||
import { ProfileForm } from './profile-form';
|
import { ProfileForm } from './profile-form';
|
||||||
|
|
||||||
export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) {
|
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 profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
const notifications = await fetchNotificationConfig();
|
const notifications = await fetchNotificationConfig();
|
||||||
|
const preferences = await fetchPreferences();
|
||||||
|
|
||||||
return <ProfilePageContent initial={profile} notifications={notifications} />;
|
return (
|
||||||
|
<ProfilePageContent initial={profile} notifications={notifications} preferences={preferences} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfilePageContent({
|
function ProfilePageContent({
|
||||||
initial,
|
initial,
|
||||||
notifications,
|
notifications,
|
||||||
|
preferences,
|
||||||
}: {
|
}: {
|
||||||
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
|
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
|
||||||
|
preferences: Awaited<ReturnType<typeof fetchPreferences>>;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Profile');
|
const t = useTranslations('Profile');
|
||||||
|
|
||||||
@@ -56,9 +63,15 @@ function ProfilePageContent({
|
|||||||
<ProfileForm initial={initial} />
|
<ProfileForm initial={initial} />
|
||||||
</div>
|
</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} />
|
<NotificationsPanel initial={notifications} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 Derived = { bmr: number; tdee: number; activityMultiplier: number };
|
||||||
type Profile = ProfileInput & { derived?: Derived };
|
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 t = useTranslations('Profile');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
@@ -62,7 +70,11 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
}
|
}
|
||||||
const profile = result.profile as Profile;
|
const profile = result.profile as Profile;
|
||||||
if (profile.derived) setSaved(profile.derived);
|
if (profile.derived) setSaved(profile.derived);
|
||||||
router.refresh();
|
if (onSaved) {
|
||||||
|
onSaved(profile);
|
||||||
|
} else {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +203,7 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? t('saving') : t('save')}
|
{form.formState.isSubmitting ? t('saving') : (saveLabel ?? t('save'))}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function ShoppingPage({ params: { locale } }: { params: { l
|
|||||||
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
||||||
});
|
});
|
||||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||||
if (!user.profile) redirect('/profile');
|
if (!user.profile) redirect('/onboarding');
|
||||||
|
|
||||||
const list = await fetchShoppingList();
|
const list = await fetchShoppingList();
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function TrackingPage({ params: { locale } }: { params: { l
|
|||||||
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
||||||
});
|
});
|
||||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||||
if (!user.profile) redirect('/profile');
|
if (!user.profile) redirect('/onboarding');
|
||||||
|
|
||||||
const entries = await fetchWeightEntries();
|
const entries = await fetchWeightEntries();
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ export async function acceptDisclaimer(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/', 'layout');
|
revalidatePath('/', 'layout');
|
||||||
redirect('/profile');
|
redirect('/onboarding');
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ export default async function WelcomePage({ params: { locale } }: { params: { lo
|
|||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { disclaimerAcceptedAt: true },
|
select: { disclaimerAcceptedAt: true },
|
||||||
});
|
});
|
||||||
if (user?.disclaimerAcceptedAt) redirect('/profile');
|
if (user?.disclaimerAcceptedAt) redirect('/onboarding');
|
||||||
|
|
||||||
return <WelcomePageContent />;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './notifications/schema.js';
|
export * from './notifications/schema.js';
|
||||||
|
export * from './preferences/schema.js';
|
||||||
export * from './nutrition/bmr.js';
|
export * from './nutrition/bmr.js';
|
||||||
export * from './nutrition/tdee.js';
|
export * from './nutrition/tdee.js';
|
||||||
export * from './nutrition/types.js';
|
export * from './nutrition/types.js';
|
||||||
|
|||||||
@@ -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<typeof preferencesPatchSchema>;
|
||||||
Reference in new issue
Block a user