diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index aa93ccf..45551b4 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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", diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx index 2b727eb..4c8ddd8 100644 --- a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -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]} setStep('preferences')} + onContinue={() => setStep('weighIn')} onBack={() => setStep('profile')} /> ) : null} + {step === 'weighIn' ? ( + setStep('preferences')} + onBack={() => setStep('conditions')} + /> + ) : null} + {step === 'preferences' ? ( setStep('notifications')} - onBack={() => setStep('conditions')} + onBack={() => setStep('weighIn')} /> ) : null} @@ -125,7 +142,7 @@ function StepGoal({ return (
-

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

+

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

{t('goalHeading')}

@@ -190,7 +207,7 @@ function StepProfile({ return (
-

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

+

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

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

@@ -242,7 +259,7 @@ function StepConditions({ return (
-

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

+

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

{t('conditionsHeading')}

@@ -362,7 +379,7 @@ function StepPreferences({ return (
-

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

+

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

{t('preferencesHeading')}

@@ -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(startKg != null ? String(startKg) : ''); + const [pending, setPending] = useState(false); + const [saved, setSaved] = useState(false); + + async function handleSave(): Promise { + 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 ( +
+
+

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

+

+ {t('weighInHeading')} +

+

+ {t('weighInHint')} +

+
+ +
+

{t('weighInLabel')}

+ 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" + /> +

{t('weighInTip')}

+
+ + {saved ?

{t('weighInSaved')}

: null} + +
+ + + +
+
+ ); +} + function StepNotifications({ onContinue }: { onContinue: () => void }) { const t = useTranslations('Onboarding'); const [supported, setSupported] = useState(true); @@ -412,7 +511,7 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) { return (
-

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

+

{t('stepEyebrow', { n: 6, total: 7 })}

{t('notificationsHeading')}

@@ -507,7 +606,7 @@ function StepDone({ goal }: { goal: Goal | null }) { return (
-

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

+

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

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

diff --git a/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx b/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx index a094518..1061ad3 100644 --- a/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx +++ b/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx @@ -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>({}); + const [storeMode, setStoreMode] = useState(false); + const [extras, setExtras] = useState([]); + 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); + 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,18 +143,30 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) { } return ( -
+

{t('checked', { n: checkedCount, total: totalCount })}

- {remainingCost > 0 ? ( -

- {t('remainingCost')}{' '} - €{' '} - {remainingCost.toFixed(2)} -

- ) : null} +
+ {remainingCost > 0 ? ( +

+ {t('remainingCost')}{' '} + €{' '} + {remainingCost.toFixed(2)} +

+ ) : null} + +
{list.groups.map((group, groupIdx) => ( @@ -109,12 +176,18 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) { className="space-y-5" >
- + {ROMAN[groupIdx] ?? groupIdx + 1}

{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' + }`} > {line.name} - + {t('fromRecipes', { names: line.recipes.join(' · ') })} {formatQuantity(line.quantity)} {line.unit} @@ -177,6 +258,94 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) { ))} + +
+
+

+ {t('extrasTitle')} +

+

+ {t('extrasHint')} +

+
+ +
+ 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' + }`} + /> + +
+ + {extras.length > 0 ? ( +
    + {[...extras] + .sort((a, b) => { + if (a.checked !== b.checked) return a.checked ? 1 : -1; + return a.name.localeCompare(b.name, 'it'); + }) + .map((it) => ( +
  • + +
  • + ))} +
+ ) : null} +

); } diff --git a/apps/web/src/components/preferences/preferences-form.tsx b/apps/web/src/components/preferences/preferences-form.tsx index 5079591..bb62d32 100644 --- a/apps/web/src/components/preferences/preferences-form.tsx +++ b/apps/web/src/components/preferences/preferences-form.tsx @@ -187,6 +187,28 @@ export function PreferencesForm({

{t('fastingHint')}

+
+ + {t('fastingTutorialToggle')} + +
+

+ {t('fastingTutorialPoint1Title')}{' '} + {t('fastingTutorialPoint1')} +

+

+ {t('fastingTutorialPoint2Title')}{' '} + {t('fastingTutorialPoint2')} +

+

+ {t('fastingTutorialPoint3Title')}{' '} + {t('fastingTutorialPoint3')} +

+

+ {t('fastingTutorialDisclaimer')} +

+
+