feat(web): editorial slow-food redesign across the whole UI

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) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 16:44:55 +02:00
1 parent f8eaf2f493
commit 59a311cfc9
26 files changed
+933 -440

No files matched your search

+4 -4
View File
@@ -14,17 +14,17 @@ test.describe('email/password authentication', () => {
// Sign up // Sign up
await page.goto('/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('Email').fill(email);
await page.getByLabel('Password').fill(password); await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Crea account' }).click(); await page.getByRole('button', { name: 'Crea account' }).click();
await page.waitForURL('/'); await page.waitForURL('/');
await expect(page.getByText(`Accesso effettuato come ${name}`)).toBeVisible(); await expect(page.getByText(`Buongiorno, ${name}`)).toBeVisible();
// Sign out // Sign out
await page.getByRole('button', { name: 'Esci' }).click(); 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 // Sign in with the same credentials
await page.goto('/sign-in'); 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.getByRole('button', { name: 'Accedi', exact: true }).click();
await page.waitForURL('/'); 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 }) => { test('shows an error message on invalid credentials', async ({ page }) => {
+4 -4
View File
@@ -4,8 +4,8 @@ test('home page renders Italian copy and CTAs', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page).toHaveTitle(/KetoPath/); await expect(page).toHaveTitle(/KetoPath/);
await expect(page.getByRole('heading', { name: 'KetoPath' })).toBeVisible(); await expect(page.getByRole('heading', { name: /Una keto italiana, sostenibile/ })).toBeVisible();
await expect(page.getByRole('link', { name: 'Inizia gratis' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Iscriviti' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Scopri come funziona' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Accedi' })).toBeVisible();
await expect(page.getByText(/non sostituiscono il parere di un medico/)).toBeVisible(); await expect(page.getByText(/non sostituiscono il parere/)).toBeVisible();
}); });
+12 -10
View File
@@ -13,20 +13,20 @@ test('profile flow — submit returns BMR/TDEE for the Michele PRD persona', asy
// Sign-up gives us an authenticated session // Sign-up gives us an authenticated session
await page.goto('/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('Email').fill(email);
await page.getByLabel('Password').fill(password); await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Crea account' }).click(); await page.getByRole('button', { name: 'Crea account' }).click();
await page.waitForURL('/'); await page.waitForURL('/');
// Open profile from home — first time we'll be redirected through /welcome // 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'); await page.waitForURL('/welcome');
// Accept the medical disclaimer (3 mandatory checkboxes + submit) // Accept the medical disclaimer (3 mandatory checkboxes + submit)
await page.getByLabel(/Confermo di essere maggiorenne/).check(); await page.getByText(/Confermo di essere maggiorenne/).click();
await page.getByLabel(/Dichiaro di non avere condizioni mediche escludenti/).check(); await page.getByText(/Dichiaro di non avere condizioni mediche escludenti/).click();
await page.getByLabel(/Accetto che KetoPath non sostituisce/).check(); await page.getByText(/Accetto che KetoPath non sostituisce/).click();
await page.getByRole('button', { name: 'Accetto e proseguo' }).click(); await page.getByRole('button', { name: 'Accetto e proseguo' }).click();
await page.waitForURL('/profile'); 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.getByLabel('Sesso').click();
await page.getByRole('option', { name: 'Maschio' }).click(); await page.getByRole('option', { name: 'Maschio' }).click();
await page.getByLabel('Altezza (cm)').fill('170'); await page.getByLabel('Altezza (cm)').fill('170');
await page.getByLabel('Peso iniziale (kg)').fill('76'); await page.getByLabel('Iniziale').fill('76');
await page.getByLabel('Peso attuale (kg)').fill('76'); await page.getByLabel('Attuale').fill('76');
await page.getByLabel('Peso obiettivo (kg)').fill('70'); await page.getByLabel('Obiettivo').fill('70');
await page.getByLabel('Livello di attività').click(); await page.getByLabel('Livello di attività').click();
await page.getByRole('option', { name: /Sedentario/ }).click(); await page.getByRole('option', { name: /Sedentario/ }).click();
await page.getByRole('button', { name: 'Salva profilo' }).click(); await page.getByRole('button', { name: 'Salva profilo' }).click();
// BMR/TDEE summary panel becomes visible after a successful save // BMR/TDEE summary panel becomes visible after a successful save
await expect(page.getByText('Calcolo personalizzato')).toBeVisible(); await expect(page.getByText('Calcolo personalizzato')).toBeVisible();
await expect(page.getByText('1583 kcal')).toBeVisible(); // BMR Michele // Numbers are rendered in monospace next to a separate "kcal" unit span,
await expect(page.getByText('1899 kcal')).toBeVisible(); // TDEE = 1582.5 × 1.2 // so check for the bare numeric values.
await expect(page.getByText('1583', { exact: true })).toBeVisible();
await expect(page.getByText('1899', { exact: true })).toBeVisible();
}); });
+43 -36
View File
@@ -1,60 +1,66 @@
{ {
"Home": { "Home": {
"title": "KetoPath", "title": "Una keto italiana, sostenibile",
"tagline": "Il tuo percorso personalizzato verso uno stile di vita chetogenico sostenibile.", "tagline": "Il sentiero che ti accompagna dalla prima settimana di adattamento al mantenimento — settimana dopo settimana, piatto dopo piatto.",
"ctaPrimary": "Inizia gratis", "ctaPrimary": "Iscriviti",
"ctaSecondary": "Scopri come funziona", "ctaSecondary": "Accedi",
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.", "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}", "signedInAs": "Accesso effettuato come {name}",
"signOut": "Esci", "signOut": "Esci",
"completeProfile": "Completa il tuo profilo", "completeProfile": "Profilo & calcoli",
"viewPlan": "Vedi il piano settimanale" "viewPlan": "Piano della settimana",
"lettura": "Continua la lettura",
"inizia": "Inizia",
"greeting": "Buongiorno, <name>{name}</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": { "Welcome": {
"title": "Prima di iniziare", "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.", "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.", "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.", "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).", "point4": "Prima di iniziare ti consigliamo esami di base: glicemia, colesterolo, profilo lipidico, funzionalità renale ed epatica.",
"checkAdult": "Confermo di essere maggiorenne.", "checkAdult": "Confermo di essere maggiorenne.",
"checkAware": "Dichiaro di non avere condizioni mediche escludenti, oppure di averle discusse con il mio medico.", "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.", "checkAccepts": "Accetto che KetoPath non sostituisce il parere medico professionale.",
"submit": "Accetto e proseguo", "submit": "Accetto e proseguo",
"submitting": "Salvataggio in corso…", "submitting": "Salvataggio…",
"legalNote": "Il consenso è registrato con data e ora come previsto dall'art. 7 GDPR." "legalNote": "Il consenso è registrato con data e ora come previsto dall'art. 7 GDPR."
}, },
"CookieBanner": { "CookieBanner": {
"aria": "Informativa cookie", "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" "dismiss": "Ho capito"
}, },
"Metadata": { "Metadata": {
"title": "KetoPath — Chetogenica e digiuno intermittente personalizzati", "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." "description": "Una keto italiana, sostenibile. Piani adattivi, tracking e lista della spesa automatica — settimana dopo settimana."
}, },
"Auth": { "Auth": {
"SignIn": { "SignIn": {
"title": "Accedi", "title": "Accedi",
"subtitle": "Bentornato. Inserisci le tue credenziali per continuare.", "subtitle": "Bentornato. Riprendi da dove avevi lasciato.",
"emailLabel": "Email", "emailLabel": "Email",
"passwordLabel": "Password", "passwordLabel": "Password",
"submit": "Accedi", "submit": "Accedi",
"submitting": "Accesso in corso…", "submitting": "Accesso in corso…",
"google": "Accedi con Google", "google": "Continua con Google",
"or": "oppure", "or": "oppure",
"noAccount": "Non hai un account? <link>Registrati</link>", "noAccount": "Non hai ancora un account? <link>Iscriviti</link>",
"error": "Credenziali non valide. Riprova." "error": "Credenziali non valide. Riprova."
}, },
"SignUp": { "SignUp": {
"title": "Registrati", "title": "Iscriviti",
"subtitle": "Crea il tuo profilo KetoPath in meno di un minuto.", "subtitle": "Crea il tuo profilo KetoPath in meno di un minuto.",
"nameLabel": "Nome", "nameLabel": "Come ti chiami",
"emailLabel": "Email", "emailLabel": "Email",
"passwordLabel": "Password", "passwordLabel": "Password",
"passwordHint": "Almeno 8 caratteri.", "passwordHint": "Almeno 8 caratteri",
"submit": "Crea account", "submit": "Crea account",
"submitting": "Creazione in corso…", "submitting": "Creazione…",
"google": "Continua con Google", "google": "Continua con Google",
"or": "oppure", "or": "oppure",
"haveAccount": "Hai già un account? <link>Accedi</link>", "haveAccount": "Hai già un account? <link>Accedi</link>",
@@ -63,14 +69,14 @@
} }
}, },
"Plan": { "Plan": {
"title": "Il tuo piano settimanale", "title": "Il tuo piano",
"weekOf": "Settimana del {date}", "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", "generate": "Genera piano",
"regenerate": "Rigenera", "regenerate": "Rigenera",
"noRecipe": "Nessuna ricetta selezionata", "noRecipe": "Nessuna ricetta selezionata",
"showAlternatives": "Vedi {n} alternative", "showAlternatives": "Vedi {n} alternative",
"hideAlternatives": "Nascondi alternative", "hideAlternatives": "Nascondi",
"swap": "Scegli", "swap": "Scegli",
"dayTotal": "{kcal} kcal totali", "dayTotal": "{kcal} kcal totali",
"meals": { "meals": {
@@ -91,32 +97,33 @@
}, },
"Profile": { "Profile": {
"title": "Il tuo profilo", "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à", "age": "Età",
"gender": "Sesso", "gender": "Sesso",
"heightCm": "Altezza (cm)", "heightCm": "Altezza (cm)",
"weightStartKg": "Peso iniziale (kg)", "weightStartKg": "Iniziale",
"weightCurrentKg": "Peso attuale (kg)", "weightCurrentKg": "Attuale",
"weightGoalKg": "Peso obiettivo (kg)", "weightGoalKg": "Obiettivo",
"activityLevel": "Livello di attività", "activityLevel": "Livello di attività",
"selectPlaceholder": "Seleziona…", "selectPlaceholder": "Seleziona…",
"save": "Salva profilo", "save": "Salva profilo",
"saving": "Salvataggio in corso…", "saving": "Salvataggio…",
"summary": "Calcolo personalizzato", "summary": "Calcolo personalizzato",
"bmr": "Metabolismo basale (BMR)", "emptySummary": "Salva il profilo per ricevere il tuo BMR, il TDEE e il target calorico giornaliero.",
"tdee": "Fabbisogno totale (TDEE)", "bmr": "Metabolismo basale",
"tdee": "Fabbisogno totale",
"activityMultiplier": "Moltiplicatore attività", "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": { "genderOptions": {
"MALE": "Maschio", "MALE": "Maschio",
"FEMALE": "Femmina", "FEMALE": "Femmina",
"OTHER": "Altro / preferisco non specificare" "OTHER": "Altro / preferisco non specificare"
}, },
"activityOptions": { "activityOptions": {
"SEDENTARY": "Sedentario (lavoro d'ufficio)", "SEDENTARY": "Sedentario · lavoro d'ufficio",
"LIGHT": "Leggero (sport 1-3 volte a settimana)", "LIGHT": "Leggero · sport 1–3 volte / settimana",
"MODERATE": "Moderato (sport 3-5 volte a settimana)", "MODERATE": "Moderato · sport 3–5 volte / settimana",
"INTENSE": "Intenso (sport 6+ volte a settimana)" "INTENSE": "Intenso · sport 6+ volte / settimana"
} }
} }
} }
@@ -1,8 +1,9 @@
import { enabledSocialProviders } from '@ketopath/auth'; import { enabledSocialProviders } from '@ketopath/auth';
import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; 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'; import { SignInForm } from './sign-in-form';
@@ -11,16 +12,49 @@ export default function SignInPage({ params: { locale } }: { params: { locale: s
const t = useTranslations('Auth.SignIn'); const t = useTranslations('Auth.SignIn');
return ( return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16"> <div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
<Card> <Masthead issueLabel="N. 02 — Accesso" />
<CardHeader> <main className="grid gap-12 py-12 sm:py-20 md:grid-cols-12">
<CardTitle>{t('title')}</CardTitle> <aside className="md:sticky md:top-12 md:col-span-6 md:self-start">
<CardDescription>{t('subtitle')}</CardDescription> <p className="editorial-eyebrow animate-fade-up">Capitolo II</p>
</CardHeader> <h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
<CardContent> <span className="block">Bentornato</span>
<span className="text-ink-soft block italic">in cucina</span>
<span className="text-pomodoro">.</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up mt-6 max-w-md text-xl italic leading-snug [animation-delay:240ms]">
{t('subtitle')}
</p>
<p className="text-ink-dim animate-fade-up mt-10 hidden max-w-md text-sm leading-relaxed [animation-delay:360ms] md:block">
{t.rich('noAccount', {
link: (chunks) => (
<Link
href="/sign-up"
className="text-pomodoro hover:decoration-ink underline decoration-[1.5px] underline-offset-[6px]"
>
{chunks}
</Link>
),
})}
</p>
</aside>
<section className="md:border-ink/15 animate-fade-up [animation-delay:240ms] md:col-span-6 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-8">{t('title')}</p>
<SignInForm googleEnabled={enabledSocialProviders.includes('google')} /> <SignInForm googleEnabled={enabledSocialProviders.includes('google')} />
</CardContent> <p className="text-ink-dim mt-8 text-sm leading-relaxed md:hidden">
</Card> {t.rich('noAccount', {
</main> link: (chunks) => (
<Link
href="/sign-up"
className="text-pomodoro underline decoration-[1.5px] underline-offset-[6px]"
>
{chunks}
</Link>
),
})}
</p>
</section>
</main>
</div>
); );
} }
@@ -1,6 +1,5 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
@@ -47,9 +46,9 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<form onSubmit={handleSubmit} className="space-y-4" noValidate> <form onSubmit={handleSubmit} className="space-y-7" noValidate>
<div className="space-y-1.5"> <div className="space-y-2">
<Label htmlFor="email">{t('emailLabel')}</Label> <Label htmlFor="email">{t('emailLabel')}</Label>
<Input <Input
id="email" id="email"
@@ -60,7 +59,7 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-2">
<Label htmlFor="password">{t('passwordLabel')}</Label> <Label htmlFor="password">{t('passwordLabel')}</Label>
<Input <Input
id="password" id="password"
@@ -73,40 +72,32 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
/> />
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-destructive text-sm"> <p role="alert" className="font-display text-pomodoro text-sm italic">
{error} {error}
</p> </p>
) : null} ) : null}
<Button type="submit" disabled={pending} className="w-full"> <Button type="submit" disabled={pending} size="lg" className="w-full">
{pending ? t('submitting') : t('submit')} {pending ? t('submitting') : t('submit')}
</Button> </Button>
</form> </form>
{googleEnabled ? ( {googleEnabled ? (
<> <>
<div className="relative"> <div className="flex items-center gap-4">
<div className="absolute inset-0 flex items-center"> <div className="bg-rule h-px flex-1" />
<div className="border-border w-full border-t" /> <span className="editorial-eyebrow">{t('or')}</span>
</div> <div className="bg-rule h-px flex-1" />
<div className="relative flex justify-center">
<span className="bg-background text-muted-foreground px-2 text-xs uppercase tracking-wider">
{t('or')}
</span>
</div>
</div> </div>
<Button type="button" variant="outline" onClick={handleGoogle} className="w-full"> <Button
type="button"
variant="outline"
onClick={handleGoogle}
size="lg"
className="w-full"
>
{t('google')} {t('google')}
</Button> </Button>
</> </>
) : null} ) : null}
<p className="text-muted-foreground text-center text-sm">
{t.rich('noAccount', {
link: (chunks) => (
<Link href="/sign-up" className="text-primary font-medium hover:underline">
{chunks}
</Link>
),
})}
</p>
</div> </div>
); );
} }
@@ -1,15 +1,9 @@
import { enabledSocialProviders } from '@ketopath/auth'; import { enabledSocialProviders } from '@ketopath/auth';
import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { import { Masthead } from '@/components/masthead';
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { SignUpForm } from './sign-up-form'; import { SignUpForm } from './sign-up-form';
@@ -18,19 +12,51 @@ export default function SignUpPage({ params: { locale } }: { params: { locale: s
const t = useTranslations('Auth.SignUp'); const t = useTranslations('Auth.SignUp');
return ( return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16"> <div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
<Card> <Masthead issueLabel="N. 03 — Iscrizione" />
<CardHeader> <main className="grid gap-12 py-12 sm:py-20 md:grid-cols-12">
<CardTitle>{t('title')}</CardTitle> <aside className="md:sticky md:top-12 md:col-span-6 md:self-start">
<CardDescription>{t('subtitle')}</CardDescription> <p className="editorial-eyebrow animate-fade-up">Capitolo III</p>
</CardHeader> <h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
<CardContent> <span className="block">Inizia il tuo</span>
<span className="text-pomodoro block italic">percorso.</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up mt-6 max-w-md text-xl italic leading-snug [animation-delay:240ms]">
{t('subtitle')}
</p>
<p className="text-ink-dim animate-fade-up mt-10 hidden max-w-md text-sm leading-relaxed [animation-delay:360ms] md:block">
{t.rich('haveAccount', {
link: (chunks) => (
<Link
href="/sign-in"
className="text-pomodoro hover:decoration-ink underline decoration-[1.5px] underline-offset-[6px]"
>
{chunks}
</Link>
),
})}
</p>
</aside>
<section className="md:border-ink/15 animate-fade-up [animation-delay:240ms] md:col-span-6 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-8">{t('title')}</p>
<SignUpForm googleEnabled={enabledSocialProviders.includes('google')} /> <SignUpForm googleEnabled={enabledSocialProviders.includes('google')} />
</CardContent> <p className="border-ink/15 text-ink-dim mt-10 border-t pt-5 text-xs leading-relaxed">
<CardFooter> {t('disclaimer')}
<p className="text-muted-foreground text-xs">{t('disclaimer')}</p> </p>
</CardFooter> <p className="text-ink-dim mt-6 text-sm leading-relaxed md:hidden">
</Card> {t.rich('haveAccount', {
</main> link: (chunks) => (
<Link
href="/sign-in"
className="text-pomodoro underline decoration-[1.5px] underline-offset-[6px]"
>
{chunks}
</Link>
),
})}
</p>
</section>
</main>
</div>
); );
} }
@@ -1,6 +1,5 @@
'use client'; 'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
@@ -48,9 +47,9 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<form onSubmit={handleSubmit} className="space-y-4" noValidate> <form onSubmit={handleSubmit} className="space-y-7" noValidate>
<div className="space-y-1.5"> <div className="space-y-2">
<Label htmlFor="name">{t('nameLabel')}</Label> <Label htmlFor="name">{t('nameLabel')}</Label>
<Input <Input
id="name" id="name"
@@ -61,7 +60,7 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-2">
<Label htmlFor="email">{t('emailLabel')}</Label> <Label htmlFor="email">{t('emailLabel')}</Label>
<Input <Input
id="email" id="email"
@@ -72,7 +71,7 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-2">
<Label htmlFor="password">{t('passwordLabel')}</Label> <Label htmlFor="password">{t('passwordLabel')}</Label>
<Input <Input
id="password" id="password"
@@ -83,43 +82,37 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<p className="text-muted-foreground text-xs">{t('passwordHint')}</p> <p className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
{t('passwordHint')}
</p>
</div> </div>
{error ? ( {error ? (
<p role="alert" className="text-destructive text-sm"> <p role="alert" className="font-display text-pomodoro text-sm italic">
{error} {error}
</p> </p>
) : null} ) : null}
<Button type="submit" disabled={pending} className="w-full"> <Button type="submit" disabled={pending} size="lg" className="w-full">
{pending ? t('submitting') : t('submit')} {pending ? t('submitting') : t('submit')}
</Button> </Button>
</form> </form>
{googleEnabled ? ( {googleEnabled ? (
<> <>
<div className="relative"> <div className="flex items-center gap-4">
<div className="absolute inset-0 flex items-center"> <div className="bg-rule h-px flex-1" />
<div className="border-border w-full border-t" /> <span className="editorial-eyebrow">{t('or')}</span>
</div> <div className="bg-rule h-px flex-1" />
<div className="relative flex justify-center">
<span className="bg-background text-muted-foreground px-2 text-xs uppercase tracking-wider">
{t('or')}
</span>
</div>
</div> </div>
<Button type="button" variant="outline" onClick={handleGoogle} className="w-full"> <Button
type="button"
variant="outline"
onClick={handleGoogle}
size="lg"
className="w-full"
>
{t('google')} {t('google')}
</Button> </Button>
</> </>
) : null} ) : null}
<p className="text-muted-foreground text-center text-sm">
{t.rich('haveAccount', {
link: (chunks) => (
<Link href="/sign-in" className="text-primary font-medium hover:underline">
{chunks}
</Link>
),
})}
</p>
</div> </div>
); );
} }
+18 -3
View File
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'; 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 { notFound } from 'next/navigation';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server'; import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
@@ -10,10 +10,25 @@ import { locales, type Locale } from '@/i18n';
import '@/styles/globals.css'; 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'], subsets: ['latin'],
variable: '--font-sans', variable: '--font-sans',
display: 'swap', display: 'swap',
weight: ['400', '500', '600'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
}); });
export function generateStaticParams() { export function generateStaticParams() {
@@ -48,7 +63,7 @@ export default async function LocaleLayout({
const messages = await getMessages(); const messages = await getMessages();
return ( return (
<html lang={locale} className={inter.variable}> <html lang={locale} className={`${fraunces.variable} ${interTight.variable} ${mono.variable}`}>
<body className="bg-background text-foreground min-h-screen font-sans antialiased"> <body className="bg-background text-foreground min-h-screen font-sans antialiased">
<NextIntlClientProvider locale={locale} messages={messages}> <NextIntlClientProvider locale={locale} messages={messages}>
{children} {children}
+110 -24
View File
@@ -2,6 +2,7 @@ import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Masthead } from '@/components/masthead';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getServerSession } from '@/lib/auth'; import { getServerSession } from '@/lib/auth';
@@ -18,31 +19,116 @@ function HomeContent({ userName }: { userName: string | null }) {
const t = useTranslations('Home'); const t = useTranslations('Home');
return ( return (
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-start justify-center gap-6 px-6 py-16"> <div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{t('title')}</h1> <Masthead />
<p className="text-muted-foreground text-lg">{t('tagline')}</p> <main className="grid gap-12 py-10 sm:py-16 md:grid-cols-12">
{userName ? ( <div className="md:col-span-7">
<div className="flex flex-wrap items-center gap-3"> <p className="editorial-eyebrow animate-fade-up">N. 01 — Manifesto</p>
<span className="text-foreground text-sm">{t('signedInAs', { name: userName })}</span> <h1 className="font-display text-display-lg text-ink animate-fade-up mt-4 font-medium [animation-delay:120ms]">
<Button asChild> {t('title')}
<Link href="/profile">{t('completeProfile')}</Link> <span className="text-pomodoro">.</span>
</Button> </h1>
<Button asChild variant="outline"> <p className="font-display text-ink-soft animate-fade-up mt-6 max-w-xl text-2xl italic leading-snug [animation-delay:240ms]">
<Link href="/plan">{t('viewPlan')}</Link> {t('tagline')}
</Button> </p>
<SignOutButton label={t('signOut')} />
</div> </div>
) : (
<div className="flex flex-wrap gap-3"> <aside className="md:border-ink/15 animate-fade-up [animation-delay:360ms] md:col-span-5 md:border-l md:pl-8">
<Button asChild> <p className="editorial-eyebrow">{userName ? t('lettura') : t('inizia')}</p>
<Link href="/sign-up">{t('ctaPrimary')}</Link> <div className="mt-6">
</Button> {userName ? <SignedInBlock userName={userName} t={t} /> : <SignedOutBlock t={t} />}
<Button asChild variant="outline"> </div>
<Link href="/sign-in">{t('ctaSecondary')}</Link> </aside>
</Button> </main>
<div className="rule" />
<footer className="grid gap-8 py-10 md:grid-cols-12">
<div className="md:col-span-7">
<p className="font-display text-ink-soft text-base italic leading-relaxed sm:text-lg">
{t('disclaimer')}
</p>
</div> </div>
)} <div className="md:border-ink/15 md:col-span-5 md:border-l md:pl-8">
<p className="text-muted-foreground mt-8 text-xs">{t('disclaimer')}</p> <p className="editorial-eyebrow">Tre principi</p>
</main> <ol className="font-display text-ink mt-4 space-y-3 text-lg leading-snug">
<li className="flex gap-4">
<span className="text-ink-dim mt-1 font-mono text-xs">I</span>
<span>{t('principle1')}</span>
</li>
<li className="flex gap-4">
<span className="text-ink-dim mt-1 font-mono text-xs">II</span>
<span>{t('principle2')}</span>
</li>
<li className="flex gap-4">
<span className="text-ink-dim mt-1 font-mono text-xs">III</span>
<span>{t('principle3')}</span>
</li>
</ol>
</div>
</footer>
<div className="rule-thick mb-10" />
</div>
);
}
function SignedOutBlock({ t }: { t: ReturnType<typeof useTranslations<'Home'>> }) {
return (
<div className="space-y-5">
<Button asChild size="lg" className="w-full">
<Link href="/sign-up">{t('ctaPrimary')}</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/sign-in">{t('ctaSecondary')}</Link>
</Button>
</div>
);
}
function SignedInBlock({
userName,
t,
}: {
userName: string;
t: ReturnType<typeof useTranslations<'Home'>>;
}) {
return (
<div className="space-y-6">
<div>
<p className="font-display text-ink text-2xl italic leading-snug">
{t.rich('greeting', {
name: () => <span className="font-medium not-italic">{userName}</span>,
})}
</p>
</div>
<ul className="space-y-px">
<NavItem href="/plan" label={t('viewPlan')} eyebrow="Cucina" />
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" />
</ul>
<SignOutButton label={t('signOut')} />
</div>
);
}
function NavItem({ href, label, eyebrow }: { href: string; label: string; eyebrow: string }) {
return (
<li>
<Link
href={href}
className="border-ink/15 hover:bg-ink hover:text-carta-light group -mx-0 flex items-baseline justify-between gap-4 border-t py-4 transition-colors last:border-b hover:-mx-3 hover:px-3"
>
<span className="flex flex-col gap-1">
<span className="editorial-eyebrow group-hover:text-carta-light/60">{eyebrow}</span>
<span className="font-display text-xl leading-tight">{label}</span>
</span>
<span
aria-hidden
className="font-display text-ink-dim group-hover:text-carta-light text-xl"
>
→
</span>
</Link>
</li>
); );
} }
+49 -18
View File
@@ -3,8 +3,8 @@ import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Masthead } from '@/components/masthead';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getServerSession } from '@/lib/auth'; import { getServerSession } from '@/lib/auth';
import { fetchCurrentPlan, regeneratePlan } from './actions'; import { fetchCurrentPlan, regeneratePlan } from './actions';
@@ -27,27 +27,58 @@ export default async function PlanPage({ params: { locale } }: { params: { local
return <PlanPageContent plan={plan} />; return <PlanPageContent plan={plan} />;
} }
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<ReturnType<typeof fetchCurrentPlan>> }) { function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurrentPlan>> }) {
const t = useTranslations('Plan'); const t = useTranslations('Plan');
return ( return (
<main className="mx-auto min-h-screen max-w-5xl px-4 py-10 sm:px-6"> <div className="mx-auto min-h-screen max-w-6xl px-6 sm:px-10">
<Card> <Masthead issueLabel="N. 05 — Cucina" />
<CardHeader className="flex flex-row items-start justify-between gap-4"> <main className="py-12 sm:py-16">
<div> <div className="grid items-end gap-6 md:grid-cols-12">
<CardTitle>{t('title')}</CardTitle> <div className="md:col-span-8">
<CardDescription> <p className="editorial-eyebrow animate-fade-up">Capitolo V</p>
{plan ? t('weekOf', { date: plan.weekStart }) : t('noPlan')} <h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
</CardDescription> {t('title')}
<span className="text-pomodoro">.</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up mt-5 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
{plan ? formatWeek(plan.weekStart) : t('noPlan')}
</p>
</div> </div>
<form action={regeneratePlan}> <div className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
<Button type="submit" variant={plan ? 'outline' : 'default'} size="sm"> <form action={regeneratePlan}>
{plan ? t('regenerate') : t('generate')} <Button type="submit" variant={plan ? 'outline' : 'default'} size="lg">
</Button> {plan ? t('regenerate') : t('generate')}
</form> </Button>
</CardHeader> </form>
<CardContent>{plan ? <PlanWeek plan={plan} /> : null}</CardContent> </div>
</Card> </div>
</main>
<div className="rule animate-rule-in my-10 [animation-delay:420ms]" />
{plan ? (
<div className="animate-fade-up [animation-delay:480ms]">
<PlanWeek plan={plan} />
</div>
) : null}
</main>
</div>
); );
} }
+40 -22
View File
@@ -17,11 +17,11 @@ const DAY_KEYS = [
'saturday', 'saturday',
'sunday', 'sunday',
] as const; ] as const;
const DAY_NUMERAL = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'];
export function PlanWeek({ plan }: { plan: CurrentPlan }) { export function PlanWeek({ plan }: { plan: CurrentPlan }) {
const t = useTranslations('Plan'); const t = useTranslations('Plan');
// Group slots by day for quick lookup.
const byDay = new Map<number, PlanSlot[]>(); const byDay = new Map<number, PlanSlot[]>();
for (const slot of plan.slots) { for (const slot of plan.slots) {
if (!byDay.has(slot.dayOfWeek)) byDay.set(slot.dayOfWeek, []); if (!byDay.has(slot.dayOfWeek)) byDay.set(slot.dayOfWeek, []);
@@ -29,25 +29,31 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-14">
{DAY_KEYS.map((dayKey, idx) => { {DAY_KEYS.map((dayKey, idx) => {
const slots = (byDay.get(idx) ?? []).sort( const slots = (byDay.get(idx) ?? []).sort(
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal), (a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
); );
const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0); const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0);
return ( return (
<section key={dayKey} aria-labelledby={`day-${dayKey}`}> <section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
<header className="mb-2 flex items-baseline justify-between"> <header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
<h2 id={`day-${dayKey}`} className="text-lg font-semibold"> <span className="font-display text-pomodoro text-xl font-medium italic leading-none">
{DAY_NUMERAL[idx]}
</span>
<h2
id={`day-${dayKey}`}
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
>
{t(`days.${dayKey}`)} {t(`days.${dayKey}`)}
</h2> </h2>
<span className="text-muted-foreground text-xs"> <span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{t('dayTotal', { kcal: Math.round(dayKcal) })} {t('dayTotal', { kcal: Math.round(dayKcal) })}
</span> </span>
</header> </header>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
{slots.map((slot) => ( {slots.map((slot, slotIdx) => (
<SlotCard key={slot.id} slot={slot} /> <SlotCard key={slot.id} slot={slot} index={slotIdx} />
))} ))}
</div> </div>
</section> </section>
@@ -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 t = useTranslations('Plan');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -70,32 +78,42 @@ function SlotCard({ slot }: { slot: PlanSlot }) {
} }
return ( return (
<article className="border-border bg-card flex flex-col rounded-md border p-3"> <article className="group relative flex flex-col gap-3 py-5 sm:py-2">
<p className="text-muted-foreground text-xs uppercase tracking-wider"> <header className="flex items-baseline justify-between gap-3">
{t(`meals.${slot.meal}`)} <p className="editorial-eyebrow flex items-baseline gap-2">
</p> <span className="font-display not-italic-children text-pomodoro text-base italic">
<p className="mt-1 text-sm font-medium leading-tight"> {MEAL_ROMAN[index]}
</span>
<span>{t(`meals.${slot.meal}`)}</span>
</p>
</header>
<p className="font-display text-ink text-lg font-medium leading-tight">
{slot.selected?.name ?? t('noRecipe')} {slot.selected?.name ?? t('noRecipe')}
</p> </p>
{slot.selected ? ( {slot.selected ? (
<p className="text-muted-foreground mt-1 text-xs">{Math.round(slot.selected.kcal)} kcal</p> <p className="text-ink-soft font-mono text-xs tracking-tight">
{Math.round(slot.selected.kcal)}{' '}
<span className="font-display text-[10px] italic">kcal</span>
</p>
) : null} ) : null}
{slot.alternatives.length > 0 ? ( {slot.alternatives.length > 0 ? (
<div className="mt-2"> <div className="mt-1">
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="text-primary text-xs hover:underline" className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[10px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
> >
{open ? t('hideAlternatives') : t('showAlternatives', { n: slot.alternatives.length })} {open ? t('hideAlternatives') : t('showAlternatives', { n: slot.alternatives.length })}
</button> </button>
{open ? ( {open ? (
<ul className="mt-2 space-y-1"> <ul className="border-ink/15 mt-3 space-y-2 border-t pt-3">
{slot.alternatives.map((alt) => ( {slot.alternatives.map((alt) => (
<li key={alt.id} className="flex items-center justify-between gap-2"> <li key={alt.id} className="flex items-baseline justify-between gap-2">
<span className="text-xs"> <span className="font-display text-ink text-sm leading-snug">
{alt.name}{' '} {alt.name}{' '}
<span className="text-muted-foreground">({Math.round(alt.kcal)} kcal)</span> <span className="text-ink-dim font-mono text-[10px]">
{Math.round(alt.kcal)} kcal
</span>
</span> </span>
<Button <Button
type="button" type="button"
+19 -11
View File
@@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth'; import { getServerSession } from '@/lib/auth';
import { fetchProfile } from './actions'; import { fetchProfile } from './actions';
@@ -33,16 +33,24 @@ function ProfilePageContent({
const t = useTranslations('Profile'); const t = useTranslations('Profile');
return ( return (
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16"> <div className="mx-auto min-h-screen max-w-4xl px-6 sm:px-10">
<Card> <Masthead issueLabel="N. 04 — Profilo" />
<CardHeader> <main className="py-12 sm:py-16">
<CardTitle>{t('title')}</CardTitle> <p className="editorial-eyebrow animate-fade-up">Capitolo IV</p>
<CardDescription>{t('subtitle')}</CardDescription> <h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
</CardHeader> {t('title')}
<CardContent> <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]" />
<div className="animate-fade-up [animation-delay:300ms]">
<ProfileForm initial={initial} /> <ProfileForm initial={initial} />
</CardContent> </div>
</Card> </main>
</main> </div>
); );
} }
+142 -102
View File
@@ -67,14 +67,56 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={onSubmit} className="space-y-5" noValidate> <form onSubmit={onSubmit} className="grid gap-12 md:grid-cols-12" noValidate>
<div className="grid grid-cols-2 gap-4"> <fieldset className="space-y-10 md:col-span-7">
<legend className="editorial-eyebrow">Anagrafica</legend>
<div className="grid grid-cols-2 gap-x-8 gap-y-8">
<FormField
control={form.control}
name="age"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>{t('age')}</FormLabel>
<FormControl>
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem className="space-y-3">
<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>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="age" name="heightCm"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="space-y-3">
<FormLabel>{t('age')}</FormLabel> <FormLabel>{t('heightCm')}</FormLabel>
<FormControl> <FormControl>
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} /> <Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
@@ -82,12 +124,41 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-6">
<p className="editorial-eyebrow">Peso (chilogrammi)</p>
<div className="grid grid-cols-3 gap-x-6 gap-y-8">
{(['weightStartKg', 'weightCurrentKg', 'weightGoalKg'] as const).map((name) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>{t(name)}</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
inputMode="decimal"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="gender" name="activityLevel"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="space-y-3">
<FormLabel>{t('gender')}</FormLabel> <FormLabel>{t('activityLevel')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? ''}> <Select onValueChange={field.onChange} value={field.value ?? ''}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -95,9 +166,9 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{GENDERS.map((g) => ( {ACTIVITY_LEVELS.map((a) => (
<SelectItem key={g} value={g}> <SelectItem key={a} value={a}>
{t(`genderOptions.${g}`)} {t(`activityOptions.${a}`)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -106,101 +177,70 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
</FormItem> </FormItem>
)} )}
/> />
</div>
<FormField {serverError ? (
control={form.control} <p role="alert" className="font-display text-pomodoro text-base italic">
name="heightCm" {serverError}
render={({ field }) => ( </p>
<FormItem> ) : null}
<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"> <div className="pt-4">
{(['weightStartKg', 'weightCurrentKg', 'weightGoalKg'] as const).map((name) => ( <Button
<FormField type="submit"
key={name} disabled={form.formState.isSubmitting}
control={form.control} size="lg"
name={name} className="w-full sm:w-auto"
render={({ field }) => ( >
<FormItem> {form.formState.isSubmitting ? t('saving') : t('save')}
<FormLabel>{t(name)}</FormLabel> </Button>
<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> </div>
) : null} </fieldset>
<aside className="md:border-ink/15 md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow">{t('summary')}</p>
{saved ? (
<div className="mt-6 space-y-8">
<Stat label={t('bmr')} value={saved.bmr} unit="kcal" emphasis />
<Stat label={t('tdee')} value={saved.tdee} unit="kcal" />
<Stat label={t('activityMultiplier')} value={saved.activityMultiplier} fixed={3} />
<p className="border-ink/15 font-display text-ink-dim border-t pt-4 text-sm italic leading-relaxed">
{t('hint')}
</p>
</div>
) : (
<p className="font-display text-ink-dim mt-6 text-base italic leading-relaxed">
{t('emptySummary')}
</p>
)}
</aside>
</form> </form>
</Form> </Form>
); );
} }
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 (
<div>
<p className="editorial-eyebrow text-[10px]">{label}</p>
<p className={`mt-2 flex items-baseline gap-2 ${emphasis ? 'text-pomodoro' : 'text-ink'}`}>
<span className="font-mono text-4xl font-medium leading-none tracking-tight">
{display}
</span>
{unit ? <span className="font-display text-ink-soft text-sm italic">{unit}</span> : null}
</p>
</div>
);
}
@@ -18,7 +18,7 @@ export function SignOutButton({ label }: { label: string }) {
} }
return ( return (
<Button type="button" variant="outline" size="sm" onClick={handleClick} disabled={pending}> <Button type="button" variant="ghost" size="sm" onClick={handleClick} disabled={pending}>
{label} {label}
</Button> </Button>
); );
@@ -24,11 +24,11 @@ export function DisclaimerForm() {
} }
return ( return (
<form onSubmit={handleSubmit} className="mt-6 space-y-3"> <form onSubmit={handleSubmit} className="space-y-5">
<Checkbox checked={adult} onChange={setAdult} label={t('checkAdult')} /> <Checkbox checked={adult} onChange={setAdult} label={t('checkAdult')} />
<Checkbox checked={aware} onChange={setAware} label={t('checkAware')} /> <Checkbox checked={aware} onChange={setAware} label={t('checkAware')} />
<Checkbox checked={accepts} onChange={setAccepts} label={t('checkAccepts')} /> <Checkbox checked={accepts} onChange={setAccepts} label={t('checkAccepts')} />
<Button type="submit" disabled={!allChecked || pending} className="mt-4 w-full"> <Button type="submit" disabled={!allChecked || pending} size="lg" className="mt-2 w-full">
{pending ? t('submitting') : t('submit')} {pending ? t('submitting') : t('submit')}
</Button> </Button>
</form> </form>
@@ -45,14 +45,32 @@ function Checkbox({
label: string; label: string;
}) { }) {
return ( return (
<label className="flex cursor-pointer items-start gap-3 text-sm"> <label className="group flex cursor-pointer items-start gap-3">
<span
className={`relative mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center border ${
checked ? 'border-ink bg-ink' : 'border-ink/40 bg-transparent'
} transition-colors`}
>
{checked ? (
<svg viewBox="0 0 20 20" className="text-carta-light h-3.5 w-3.5">
<path
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
d="M4 11l4 4 8-9"
/>
</svg>
) : null}
</span>
<input <input
type="checkbox" type="checkbox"
className="border-input text-primary focus-visible:ring-ring mt-0.5 h-4 w-4 rounded border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" className="sr-only"
checked={checked} checked={checked}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
/> />
<span>{label}</span> <span className="font-display text-ink text-base leading-snug">{label}</span>
</label> </label>
); );
} }
+45 -40
View File
@@ -3,14 +3,7 @@ import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { import { Masthead } from '@/components/masthead';
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { getServerSession } from '@/lib/auth'; import { getServerSession } from '@/lib/auth';
import { DisclaimerForm } from './disclaimer-form'; import { DisclaimerForm } from './disclaimer-form';
@@ -33,37 +26,49 @@ function WelcomePageContent() {
const t = useTranslations('Welcome'); const t = useTranslations('Welcome');
return ( return (
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16"> <div className="mx-auto min-h-screen max-w-3xl px-6 sm:px-10">
<Card> <Masthead issueLabel="Editoriale — Nota del medico" />
<CardHeader> <main className="py-12 sm:py-16">
<CardTitle>{t('title')}</CardTitle> <p className="editorial-eyebrow animate-fade-up">Lettera al lettore</p>
<CardDescription>{t('subtitle')}</CardDescription> <h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
</CardHeader> {t('title')}
<CardContent> <span className="text-pomodoro">.</span>
<ol className="space-y-3 text-sm"> </h1>
<li> <p className="font-display text-ink-soft animate-fade-up mt-6 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
<span className="font-medium">1. </span> {t('subtitle')}
{t('point1')} </p>
</li>
<li> <div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
<span className="font-medium">2. </span>
{t('point2')} <div className="grid gap-10 md:grid-cols-12">
</li> <div className="animate-fade-up [animation-delay:300ms] md:col-span-7">
<li> <p className="editorial-eyebrow mb-6">Quattro principi</p>
<span className="font-medium">3. </span> <ol className="space-y-6">
{t('point3')} {(['point1', 'point2', 'point3', 'point4'] as const).map((key, idx) => (
</li> <li key={key} className="grid grid-cols-[2.5rem_1fr] gap-4">
<li> <span className="font-display text-pomodoro pt-1 text-3xl font-medium italic leading-none">
<span className="font-medium">4. </span> {romanize(idx + 1)}
{t('point4')} </span>
</li> <p className="font-display text-ink text-lg leading-snug">{t(key)}</p>
</ol> </li>
<DisclaimerForm /> ))}
</CardContent> </ol>
<CardFooter> </div>
<p className="text-muted-foreground text-xs">{t('legalNote')}</p> <div className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-8">
</CardFooter> <p className="editorial-eyebrow mb-6">Conferma</p>
</Card> <DisclaimerForm />
</main> </div>
</div>
<div className="rule mt-12" />
<p className="font-display text-ink-dim mt-5 text-sm italic leading-relaxed">
{t('legalNote')}
</p>
</main>
</div>
); );
} }
function romanize(n: number): string {
return ['I', 'II', 'III', 'IV'][n - 1] ?? String(n);
}
+7 -4
View File
@@ -29,11 +29,14 @@ export function CookieBanner() {
<div <div
role="region" role="region"
aria-label={t('aria')} aria-label={t('aria')}
className="bg-background fixed inset-x-0 bottom-0 z-50 border-t p-4 shadow-lg" className="border-ink bg-carta-light/95 fixed inset-x-0 bottom-0 z-50 border-t px-6 py-4 backdrop-blur-sm sm:px-10"
> >
<div className="mx-auto flex max-w-3xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mx-auto flex max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm">{t('message')}</p> <p className="font-display text-ink-soft text-sm italic leading-snug">
<Button type="button" size="sm" onClick={dismiss}> <span className="editorial-eyebrow mr-2 not-italic">Privacy</span>
{t('message')}
</p>
<Button type="button" size="sm" variant="outline" onClick={dismiss}>
{t('dismiss')} {t('dismiss')}
</Button> </Button>
</div> </div>
+42
View File
@@ -0,0 +1,42 @@
import Link from 'next/link';
import { cn } from '@/lib/utils';
interface MastheadProps {
issueLabel?: string;
className?: string;
}
const FORMATTER = new Intl.DateTimeFormat('it-IT', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
/**
* Top-of-page editorial header. Three columns on desktop:
* ┌──────────────────┬─────────────────────┬──────────────────┐
* │ KP · monogram │ ANNO I · N. 01 │ Lunedì 29 aprile │
* └──────────────────┴─────────────────────┴──────────────────┘
* On mobile collapses to a row with monogram + date only.
*/
export function Masthead({ issueLabel = 'ANNO I · N. 01', className }: MastheadProps) {
const today = FORMATTER.format(new Date());
return (
<header className={cn('relative pb-5 pt-6', className)}>
<div className="rule-thick mb-4" />
<div className="flex items-baseline justify-between gap-4">
<Link href="/" className="monogram tracking-tight" aria-label="KetoPath">
KetoPath<span className="text-pomodoro">.</span>
</Link>
<p className="editorial-eyebrow hidden sm:block">{issueLabel}</p>
<p className="text-ink-soft font-mono text-[11px] tracking-wide first-letter:uppercase">
{today}
</p>
</div>
<div className="rule mt-4" />
</header>
);
}
+11 -10
View File
@@ -5,21 +5,22 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap font-mono text-[11px] uppercase tracking-widest transition-[background,color,border] duration-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-primary text-primary-foreground hover:bg-ink hover:text-carta-light',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-ink',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', outline: 'border border-ink bg-transparent text-ink hover:bg-ink hover:text-carta-light',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-ink hover:text-carta-light',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost:
link: 'text-primary underline-offset-4 hover:underline', 'text-ink underline-offset-[6px] decoration-pomodoro decoration-[1.5px] hover:underline',
link: 'text-pomodoro underline decoration-[1.5px] underline-offset-[6px] hover:decoration-ink',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-5 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-8 px-3 text-[10px]',
lg: 'h-11 rounded-md px-6', lg: 'h-12 px-7 text-xs',
icon: 'h-10 w-10', icon: 'h-10 w-10',
}, },
}, },
+25 -7
View File
@@ -6,7 +6,7 @@ export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('bg-card text-card-foreground rounded-lg border shadow-sm', className)} className={cn('bg-card text-card-foreground border-ink/15 relative border-y', className)}
{...props} {...props}
/> />
), ),
@@ -15,16 +15,23 @@ Card.displayName = 'Card';
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> <div
ref={ref}
className={cn('flex flex-col gap-3 px-6 pb-6 pt-8 sm:px-10 sm:pt-10', className)}
{...props}
/>
), ),
); );
CardHeader.displayName = 'CardHeader'; CardHeader.displayName = 'CardHeader';
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>( export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h3 <h2
ref={ref} ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)} className={cn(
'font-display text-ink text-3xl font-medium leading-[0.95] tracking-tight sm:text-4xl',
className,
)}
{...props} {...props}
/> />
), ),
@@ -35,20 +42,31 @@ export const CardDescription = forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement> HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} /> <p
ref={ref}
className={cn('font-display text-ink-soft text-base italic leading-snug', className)}
{...props}
/>
)); ));
CardDescription.displayName = 'CardDescription'; CardDescription.displayName = 'CardDescription';
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> <div ref={ref} className={cn('px-6 pb-8 sm:px-10 sm:pb-10', className)} {...props} />
), ),
); );
CardContent.displayName = 'CardContent'; CardContent.displayName = 'CardContent';
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} /> <div
ref={ref}
className={cn(
'border-ink/10 text-ink-soft flex items-center border-t px-6 py-5 text-xs sm:px-10',
className,
)}
{...props}
/>
), ),
); );
CardFooter.displayName = 'CardFooter'; CardFooter.displayName = 'CardFooter';
+6 -1
View File
@@ -8,7 +8,12 @@ export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputE
type={type} type={type}
ref={ref} ref={ref}
className={cn( className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'border-ink/40 flex h-11 w-full border-0 border-b bg-transparent px-0 pb-2 pt-3',
'font-display text-ink placeholder:text-ink-dim text-lg leading-tight placeholder:italic',
'transition-colors',
'focus-visible:border-ink focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'reac
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const labelVariants = cva( const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 'font-mono text-[10px] uppercase tracking-widest leading-none text-ink-soft peer-disabled:cursor-not-allowed peer-disabled:opacity-60',
); );
export const Label = forwardRef< export const Label = forwardRef<
+11 -5
View File
@@ -17,14 +17,17 @@ export const SelectTrigger = forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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', 'border-ink/40 font-display text-ink flex h-11 w-full items-center justify-between border-0 border-b bg-transparent px-0 pb-2 pt-3 text-lg leading-tight',
'placeholder:text-ink-dim data-[placeholder]:text-ink-dim data-[placeholder]:italic',
'focus:border-ink focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="text-ink-soft h-4 w-4" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)); ));
@@ -39,7 +42,8 @@ export const SelectContent = forwardRef<
ref={ref} ref={ref}
position={position} position={position}
className={cn( 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', 'border-ink/30 bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden border shadow-[6px_6px_0_0_hsl(var(--ink)/0.08)]',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
position === 'popper' && 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', '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, className,
@@ -67,14 +71,16 @@ export const SelectItem = forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( 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', 'font-display relative flex w-full cursor-default select-none items-center py-2 pl-8 pr-3 text-base leading-tight outline-none',
'focus:bg-ink focus:text-carta-light data-[state=checked]:font-medium',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className, className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-3.5 w-3.5" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+147 -44
View File
@@ -2,59 +2,162 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ─────────────────────────────────────────────────────────────────────────
* KetoPath — palette editoriale
* carta parchment / cream paper
* ink warm black
* oliva deep olive
* pomodoro terracotta red
* oro aged gold
* rule hairline border
* ───────────────────────────────────────────────────────────────────────── */
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --carta: 42 38% 94%;
--foreground: 222.2 84% 4.9%; --carta-light: 44 45% 97%;
--card: 0 0% 100%; --carta-dim: 40 30% 88%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%; --ink: 28 14% 11%;
--popover-foreground: 222.2 84% 4.9%; --ink-soft: 28 10% 28%;
--primary: 142 71% 32%; --ink-dim: 28 8% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%; --oliva: 78 26% 22%;
--secondary-foreground: 222.2 47.4% 11.2%; --pomodoro: 8 55% 38%;
--muted: 210 40% 96%; --oro: 38 60% 36%;
--muted-foreground: 215.4 16.3% 46.9%; --rule: 32 15% 62%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%; --background: var(--carta);
--destructive: 0 84.2% 60.2%; --foreground: var(--ink);
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%; --card: var(--carta-light);
--input: 214.3 31.8% 91.4%; --card-foreground: var(--ink);
--ring: 142 71% 32%; --popover: var(--carta-light);
--radius: 0.5rem; --popover-foreground: var(--ink);
--primary: var(--oliva);
--primary-foreground: var(--carta-light);
--secondary: var(--carta-dim);
--secondary-foreground: var(--ink);
--muted: var(--carta-dim);
--muted-foreground: var(--ink-dim);
--accent: var(--pomodoro);
--accent-foreground: var(--carta-light);
--destructive: var(--pomodoro);
--destructive-foreground: var(--carta-light);
--border: var(--rule);
--input: var(--rule);
--ring: var(--oliva);
--radius: 2px;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --carta: 0 22% 9%;
--foreground: 210 40% 98%; --carta-light: 0 18% 13%;
--card: 222.2 84% 4.9%; --carta-dim: 0 14% 18%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --ink: 38 28% 92%;
--popover-foreground: 210 40% 98%; --ink-soft: 38 18% 75%;
--primary: 142 71% 45%; --ink-dim: 38 10% 55%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%; --oliva: 75 32% 62%;
--secondary-foreground: 210 40% 98%; --pomodoro: 8 60% 58%;
--muted: 217.2 32.6% 17.5%; --oro: 38 70% 60%;
--muted-foreground: 215 20.2% 65.1%; --rule: 32 8% 30%;
--accent: 217.2 32.6% 17.5%; }
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 142 71% 45%;
}
}
@layer base {
* { * {
@apply border-border; @apply border-border;
} }
body {
@apply bg-background text-foreground; html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground font-sans;
font-feature-settings:
'ss01' on,
'cv11' on;
}
/* paper grain — applied once, fixed, non-interactive */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.045;
mix-blend-mode: multiply;
}
.dark body::before {
mix-blend-mode: screen;
opacity: 0.06;
}
body > * {
position: relative;
z-index: 1;
}
.font-display,
h1,
h2,
h3 {
font-optical-sizing: auto;
font-feature-settings:
'ss01' on,
'liga' on,
'dlig' on;
}
.font-mono {
font-feature-settings:
'tnum' on,
'zero' on;
}
::selection {
background-color: hsl(var(--ink));
color: hsl(var(--carta-light));
}
}
@layer components {
.rule {
@apply bg-rule h-px w-full origin-left;
}
.rule-thick {
@apply bg-ink h-[2px] w-full origin-left;
}
.editorial-eyebrow {
@apply text-ink-soft font-mono text-[11px] uppercase tracking-widest;
}
.editorial-num {
@apply font-display text-ink-soft text-[0.85em] font-medium italic;
}
.drop-cap::first-letter {
@apply font-display float-left mr-3 mt-1 text-[3.75em] font-medium leading-[0.85];
color: hsl(var(--oliva));
}
.monogram {
@apply font-display text-2xl italic leading-none tracking-tight;
} }
} }
+43 -2
View File
@@ -12,9 +12,33 @@ const config: Config = {
}, },
extend: { extend: {
fontFamily: { fontFamily: {
display: ['var(--font-display)', 'Georgia', 'serif'],
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'], sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
mono: ['var(--font-mono)', 'ui-monospace', 'monospace'],
},
fontSize: {
// tighter editorial display sizes
display: ['clamp(2.75rem, 7vw, 5rem)', { lineHeight: '0.95', letterSpacing: '-0.025em' }],
'display-lg': [
'clamp(3.5rem, 10vw, 7.5rem)',
{ lineHeight: '0.92', letterSpacing: '-0.035em' },
],
}, },
colors: { colors: {
carta: {
DEFAULT: 'hsl(var(--carta))',
light: 'hsl(var(--carta-light))',
dim: 'hsl(var(--carta-dim))',
},
ink: {
DEFAULT: 'hsl(var(--ink))',
soft: 'hsl(var(--ink-soft))',
dim: 'hsl(var(--ink-dim))',
},
oliva: 'hsl(var(--oliva))',
pomodoro: 'hsl(var(--pomodoro))',
oro: 'hsl(var(--oro))',
rule: 'hsl(var(--rule))',
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
@@ -51,8 +75,25 @@ const config: Config = {
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 1px)',
sm: 'calc(var(--radius) - 4px)', sm: '1px',
},
letterSpacing: {
widest: '0.22em',
},
keyframes: {
'fade-up': {
'0%': { opacity: '0', transform: 'translateY(0.75rem)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'rule-in': {
'0%': { transform: 'scaleX(0)' },
'100%': { transform: 'scaleX(1)' },
},
},
animation: {
'fade-up': 'fade-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both',
'rule-in': 'rule-in 800ms cubic-bezier(0.7, 0, 0.3, 1) both',
}, },
}, },
}, },