Files
ketopath/apps/web/src/app/[locale]/tracking/page.tsx
T
lucianoandClaude Opus 4.7 a5d8dc355d feat(ui): nav app persistente nel masthead (desktop + drawer mobile)
L'utente non aveva modo di saltare da una pagina app all'altra senza tornare alla home (solo il logo era cliccabile). Aggiunta nav globale:

- **Desktop**: row di link orizzontale a destra del logo (Cucina · Tracking · Digiuno · Spesa · Profilo · Abbonamento). Pagina corrente evidenziata in pomodoro, hover sulle altre.
- **Mobile**: bottone hamburger (≡ Menu) che apre un drawer sotto la riga di header con le 6 voci. Si chiude da solo al cambio di pathname.

`<Masthead>` ora è un Client Component (serve usePathname). Prop `showNav` (default false) controlla la nav: attivata sulle 7 pagine logged-in (`/`, /plan, /tracking, /fasting, /shopping, /profile, /billing, /recipes/[id]). Lasciata off su welcome, onboarding, sign-in, sign-up.

Aggiunti i18n keys: navPlan/navTracking/navFasting/navShopping/navProfile/navBilling. Rimossi gli `issueLabel="N. XX — ..."" dalle pagine app (`issueLabel` resta supportato per pagine senza nav).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:29:43 +02:00

157 lines
6.0 KiB
TypeScript

import { prisma } from '@ketopath/db';
import { redirect } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { CursorGlow } from '@/components/cursor-glow';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchProfile } from '../profile/actions';
import { fetchTodayCheckIn, fetchWeightEntries } from './actions';
import { DailyCheckIn } from './daily-check-in';
import { WeightEntryForm } from './weight-entry-form';
import { WeightHistory } from './weight-history';
type ProfileLike = {
weightGoalKg?: number;
weightStartKg?: number;
alertWeightKg?: number | null;
} | null;
export default async function TrackingPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
if (!session?.user) redirect('/sign-in');
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
if (!user.profile) redirect('/onboarding');
const [entries, profile, todayCheckIn] = await Promise.all([
fetchWeightEntries(),
fetchProfile(),
fetchTodayCheckIn(),
]);
// Calcolato server-side per evitare hydration mismatch su WeightEntryForm.
const now = new Date();
const todayISO = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
return (
<TrackingPageContent
entries={entries}
profile={profile as ProfileLike}
todayCheckIn={todayCheckIn}
todayISO={todayISO}
/>
);
}
function TrackingPageContent({
entries,
profile,
todayCheckIn,
todayISO,
}: {
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
profile: ProfileLike;
todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>;
todayISO: string;
}) {
const t = useTranslations('Tracking');
const latest = entries[0] ?? null;
const goalKg = typeof profile?.weightGoalKg === 'number' ? profile.weightGoalKg : null;
const startKg = typeof profile?.weightStartKg === 'number' ? profile.weightStartKg : null;
const alertKg = typeof profile?.alertWeightKg === 'number' ? profile.alertWeightKg : null;
const alertActive = alertKg != null && latest != null && latest.weightKg >= alertKg;
return (
<div className="relative">
<div className="min-h-screen w-full px-6 sm:px-10 lg:px-16 xl:px-24">
<Masthead showNav />
<main className="relative overflow-hidden pb-24 pt-10 sm:pt-12">
<CursorGlow color="hsl(var(--oro))" size={520} />
<div
aria-hidden
className="mesh-blob mesh-blob--oro animate-float-y -right-32 top-12 h-[28rem] w-[28rem] opacity-50"
/>
<div
aria-hidden
className="mesh-blob mesh-blob--oliva animate-float-x -left-24 top-72 h-72 w-72 opacity-40"
/>
<span
aria-hidden
className="font-display text-stroke-thin text-chapter pointer-events-none absolute -right-4 -top-12 select-none italic"
>
VI
</span>
<div
aria-hidden
className="pointer-events-none absolute right-0 top-32 hidden md:block"
style={{ writingMode: 'vertical-rl' }}
>
<p className="text-ink-soft font-mono text-xs uppercase tracking-[0.4em]">
Capitolo VI — Tracking
</p>
</div>
<h1 className="font-display text-mega bleed-left animate-fade-up relative mt-8 font-medium [animation-delay:120ms]">
<span className="text-ink block">
{t('title')}
<span className="text-pomodoro">.</span>
</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up relative mt-10 max-w-2xl text-2xl italic leading-snug [animation-delay:240ms] md:ml-[16.66%]">
{t('subtitle')}
</p>
<p className="animate-fade-up relative mt-6 [animation-delay:300ms] md:ml-[16.66%]">
<a
href="/api/tracking-export"
target="_blank"
rel="noopener noreferrer"
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
>
{t('exportPdf')} ↗
</a>
</p>
<div className="rule animate-rule-in relative my-12 [animation-delay:360ms]" />
<DailyCheckIn initial={todayCheckIn} />
{alertActive && alertKg != null && latest != null ? (
<aside className="border-pomodoro/40 bg-pomodoro/5 mb-10 border-2 border-dashed p-5">
<p className="editorial-eyebrow">{t('alertEyebrow')}</p>
<p className="font-display text-ink mt-3 text-base leading-snug">
{t('alertMessage', {
current: latest.weightKg.toFixed(1),
threshold: alertKg.toFixed(1),
})}
</p>
<p className="font-display text-ink-soft mt-2 text-sm italic leading-snug">
{t('alertHint')}
</p>
</aside>
) : null}
<div className="relative grid gap-12 md:grid-cols-12">
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
<WeightEntryForm latest={latest} todayISO={todayISO} />
</section>
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-6">{t('history')}</p>
<WeightHistory entries={entries} goalKg={goalKg} startKg={startKg} />
</aside>
</div>
</main>
</div>
</div>
);
}