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
@@ -1,8 +1,9 @@
|
||||
import { enabledSocialProviders } from '@ketopath/auth';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
|
||||
import { SignInForm } from './sign-in-form';
|
||||
|
||||
@@ -11,16 +12,49 @@ export default function SignInPage({ params: { locale } }: { params: { locale: s
|
||||
const t = useTranslations('Auth.SignIn');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||
<Masthead issueLabel="N. 02 — Accesso" />
|
||||
<main className="grid gap-12 py-12 sm:py-20 md:grid-cols-12">
|
||||
<aside className="md:sticky md:top-12 md:col-span-6 md:self-start">
|
||||
<p className="editorial-eyebrow animate-fade-up">Capitolo II</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||
<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')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
<p className="text-ink-dim mt-8 text-sm leading-relaxed md:hidden">
|
||||
{t.rich('noAccount', {
|
||||
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';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
@@ -47,9 +46,9 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-7" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('emailLabel')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -60,7 +59,7 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
@@ -73,40 +72,32 @@ export function SignInForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
<p role="alert" className="font-display text-pomodoro text-sm italic">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
<Button type="submit" disabled={pending} size="lg" className="w-full">
|
||||
{pending ? t('submitting') : t('submit')}
|
||||
</Button>
|
||||
</form>
|
||||
{googleEnabled ? (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="border-border w-full border-t" />
|
||||
</div>
|
||||
<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 className="flex items-center gap-4">
|
||||
<div className="bg-rule h-px flex-1" />
|
||||
<span className="editorial-eyebrow">{t('or')}</span>
|
||||
<div className="bg-rule h-px flex-1" />
|
||||
</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')}
|
||||
</Button>
|
||||
</>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import { enabledSocialProviders } from '@ketopath/auth';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
|
||||
import { SignUpForm } from './sign-up-form';
|
||||
|
||||
@@ -18,19 +12,51 @@ export default function SignUpPage({ params: { locale } }: { params: { locale: s
|
||||
const t = useTranslations('Auth.SignUp');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||
<Masthead issueLabel="N. 03 — Iscrizione" />
|
||||
<main className="grid gap-12 py-12 sm:py-20 md:grid-cols-12">
|
||||
<aside className="md:sticky md:top-12 md:col-span-6 md:self-start">
|
||||
<p className="editorial-eyebrow animate-fade-up">Capitolo III</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||
<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')} />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-muted-foreground text-xs">{t('disclaimer')}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</main>
|
||||
<p className="border-ink/15 text-ink-dim mt-10 border-t pt-5 text-xs leading-relaxed">
|
||||
{t('disclaimer')}
|
||||
</p>
|
||||
<p className="text-ink-dim mt-6 text-sm leading-relaxed md:hidden">
|
||||
{t.rich('haveAccount', {
|
||||
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';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
@@ -48,9 +47,9 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-7" noValidate>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t('nameLabel')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@@ -61,7 +60,7 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('emailLabel')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -72,7 +71,7 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('passwordLabel')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
@@ -83,43 +82,37 @@ export function SignUpForm({ googleEnabled }: { googleEnabled: boolean }) {
|
||||
value={password}
|
||||
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>
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
<p role="alert" className="font-display text-pomodoro text-sm italic">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
<Button type="submit" disabled={pending} size="lg" className="w-full">
|
||||
{pending ? t('submitting') : t('submit')}
|
||||
</Button>
|
||||
</form>
|
||||
{googleEnabled ? (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="border-border w-full border-t" />
|
||||
</div>
|
||||
<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 className="flex items-center gap-4">
|
||||
<div className="bg-rule h-px flex-1" />
|
||||
<span className="editorial-eyebrow">{t('or')}</span>
|
||||
<div className="bg-rule h-px flex-1" />
|
||||
</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')}
|
||||
</Button>
|
||||
</>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Fraunces, Inter_Tight, JetBrains_Mono } from 'next/font/google';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
@@ -10,10 +10,25 @@ import { locales, type Locale } from '@/i18n';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
const fraunces = Fraunces({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
style: ['normal', 'italic'],
|
||||
});
|
||||
|
||||
const interTight = Inter_Tight({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
export function generateStaticParams() {
|
||||
@@ -48,7 +63,7 @@ export default async function LocaleLayout({
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<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">
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
@@ -18,31 +19,116 @@ function HomeContent({ userName }: { userName: string | null }) {
|
||||
const t = useTranslations('Home');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-3xl flex-col items-start justify-center gap-6 px-6 py-16">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">{t('title')}</h1>
|
||||
<p className="text-muted-foreground text-lg">{t('tagline')}</p>
|
||||
{userName ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-foreground text-sm">{t('signedInAs', { name: userName })}</span>
|
||||
<Button asChild>
|
||||
<Link href="/profile">{t('completeProfile')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/plan">{t('viewPlan')}</Link>
|
||||
</Button>
|
||||
<SignOutButton label={t('signOut')} />
|
||||
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||
<Masthead />
|
||||
<main className="grid gap-12 py-10 sm:py-16 md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<p className="editorial-eyebrow animate-fade-up">N. 01 — Manifesto</p>
|
||||
<h1 className="font-display text-display-lg text-ink animate-fade-up mt-4 font-medium [animation-delay:120ms]">
|
||||
{t('title')}
|
||||
<span className="text-pomodoro">.</span>
|
||||
</h1>
|
||||
<p className="font-display text-ink-soft animate-fade-up mt-6 max-w-xl text-2xl italic leading-snug [animation-delay:240ms]">
|
||||
{t('tagline')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/sign-up">{t('ctaPrimary')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/sign-in">{t('ctaSecondary')}</Link>
|
||||
</Button>
|
||||
|
||||
<aside className="md:border-ink/15 animate-fade-up [animation-delay:360ms] md:col-span-5 md:border-l md:pl-8">
|
||||
<p className="editorial-eyebrow">{userName ? t('lettura') : t('inizia')}</p>
|
||||
<div className="mt-6">
|
||||
{userName ? <SignedInBlock userName={userName} t={t} /> : <SignedOutBlock t={t} />}
|
||||
</div>
|
||||
</aside>
|
||||
</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>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-8 text-xs">{t('disclaimer')}</p>
|
||||
</main>
|
||||
<div className="md:border-ink/15 md:col-span-5 md:border-l md:pl-8">
|
||||
<p className="editorial-eyebrow">Tre principi</p>
|
||||
<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 { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchCurrentPlan, regeneratePlan } from './actions';
|
||||
@@ -27,27 +27,58 @@ export default async function PlanPage({ params: { locale } }: { params: { local
|
||||
return <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>> }) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
return (
|
||||
<main className="mx-auto min-h-screen max-w-5xl px-4 py-10 sm:px-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{plan ? t('weekOf', { date: plan.weekStart }) : t('noPlan')}
|
||||
</CardDescription>
|
||||
<div className="mx-auto min-h-screen max-w-6xl px-6 sm:px-10">
|
||||
<Masthead issueLabel="N. 05 — Cucina" />
|
||||
<main className="py-12 sm:py-16">
|
||||
<div className="grid items-end gap-6 md:grid-cols-12">
|
||||
<div className="md:col-span-8">
|
||||
<p className="editorial-eyebrow animate-fade-up">Capitolo V</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||
{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>
|
||||
<form action={regeneratePlan}>
|
||||
<Button type="submit" variant={plan ? 'outline' : 'default'} size="sm">
|
||||
{plan ? t('regenerate') : t('generate')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardHeader>
|
||||
<CardContent>{plan ? <PlanWeek plan={plan} /> : null}</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
<div className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
|
||||
<form action={regeneratePlan}>
|
||||
<Button type="submit" variant={plan ? 'outline' : 'default'} size="lg">
|
||||
{plan ? t('regenerate') : t('generate')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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',
|
||||
'sunday',
|
||||
] as const;
|
||||
const DAY_NUMERAL = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'];
|
||||
|
||||
export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
// Group slots by day for quick lookup.
|
||||
const byDay = new Map<number, PlanSlot[]>();
|
||||
for (const slot of plan.slots) {
|
||||
if (!byDay.has(slot.dayOfWeek)) byDay.set(slot.dayOfWeek, []);
|
||||
@@ -29,25 +29,31 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-14">
|
||||
{DAY_KEYS.map((dayKey, idx) => {
|
||||
const slots = (byDay.get(idx) ?? []).sort(
|
||||
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
|
||||
);
|
||||
const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0);
|
||||
return (
|
||||
<section key={dayKey} aria-labelledby={`day-${dayKey}`}>
|
||||
<header className="mb-2 flex items-baseline justify-between">
|
||||
<h2 id={`day-${dayKey}`} className="text-lg font-semibold">
|
||||
<section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
|
||||
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
|
||||
<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}`)}
|
||||
</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) })}
|
||||
</span>
|
||||
</header>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{slots.map((slot) => (
|
||||
<SlotCard key={slot.id} slot={slot} />
|
||||
<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, slotIdx) => (
|
||||
<SlotCard key={slot.id} slot={slot} index={slotIdx} />
|
||||
))}
|
||||
</div>
|
||||
</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 [open, setOpen] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -70,32 +78,42 @@ function SlotCard({ slot }: { slot: PlanSlot }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="border-border bg-card flex flex-col rounded-md border p-3">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
{t(`meals.${slot.meal}`)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium leading-tight">
|
||||
<article className="group relative flex flex-col gap-3 py-5 sm:py-2">
|
||||
<header className="flex items-baseline justify-between gap-3">
|
||||
<p className="editorial-eyebrow flex items-baseline gap-2">
|
||||
<span className="font-display not-italic-children text-pomodoro text-base italic">
|
||||
{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')}
|
||||
</p>
|
||||
{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}
|
||||
{slot.alternatives.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
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 })}
|
||||
</button>
|
||||
{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) => (
|
||||
<li key={alt.id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs">
|
||||
<li key={alt.id} className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-display text-ink text-sm leading-snug">
|
||||
{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>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchProfile } from './actions';
|
||||
@@ -33,16 +33,24 @@ function ProfilePageContent({
|
||||
const t = useTranslations('Profile');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mx-auto min-h-screen max-w-4xl px-6 sm:px-10">
|
||||
<Masthead issueLabel="N. 04 — Profilo" />
|
||||
<main className="py-12 sm:py-16">
|
||||
<p className="editorial-eyebrow animate-fade-up">Capitolo IV</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||
{t('title')}
|
||||
<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} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,14 +67,56 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-5" noValidate>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<form onSubmit={onSubmit} className="grid gap-12 md:grid-cols-12" noValidate>
|
||||
<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
|
||||
control={form.control}
|
||||
name="age"
|
||||
name="heightCm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('age')}</FormLabel>
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>{t('heightCm')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
@@ -82,12 +124,41 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
||||
</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
|
||||
control={form.control}
|
||||
name="gender"
|
||||
name="activityLevel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('gender')}</FormLabel>
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>{t('activityLevel')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? ''}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -95,9 +166,9 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{GENDERS.map((g) => (
|
||||
<SelectItem key={g} value={g}>
|
||||
{t(`genderOptions.${g}`)}
|
||||
{ACTIVITY_LEVELS.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{t(`activityOptions.${a}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -106,101 +177,70 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="heightCm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('heightCm')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" inputMode="numeric" {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{serverError ? (
|
||||
<p role="alert" className="font-display text-pomodoro text-base italic">
|
||||
{serverError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(['weightStartKg', 'weightCurrentKg', 'weightGoalKg'] as const).map((name) => (
|
||||
<FormField
|
||||
key={name}
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(name)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
inputMode="decimal"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="activityLevel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('activityLevel')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? ''}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ACTIVITY_LEVELS.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{t(`activityOptions.${a}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{serverError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{serverError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
|
||||
{form.formState.isSubmitting ? t('saving') : t('save')}
|
||||
</Button>
|
||||
|
||||
{saved ? (
|
||||
<div className="bg-secondary mt-6 rounded-md p-4 text-sm">
|
||||
<p className="font-medium">{t('summary')}</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>
|
||||
{t('bmr')}: <span className="font-mono">{saved.bmr} kcal</span>
|
||||
</li>
|
||||
<li>
|
||||
{t('tdee')}: <span className="font-mono">{saved.tdee} kcal</span>
|
||||
</li>
|
||||
<li>
|
||||
{t('activityMultiplier')}:{' '}
|
||||
<span className="font-mono">{saved.activityMultiplier}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-2 text-xs">{t('hint')}</p>
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{form.formState.isSubmitting ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleClick} disabled={pending}>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleClick} disabled={pending}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -24,11 +24,11 @@ export function DisclaimerForm() {
|
||||
}
|
||||
|
||||
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={aware} onChange={setAware} label={t('checkAware')} />
|
||||
<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')}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -45,14 +45,32 @@ function Checkbox({
|
||||
label: string;
|
||||
}) {
|
||||
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
|
||||
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}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
<span className="font-display text-ink text-base leading-snug">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,7 @@ import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { DisclaimerForm } from './disclaimer-form';
|
||||
@@ -33,37 +26,49 @@ function WelcomePageContent() {
|
||||
const t = useTranslations('Welcome');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-3 text-sm">
|
||||
<li>
|
||||
<span className="font-medium">1. </span>
|
||||
{t('point1')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">2. </span>
|
||||
{t('point2')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">3. </span>
|
||||
{t('point3')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">4. </span>
|
||||
{t('point4')}
|
||||
</li>
|
||||
</ol>
|
||||
<DisclaimerForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-muted-foreground text-xs">{t('legalNote')}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</main>
|
||||
<div className="mx-auto min-h-screen max-w-3xl px-6 sm:px-10">
|
||||
<Masthead issueLabel="Editoriale — Nota del medico" />
|
||||
<main className="py-12 sm:py-16">
|
||||
<p className="editorial-eyebrow animate-fade-up">Lettera al lettore</p>
|
||||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||
{t('title')}
|
||||
<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="grid gap-10 md:grid-cols-12">
|
||||
<div className="animate-fade-up [animation-delay:300ms] md:col-span-7">
|
||||
<p className="editorial-eyebrow mb-6">Quattro principi</p>
|
||||
<ol className="space-y-6">
|
||||
{(['point1', 'point2', 'point3', 'point4'] as const).map((key, idx) => (
|
||||
<li key={key} className="grid grid-cols-[2.5rem_1fr] gap-4">
|
||||
<span className="font-display text-pomodoro pt-1 text-3xl font-medium italic leading-none">
|
||||
{romanize(idx + 1)}
|
||||
</span>
|
||||
<p className="font-display text-ink text-lg leading-snug">{t(key)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-8">
|
||||
<p className="editorial-eyebrow mb-6">Conferma</p>
|
||||
<DisclaimerForm />
|
||||
</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);
|
||||
}
|
||||
Reference in new issue
Block a user