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:
1 parent
f8eaf2f493
commit
59a311cfc9
26 files changed
+933
-440
No files matched your search
@@ -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,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();
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user