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:
1 parent
4633f42bc5
commit
3b895191f5
4 files changed
+341
-29
No files matched your search
@@ -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,18 +143,30 @@ 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>
|
||||
{remainingCost > 0 ? (
|
||||
<p className="text-ink font-mono text-sm tabular-nums">
|
||||
{t('remainingCost')}{' '}
|
||||
<span className="font-display text-ink-soft text-xs italic">€</span>{' '}
|
||||
{remainingCost.toFixed(2)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-baseline gap-4">
|
||||
{remainingCost > 0 ? (
|
||||
<p className="text-ink font-mono text-sm tabular-nums">
|
||||
{t('remainingCost')}{' '}
|
||||
<span className="font-display text-ink-soft text-xs italic">€</span>{' '}
|
||||
{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'}
|
||||
|
||||
Reference in new issue
Block a user