// FOOD-рулетка — Telegram Mini App redesign // All screens + roulette in one file to keep scope clean. const { useState, useEffect, useRef, useMemo, useCallback } = React; // ─── Data ─────────────────────────────────────────────────────────── const SLOT_LABEL = { breakfast: "Завтрак", lunch: "Обед", snack: "Полдник", dinner: "Ужин", late: "Поздник", extra_snack: "Перекус", }; const SLOTS = ["breakfast", "lunch", "snack", "dinner", "late"]; const SLOT_EMOJI = { breakfast: "🍳", lunch: "🥩", snack: "🧀", dinner: "🐟", late: "🥛", extra_snack: "🍩", }; const INITIAL_LIBRARY = { breakfast: [ { id: 1, name: "Омлет с авокадо", carbs_g: 4 }, { id: 2, name: "Греческий йогурт с орехами", carbs_g: 8 }, { id: 3, name: "Творог с малиной", carbs_g: 6 }, { id: 4, name: "Яйца пашот + бекон", carbs_g: 2 }, ], lunch: [ { id: 10, name: "Стейк рибай + спаржа", carbs_g: 5 }, { id: 11, name: "Куриная грудка + цуккини", carbs_g: 7 }, { id: 12, name: "Лосось на гриле + брокколи", carbs_g: 6 }, { id: 13, name: "Тёплый салат с тунцом", carbs_g: 9 }, ], snack: [ { id: 20, name: "Сыр чеддер 40г", carbs_g: 1 }, { id: 21, name: "Миндаль 30г", carbs_g: 6 }, { id: 22, name: "Оливки + пармезан", carbs_g: 3 }, ], dinner: [ { id: 30, name: "Креветки в чесночном масле", carbs_g: 4 }, { id: 31, name: "Индейка карри + цветная капуста", carbs_g: 9 }, { id: 32, name: "Свинина + квашеная капуста", carbs_g: 6 }, ], late: [ { id: 40, name: "Кефир 200мл", carbs_g: 5 }, { id: 41, name: "Ряженка 150мл", carbs_g: 6 }, ], }; const INITIAL_MENU = { breakfast: { id: 2, name: "Греческий йогурт с орехами", carbs_g: 8 }, lunch: { id: 10, name: "Стейк рибай + спаржа", carbs_g: 5 }, snack: { id: 20, name: "Сыр чеддер 40г", carbs_g: 1 }, dinner: { id: 30, name: "Креветки в чесночном масле", carbs_g: 4 }, late: { id: 40, name: "Кефир 200мл", carbs_g: 5 }, }; // ─── Rarity helpers ──────────────────────────────────────────────── function rarityOf(carbs) { if (carbs <= 5) return "legend"; if (carbs <= 10) return "epic"; if (carbs <= 18) return "rare"; return "common"; } const RARITY_NAME = { legend: "Легендарка", epic: "Эпик", rare: "Редкое", common: "Обычное", }; function rarityClass(carbs) { return `r-${rarityOf(carbs)}`; } // ─── Format helpers ──────────────────────────────────────────────── const TODAY_PRETTY = () => { const d = new Date(2026, 4, 15); // pinned for screenshot consistency return d.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" }); }; // ─── Tiny svg icons ──────────────────────────────────────────────── const Icon = { back: ( ), check: ( ), case: ( ), report: ( ), menu: ( ), stats: ( ), reroll: ( ), flame: ( ), diamond: ( ), trash: ( ), plus: ( ), warn: ( ), }; // ─── Reusable bits ───────────────────────────────────────────────── function AppBar({ title, onBack, right }) { return (
{onBack ? ( ) :
}
{title}
{right}
); } function CreditChip({ value }) { return ( {value} ); } function StreakChip({ value }) { return ( {Icon.flame} {value} ); } // Section wrapper function Section({ children, style }) { return (
{children}
); } // ─── HOME ────────────────────────────────────────────────────────── function Home({ go, state }) { const { credits, streak, menu, reportsCount } = state; const reportedToday = reportsCount > 0; return (
{/* Brand row */}
{TODAY_PRETTY()}
FOODрулетка
{/* Hero CTA — Case of the day */}
{/* 2×2 tile grid */}
{/* Today's plan preview */}
План на сегодня
{SLOTS.map((s, i) => { const d = menu[s]; const cls = d ? rarityClass(d.carbs_g) : ''; return (
{SLOT_EMOJI[s]}
{SLOT_LABEL[s]}
{d ? d.name : 'не назначено'}
{d && {d.carbs_g}г}
); })}
{/* Reset link */}
); } // ─── ROULETTE ────────────────────────────────────────────────────── function Roulette({ pool, target, onDone }) { const [offset, setOffset] = useState(0); const [spinning, setSpinning] = useState(false); const stripRef = useRef(null); const wrapRef = useRef(null); const strip = useRef([]); if (strip.current.length === 0) { const items = []; for (let i = 0; i < 38; i++) { items.push(pool[Math.floor(Math.random() * pool.length)] || target); } items.push(target); for (let i = 0; i < 6; i++) { items.push(pool[Math.floor(Math.random() * pool.length)] || target); } strip.current = items; } const targetIndex = 38; const cardWidth = 130; // 120 + 10 gap useEffect(() => { const wrap = wrapRef.current; if (!wrap) return; const center = wrap.clientWidth / 2; const jitter = (Math.random() - 0.5) * (cardWidth - 40); const finalOffset = targetIndex * cardWidth + cardWidth / 2 - center + jitter; requestAnimationFrame(() => { setSpinning(true); setOffset(-finalOffset); }); const t = setTimeout(onDone, 5300); return () => clearTimeout(t); }, []); return (
{strip.current.map((d, i) => (
{SLOT_EMOJI[d.meal_slot] || ''} {SLOT_LABEL[d.meal_slot] || ''}
{d.name}
{d.carbs_g} г углей
))}
); } // ─── CASE OPEN ───────────────────────────────────────────────────── function CaseOpen({ onBack, state, setState }) { const { credits, menu } = state; const [phase, setPhase] = useState("intro"); // intro | spinning | revealed const [spinSlot, setSpinSlot] = useState(null); // null = full | slot const pool = useMemo(() => { const flat = []; for (const s of SLOTS) for (const d of INITIAL_LIBRARY[s] || []) flat.push({ ...d, meal_slot: s }); return flat; }, []); function startFull() { setSpinSlot("__full__"); setPhase("spinning"); } function startReroll(slot) { if (credits < 1) return; setSpinSlot(slot); setPhase("spinning"); // pick a new dish for that slot const choices = INITIAL_LIBRARY[slot].filter(d => d.id !== menu[slot]?.id); const next = choices[Math.floor(Math.random() * choices.length)] || INITIAL_LIBRARY[slot][0]; setState(s => ({ ...s, credits: s.credits - 1, menu: { ...s.menu, [slot]: next } })); } const targetForRoulette = spinSlot === "__full__" ? { ...Object.values(menu)[0], meal_slot: SLOTS[0] } : spinSlot ? { ...menu[spinSlot], meal_slot: spinSlot } : null; return (
} /> {phase === "intro" && ( <>
{Icon.case}
15 мая
Кейс готов к открытию
Внутри — случайные блюда из твоей библиотеки.
Можно перекрутить любое за 1 💎.
Шансы выпадения
{['legend','epic','rare','common'].map(r => (
))}
)} {phase === "spinning" && targetForRoulette && ( <>
{spinSlot === "__full__" ? 'Открытие кейса…' : `Перекрут · ${SLOT_LABEL[spinSlot]}`}
setPhase("revealed")} />
крутим…
)} {phase === "revealed" && ( <>
Меню на сегодня
{SLOTS.map((s, idx) => { const d = menu[s]; if (!d) return null; const rcls = rarityClass(d.carbs_g); return (
{SLOT_EMOJI[s]}
{SLOT_LABEL[s]} · {RARITY_NAME[rarityOf(d.carbs_g)]}
{d.name}
{d.carbs_g} г углеводов
); })}
)}
); } // ─── REPORT ──────────────────────────────────────────────────────── function Report({ onBack, state, setState }) { const { menu, credits, reports } = state; const [extraOpen, setExtraOpen] = useState(false); const [extraName, setExtraName] = useState(""); const [extraCarbs, setExtraCarbs] = useState(""); const planned = SLOTS.filter(s => menu[s]); const eatenCount = planned.filter(s => reports[s]?.eaten).length; const cleanCount = planned.filter(s => reports[s]?.eaten === true).length; const totalCarbs = planned.reduce((sum, s) => reports[s]?.eaten ? sum + menu[s].carbs_g : sum, 0) + Object.values(reports).reduce((sum, r) => sum + (r.extra_carbs || 0), 0); function toggle(slot) { setState(s => ({ ...s, reports: { ...s.reports, [slot]: { eaten: !s.reports[slot]?.eaten } }, })); } function saveExtra() { const c = parseFloat(extraCarbs); if (!extraName.trim() || isNaN(c)) return; setState(s => ({ ...s, reports: { ...s.reports, ['extra_' + Date.now()]: { extra_name: extraName, extra_carbs: c, eaten: true } }, })); setExtraOpen(false); setExtraName(""); setExtraCarbs(""); } const extras = Object.entries(reports).filter(([k]) => k.startsWith('extra_')).map(([_, r]) => r); return (
} /> {/* Progress hero */}
прогресс дня
{eatenCount} / {planned.length} приёмов
всего углей
30 ? 'var(--warn)' : 'var(--acc)' }}> {totalCarbs}г
Отмечай съеденное — копи кредиты {eatenCount === planned.length && planned.length > 0 && ( ✓ День закрыт )}
{/* Slot list with checks */}
план
{planned.map(s => { const d = menu[s]; const eaten = !!reports[s]?.eaten; const rcls = rarityClass(d.carbs_g); return (
{SLOT_EMOJI[s]}
{SLOT_LABEL[s]}
{d.name}
{d.carbs_g} г углей
toggle(s)}>
); })}
{/* Extras */}
{!extraOpen ? ( ) : (
Внеплановый перекус
setExtraName(e.target.value)} />
setExtraCarbs(e.target.value)} />
)} {extras.length > 0 && ( <>
Сверх плана
{extras.map((r, i) => (
🍩
{r.extra_name}
+{r.extra_carbs}г
))}
)}
); } // ─── MENU EDITOR ─────────────────────────────────────────────────── function MenuEditor({ onBack, library, setLibrary }) { const [slot, setSlot] = useState(SLOTS[0]); const [name, setName] = useState(""); const [carbs, setCarbs] = useState(""); function add() { if (!name.trim()) return; const c = parseFloat(carbs); if (isNaN(c) || c < 0) return; setLibrary(prev => ({ ...prev, [slot]: [...(prev[slot] || []), { id: Date.now(), name: name.trim(), carbs_g: c }], })); setName(""); setCarbs(""); } function del(id) { setLibrary(prev => ({ ...prev, [slot]: prev[slot].filter(d => d.id !== id), })); } const list = library[slot] || []; const totalDishes = Object.values(library).reduce((sum, arr) => sum + arr.length, 0); return (
{totalDishes} блюд} />
{SLOTS.map(s => ( ))}
Добавить в «{SLOT_LABEL[slot]}»
setName(e.target.value)} />
setCarbs(e.target.value)} /> {carbs !== "" && !isNaN(parseFloat(carbs)) && (
{RARITY_NAME[rarityOf(parseFloat(carbs))]}
)}
В библиотеке
{list.length}
{list.length === 0 ? (
{SLOT_EMOJI[slot]}
Пока пусто.
Добавь первое блюдо.
) : (
{list.map(d => (
{d.name}
{d.carbs_g} г
))}
)}
); } // ─── WEEK STATS ──────────────────────────────────────────────────── function WeekStats({ onBack, state }) { const { credits, streak } = state; // mocked week data const days = [ { d: 'Пн', carbs: 8, clean: true }, { d: 'Вт', carbs: 12, clean: true }, { d: 'Ср', carbs: 32, clean: false }, { d: 'Чт', carbs: 9, clean: true }, { d: 'Пт', carbs: 6, clean: true }, { d: 'Сб', carbs: 14, clean: true }, { d: 'Вс', carbs: 5, clean: true, today: true }, ]; const maxCarb = 40; const avg = (days.reduce((s, d) => s + d.carbs, 0) / days.length).toFixed(1); const cleanCount = days.filter(d => d.clean).length; return (
} /> {/* Top stat tiles */}
Средне углей/день
{avg} г
↓ −2.4г к прошлой
Чистых дней
{cleanCount} / 7
86% выполнение
Текущий стрик
🔥 {streak} дней подряд
{[1,2,3,4,5,6,7].map(i => (
))}
{/* Day bar chart */}
углеводы по дням
лимит 30г
{days.map((day, i) => { const rcls = rarityClass(day.carbs); const heightPct = (day.carbs / maxCarb) * 100; return (
{day.d}
); })}
0–5г 6–10г 11–18г 19+г
{/* Advice */}
Совет на неделю
Среда выбилась из ритма — 32г углей. Попробуй заранее назначить ужин на «трудные» дни, чтобы не сваливаться в перекус. Пятница и воскресенье — образцовые, держи там же.
); } // ─── ROOT APP ────────────────────────────────────────────────────── function FoodRouletteApp() { const [screen, setScreen] = useState("home"); const [library, setLibrary] = useState(INITIAL_LIBRARY); const [state, setState] = useState({ credits: 7, streak: 4, menu: INITIAL_MENU, reports: { breakfast: { eaten: true }, lunch: { eaten: true }, }, }); const reportsCount = SLOTS.filter(s => state.reports[s]?.eaten).length; const homeState = { ...state, reportsCount }; if (screen === "case") return setScreen("home")} state={state} setState={setState} />; if (screen === "report") return setScreen("home")} state={state} setState={setState} />; if (screen === "menu") return setScreen("home")} library={library} setLibrary={setLibrary} />; if (screen === "stats") return setScreen("home")} state={state} />; return ; } window.FoodRouletteApp = FoodRouletteApp;