From 59a311cfc97250078d5d81d9db6740453d3e3784 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 16:44:55 +0200 Subject: [PATCH] feat(web): editorial slow-food redesign across the whole UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A complete visual + tonal pass on every page. Replaces the previous shadcn slate+emerald defaults with an Italian editorial aesthetic — refined slow-food magazine crossed with a luxury cookbook. Mobile-first, premium, distinctly Italian, no generic AI-wellness vibes. Tokens: - New palette: carta (cream parchment), ink (warm black), oliva (deep olive), pomodoro (terracotta), oro (aged gold), rule (hairline). All exposed as HSL CSS variables and as Tailwind colors. Dark mode = oxblood/cream. - Type stack: Fraunces (variable, optical-sizing auto, italic + roman) for display / Inter Tight for body / JetBrains Mono with tnum + zero for numerals. Replaces Inter-only setup. - Font feature settings on body and .font-display for ligatures, opsz and stylistic alternates. - Subtle SVG paper grain on body::before (multiply blend, 4.5% opacity). - 2px radius default (editorial print, not soft web). Component primitives: - Button — mono uppercase 11px tracking-widest, sharp corners, hover inverts ink/cream. Variants tuned for the new palette. - Input — bottom-only hairline, Fraunces 18px text, italic placeholders. - Label — tiny mono caps, ink-soft. - Card — top + bottom hairlines only, no shadow; Fraunces title and italic description; CardFooter has its own top-rule. - Select — same hairline-bottom trigger as Input; SelectContent panel uses a hard 6px offset shadow for an editorial print stamp feel. New component: - Masthead — top-of-page newspaper header with thick top-rule, KP wordmark with terracotta dot, "ANNO I · N. 0X" issue label, today's date in Italian. Pages: - / — Massive Fraunces "Una keto italiana, sostenibile." with terracotta dot, italic Fraunces tagline, right-column nav with category eyebrows (CUCINA / PROFILO) and arrow rule. Bottom: "Tre principi" with roman numerals I/II/III. Disclaimer in italic. - /sign-in — "CAPITOLO II / Bentornato in cucina." split layout. - /sign-up — "CAPITOLO III / Inizia il tuo percorso." Italian copy refresh. - /welcome — Editor's-letter layout with the four PRD §14.3 points as a Roman-numeral list; custom hand-drawn check icon for the consent boxes. - /profile — 7/5 split: form on the left (anagrafica + peso group), summary on the right with massive mono numerals (BMR, TDEE, multiplier) and italic "kcal" units; empty-state copy until the profile is saved. - /plan — "CAPITOLO V / Il tuo piano." Italian-formatted week range. Each day has a Roman-numeral mark (I-VII), Fraunces day name, mono kcal total. Each meal slot uses Roman numerals (I-IV) for the meal index. - Cookie banner — refreshed to match (italic Fraunces, "PRIVACY" eyebrow, outline button). Italian copy refresh: - Headline: "Una keto italiana, sostenibile" (was "KetoPath") - Tagline: narrative arc instead of marketing line - CTAs: "Iscriviti" / "Accedi" (was "Inizia gratis" / "Scopri come funziona") - Greeting: "Buongiorno, {name}." (was "Accesso effettuato come ...") - Profile: weight columns become Iniziale / Attuale / Obiettivo under a "Peso (chilogrammi)" group eyebrow. E2E updated to match: home looks for the new headline and CTA labels; profile uses the new "Come ti chiami" / "Iniziale / Attuale / Obiettivo" labels and asserts numeric values separately from the "kcal" unit span. All 4 specs pass. Verified visually at 1280×900 and 375×812. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/e2e/auth.spec.ts | 8 +- apps/web/e2e/home.spec.ts | 8 +- apps/web/e2e/profile.spec.ts | 22 +- apps/web/messages/it.json | 79 +++--- .../src/app/[locale]/(auth)/sign-in/page.tsx | 56 +++- .../[locale]/(auth)/sign-in/sign-in-form.tsx | 43 ++- .../src/app/[locale]/(auth)/sign-up/page.tsx | 68 +++-- .../[locale]/(auth)/sign-up/sign-up-form.tsx | 49 ++-- apps/web/src/app/[locale]/layout.tsx | 21 +- apps/web/src/app/[locale]/page.tsx | 134 ++++++++-- apps/web/src/app/[locale]/plan/page.tsx | 67 +++-- apps/web/src/app/[locale]/plan/plan-week.tsx | 62 +++-- apps/web/src/app/[locale]/profile/page.tsx | 30 ++- .../src/app/[locale]/profile/profile-form.tsx | 244 ++++++++++-------- apps/web/src/app/[locale]/sign-out-button.tsx | 2 +- .../app/[locale]/welcome/disclaimer-form.tsx | 28 +- apps/web/src/app/[locale]/welcome/page.tsx | 85 +++--- apps/web/src/components/cookie-banner.tsx | 11 +- apps/web/src/components/masthead.tsx | 42 +++ apps/web/src/components/ui/button.tsx | 21 +- apps/web/src/components/ui/card.tsx | 32 ++- apps/web/src/components/ui/input.tsx | 7 +- apps/web/src/components/ui/label.tsx | 2 +- apps/web/src/components/ui/select.tsx | 16 +- apps/web/src/styles/globals.css | 191 ++++++++++---- apps/web/tailwind.config.ts | 45 +++- 26 files changed, 933 insertions(+), 440 deletions(-) create mode 100644 apps/web/src/components/masthead.tsx diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index 90b6ad4..ea795a4 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -14,17 +14,17 @@ test.describe('email/password authentication', () => { // Sign up await page.goto('/sign-up'); - await page.getByLabel('Nome').fill(name); + await page.getByLabel('Come ti chiami').fill(name); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Crea account' }).click(); await page.waitForURL('/'); - await expect(page.getByText(`Accesso effettuato come ${name}`)).toBeVisible(); + await expect(page.getByText(`Buongiorno, ${name}`)).toBeVisible(); // Sign out await page.getByRole('button', { name: 'Esci' }).click(); - await expect(page.getByRole('link', { name: 'Inizia gratis' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Iscriviti' })).toBeVisible(); // Sign in with the same credentials await page.goto('/sign-in'); @@ -33,7 +33,7 @@ test.describe('email/password authentication', () => { await page.getByRole('button', { name: 'Accedi', exact: true }).click(); await page.waitForURL('/'); - await expect(page.getByText(`Accesso effettuato come ${name}`)).toBeVisible(); + await expect(page.getByText(`Buongiorno, ${name}`)).toBeVisible(); }); test('shows an error message on invalid credentials', async ({ page }) => { diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts index 0cf9d74..ef27d79 100644 --- a/apps/web/e2e/home.spec.ts +++ b/apps/web/e2e/home.spec.ts @@ -4,8 +4,8 @@ test('home page renders Italian copy and CTAs', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/KetoPath/); - await expect(page.getByRole('heading', { name: 'KetoPath' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Inizia gratis' })).toBeVisible(); - await expect(page.getByRole('link', { name: 'Scopri come funziona' })).toBeVisible(); - await expect(page.getByText(/non sostituiscono il parere di un medico/)).toBeVisible(); + await expect(page.getByRole('heading', { name: /Una keto italiana, sostenibile/ })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Iscriviti' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Accedi' })).toBeVisible(); + await expect(page.getByText(/non sostituiscono il parere/)).toBeVisible(); }); diff --git a/apps/web/e2e/profile.spec.ts b/apps/web/e2e/profile.spec.ts index 09b69b6..b1bfd12 100644 --- a/apps/web/e2e/profile.spec.ts +++ b/apps/web/e2e/profile.spec.ts @@ -13,20 +13,20 @@ test('profile flow — submit returns BMR/TDEE for the Michele PRD persona', asy // Sign-up gives us an authenticated session await page.goto('/sign-up'); - await page.getByLabel('Nome').fill(name); + await page.getByLabel('Come ti chiami').fill(name); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Crea account' }).click(); await page.waitForURL('/'); // Open profile from home — first time we'll be redirected through /welcome - await page.getByRole('link', { name: 'Completa il tuo profilo' }).click(); + await page.getByRole('link', { name: 'Profilo & calcoli' }).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.getByText(/Confermo di essere maggiorenne/).click(); + await page.getByText(/Dichiaro di non avere condizioni mediche escludenti/).click(); + await page.getByText(/Accetto che KetoPath non sostituisce/).click(); await page.getByRole('button', { name: 'Accetto e proseguo' }).click(); await page.waitForURL('/profile'); @@ -35,15 +35,17 @@ test('profile flow — submit returns BMR/TDEE for the Michele PRD persona', asy 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('Iniziale').fill('76'); + await page.getByLabel('Attuale').fill('76'); + await page.getByLabel('Obiettivo').fill('70'); 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 await expect(page.getByText('Calcolo personalizzato')).toBeVisible(); - await expect(page.getByText('1583 kcal')).toBeVisible(); // BMR Michele - await expect(page.getByText('1899 kcal')).toBeVisible(); // TDEE = 1582.5 × 1.2 + // Numbers are rendered in monospace next to a separate "kcal" unit span, + // so check for the bare numeric values. + await expect(page.getByText('1583', { exact: true })).toBeVisible(); + await expect(page.getByText('1899', { exact: true })).toBeVisible(); }); diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 48addb9..6edeeeb 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -1,60 +1,66 @@ { "Home": { - "title": "KetoPath", - "tagline": "Il tuo percorso personalizzato verso uno stile di vita chetogenico sostenibile.", - "ctaPrimary": "Inizia gratis", - "ctaSecondary": "Scopri come funziona", - "disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.", + "title": "Una keto italiana, sostenibile", + "tagline": "Il sentiero che ti accompagna dalla prima settimana di adattamento al mantenimento — settimana dopo settimana, piatto dopo piatto.", + "ctaPrimary": "Iscriviti", + "ctaSecondary": "Accedi", + "disclaimer": "KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.", "signedInAs": "Accesso effettuato come {name}", "signOut": "Esci", - "completeProfile": "Completa il tuo profilo", - "viewPlan": "Vedi il piano settimanale" + "completeProfile": "Profilo & calcoli", + "viewPlan": "Piano della settimana", + "lettura": "Continua la lettura", + "inizia": "Inizia", + "greeting": "Buongiorno, {name}.", + "principle1": "Personalizzazione totale: ciò che vedi è solo ciò che ti riguarda.", + "principle2": "Cucina italiana e mediterranea: ingredienti veri, di stagione, reperibili.", + "principle3": "Progressività: dalla fase intensiva al mantenimento, senza strappi." }, "Welcome": { "title": "Prima di iniziare", - "subtitle": "KetoPath è uno strumento di stile di vita, non un dispositivo medico. Conferma queste 4 cose prima di proseguire.", + "subtitle": "KetoPath è uno strumento di stile di vita, non un dispositivo medico. Quattro punti, prima di proseguire.", "point1": "L'app fornisce solo suggerimenti di stile di vita basati sui parametri che inserisci.", "point2": "Non diagnostica patologie, non prescrive trattamenti, non sostituisce il parere del tuo medico.", - "point3": "Se hai diabete di tipo 1, sei in gravidanza o allattamento, hai disturbi alimentari, problemi cardiaci o renali, parla prima con il tuo medico.", - "point4": "Prima di iniziare ti consigliamo esami del sangue di base (glicemia, colesterolo, profilo lipidico, funzionalità renale ed epatica).", + "point3": "Se hai diabete di tipo 1, sei in gravidanza o allattamento, hai disturbi alimentari, problemi cardiaci o renali — parla prima con il tuo medico.", + "point4": "Prima di iniziare ti consigliamo esami di base: glicemia, colesterolo, profilo lipidico, funzionalità renale ed epatica.", "checkAdult": "Confermo di essere maggiorenne.", "checkAware": "Dichiaro di non avere condizioni mediche escludenti, oppure di averle discusse con il mio medico.", "checkAccepts": "Accetto che KetoPath non sostituisce il parere medico professionale.", "submit": "Accetto e proseguo", - "submitting": "Salvataggio in corso…", + "submitting": "Salvataggio…", "legalNote": "Il consenso è registrato con data e ora come previsto dall'art. 7 GDPR." }, "CookieBanner": { "aria": "Informativa cookie", - "message": "Usiamo solo cookie essenziali per l'autenticazione e la sessione. Niente analytics o tracker di terze parti.", + "message": "Usiamo solo cookie essenziali per autenticazione e sessione. Niente analytics o tracker di terze parti.", "dismiss": "Ho capito" }, "Metadata": { - "title": "KetoPath — Chetogenica e digiuno intermittente personalizzati", - "description": "Web app italiana per la gestione strutturata di chetogenica e digiuno intermittente, con piani adattivi, tracking e lista della spesa automatica." + "title": "KetoPath — Chetogenica e digiuno intermittente, personalizzati", + "description": "Una keto italiana, sostenibile. Piani adattivi, tracking e lista della spesa automatica — settimana dopo settimana." }, "Auth": { "SignIn": { "title": "Accedi", - "subtitle": "Bentornato. Inserisci le tue credenziali per continuare.", + "subtitle": "Bentornato. Riprendi da dove avevi lasciato.", "emailLabel": "Email", "passwordLabel": "Password", "submit": "Accedi", "submitting": "Accesso in corso…", - "google": "Accedi con Google", + "google": "Continua con Google", "or": "oppure", - "noAccount": "Non hai un account? Registrati", + "noAccount": "Non hai ancora un account? Iscriviti", "error": "Credenziali non valide. Riprova." }, "SignUp": { - "title": "Registrati", + "title": "Iscriviti", "subtitle": "Crea il tuo profilo KetoPath in meno di un minuto.", - "nameLabel": "Nome", + "nameLabel": "Come ti chiami", "emailLabel": "Email", "passwordLabel": "Password", - "passwordHint": "Almeno 8 caratteri.", + "passwordHint": "Almeno 8 caratteri", "submit": "Crea account", - "submitting": "Creazione in corso…", + "submitting": "Creazione…", "google": "Continua con Google", "or": "oppure", "haveAccount": "Hai già un account? Accedi", @@ -63,14 +69,14 @@ } }, "Plan": { - "title": "Il tuo piano settimanale", + "title": "Il tuo piano", "weekOf": "Settimana del {date}", - "noPlan": "Non hai ancora un piano. Generalo per ricevere ricette adatte alla tua fase.", + "noPlan": "Non hai ancora un piano. Generalo: l'algoritmo sceglie le ricette adatte alla tua fase e ai tuoi macros.", "generate": "Genera piano", "regenerate": "Rigenera", "noRecipe": "Nessuna ricetta selezionata", "showAlternatives": "Vedi {n} alternative", - "hideAlternatives": "Nascondi alternative", + "hideAlternatives": "Nascondi", "swap": "Scegli", "dayTotal": "{kcal} kcal totali", "meals": { @@ -91,32 +97,33 @@ }, "Profile": { "title": "Il tuo profilo", - "subtitle": "Inserisci i tuoi dati per calcolare BMR e TDEE personalizzati.", + "subtitle": "I dati che ci servono per calcolare BMR, TDEE e i target di macros della settimana.", "age": "Età", "gender": "Sesso", "heightCm": "Altezza (cm)", - "weightStartKg": "Peso iniziale (kg)", - "weightCurrentKg": "Peso attuale (kg)", - "weightGoalKg": "Peso obiettivo (kg)", + "weightStartKg": "Iniziale", + "weightCurrentKg": "Attuale", + "weightGoalKg": "Obiettivo", "activityLevel": "Livello di attività", "selectPlaceholder": "Seleziona…", "save": "Salva profilo", - "saving": "Salvataggio in corso…", + "saving": "Salvataggio…", "summary": "Calcolo personalizzato", - "bmr": "Metabolismo basale (BMR)", - "tdee": "Fabbisogno totale (TDEE)", + "emptySummary": "Salva il profilo per ricevere il tuo BMR, il TDEE e il target calorico giornaliero.", + "bmr": "Metabolismo basale", + "tdee": "Fabbisogno totale", "activityMultiplier": "Moltiplicatore attività", - "hint": "I valori sono stime basate sulla formula Mifflin-St Jeor. Non sostituiscono il parere di un nutrizionista.", + "hint": "Valori stimati con la 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)" + "SEDENTARY": "Sedentario · lavoro d'ufficio", + "LIGHT": "Leggero · sport 1–3 volte / settimana", + "MODERATE": "Moderato · sport 3–5 volte / settimana", + "INTENSE": "Intenso · sport 6+ volte / settimana" } } } diff --git a/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx b/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx index 3d7ffa4..49b87d8 100644 --- a/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx @@ -1,8 +1,9 @@ import { enabledSocialProviders } from '@ketopath/auth'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Masthead } from '@/components/masthead'; import { SignInForm } from './sign-in-form'; @@ -11,16 +12,49 @@ export default function SignInPage({ params: { locale } }: { params: { locale: s const t = useTranslations('Auth.SignIn'); return ( -
- - - {t('title')} - {t('subtitle')} - - +
+ +
+ +
+

{t('title')}

- - -
+

+ {t.rich('noAccount', { + link: (chunks) => ( + + {chunks} + + ), + })} +

+ +
+ ); } diff --git a/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx b/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx index b73e219..36d1336 100644 --- a/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx +++ b/apps/web/src/app/[locale]/(auth)/sign-in/sign-in-form.tsx @@ -1,6 +1,5 @@ 'use client'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useState, type FormEvent } from 'react'; @@ -47,9 +46,9 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) { } return ( -
-
-
+
+ +
setEmail(e.target.value)} />
-
+
{error ? ( -

+

{error}

) : null} - {googleEnabled ? ( <> -
-
-
-
-
- - {t('or')} - -
+
+
+ {t('or')} +
- ) : null} -

- {t.rich('noAccount', { - link: (chunks) => ( - - {chunks} - - ), - })} -

); } diff --git a/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx b/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx index 68e977d..0e4d349 100644 --- a/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +++ b/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx @@ -1,15 +1,9 @@ import { enabledSocialProviders } from '@ketopath/auth'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Masthead } from '@/components/masthead'; import { SignUpForm } from './sign-up-form'; @@ -18,19 +12,51 @@ export default function SignUpPage({ params: { locale } }: { params: { locale: s const t = useTranslations('Auth.SignUp'); return ( -
- - - {t('title')} - {t('subtitle')} - - +
+ +
+ +
+

{t('title')}

- - -

{t('disclaimer')}

-
- -
+

+ {t('disclaimer')} +

+

+ {t.rich('haveAccount', { + link: (chunks) => ( + + {chunks} + + ), + })} +

+ +
+
); } diff --git a/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx b/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx index 75bd5b2..2dbeb3c 100644 --- a/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx +++ b/apps/web/src/app/[locale]/(auth)/sign-up/sign-up-form.tsx @@ -1,6 +1,5 @@ 'use client'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useState, type FormEvent } from 'react'; @@ -48,9 +47,9 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) { } return ( -
-
-
+
+ +
setName(e.target.value)} />
-
+
setEmail(e.target.value)} />
-
+
setPassword(e.target.value)} /> -

{t('passwordHint')}

+

+ {t('passwordHint')} +

{error ? ( -

+

{error}

) : null} - {googleEnabled ? ( <> -
-
-
-
-
- - {t('or')} - -
+
+
+ {t('or')} +
- ) : null} -

- {t.rich('haveAccount', { - link: (chunks) => ( - - {chunks} - - ), - })} -

); } diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx index 757913c..3d93b9c 100644 --- a/apps/web/src/app/[locale]/layout.tsx +++ b/apps/web/src/app/[locale]/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; +import { Fraunces, Inter_Tight, JetBrains_Mono } from 'next/font/google'; import { notFound } from 'next/navigation'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'; @@ -10,10 +10,25 @@ import { locales, type Locale } from '@/i18n'; import '@/styles/globals.css'; -const inter = Inter({ +const fraunces = Fraunces({ + subsets: ['latin'], + variable: '--font-display', + display: 'swap', + style: ['normal', 'italic'], +}); + +const interTight = Inter_Tight({ subsets: ['latin'], variable: '--font-sans', display: 'swap', + weight: ['400', '500', '600'], +}); + +const mono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', + display: 'swap', + weight: ['400', '500'], }); export function generateStaticParams() { @@ -48,7 +63,7 @@ export default async function LocaleLayout({ const messages = await getMessages(); return ( - + {children} diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 1790a20..2e6596e 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; +import { Masthead } from '@/components/masthead'; import { Button } from '@/components/ui/button'; import { getServerSession } from '@/lib/auth'; @@ -18,31 +19,116 @@ function HomeContent({ userName }: { userName: string | null }) { const t = useTranslations('Home'); return ( -
-

{t('title')}

-

{t('tagline')}

- {userName ? ( -
- {t('signedInAs', { name: userName })} - - - +
+ +
+
+

N. 01 — Manifesto

+

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

+

+ {t('tagline')} +

- ) : ( -
- - + + +
+ +
+ +
+
+

+ {t('disclaimer')} +

- )} -

{t('disclaimer')}

-
+
+

Tre principi

+
    +
  1. + I + {t('principle1')} +
  2. +
  3. + II + {t('principle2')} +
  4. +
  5. + III + {t('principle3')} +
  6. +
+
+ + +
+
+ ); +} + +function SignedOutBlock({ t }: { t: ReturnType> }) { + return ( +
+ + +
+ ); +} + +function SignedInBlock({ + userName, + t, +}: { + userName: string; + t: ReturnType>; +}) { + return ( +
+
+

+ {t.rich('greeting', { + name: () => {userName}, + })} +

+
+
    + + +
+ +
+ ); +} + +function NavItem({ href, label, eyebrow }: { href: string; label: string; eyebrow: string }) { + return ( +
  • + + + {eyebrow} + {label} + + + → + + +
  • ); } diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index 45633b0..6805b99 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -3,8 +3,8 @@ import { redirect } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; +import { Masthead } from '@/components/masthead'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { getServerSession } from '@/lib/auth'; import { fetchCurrentPlan, regeneratePlan } from './actions'; @@ -27,27 +27,58 @@ export default async function PlanPage({ params: { locale } }: { params: { local return ; } +const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'long', + year: 'numeric', +}); + +function formatWeek(weekStart: string): string { + const start = new Date(`${weekStart}T00:00:00`); + const end = new Date(start); + end.setDate(end.getDate() + 6); + const startStr = new Intl.DateTimeFormat('it-IT', { day: 'numeric', month: 'long' }).format( + start, + ); + const endStr = ITALIAN_DATE.format(end); + return `${startStr} – ${endStr}`; +} + function PlanPageContent({ plan }: { plan: Awaited> }) { const t = useTranslations('Plan'); return ( -
    - - -
    - {t('title')} - - {plan ? t('weekOf', { date: plan.weekStart }) : t('noPlan')} - +
    + +
    +
    +
    +

    Capitolo V

    +

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

    +

    + {plan ? formatWeek(plan.weekStart) : t('noPlan')} +

    -
    - -
    - - {plan ? : null} - -
    +
    +
    + +
    +
    +
    + +
    + + {plan ? ( +
    + +
    + ) : null} +
    +
    ); } diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index b576f01..08c1ff3 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -17,11 +17,11 @@ const DAY_KEYS = [ 'saturday', 'sunday', ] as const; +const DAY_NUMERAL = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']; export function PlanWeek({ plan }: { plan: CurrentPlan }) { const t = useTranslations('Plan'); - // Group slots by day for quick lookup. const byDay = new Map(); for (const slot of plan.slots) { if (!byDay.has(slot.dayOfWeek)) byDay.set(slot.dayOfWeek, []); @@ -29,25 +29,31 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { } return ( -
    +
    {DAY_KEYS.map((dayKey, idx) => { const slots = (byDay.get(idx) ?? []).sort( (a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal), ); const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0); return ( -
    -
    -

    +
    +
    + + {DAY_NUMERAL[idx]} + +

    {t(`days.${dayKey}`)}

    - + {t('dayTotal', { kcal: Math.round(dayKcal) })}
    -
    - {slots.map((slot) => ( - +
    + {slots.map((slot, slotIdx) => ( + ))}
    @@ -57,7 +63,9 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { ); } -function SlotCard({ slot }: { slot: PlanSlot }) { +const MEAL_ROMAN = ['I', 'II', 'III', 'IV']; + +function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { const t = useTranslations('Plan'); const [open, setOpen] = useState(false); const [pending, startTransition] = useTransition(); @@ -70,32 +78,42 @@ function SlotCard({ slot }: { slot: PlanSlot }) { } return ( -
    -

    - {t(`meals.${slot.meal}`)} -

    -

    +

    +
    +

    + + {MEAL_ROMAN[index]} + + {t(`meals.${slot.meal}`)} +

    +
    +

    {slot.selected?.name ?? t('noRecipe')}

    {slot.selected ? ( -

    {Math.round(slot.selected.kcal)} kcal

    +

    + {Math.round(slot.selected.kcal)}{' '} + kcal +

    ) : null} {slot.alternatives.length > 0 ? ( -
    +
    {open ? ( -
      +
        {slot.alternatives.map((alt) => ( -
      • - +
      • + {alt.name}{' '} - ({Math.round(alt.kcal)} kcal) + + {Math.round(alt.kcal)} kcal +
    ); } diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 7582d3f..44c91cc 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -67,14 +67,56 @@ export function ProfileForm({ initial }: { initial: Profile | null }) { return (
    - -
    + +
    + Anagrafica + +
    + ( + + {t('age')} + + + + + + )} + /> + ( + + {t('gender')} + + + + )} + /> +
    + ( - - {t('age')} + + {t('heightCm')} @@ -82,12 +124,41 @@ export function ProfileForm({ initial }: { initial: Profile | null }) { )} /> + +
    +

    Peso (chilogrammi)

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

    + {serverError} +

    + ) : null} -
    - {(['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} +
    + + ); } + +function Stat({ + label, + value, + unit, + emphasis = false, + fixed, +}: { + label: string; + value: number; + unit?: string; + emphasis?: boolean; + fixed?: number; +}) { + const display = fixed != null ? value.toFixed(fixed) : Math.round(value).toString(); + return ( +
    +

    {label}

    +

    + + {display} + + {unit ? {unit} : null} +

    +
    + ); +} diff --git a/apps/web/src/app/[locale]/sign-out-button.tsx b/apps/web/src/app/[locale]/sign-out-button.tsx index 3d9d372..3cfbe03 100644 --- a/apps/web/src/app/[locale]/sign-out-button.tsx +++ b/apps/web/src/app/[locale]/sign-out-button.tsx @@ -18,7 +18,7 @@ export function SignOutButton({ label }: { label: string }) { } return ( - ); diff --git a/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx b/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx index a7c866f..50de84c 100644 --- a/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx +++ b/apps/web/src/app/[locale]/welcome/disclaimer-form.tsx @@ -24,11 +24,11 @@ export function DisclaimerForm() { } return ( -
    + - @@ -45,14 +45,32 @@ function Checkbox({ label: string; }) { return ( -