From e7158abbad8fc028868334008473a5f93d926de2 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 16:15:21 +0200 Subject: [PATCH] feat(web): refactor profile to shadcn Form + Select primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
... blocks; gender and activityLevel use the new Select with placeholder, the rest use Input; validation errors land in 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) --- apps/web/e2e/profile.spec.ts | 15 +- apps/web/package.json | 1 + apps/web/playwright.config.ts | 26 +- .../src/app/[locale]/profile/profile-form.tsx | 275 ++++++++++-------- apps/web/src/components/ui/form.tsx | 147 ++++++++++ apps/web/src/components/ui/select.tsx | 83 ++++++ pnpm-lock.yaml | Bin 294246 -> 319321 bytes 7 files changed, 410 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/components/ui/form.tsx create mode 100644 apps/web/src/components/ui/select.tsx diff --git a/apps/web/e2e/profile.spec.ts b/apps/web/e2e/profile.spec.ts index 4d00dfa..09b69b6 100644 --- a/apps/web/e2e/profile.spec.ts +++ b/apps/web/e2e/profile.spec.ts @@ -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 diff --git a/apps/web/package.json b/apps/web/package.json index cacc8f5..29f11ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 10c1e33..22c1d1b 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -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, + }, + ], }); diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 220846b..7582d3f 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -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(null); const [saved, setSaved] = useState(initial?.derived ?? null); - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ 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 ( - -
- - - - - - -
- - - - - -
- - + +
+ ( + + {t('age')} + + + + + + )} /> - - - ( + + {t('gender')} + + + + )} /> - - - - -
- - - - - - {serverError ? ( -

- {serverError} -

- ) : null} - - - - {saved ? ( -
-

{t('summary')}

-
    -
  • - {t('bmr')}: {saved.bmr} kcal -
  • -
  • - {t('tdee')}: {saved.tdee} kcal -
  • -
  • - {t('activityMultiplier')}:{' '} - {saved.activityMultiplier} -
  • -
-

{t('hint')}

- ) : null} - - ); -} -function Field({ - id, - label, - error, - children, -}: { - id: string; - label: string; - error?: string | undefined; - children: React.ReactNode; -}) { - return ( -
- - {children} - {error ?

{error}

: null} -
+ ( + + {t('heightCm')} + + + + + + )} + /> + +
+ {(['weightStartKg', 'weightCurrentKg', 'weightGoalKg'] as const).map((name) => ( + ( + + {t(name)} + + + + + + )} + /> + ))} +
+ + ( + + {t('activityLevel')} + + + + )} + /> + + {serverError ? ( +

+ {serverError} +

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

{t('summary')}

+
    +
  • + {t('bmr')}: {saved.bmr} kcal +
  • +
  • + {t('tdee')}: {saved.tdee} kcal +
  • +
  • + {t('activityMultiplier')}:{' '} + {saved.activityMultiplier} +
  • +
+

{t('hint')}

+
+ ) : null} + + ); } diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx new file mode 100644 index 0000000..4c779f6 --- /dev/null +++ b/apps/web/src/components/ui/form.tsx @@ -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 = FieldPath, +> = { name: TName }; + +const FormFieldContext = createContext(null); + +export const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => ( + + + +); + +type FormItemContextValue = { id: string }; +const FormItemContext = createContext(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 '); + + 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>( + ({ className, ...props }, ref) => { + const id = useId(); + return ( + +
+ + ); + }, +); +FormItem.displayName = 'FormItem'; + +export const FormLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + return ( +