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:
1 parent
624152b29b
commit
ded4215664
2 files changed
+323
-38
No files matched your search
@@ -167,22 +167,75 @@
|
|||||||
},
|
},
|
||||||
"Onboarding": {
|
"Onboarding": {
|
||||||
"title": "Configura il tuo percorso",
|
"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",
|
"stepsTitle": "Tappe",
|
||||||
"stepEyebrow": "Tappa {n} di {total}",
|
"stepEyebrow": "Tappa {n} di {total}",
|
||||||
"next": "Salva e continua",
|
"next": "Salva e continua",
|
||||||
"back": "← Indietro",
|
"back": "← Indietro",
|
||||||
|
"skipForNow": "Salta per ora",
|
||||||
|
"working": "Attendere…",
|
||||||
"steps": {
|
"steps": {
|
||||||
|
"goal": "L'obiettivo",
|
||||||
"profile": "Anagrafica e attività",
|
"profile": "Anagrafica e attività",
|
||||||
"preferences": "Esclusioni e cucine",
|
"preferences": "Esclusioni e cucine",
|
||||||
|
"notifications": "Promemoria",
|
||||||
"done": "Pronti a partire"
|
"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",
|
"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.",
|
"profileHint": "Età, sesso, altezza, peso e attività: bastano per calcolare BMR, TDEE e i target di macros.",
|
||||||
"preferencesHeading": "Cosa eviti, cosa ami",
|
"preferencesHeading": "Cosa eviti, cosa ami",
|
||||||
"preferencesHint": "Le esclusioni filtrano davvero le ricette del piano. Le cucine sono un suggerimento al matchmaking.",
|
"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.",
|
"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",
|
"goToPlan": "Genera il primo piano",
|
||||||
"skipToHome": "Esplora l'app →"
|
"skipToHome": "Esplora l'app →"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import Link from 'next/link';
|
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 } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_PREFERENCES_VALUES,
|
DEFAULT_PREFERENCES_VALUES,
|
||||||
@@ -17,14 +17,19 @@ import {
|
|||||||
type PreferencesValues,
|
type PreferencesValues,
|
||||||
} from '@/components/preferences/preferences-form';
|
} from '@/components/preferences/preferences-form';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { savePreferences, type PreferencesView } from '../profile/preferences-actions';
|
||||||
import type { ProfileForm } from '../profile/profile-form';
|
|
||||||
import { ProfileForm as ProfileFormComponent } 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];
|
type StepKey = (typeof STEPS)[number];
|
||||||
|
|
||||||
|
const GOALS = ['LOSE', 'MAINTAIN', 'ENERGY'] as const;
|
||||||
|
type Goal = (typeof GOALS)[number];
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
interface OnboardingFlowProps {
|
||||||
initialProfile: Parameters<typeof ProfileForm>[0]['initial'];
|
initialProfile: Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
initialPreferences: PreferencesView | null;
|
initialPreferences: PreferencesView | null;
|
||||||
@@ -32,14 +37,13 @@ interface OnboardingFlowProps {
|
|||||||
|
|
||||||
export function OnboardingFlow({ initialProfile, initialPreferences }: OnboardingFlowProps) {
|
export function OnboardingFlow({ initialProfile, initialPreferences }: OnboardingFlowProps) {
|
||||||
const t = useTranslations('Onboarding');
|
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>(() => {
|
const [step, setStep] = useState<StepKey>(() => {
|
||||||
if (!initialProfile) return 'profile';
|
if (!initialProfile) return 'goal';
|
||||||
if (!initialPreferences || initialPreferences.exclusions.length === 0) return 'preferences';
|
if (!initialPreferences || initialPreferences.exclusions.length === 0) return 'preferences';
|
||||||
return 'done';
|
return 'done';
|
||||||
});
|
});
|
||||||
|
const [goal, setGoal] = useState<Goal | null>(null);
|
||||||
|
|
||||||
const stepIndex = STEPS.indexOf(step);
|
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 < stepIndex ? 'text-oliva' : i === stepIndex ? 'text-pomodoro' : 'text-ink-dim'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{['I', 'II', 'III'][i]}
|
{['I', 'II', 'III', 'IV', 'V'][i]}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`font-display text-lg leading-tight ${
|
className={`font-display text-lg leading-tight ${
|
||||||
@@ -70,11 +74,15 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="md:col-span-8">
|
<div className="md:col-span-8">
|
||||||
|
{step === 'goal' ? (
|
||||||
|
<StepGoal selected={goal} onSelect={setGoal} onContinue={() => setStep('profile')} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{step === 'profile' ? (
|
{step === 'profile' ? (
|
||||||
<StepProfile
|
<StepProfile
|
||||||
initial={initialProfile}
|
initial={initialProfile}
|
||||||
|
goal={goal}
|
||||||
onSaved={() => setStep('preferences')}
|
onSaved={() => setStep('preferences')}
|
||||||
label={t('next')}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -83,41 +91,106 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
|||||||
initial={
|
initial={
|
||||||
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
|
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
|
||||||
}
|
}
|
||||||
onSaved={() => setStep('done')}
|
onSaved={() => setStep('notifications')}
|
||||||
label={t('next')}
|
|
||||||
onBack={() => setStep('profile')}
|
onBack={() => setStep('profile')}
|
||||||
backLabel={t('back')}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'done' ? <StepDone onFinish={() => router.push('/plan')} /> : null}
|
{step === 'notifications' ? <StepNotifications onContinue={() => setStep('done')} /> : null}
|
||||||
|
|
||||||
|
{step === 'done' ? <StepDone goal={goal} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepProfile({
|
function StepGoal({
|
||||||
initial,
|
selected,
|
||||||
onSaved,
|
onSelect,
|
||||||
label,
|
onContinue,
|
||||||
}: {
|
}: {
|
||||||
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
selected: Goal | null;
|
||||||
onSaved: () => void;
|
onSelect: (g: Goal) => void;
|
||||||
label: string;
|
onContinue: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Onboarding');
|
const t = useTranslations('Onboarding');
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<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">
|
<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>
|
</h2>
|
||||||
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
||||||
{t('profileHint')}
|
{t('profileHint')}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<ProfileFormComponent initial={initial} onSaved={onSaved} saveLabel={label} />
|
<ProfileFormComponent initial={initial} onSaved={onSaved} saveLabel={t('next')} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,15 +198,11 @@ function StepProfile({
|
|||||||
function StepPreferences({
|
function StepPreferences({
|
||||||
initial,
|
initial,
|
||||||
onSaved,
|
onSaved,
|
||||||
label,
|
|
||||||
onBack,
|
onBack,
|
||||||
backLabel,
|
|
||||||
}: {
|
}: {
|
||||||
initial: PreferencesValues;
|
initial: PreferencesValues;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
label: string;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
backLabel: string;
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Onboarding');
|
const t = useTranslations('Onboarding');
|
||||||
|
|
||||||
@@ -146,7 +215,7 @@ function StepPreferences({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<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">
|
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||||
{t('preferencesHeading')}
|
{t('preferencesHeading')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -154,33 +223,170 @@ function StepPreferences({
|
|||||||
{t('preferencesHint')}
|
{t('preferencesHint')}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<PreferencesForm initial={initial} onSubmit={handleSubmit} submitLabel={label} />
|
<PreferencesForm initial={initial} onSubmit={handleSubmit} submitLabel={t('next')} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepDone({ onFinish }: { onFinish: () => void }) {
|
function StepNotifications({ onContinue }: { onContinue: () => void }) {
|
||||||
const t = useTranslations('Onboarding');
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<header className="space-y-2">
|
||||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 3 })}</p>
|
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 5 })}</p>
|
||||||
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
|
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||||
{t('doneHeading')}
|
{t('notificationsHeading')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-display text-ink-soft max-w-xl text-xl italic leading-snug">
|
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
||||||
{t('doneHint')}
|
{t('notificationsHint')}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<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')}
|
{t('goToPlan')}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<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 {
|
function toFormValues(view: PreferencesView): PreferencesValues {
|
||||||
return {
|
return {
|
||||||
exclusions: view.exclusions as ExclusionGroup[],
|
exclusions: view.exclusions as ExclusionGroup[],
|
||||||
|
|||||||
Reference in new issue
Block a user