feat: §6 quick wins (Gruppo B) — store mode, extras, tutorial digiuno, pesata di partenza

- Shopping: modalità "al supermercato" (font/touch più grandi, persiste in localStorage); articoli extra ad personam (aggiunta/rimozione, persisteno per piano).
- Preferenze: tutorial lampo del digiuno intermittente (disclosure con 3 punti + disclaimer medico) sopra il selettore protocollo.
- Onboarding: nuovo step IV "Pesata di partenza" tra condizioni e preferenze, salva via saveWeightEntry server action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-30 00:44:57 +02:00
1 parent 4633f42bc5
commit 3b895191f5
4 files changed
+334 -22

No files matched your search

+23 -1
View File
@@ -213,7 +213,7 @@
},
"Onboarding": {
"title": "Configura il tuo percorso",
"subtitle": "Cinque passaggi: l'obiettivo, i numeri, le preferenze, le notifiche e via al primo piano.",
"subtitle": "Sette passaggi: l'obiettivo, i numeri, la pesata di partenza, le preferenze, le notifiche e via al primo piano.",
"stepsTitle": "Tappe",
"stepEyebrow": "Tappa {n} di {total}",
"next": "Salva e continua",
@@ -224,6 +224,7 @@
"goal": "L'obiettivo",
"profile": "Anagrafica e attività",
"conditions": "Condizioni mediche",
"weighIn": "Pesata di partenza",
"preferences": "Esclusioni e cucine",
"notifications": "Promemoria",
"done": "Pronti a partire"
@@ -272,6 +273,11 @@
"ENERGY": "I numeri che ti raccontano"
},
"profileHint": "Età, sesso, altezza, peso e attività: bastano per calcolare BMR, TDEE e i target di macros.",
"weighInHeading": "Una pesata, prima di iniziare",
"weighInHint": "Il punto di partenza è la base di tutti i calcoli che seguiranno. Una sola pesata, qui, ora.",
"weighInLabel": "Peso di oggi (kg)",
"weighInTip": "Sulla bilancia al mattino, dopo essere andato in bagno, prima di colazione. Senza vestiti se possibile.",
"weighInSaved": "Pesata registrata: la troverai in /tracking.",
"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?",
@@ -335,6 +341,14 @@
"fastingTitle": "Protocollo digiuno predefinito",
"fastingHint": "Sarà selezionato in /fasting alla prossima sessione.",
"fastingNone": "Nessuno per ora",
"fastingTutorialToggle": "Cos'è il digiuno intermittente? Apri la guida lampo",
"fastingTutorialPoint1Title": "Cosa significa.",
"fastingTutorialPoint1": "Si alternano una finestra alimentare (in cui si mangia) e una di digiuno (acqua, tè, caffè senza zucchero). Il numero davanti ai due punti indica le ore di digiuno: 16:8 = 16h di digiuno, 8h di pasti.",
"fastingTutorialPoint2Title": "Come iniziare.",
"fastingTutorialPoint2": "Se sei alle prime armi, parti con 14:10 per qualche settimana e sali gradualmente. Lo schema 16:8 è quello più studiato: la finestra di pasto va spesso da mezzogiorno alle 20.",
"fastingTutorialPoint3Title": "Cosa rispettare.",
"fastingTutorialPoint3": "Idratati molto, non saltare gli elettroliti dopo le 18 ore, ascolta il corpo. Se prendi farmaci o hai patologie croniche, parlane prima con il tuo medico.",
"fastingTutorialDisclaimer": "KetoPath non sostituisce il parere di un medico. In gravidanza, allattamento o con disturbi alimentari il digiuno intermittente non è raccomandato.",
"save": "Salva preferenze",
"saving": "Salvataggio…",
"saved": "Preferenze aggiornate.",
@@ -437,6 +451,14 @@
"estimatedCost": "Spesa stimata",
"checked": "{n} di {total}",
"remainingCost": "Restano",
"storeModeOn": "✓ Modalità supermercato",
"storeModeOff": "Modalità supermercato",
"extrasTitle": "Articoli extra",
"extrasHint": "Aggiungi qui le cose che ti servono ma non sono nel piano (sale, olio, carta forno…). Restano solo sul tuo dispositivo.",
"extrasPlaceholder": "es. olio extravergine",
"extrasAdd": "Aggiungi",
"extrasToggle": "Spunta articolo",
"extrasRemove": "Rimuovi articolo",
"fromRecipes": "Per: {names}",
"categoryLabel": {
"verdura": "Verdura",
@@ -27,8 +27,17 @@ import { fetchProfile, saveConditions } from '../profile/actions';
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
import type { ProfileForm } from '../profile/profile-form';
import { saveWeightEntry } from '../tracking/actions';
const STEPS = ['goal', 'profile', 'conditions', 'preferences', 'notifications', 'done'] as const;
const STEPS = [
'goal',
'profile',
'conditions',
'weighIn',
'preferences',
'notifications',
'done',
] as const;
type StepKey = (typeof STEPS)[number];
const GOALS = ['LOSE', 'MAINTAIN', 'ENERGY'] as const;
@@ -63,7 +72,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
i < stepIndex ? 'text-oliva' : i === stepIndex ? 'text-pomodoro' : 'text-ink-dim'
}`}
>
{['I', 'II', 'III', 'IV', 'V', 'VI'][i]}
{['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'][i]}
</span>
<span
className={`font-display text-lg leading-tight ${
@@ -89,18 +98,26 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
{step === 'conditions' ? (
<StepConditions
initial={(initialProfile?.medicalConditions as string[] | undefined) ?? []}
onContinue={() => setStep('preferences')}
onContinue={() => setStep('weighIn')}
onBack={() => setStep('profile')}
/>
) : null}
{step === 'weighIn' ? (
<StepWeighIn
startKg={initialProfile?.weightStartKg ?? null}
onContinue={() => setStep('preferences')}
onBack={() => setStep('conditions')}
/>
) : null}
{step === 'preferences' ? (
<StepPreferences
initial={
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
}
onSaved={() => setStep('notifications')}
onBack={() => setStep('conditions')}
onBack={() => setStep('weighIn')}
/>
) : null}
@@ -125,7 +142,7 @@ function StepGoal({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 6 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 7 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('goalHeading')}
</h2>
@@ -190,7 +207,7 @@ function StepProfile({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 6 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 7 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}
</h2>
@@ -242,7 +259,7 @@ function StepConditions({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 6 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 7 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('conditionsHeading')}
</h2>
@@ -362,7 +379,7 @@ function StepPreferences({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 6 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 7 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('preferencesHeading')}
</h2>
@@ -382,6 +399,88 @@ function StepPreferences({
);
}
function StepWeighIn({
startKg,
onContinue,
onBack,
}: {
startKg: number | null;
onContinue: () => void;
onBack: () => void;
}) {
const t = useTranslations('Onboarding');
const [todayKg, setTodayKg] = useState<string>(startKg != null ? String(startKg) : '');
const [pending, setPending] = useState(false);
const [saved, setSaved] = useState(false);
async function handleSave(): Promise<void> {
const value = Number(todayKg);
if (!Number.isFinite(value) || value < 35 || value > 300) {
onContinue();
return;
}
setPending(true);
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await saveWeightEntry({ date: today, weightKg: value });
if (result.ok) setSaved(true);
setPending(false);
onContinue();
}
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 7 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('weighInHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('weighInHint')}
</p>
</header>
<div className="max-w-sm space-y-3">
<p className="editorial-eyebrow">{t('weighInLabel')}</p>
<input
type="number"
step="0.1"
min={35}
max={300}
inputMode="decimal"
value={todayKg}
onChange={(e) => setTodayKg(e.target.value)}
placeholder={startKg != null ? String(startKg) : ''}
className="border-ink/30 focus:border-ink font-display text-ink w-full border-b bg-transparent py-2 text-2xl outline-none"
/>
<p className="font-display text-ink-soft text-xs italic">{t('weighInTip')}</p>
</div>
{saved ? <p className="font-display text-oliva text-sm italic">{t('weighInSaved')}</p> : null}
<div className="flex flex-wrap items-center gap-4">
<Button type="button" size="lg" onClick={handleSave} disabled={pending}>
{pending ? t('working') : 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>
<button
type="button"
onClick={onBack}
className="text-ink-soft hover:text-ink font-mono text-[11px] uppercase tracking-widest"
>
{t('back')}
</button>
</div>
</div>
);
}
function StepNotifications({ onContinue }: { onContinue: () => void }) {
const t = useTranslations('Onboarding');
const [supported, setSupported] = useState(true);
@@ -412,7 +511,7 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) {
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 6 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 6, total: 7 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('notificationsHeading')}
</h2>
@@ -507,7 +606,7 @@ function StepDone({ goal }: { goal: Goal | null }) {
return (
<div className="space-y-10">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 6, total: 6 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 7, total: 7 })}</p>
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}
</h2>
@@ -32,6 +32,18 @@ function storageKey(planId: string): string {
return `ketopath:shopping:${planId}`;
}
const STORE_MODE_KEY = 'ketopath:shopping:storeMode';
interface ExtraItem {
id: string;
name: string;
checked: boolean;
}
function extrasKey(planId: string): string {
return `ketopath:shopping:extras:${planId}`;
}
function lineKey(line: ShoppingLine): string {
return `${line.ingredientId}::${line.unit}`;
}
@@ -39,17 +51,60 @@ function lineKey(line: ShoppingLine): string {
export function ShoppingChecklist({ list }: { list: ShoppingList }) {
const t = useTranslations('Shopping');
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [storeMode, setStoreMode] = useState(false);
const [extras, setExtras] = useState<ExtraItem[]>([]);
const [extraInput, setExtraInput] = useState('');
// Load locally persisted check state per piano.
useEffect(() => {
try {
const raw = window.localStorage.getItem(storageKey(list.plan.id));
if (raw) setChecked(JSON.parse(raw) as Record<string, boolean>);
const sm = window.localStorage.getItem(STORE_MODE_KEY);
if (sm === '1') setStoreMode(true);
const rawExtras = window.localStorage.getItem(extrasKey(list.plan.id));
if (rawExtras) setExtras(JSON.parse(rawExtras) as ExtraItem[]);
} catch {
/* no-op */
}
}, [list.plan.id]);
useEffect(() => {
try {
window.localStorage.setItem(extrasKey(list.plan.id), JSON.stringify(extras));
} catch {
/* no-op */
}
}, [extras, list.plan.id]);
function addExtra(): void {
const name = extraInput.trim();
if (!name) return;
setExtras((prev) => [
...prev,
{ id: `extra_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, name, checked: false },
]);
setExtraInput('');
}
function toggleExtra(id: string): void {
setExtras((prev) => prev.map((e) => (e.id === id ? { ...e, checked: !e.checked } : e)));
}
function removeExtra(id: string): void {
setExtras((prev) => prev.filter((e) => e.id !== id));
}
function toggleStoreMode(): void {
setStoreMode((v) => {
const next = !v;
try {
window.localStorage.setItem(STORE_MODE_KEY, next ? '1' : '0');
} catch {
/* no-op */
}
return next;
});
}
useEffect(() => {
try {
window.localStorage.setItem(storageKey(list.plan.id), JSON.stringify(checked));
@@ -88,11 +143,12 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
}
return (
<div className="space-y-12">
<div className={storeMode ? 'space-y-16 text-lg' : 'space-y-12'}>
<div className="flex flex-wrap items-baseline justify-between gap-4">
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('checked', { n: checkedCount, total: totalCount })}
</p>
<div className="flex items-baseline gap-4">
{remainingCost > 0 ? (
<p className="text-ink font-mono text-sm tabular-nums">
{t('remainingCost')}{' '}
@@ -100,6 +156,17 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
{remainingCost.toFixed(2)}
</p>
) : null}
<button
type="button"
onClick={toggleStoreMode}
aria-pressed={storeMode}
className={`font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors ${
storeMode ? 'text-pomodoro' : 'text-ink-soft hover:text-ink decoration-pomodoro'
}`}
>
{storeMode ? t('storeModeOn') : t('storeModeOff')}
</button>
</div>
</div>
{list.groups.map((group, groupIdx) => (
@@ -109,12 +176,18 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
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">
<span
className={`font-display text-pomodoro font-medium italic leading-none ${
storeMode ? 'text-3xl' : 'text-xl'
}`}
>
{ROMAN[groupIdx] ?? groupIdx + 1}
</span>
<h2
id={`group-${group.category}`}
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
className={`font-display text-ink font-medium leading-none tracking-tight ${
storeMode ? 'text-4xl sm:text-5xl' : 'text-2xl sm:text-3xl'
}`}
>
{t.has(`categoryLabel.${group.category}`)
? t(`categoryLabel.${group.category}`)
@@ -143,30 +216,38 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
type="button"
onClick={() => toggle(line)}
aria-pressed={isChecked}
className="hover:bg-ink/5 group grid w-full grid-cols-[1.25rem_1fr_auto] items-baseline gap-4 py-4 pr-2 text-left transition-colors"
className={`hover:bg-ink/5 group grid w-full items-baseline gap-4 text-left transition-colors ${
storeMode
? 'grid-cols-[1.75rem_1fr_auto] py-6 pr-3'
: 'grid-cols-[1.25rem_1fr_auto] py-4 pr-2'
}`}
>
<span
aria-hidden
className={`border-ink h-3 w-3 translate-y-1 border-[1.5px] transition-colors ${
className={`border-ink translate-y-1 border-[1.5px] transition-colors ${
isChecked ? 'bg-ink' : 'bg-transparent'
}`}
} ${storeMode ? 'h-5 w-5' : 'h-3 w-3'}`}
/>
<span className="flex flex-col gap-1">
<span
className={`font-display text-ink text-lg leading-tight transition-colors ${
className={`font-display text-ink leading-tight transition-colors ${
isChecked ? 'text-ink-dim line-through' : ''
}`}
} ${storeMode ? 'text-2xl' : 'text-lg'}`}
>
{line.name}
</span>
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
<span
className={`text-ink-soft font-mono uppercase tracking-widest ${
storeMode ? 'text-xs' : 'text-[10px]'
}`}
>
{t('fromRecipes', { names: line.recipes.join(' · ') })}
</span>
</span>
<span
className={`text-ink font-mono text-sm tabular-nums transition-colors ${
className={`text-ink font-mono tabular-nums transition-colors ${
isChecked ? 'text-ink-dim' : ''
}`}
} ${storeMode ? 'text-xl' : 'text-sm'}`}
>
{formatQuantity(line.quantity)} {line.unit}
</span>
@@ -177,6 +258,94 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
</ul>
</section>
))}
<section className="space-y-5">
<header className="border-ink/15 border-b pb-3">
<h2
className={`font-display text-ink font-medium leading-none tracking-tight ${
storeMode ? 'text-4xl sm:text-5xl' : 'text-2xl sm:text-3xl'
}`}
>
{t('extrasTitle')}
</h2>
<p className="font-display text-ink-soft mt-2 text-sm italic leading-snug">
{t('extrasHint')}
</p>
</header>
<div className="flex gap-3">
<input
type="text"
value={extraInput}
onChange={(e) => setExtraInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addExtra();
}
}}
maxLength={80}
placeholder={t('extrasPlaceholder')}
className={`border-ink/30 focus:border-ink font-display text-ink w-full border-b bg-transparent py-2 outline-none ${
storeMode ? 'text-xl' : 'text-base'
}`}
/>
<button
type="button"
onClick={addExtra}
disabled={!extraInput.trim()}
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 disabled:opacity-40"
>
{t('extrasAdd')}
</button>
</div>
{extras.length > 0 ? (
<ul className="divide-ink/10 divide-y">
{[...extras]
.sort((a, b) => {
if (a.checked !== b.checked) return a.checked ? 1 : -1;
return a.name.localeCompare(b.name, 'it');
})
.map((it) => (
<li
key={it.id}
className={`grid items-baseline gap-4 ${
storeMode
? 'grid-cols-[1.75rem_1fr_auto] py-6'
: 'grid-cols-[1.25rem_1fr_auto] py-4'
}`}
>
<button
type="button"
onClick={() => toggleExtra(it.id)}
aria-pressed={it.checked}
aria-label={t('extrasToggle')}
className={`border-ink translate-y-1 border-[1.5px] transition-colors ${
it.checked ? 'bg-ink' : 'hover:bg-ink/30 bg-transparent'
} ${storeMode ? 'h-5 w-5' : 'h-3 w-3'}`}
/>
<span
className={`font-display text-ink leading-tight transition-colors ${
it.checked ? 'text-ink-dim line-through' : ''
} ${storeMode ? 'text-2xl' : 'text-lg'}`}
>
{it.name}
</span>
<button
type="button"
onClick={() => removeExtra(it.id)}
aria-label={t('extrasRemove')}
title={t('extrasRemove')}
className="text-ink-dim hover:text-pomodoro font-mono text-xs leading-none transition-colors"
>
×
</button>
</li>
))}
</ul>
) : null}
</section>
</div>
);
}
@@ -187,6 +187,28 @@ export function PreferencesForm({
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('fastingHint')}
</p>
<details className="border-ink/15 bg-carta-light/40 group border p-4">
<summary className="text-ink-soft hover:text-ink cursor-pointer list-none font-mono text-[10px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]">
{t('fastingTutorialToggle')}
</summary>
<div className="mt-4 space-y-3">
<p className="font-display text-ink text-sm leading-relaxed">
<strong className="not-italic">{t('fastingTutorialPoint1Title')}</strong>{' '}
{t('fastingTutorialPoint1')}
</p>
<p className="font-display text-ink text-sm leading-relaxed">
<strong className="not-italic">{t('fastingTutorialPoint2Title')}</strong>{' '}
{t('fastingTutorialPoint2')}
</p>
<p className="font-display text-ink text-sm leading-relaxed">
<strong className="not-italic">{t('fastingTutorialPoint3Title')}</strong>{' '}
{t('fastingTutorialPoint3')}
</p>
<p className="font-display text-ink-dim text-xs italic leading-snug">
{t('fastingTutorialDisclaimer')}
</p>
</div>
</details>
<div className="max-w-xs">
<Select
value={values.fastingProtocol ?? 'NONE'}