feat: profile flow — Zod schema, /me/profile API, /profile page with BMR/TDEE
@ketopath/shared — new module profile/schema:
- profileInputSchema (Zod): age 18-110, gender, height 120-230 cm, weights
35-300 kg, activityLevel, optional targetDate
- GENDERS / ACTIVITY_LEVELS string-literal arrays for UI iteration
- Re-exported from @ketopath/shared
apps/api — new module modules/profile:
- PUT /me/profile: requireAuth, validates body via profileInputSchema, upserts
the row, returns the saved profile + derived { bmr, tdee, activityMultiplier }
(BMR/TDEE computed via @ketopath/shared)
- GET /me/profile: requireAuth, returns the same shape, 404 when missing
- Decimal columns serialised back as numbers
apps/web — new /profile page:
- src/app/[locale]/profile/page.tsx (server): redirects unauthenticated users
to /sign-in, calls fetchProfile to hydrate the form
- profile-form.tsx (client): react-hook-form + zodResolver bound to the same
shared schema, native styled selects for gender/activityLevel until shadcn
Select arrives, post-submit panel showing BMR/TDEE/multiplier
- actions.ts: server actions saveProfile / fetchProfile that proxy the call
to API_URL via cookie passthrough (no CORS, no exposed token)
- Home page gains a "Completa il tuo profilo" CTA when signed in
- API_URL env var added to .env.example
- Italian copy in messages/it.json under Profile
Verified end-to-end with the "Michele" PRD persona (49, M, 170cm, 76kg, SEDENTARY):
BMR = 1583 kcal, TDEE = 1899 kcal, multiplier 1.2 — matches the unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac2227fdec
commit
1d904484e6
12 files changed
+398
-1
No files matched your search
@@ -8,6 +8,7 @@ import { env } from './config/env.js';
|
|||||||
import { dbRoutes } from './modules/db/db.routes.js';
|
import { dbRoutes } from './modules/db/db.routes.js';
|
||||||
import { healthRoutes } from './modules/health/health.routes.js';
|
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 { profileRoutes } from './modules/profile/profile.routes.js';
|
||||||
import { authPlugin } from './plugins/auth.js';
|
import { authPlugin } from './plugins/auth.js';
|
||||||
import { prismaPlugin } from './plugins/prisma.js';
|
import { prismaPlugin } from './plugins/prisma.js';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(dbRoutes);
|
await app.register(dbRoutes);
|
||||||
await app.register(meRoutes);
|
await app.register(meRoutes);
|
||||||
|
await app.register(profileRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
ACTIVITY_MULTIPLIERS,
|
||||||
|
calculateBmr,
|
||||||
|
calculateTdee,
|
||||||
|
profileInputSchema,
|
||||||
|
type ActivityLevel,
|
||||||
|
type Gender,
|
||||||
|
} from '@ketopath/shared';
|
||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
|
||||||
|
function serialize(
|
||||||
|
profile: {
|
||||||
|
age: number;
|
||||||
|
gender: Gender;
|
||||||
|
heightCm: number;
|
||||||
|
weightStartKg: { toNumber(): number };
|
||||||
|
weightCurrentKg: { toNumber(): number };
|
||||||
|
weightGoalKg: { toNumber(): number };
|
||||||
|
activityLevel: ActivityLevel;
|
||||||
|
targetDate: Date | null;
|
||||||
|
currentPhase: string;
|
||||||
|
} | null,
|
||||||
|
) {
|
||||||
|
if (!profile) return null;
|
||||||
|
const weightCurrentKg = profile.weightCurrentKg.toNumber();
|
||||||
|
const bmr = calculateBmr({
|
||||||
|
weightKg: weightCurrentKg,
|
||||||
|
heightCm: profile.heightCm,
|
||||||
|
ageYears: profile.age,
|
||||||
|
gender: profile.gender,
|
||||||
|
});
|
||||||
|
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||||
|
return {
|
||||||
|
age: profile.age,
|
||||||
|
gender: profile.gender,
|
||||||
|
heightCm: profile.heightCm,
|
||||||
|
weightStartKg: profile.weightStartKg.toNumber(),
|
||||||
|
weightCurrentKg,
|
||||||
|
weightGoalKg: profile.weightGoalKg.toNumber(),
|
||||||
|
activityLevel: profile.activityLevel,
|
||||||
|
targetDate: profile.targetDate?.toISOString() ?? null,
|
||||||
|
currentPhase: profile.currentPhase,
|
||||||
|
derived: {
|
||||||
|
bmr: Math.round(bmr),
|
||||||
|
tdee: Math.round(tdee),
|
||||||
|
activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
fastify.get('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const profile = await fastify.prisma.profile.findUnique({
|
||||||
|
where: { userId: request.user!.id },
|
||||||
|
});
|
||||||
|
if (!profile) return reply.code(404).send({ error: 'profile_not_found' });
|
||||||
|
return { profile: serialize(profile) };
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.put('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const parsed = profileInputSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
const userId = request.user!.id;
|
||||||
|
|
||||||
|
const profile = await fastify.prisma.profile.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
age: data.age,
|
||||||
|
gender: data.gender,
|
||||||
|
heightCm: data.heightCm,
|
||||||
|
weightStartKg: data.weightStartKg,
|
||||||
|
weightCurrentKg: data.weightCurrentKg,
|
||||||
|
weightGoalKg: data.weightGoalKg,
|
||||||
|
activityLevel: data.activityLevel,
|
||||||
|
targetDate: data.targetDate,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
age: data.age,
|
||||||
|
gender: data.gender,
|
||||||
|
heightCm: data.heightCm,
|
||||||
|
weightStartKg: data.weightStartKg,
|
||||||
|
weightCurrentKg: data.weightCurrentKg,
|
||||||
|
weightGoalKg: data.weightGoalKg,
|
||||||
|
activityLevel: data.activityLevel,
|
||||||
|
targetDate: data.targetDate ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { profile: serialize(profile) };
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# Next.js
|
# Next.js
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# API server URL (server-only — non esporre al browser)
|
||||||
|
API_URL=http://localhost:4000
|
||||||
|
|
||||||
# Better Auth (deve coincidere con apps/api/.env)
|
# Better Auth (deve coincidere con apps/api/.env)
|
||||||
# Genera un secret a 32+ byte: `openssl rand -base64 32`
|
# Genera un secret a 32+ byte: `openssl rand -base64 32`
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"ctaSecondary": "Scopri come funziona",
|
"ctaSecondary": "Scopri come funziona",
|
||||||
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.",
|
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.",
|
||||||
"signedInAs": "Accesso effettuato come {name}",
|
"signedInAs": "Accesso effettuato come {name}",
|
||||||
"signOut": "Esci"
|
"signOut": "Esci",
|
||||||
|
"completeProfile": "Completa il tuo profilo"
|
||||||
},
|
},
|
||||||
"Metadata": {
|
"Metadata": {
|
||||||
"title": "KetoPath — Chetogenica e digiuno intermittente personalizzati",
|
"title": "KetoPath — Chetogenica e digiuno intermittente personalizzati",
|
||||||
@@ -40,5 +41,35 @@
|
|||||||
"error": "Non è stato possibile completare la registrazione. Riprova.",
|
"error": "Non è stato possibile completare la registrazione. Riprova.",
|
||||||
"disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico."
|
"disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Profile": {
|
||||||
|
"title": "Il tuo profilo",
|
||||||
|
"subtitle": "Inserisci i tuoi dati per calcolare BMR e TDEE personalizzati.",
|
||||||
|
"age": "Età",
|
||||||
|
"gender": "Sesso",
|
||||||
|
"heightCm": "Altezza (cm)",
|
||||||
|
"weightStartKg": "Peso iniziale (kg)",
|
||||||
|
"weightCurrentKg": "Peso attuale (kg)",
|
||||||
|
"weightGoalKg": "Peso obiettivo (kg)",
|
||||||
|
"activityLevel": "Livello di attività",
|
||||||
|
"selectPlaceholder": "Seleziona…",
|
||||||
|
"save": "Salva profilo",
|
||||||
|
"saving": "Salvataggio in corso…",
|
||||||
|
"summary": "Calcolo personalizzato",
|
||||||
|
"bmr": "Metabolismo basale (BMR)",
|
||||||
|
"tdee": "Fabbisogno totale (TDEE)",
|
||||||
|
"activityMultiplier": "Moltiplicatore attività",
|
||||||
|
"hint": "I valori sono stime basate sulla formula Mifflin-St Jeor. Non sostituiscono il parere di un nutrizionista.",
|
||||||
|
"genderOptions": {
|
||||||
|
"MALE": "Maschio",
|
||||||
|
"FEMALE": "Femmina",
|
||||||
|
"OTHER": "Altro / preferisco non specificare"
|
||||||
|
},
|
||||||
|
"activityOptions": {
|
||||||
|
"SEDENTARY": "Sedentario (lavoro d'ufficio)",
|
||||||
|
"LIGHT": "Leggero (sport 1-3 volte a settimana)",
|
||||||
|
"MODERATE": "Moderato (sport 3-5 volte a settimana)",
|
||||||
|
"INTENSE": "Intenso (sport 6+ volte a settimana)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,9 @@ function HomeContent({ userName }: { userName: string | null }) {
|
|||||||
{userName ? (
|
{userName ? (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-foreground text-sm">{t('signedInAs', { name: userName })}</span>
|
<span className="text-foreground text-sm">{t('signedInAs', { name: userName })}</span>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/profile">{t('completeProfile')}</Link>
|
||||||
|
</Button>
|
||||||
<SignOutButton label={t('signOut')} />
|
<SignOutButton label={t('signOut')} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { profileInputSchema, type ProfileInput } from '@ketopath/shared';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||||
|
|
||||||
|
export type SaveProfileResult = { ok: true; profile: unknown } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function saveProfile(input: ProfileInput): Promise<SaveProfileResult> {
|
||||||
|
const parsed = profileInputSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false, error: parsed.error.issues[0]?.message ?? 'invalid_input' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = headers().get('cookie') ?? '';
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/me/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', cookie },
|
||||||
|
body: JSON.stringify(parsed.data),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, error: `api_error_${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { profile: unknown };
|
||||||
|
return { ok: true, profile: data.profile };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(): Promise<unknown | null> {
|
||||||
|
const cookie = headers().get('cookie') ?? '';
|
||||||
|
const res = await fetch(`${API_URL}/me/profile`, {
|
||||||
|
headers: { cookie },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) throw new Error(`api_error_${res.status}`);
|
||||||
|
const data = (await res.json()) as { profile: unknown };
|
||||||
|
return data.profile;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { getServerSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
import { fetchProfile } from './actions';
|
||||||
|
import { ProfileForm } from './profile-form';
|
||||||
|
|
||||||
|
export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) {
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (!session?.user) redirect('/sign-in');
|
||||||
|
|
||||||
|
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
|
|
||||||
|
return <ProfilePageContent initial={profile} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfilePageContent({
|
||||||
|
initial,
|
||||||
|
}: {
|
||||||
|
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('Profile');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
|
<CardDescription>{t('subtitle')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProfileForm initial={initial} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { ACTIVITY_LEVELS, GENDERS, profileInputSchema, type ProfileInput } from '@ketopath/shared';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
import { saveProfile } from './actions';
|
||||||
|
|
||||||
|
type Derived = { bmr: number; tdee: number; activityMultiplier: number };
|
||||||
|
type Profile = ProfileInput & { derived?: Derived };
|
||||||
|
|
||||||
|
export function ProfileForm({ initial }: { initial: Profile | null }) {
|
||||||
|
const t = useTranslations('Profile');
|
||||||
|
const router = useRouter();
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState<Derived | null>(initial?.derived ?? null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<ProfileInput>({
|
||||||
|
resolver: zodResolver(profileInputSchema),
|
||||||
|
defaultValues: initial
|
||||||
|
? {
|
||||||
|
age: initial.age,
|
||||||
|
gender: initial.gender,
|
||||||
|
heightCm: initial.heightCm,
|
||||||
|
weightStartKg: initial.weightStartKg,
|
||||||
|
weightCurrentKg: initial.weightCurrentKg,
|
||||||
|
weightGoalKg: initial.weightGoalKg,
|
||||||
|
activityLevel: initial.activityLevel,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (input) => {
|
||||||
|
setServerError(null);
|
||||||
|
const result = await saveProfile(input);
|
||||||
|
if (!result.ok) {
|
||||||
|
setServerError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profile = result.profile as Profile;
|
||||||
|
if (profile.derived) setSaved(profile.derived);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-5" noValidate>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label={t('age')} error={errors.age?.message}>
|
||||||
|
<Input type="number" inputMode="numeric" {...register('age')} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('gender')} error={errors.gender?.message}>
|
||||||
|
<select
|
||||||
|
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
{...register('gender')}
|
||||||
|
>
|
||||||
|
<option value="">{t('selectPlaceholder')}</option>
|
||||||
|
{GENDERS.map((g) => (
|
||||||
|
<option key={g} value={g}>
|
||||||
|
{t(`genderOptions.${g}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label={t('heightCm')} error={errors.heightCm?.message}>
|
||||||
|
<Input type="number" inputMode="numeric" {...register('heightCm')} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Field label={t('weightStartKg')} error={errors.weightStartKg?.message}>
|
||||||
|
<Input type="number" step="0.1" inputMode="decimal" {...register('weightStartKg')} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('weightCurrentKg')} error={errors.weightCurrentKg?.message}>
|
||||||
|
<Input type="number" step="0.1" inputMode="decimal" {...register('weightCurrentKg')} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('weightGoalKg')} error={errors.weightGoalKg?.message}>
|
||||||
|
<Input type="number" step="0.1" inputMode="decimal" {...register('weightGoalKg')} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label={t('activityLevel')} error={errors.activityLevel?.message}>
|
||||||
|
<select
|
||||||
|
className="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
{...register('activityLevel')}
|
||||||
|
>
|
||||||
|
<option value="">{t('selectPlaceholder')}</option>
|
||||||
|
{ACTIVITY_LEVELS.map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{t(`activityOptions.${a}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{serverError ? (
|
||||||
|
<p role="alert" className="text-destructive text-sm">
|
||||||
|
{serverError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
|
{isSubmitting ? t('saving') : t('save')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{saved ? (
|
||||||
|
<div className="bg-secondary mt-6 rounded-md p-4 text-sm">
|
||||||
|
<p className="font-medium">{t('summary')}</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
<li>
|
||||||
|
{t('bmr')}: <span className="font-mono">{saved.bmr} kcal</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{t('tdee')}: <span className="font-mono">{saved.tdee} kcal</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{t('activityMultiplier')}:{' '}
|
||||||
|
<span className="font-mono">{saved.activityMultiplier}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">{t('hint')}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{error ? <p className="text-destructive text-xs">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ketopath/eslint-config": "workspace:*",
|
"@ketopath/eslint-config": "workspace:*",
|
||||||
"@ketopath/tsconfig": "workspace:*"
|
"@ketopath/tsconfig": "workspace:*"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
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';
|
||||||
|
export * from './profile/schema.js';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] as const;
|
||||||
|
export const ACTIVITY_LEVELS = ['SEDENTARY', 'LIGHT', 'MODERATE', 'INTENSE'] as const;
|
||||||
|
|
||||||
|
export const profileInputSchema = z.object({
|
||||||
|
age: z.coerce.number().int().min(18, 'Devi avere almeno 18 anni').max(110),
|
||||||
|
gender: z.enum(GENDERS),
|
||||||
|
heightCm: z.coerce.number().int().min(120).max(230),
|
||||||
|
weightStartKg: z.coerce.number().min(35).max(300),
|
||||||
|
weightCurrentKg: z.coerce.number().min(35).max(300),
|
||||||
|
weightGoalKg: z.coerce.number().min(35).max(300),
|
||||||
|
activityLevel: z.enum(ACTIVITY_LEVELS),
|
||||||
|
targetDate: z.coerce.date().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProfileInput = z.input<typeof profileInputSchema>;
|
||||||
|
export type ProfileInputParsed = z.output<typeof profileInputSchema>;
|
||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user