feat(web): refactor profile to shadcn Form + Select primitives

- Add shadcn Form helpers (Form, FormField, FormItem, FormLabel, FormControl,
  FormMessage, FormDescription) on top of react-hook-form's Controller
- Add shadcn Select wrapping @radix-ui/react-select with Trigger / Content /
  Item / Value, lucide chevron + check icons
- Profile form now wraps fields in <Form>...<FormField> blocks; gender and
  activityLevel use the new Select with placeholder, the rest use Input;
  validation errors land in <FormMessage> per field automatically

Playwright config:
- webServer is now an array — Playwright boots both api (:4000) and web (:3000)
  before the suite, so server actions that proxy to API_URL work in CI/local
- profile.spec.ts updated for the new flow: post-signup goes through /welcome
  to accept the medical disclaimer, then through Select primitives via
  combobox click + role=option for Sesso and Livello di attività

Verified: pnpm test:e2e — 4/4 specs pass (home + 2 auth + profile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 16:15:21 +02:00
1 parent 4b6c3f6ae9
commit e7158abbad
7 files changed
+410 -137

No files matched your search

+12 -3
View File
@@ -19,18 +19,27 @@ test('profile flow — submit returns BMR/TDEE for the Michele PRD persona', asy
await page.getByRole('button', { name: 'Crea account' }).click();
await page.waitForURL('/');
// Open the profile page from the home CTA
// Open profile from home — first time we'll be redirected through /welcome
await page.getByRole('link', { name: 'Completa il tuo profilo' }).click();
await page.waitForURL('/welcome');
// Accept the medical disclaimer (3 mandatory checkboxes + submit)
await page.getByLabel(/Confermo di essere maggiorenne/).check();
await page.getByLabel(/Dichiaro di non avere condizioni mediche escludenti/).check();
await page.getByLabel(/Accetto che KetoPath non sostituisce/).check();
await page.getByRole('button', { name: 'Accetto e proseguo' }).click();
await page.waitForURL('/profile');
// Fill the form with the "Michele" PRD persona values
await page.getByLabel('Età').fill('49');
await page.getByLabel('Sesso').selectOption('MALE');
await page.getByLabel('Sesso').click();
await page.getByRole('option', { name: 'Maschio' }).click();
await page.getByLabel('Altezza (cm)').fill('170');
await page.getByLabel('Peso iniziale (kg)').fill('76');
await page.getByLabel('Peso attuale (kg)').fill('76');
await page.getByLabel('Peso obiettivo (kg)').fill('70');
await page.getByLabel('Livello di attività').selectOption('SEDENTARY');
await page.getByLabel('Livello di attività').click();
await page.getByRole('option', { name: /Sedentario/ }).click();
await page.getByRole('button', { name: 'Salva profilo' }).click();
// BMR/TDEE summary panel becomes visible after a successful save
+1
View File
@@ -17,6 +17,7 @@
"@ketopath/shared": "workspace:*",
"@ketopath/ui": "workspace:*",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@sentry/nextjs": "^8.30.0",
"better-auth": "^1.0.21",
+18 -8
View File
@@ -1,7 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
const PORT = 3000;
const baseURL = `http://localhost:${PORT}`;
const WEB_PORT = 3000;
const API_PORT = 4000;
const baseURL = `http://localhost:${WEB_PORT}`;
export default defineConfig({
testDir: './e2e',
@@ -15,10 +16,19 @@ export default defineConfig({
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'pnpm dev',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
webServer: [
{
command: 'pnpm --filter @ketopath/api dev',
url: `http://localhost:${API_PORT}/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
cwd: '../..',
},
{
command: 'pnpm dev',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
});
+149 -126
View File
@@ -8,8 +8,22 @@ import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { saveProfile } from './actions';
@@ -22,11 +36,7 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
const [serverError, setServerError] = useState<string | null>(null);
const [saved, setSaved] = useState<Derived | null>(initial?.derived ?? null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileInput>({
const form = useForm<ProfileInput>({
resolver: zodResolver(profileInputSchema),
...(initial
? {
@@ -43,7 +53,7 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
: {}),
});
const onSubmit = handleSubmit(async (input) => {
const onSubmit = form.handleSubmit(async (input) => {
setServerError(null);
const result = await saveProfile(input);
if (!result.ok) {
@@ -56,128 +66,141 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
});
return (
<form onSubmit={onSubmit} className="space-y-5" noValidate>
<div className="grid grid-cols-2 gap-4">
<Field id="age" label={t('age')} error={errors.age?.message}>
<Input id="age" type="number" inputMode="numeric" {...register('age')} />
</Field>
<Field id="gender" label={t('gender')} error={errors.gender?.message}>
<select
id="gender"
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 id="heightCm" label={t('heightCm')} error={errors.heightCm?.message}>
<Input id="heightCm" type="number" inputMode="numeric" {...register('heightCm')} />
</Field>
<div className="grid grid-cols-3 gap-4">
<Field id="weightStartKg" label={t('weightStartKg')} error={errors.weightStartKg?.message}>
<Input
id="weightStartKg"
type="number"
step="0.1"
inputMode="decimal"
{...register('weightStartKg')}
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-5" noValidate>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="age"
render={({ field }) => (
<FormItem>
<FormLabel>{t('age')}</FormLabel>
<FormControl>
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Field>
<Field
id="weightCurrentKg"
label={t('weightCurrentKg')}
error={errors.weightCurrentKg?.message}
>
<Input
id="weightCurrentKg"
type="number"
step="0.1"
inputMode="decimal"
{...register('weightCurrentKg')}
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel>{t('gender')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? ''}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectPlaceholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{GENDERS.map((g) => (
<SelectItem key={g} value={g}>
{t(`genderOptions.${g}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</Field>
<Field id="weightGoalKg" label={t('weightGoalKg')} error={errors.weightGoalKg?.message}>
<Input
id="weightGoalKg"
type="number"
step="0.1"
inputMode="decimal"
{...register('weightGoalKg')}
/>
</Field>
</div>
<Field id="activityLevel" label={t('activityLevel')} error={errors.activityLevel?.message}>
<select
id="activityLevel"
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({
id,
label,
error,
children,
}: {
id: string;
label: string;
error?: string | undefined;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
{error ? <p className="text-destructive text-xs">{error}</p> : null}
</div>
<FormField
control={form.control}
name="heightCm"
render={({ field }) => (
<FormItem>
<FormLabel>{t('heightCm')}</FormLabel>
<FormControl>
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-3 gap-4">
{(['weightStartKg', 'weightCurrentKg', 'weightGoalKg'] as const).map((name) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{t(name)}</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
inputMode="decimal"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<FormField
control={form.control}
name="activityLevel"
render={({ field }) => (
<FormItem>
<FormLabel>{t('activityLevel')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? ''}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectPlaceholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{ACTIVITY_LEVELS.map((a) => (
<SelectItem key={a} value={a}>
{t(`activityOptions.${a}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{serverError ? (
<p role="alert" className="text-destructive text-sm">
{serverError}
</p>
) : null}
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
{form.formState.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>
</Form>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
createContext,
forwardRef,
useContext,
useId,
type ComponentPropsWithoutRef,
type ElementRef,
type HTMLAttributes,
} from 'react';
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
export const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { name: TName };
const FormFieldContext = createContext<FormFieldContextValue | null>(null);
export const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
type FormItemContextValue = { id: string };
const FormItemContext = createContext<FormItemContextValue | null>(null);
export function useFormField() {
const fieldContext = useContext(FormFieldContext);
const itemContext = useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
if (!fieldContext) throw new Error('useFormField must be used within <FormField>');
const fieldState = getFieldState(fieldContext.name, formState);
const id = itemContext?.id ?? '';
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
}
export const FormItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-1.5', className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = 'FormItem';
export const FormLabel = forwardRef<
ElementRef<typeof LabelPrimitive.Root>,
ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
htmlFor={formItemId}
className={cn(error && 'text-destructive', className)}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
export const FormControl = forwardRef<
ElementRef<typeof Slot>,
ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
export const FormDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-muted-foreground text-xs', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error.message ?? '') : children;
if (!body) return null;
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-destructive text-xs font-medium', className)}
{...props}
>
{body}
</p>
);
},
);
FormMessage.displayName = 'FormMessage';
+83
View File
@@ -0,0 +1,83 @@
'use client';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown } from 'lucide-react';
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
import { cn } from '@/lib/utils';
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = forwardRef<
ElementRef<typeof SelectPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
export const SelectContent = forwardRef<
ElementRef<typeof SelectPrimitive.Content>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectItem = forwardRef<
ElementRef<typeof SelectPrimitive.Item>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
BIN
View File
Binary file not shown.