feat(web): onboarding ricco — goal step, notifiche, done celebrativo

- Step "L'obiettivo" iniziale (perdere peso / mantenere / energia):
  card-based, client-state, personalizza il copy degli step successivi
- Step "Promemoria": opt-in push notifications inline durante l'onboarding,
  riusa subscribe() del push-client; skippable
- Step "Pronti a partire" arricchito:
  · auto-genera il primo piano in background al primo render
  · copy che cambia in base al goal scelto
  · 3 next-cards (Cucina, Digiuno, Tracking) per orientare l'utente
  · banner "Aggiungi alla Home" condizionale per iOS Safari non-standalone
    (necessario per ricevere push su iPhone, vedi ADR 0003)
- Smart-jump aggiornato per la nuova sequenza (5 step invece di 3)
- i18n: namespace Onboarding esteso con goal/notifications/iOS install copy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 22:18:45 +02:00
1 parent 624152b29b
commit ded4215664
2 files changed
+323 -38

No files matched your search

+55 -2
View File
@@ -167,22 +167,75 @@
},
"Onboarding": {
"title": "Configura il tuo percorso",
"subtitle": "Tre passaggi: parametri personali, preferenze, e via al primo piano.",
"subtitle": "Cinque passaggi: l'obiettivo, i numeri, le preferenze, le notifiche e via al primo piano.",
"stepsTitle": "Tappe",
"stepEyebrow": "Tappa {n} di {total}",
"next": "Salva e continua",
"back": "← Indietro",
"skipForNow": "Salta per ora",
"working": "Attendere…",
"steps": {
"goal": "L'obiettivo",
"profile": "Anagrafica e attività",
"preferences": "Esclusioni e cucine",
"notifications": "Promemoria",
"done": "Pronti a partire"
},
"goalHeading": "Perché sei qui?",
"goalHint": "Scegli il motivo principale: ci aiuterà a parlarti nel modo giusto. Potrai sempre cambiarlo.",
"goals": {
"LOSE": {
"title": "Perdere peso",
"hint": "Deficit calorico graduale, fase intensiva poi mantenimento."
},
"MAINTAIN": {
"title": "Mantenere il peso",
"hint": "Equilibrio tra macros e digiuno per restare stabile."
},
"ENERGY": {
"title": "Energia e salute",
"hint": "Lucidità mentale, glicemia stabile, infiammazione bassa."
}
},
"profileHeading": "I numeri che ti riguardano",
"profileHeadingGoal": {
"LOSE": "I numeri da cui partire",
"MAINTAIN": "I numeri da mantenere",
"ENERGY": "I numeri che ti raccontano"
},
"profileHint": "Età, sesso, altezza, peso e attività: bastano per calcolare BMR, TDEE e i target di macros.",
"preferencesHeading": "Cosa eviti, cosa ami",
"preferencesHint": "Le esclusioni filtrano davvero le ricette del piano. Le cucine sono un suggerimento al matchmaking.",
"notificationsHeading": "Vuoi un promemoria?",
"notificationsHint": "Un piccolo nudge il lunedì per la pesata, e altre tappe in arrivo. Niente spam: scegli tu cosa ricevere.",
"notificationsActivate": "Attiva promemoria",
"notificationsActive": "Promemoria attivati. Bene così.",
"notificationsBlocked": "Hai bloccato le notifiche per questo sito. Le potrai riattivare dalle impostazioni del browser.",
"notificationsUnsupported": "Il tuo browser non supporta le notifiche push. Su iOS apri prima KetoPath dalla Home.",
"notificationsError": "Non è stato possibile attivarle. Riprova più tardi dal tuo profilo.",
"doneHeading": "Tutto pronto.",
"doneHint": "Puoi generare il primo piano della settimana adesso, oppure esplorare l'app a tuo ritmo.",
"doneHeadingGoal": {
"LOSE": "Si parte. Un passo alla volta.",
"MAINTAIN": "Si parte. Mantenendo l'equilibrio.",
"ENERGY": "Si parte. Con più lucidità."
},
"doneHint": "Puoi esplorare l'app a tuo ritmo: ogni sezione resta sempre a portata.",
"doneGenerating": "Stiamo preparando il primo piano per la tua settimana…",
"donePlanReady": "Il primo piano è pronto. Dai un'occhiata o tuffati in cucina.",
"nextPlanEyebrow": "Cucina",
"nextPlanTitle": "Il piano della settimana",
"nextPlanHint": "Sette giorni, quattro pasti al dì, scelti dal matchmaking.",
"nextFastingEyebrow": "Digiuno",
"nextFastingTitle": "Una sessione di digiuno",
"nextFastingHint": "Scegli un protocollo, lascia che il timer scandisca il ritmo.",
"nextTrackingEyebrow": "Tracking",
"nextTrackingTitle": "La pesata di oggi",
"nextTrackingHint": "Pochi numeri, una volta a settimana. Costanza prima di tutto.",
"iosInstallEyebrow": "Solo iOS — per ricevere le notifiche",
"iosInstallTitle": "Installa KetoPath sulla Home in tre passaggi.",
"iosInstallStep1": "Tocca il pulsante Condividi (icona quadrato con freccia in alto) nella barra di Safari.",
"iosInstallStep2": "Scorri e seleziona “Aggiungi alla schermata Home”.",
"iosInstallStep3": "Apri KetoPath dalla Home: ora le notifiche funzionano anche su iPhone.",
"goToPlan": "Genera il primo piano",
"skipToHome": "Esplora l'app →"
},
@@ -9,7 +9,7 @@ import {
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
DEFAULT_PREFERENCES_VALUES,
@@ -17,14 +17,19 @@ import {
type PreferencesValues,
} from '@/components/preferences/preferences-form';
import { Button } from '@/components/ui/button';
import { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client';
import { regeneratePlan } from '../plan/actions';
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
import type { ProfileForm } from '../profile/profile-form';
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
import type { ProfileForm } from '../profile/profile-form';
const STEPS = ['profile', 'preferences', 'done'] as const;
const STEPS = ['goal', 'profile', 'preferences', 'notifications', 'done'] as const;
type StepKey = (typeof STEPS)[number];
const GOALS = ['LOSE', 'MAINTAIN', 'ENERGY'] as const;
type Goal = (typeof GOALS)[number];
interface OnboardingFlowProps {
initialProfile: Parameters<typeof ProfileForm>[0]['initial'];
initialPreferences: PreferencesView | null;
@@ -32,14 +37,13 @@ interface OnboardingFlowProps {
export function OnboardingFlow({ initialProfile, initialPreferences }: OnboardingFlowProps) {
const t = useTranslations('Onboarding');
const router = useRouter();
// Lo step di partenza salta i form già completati: comodo per chi torna a metà.
const [step, setStep] = useState<StepKey>(() => {
if (!initialProfile) return 'profile';
if (!initialProfile) return 'goal';
if (!initialPreferences || initialPreferences.exclusions.length === 0) return 'preferences';
return 'done';
});
const [goal, setGoal] = useState<Goal | null>(null);
const stepIndex = STEPS.indexOf(step);
@@ -55,7 +59,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
i < stepIndex ? 'text-oliva' : i === stepIndex ? 'text-pomodoro' : 'text-ink-dim'
}`}
>
{['I', 'II', 'III'][i]}
{['I', 'II', 'III', 'IV', 'V'][i]}
</span>
<span
className={`font-display text-lg leading-tight ${
@@ -70,11 +74,15 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
</aside>
<div className="md:col-span-8">
{step === 'goal' ? (
<StepGoal selected={goal} onSelect={setGoal} onContinue={() => setStep('profile')} />
) : null}
{step === 'profile' ? (
<StepProfile
initial={initialProfile}
goal={goal}
onSaved={() => setStep('preferences')}
label={t('next')}
/>
) : null}
@@ -83,41 +91,106 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
initial={
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
}
onSaved={() => setStep('done')}
label={t('next')}
onSaved={() => setStep('notifications')}
onBack={() => setStep('profile')}
backLabel={t('back')}
/>
) : null}
{step === 'done' ? <StepDone onFinish={() => router.push('/plan')} /> : null}
{step === 'notifications' ? <StepNotifications onContinue={() => setStep('done')} /> : null}
{step === 'done' ? <StepDone goal={goal} /> : null}
</div>
</section>
);
}
function StepProfile({
initial,
onSaved,
label,
function StepGoal({
selected,
onSelect,
onContinue,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
onSaved: () => void;
label: string;
selected: Goal | null;
onSelect: (g: Goal) => void;
onContinue: () => void;
}) {
const t = useTranslations('Onboarding');
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 3 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 5 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('profileHeading')}
{t('goalHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('goalHint')}
</p>
</header>
<div className="grid gap-4 sm:grid-cols-3">
{GOALS.map((g, i) => (
<button
key={g}
type="button"
onClick={() => onSelect(g)}
aria-pressed={selected === g}
className={`group flex flex-col gap-3 border p-5 text-left transition-colors ${
selected === g
? 'border-ink bg-ink text-carta-light'
: 'border-ink/20 hover:border-ink hover:bg-ink/5 bg-transparent'
}`}
>
<span
className={`font-mono text-[10px] uppercase tracking-widest ${
selected === g ? 'text-carta-light/60' : 'text-ink-dim'
}`}
>
{['I', 'II', 'III'][i]}
</span>
<span className="font-display text-xl font-medium leading-tight">
{t(`goals.${g}.title`)}
</span>
<span
className={`font-display text-sm italic leading-snug ${
selected === g ? 'text-carta-light/80' : 'text-ink-soft'
}`}
>
{t(`goals.${g}.hint`)}
</span>
</button>
))}
</div>
<div>
<Button type="button" size="lg" onClick={onContinue} disabled={!selected}>
{t('next')}
</Button>
</div>
</div>
);
}
function StepProfile({
initial,
goal,
onSaved,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
goal: Goal | null;
onSaved: () => void;
}) {
const t = useTranslations('Onboarding');
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 5 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('profileHint')}
</p>
</header>
<ProfileFormComponent initial={initial} onSaved={onSaved} saveLabel={label} />
<ProfileFormComponent initial={initial} onSaved={onSaved} saveLabel={t('next')} />
</div>
);
}
@@ -125,15 +198,11 @@ function StepProfile({
function StepPreferences({
initial,
onSaved,
label,
onBack,
backLabel,
}: {
initial: PreferencesValues;
onSaved: () => void;
label: string;
onBack: () => void;
backLabel: string;
}) {
const t = useTranslations('Onboarding');
@@ -146,7 +215,7 @@ function StepPreferences({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 3 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 5 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('preferencesHeading')}
</h2>
@@ -154,33 +223,170 @@ function StepPreferences({
{t('preferencesHint')}
</p>
</header>
<PreferencesForm initial={initial} onSubmit={handleSubmit} submitLabel={label} />
<PreferencesForm initial={initial} onSubmit={handleSubmit} submitLabel={t('next')} />
<button
type="button"
onClick={onBack}
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"
>
{backLabel}
{t('back')}
</button>
</div>
);
}
function StepDone({ onFinish }: { onFinish: () => void }) {
function StepNotifications({ onContinue }: { onContinue: () => void }) {
const t = useTranslations('Onboarding');
const [supported, setSupported] = useState(true);
const [permission, setPermission] = useState<NotificationPermission | 'unknown'>('unknown');
const [hasSub, setHasSub] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSupported(pushSupported());
if (typeof Notification !== 'undefined') setPermission(Notification.permission);
void getCurrentSubscription().then((s) => setHasSub(!!s));
}, []);
async function activate(): Promise<void> {
setError(null);
setPending(true);
const result = await subscribe();
setPending(false);
if (!result.ok) {
setError(t('notificationsError'));
return;
}
setHasSub(true);
setPermission('granted');
}
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 3 })}</p>
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
{t('doneHeading')}
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 5 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('notificationsHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-xl italic leading-snug">
{t('doneHint')}
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('notificationsHint')}
</p>
</header>
{!supported ? (
<p className="font-display text-ink-soft text-base italic">
{t('notificationsUnsupported')}
</p>
) : hasSub ? (
<p className="font-display text-oliva text-base italic">{t('notificationsActive')}</p>
) : permission === 'denied' ? (
<p className="font-display text-pomodoro text-base italic">{t('notificationsBlocked')}</p>
) : (
<Button type="button" size="lg" variant="outline" onClick={activate} disabled={pending}>
{pending ? t('working') : t('notificationsActivate')}
</Button>
)}
{error ? (
<p className="font-display text-pomodoro text-base italic" role="alert">
{error}
</p>
) : null}
<div className="flex flex-wrap items-center gap-4 pt-2">
<Button type="button" size="lg" onClick={onContinue}>
{t('next')}
</Button>
<button
type="button"
onClick={onContinue}
className="text-ink-soft hover:text-ink font-mono text-[11px] uppercase tracking-widest"
>
{t('skipForNow')}
</button>
</div>
</div>
);
}
function StepDone({ goal }: { goal: Goal | null }) {
const t = useTranslations('Onboarding');
const router = useRouter();
const [planStatus, setPlanStatus] = useState<'idle' | 'pending' | 'ready' | 'error'>('idle');
const [iosInstallHint, setIosInstallHint] = useState(false);
// Genera il primo piano se non esiste — best-effort.
useEffect(() => {
if (planStatus !== 'idle') return;
setPlanStatus('pending');
regeneratePlan()
.then(() => setPlanStatus('ready'))
.catch(() => setPlanStatus('error'));
}, [planStatus]);
// iOS Safari non-standalone: serve "Aggiungi alla Home" per le push.
useEffect(() => {
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/i.test(ua) && !/CriOS|FxiOS|EdgiOS/i.test(ua);
const standalone =
window.matchMedia('(display-mode: standalone)').matches ||
// legacy iOS: window.navigator.standalone
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
setIosInstallHint(isIOS && !standalone);
}, []);
return (
<div className="space-y-10">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 5 })}</p>
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-xl italic leading-snug">
{planStatus === 'pending'
? t('doneGenerating')
: planStatus === 'ready'
? t('donePlanReady')
: t('doneHint')}
</p>
</header>
<div className="grid gap-4 sm:grid-cols-3">
<NextCard
eyebrow={t('nextPlanEyebrow')}
title={t('nextPlanTitle')}
hint={t('nextPlanHint')}
href="/plan"
/>
<NextCard
eyebrow={t('nextFastingEyebrow')}
title={t('nextFastingTitle')}
hint={t('nextFastingHint')}
href="/fasting"
/>
<NextCard
eyebrow={t('nextTrackingEyebrow')}
title={t('nextTrackingTitle')}
hint={t('nextTrackingHint')}
href="/tracking"
/>
</div>
{iosInstallHint ? (
<div className="border-ink/20 bg-carta-light flex flex-col gap-3 border-2 border-dashed p-5">
<p className="editorial-eyebrow">{t('iosInstallEyebrow')}</p>
<p className="font-display text-ink text-lg leading-snug">{t('iosInstallTitle')}</p>
<ol className="font-display text-ink-soft list-decimal pl-5 text-sm italic leading-relaxed">
<li>{t('iosInstallStep1')}</li>
<li>{t('iosInstallStep2')}</li>
<li>{t('iosInstallStep3')}</li>
</ol>
</div>
) : null}
<div className="flex flex-wrap items-center gap-4">
<Button type="button" size="lg" onClick={onFinish}>
<Button type="button" size="lg" onClick={() => router.push('/plan')}>
{t('goToPlan')}
</Button>
<Link
@@ -194,6 +400,32 @@ function StepDone({ onFinish }: { onFinish: () => void }) {
);
}
function NextCard({
eyebrow,
title,
hint,
href,
}: {
eyebrow: string;
title: string;
hint: string;
href: string;
}) {
return (
<Link
href={href}
className="border-ink/20 hover:border-ink hover:bg-ink/5 group flex flex-col gap-3 border p-5 transition-colors"
>
<span className="editorial-eyebrow">{eyebrow}</span>
<span className="font-display text-ink text-xl font-medium leading-tight">{title}</span>
<span className="font-display text-ink-soft text-sm italic leading-snug">{hint}</span>
<span aria-hidden className="text-ink-dim mt-2 font-mono text-xs">
→
</span>
</Link>
);
}
function toFormValues(view: PreferencesView): PreferencesValues {
return {
exclusions: view.exclusions as ExclusionGroup[],