From ded4215664f74f785d5aabc9a0535f77bfcb1a0b Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 22:18:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20onboarding=20ricco=20=E2=80=94=20g?= =?UTF-8?q?oal=20step,=20notifiche,=20done=20celebrativo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/web/messages/it.json | 57 +++- .../[locale]/onboarding/onboarding-flow.tsx | 304 +++++++++++++++--- 2 files changed, 323 insertions(+), 38 deletions(-) diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 8a0c690..572cc79 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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 →" }, diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx index 9712736..95575bb 100644 --- a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -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[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(() => { - if (!initialProfile) return 'profile'; + if (!initialProfile) return 'goal'; if (!initialPreferences || initialPreferences.exclusions.length === 0) return 'preferences'; return 'done'; }); + const [goal, setGoal] = useState(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]}
+ {step === 'goal' ? ( + setStep('profile')} /> + ) : null} + {step === 'profile' ? ( 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' ? router.push('/plan')} /> : null} + {step === 'notifications' ? setStep('done')} /> : null} + + {step === 'done' ? : null}
); } -function StepProfile({ - initial, - onSaved, - label, +function StepGoal({ + selected, + onSelect, + onContinue, }: { - initial: Parameters[0]['initial']; - onSaved: () => void; - label: string; + selected: Goal | null; + onSelect: (g: Goal) => void; + onContinue: () => void; }) { const t = useTranslations('Onboarding'); return (
-

{t('stepEyebrow', { n: 1, total: 3 })}

+

{t('stepEyebrow', { n: 1, total: 5 })}

- {t('profileHeading')} + {t('goalHeading')} +

+

+ {t('goalHint')} +

+
+ +
+ {GOALS.map((g, i) => ( + + ))} +
+ +
+ +
+
+ ); +} + +function StepProfile({ + initial, + goal, + onSaved, +}: { + initial: Parameters[0]['initial']; + goal: Goal | null; + onSaved: () => void; +}) { + const t = useTranslations('Onboarding'); + return ( +
+
+

{t('stepEyebrow', { n: 2, total: 5 })}

+

+ {goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}

{t('profileHint')}

- +
); } @@ -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 (
-

{t('stepEyebrow', { n: 2, total: 3 })}

+

{t('stepEyebrow', { n: 3, total: 5 })}

{t('preferencesHeading')}

@@ -154,33 +223,170 @@ function StepPreferences({ {t('preferencesHint')}

- +
); } -function StepDone({ onFinish }: { onFinish: () => void }) { +function StepNotifications({ onContinue }: { onContinue: () => void }) { const t = useTranslations('Onboarding'); + const [supported, setSupported] = useState(true); + const [permission, setPermission] = useState('unknown'); + const [hasSub, setHasSub] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setSupported(pushSupported()); + if (typeof Notification !== 'undefined') setPermission(Notification.permission); + void getCurrentSubscription().then((s) => setHasSub(!!s)); + }, []); + + async function activate(): Promise { + setError(null); + setPending(true); + const result = await subscribe(); + setPending(false); + if (!result.ok) { + setError(t('notificationsError')); + return; + } + setHasSub(true); + setPermission('granted'); + } + return (
-

{t('stepEyebrow', { n: 3, total: 3 })}

-

- {t('doneHeading')} +

{t('stepEyebrow', { n: 4, total: 5 })}

+

+ {t('notificationsHeading')}

-

- {t('doneHint')} +

+ {t('notificationsHint')}

+ + {!supported ? ( +

+ {t('notificationsUnsupported')} +

+ ) : hasSub ? ( +

{t('notificationsActive')}

+ ) : permission === 'denied' ? ( +

{t('notificationsBlocked')}

+ ) : ( + + )} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+ ); +} + +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 ( +
+
+

{t('stepEyebrow', { n: 5, total: 5 })}

+

+ {goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')} +

+

+ {planStatus === 'pending' + ? t('doneGenerating') + : planStatus === 'ready' + ? t('donePlanReady') + : t('doneHint')} +

+
+ +
+ + + +
+ + {iosInstallHint ? ( +
+

{t('iosInstallEyebrow')}

+

{t('iosInstallTitle')}

+
    +
  1. {t('iosInstallStep1')}
  2. +
  3. {t('iosInstallStep2')}
  4. +
  5. {t('iosInstallStep3')}
  6. +
+
+ ) : null} +
- void }) { ); } +function NextCard({ + eyebrow, + title, + hint, + href, +}: { + eyebrow: string; + title: string; + hint: string; + href: string; +}) { + return ( + + {eyebrow} + {title} + {hint} + + → + + + ); +} + function toFormValues(view: PreferencesView): PreferencesValues { return { exclusions: view.exclusions as ExclusionGroup[],