// 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 ? (
{Icon.back}Назад
) :
}
{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 */}
go("case")}>
Кейс дня
Открой меню на сегодня
5 приёмов пищи · крутка бесплатно · перекрут 1 💎
{/* Mini case visual */}
{SLOTS.map(s => {
const d = menu[s];
const cls = d ? rarityClass(d.carbs_g) : 'r-common';
return (
{SLOT_EMOJI[s]}
);
})}
{/* 2×2 tile grid */}
go("report")}>
{Icon.report}
{reportedToday &&
активно
}
Отчёт
{reportsCount}/5 отмечено
go("menu")}>
{Icon.menu}
Меню
17 блюд в библиотеке
go("stats")} style={{ gridColumn: 'span 2' }}>
Сводка за неделю
советы + статистика углей
{/* 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 */}
{
if (confirm("Сбросить профиль полностью?")) alert("(демо) Профиль сброшен");
}} style={{
background: 'none', border: 'none', color: 'var(--muted)',
fontSize: 12, cursor: 'pointer', width: '100%', padding: 8,
}}>
Сбросить профиль
);
}
// ─── 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}
))}
);
}
// ─── 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" && (
<>
Кейс готов к открытию
Внутри — случайные блюда из твоей библиотеки. Можно перекрутить любое за 1 💎.
{Icon.case}
Открыть кейс
Шансы выпадения
{['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} г углеводов
startReroll(s)}
title="Перекрутить за 1💎"
style={{ width: 'auto', flexShrink: 0 }}
>
{Icon.reroll}1
);
})}
startReroll(SLOTS[0])}>
{Icon.reroll}Перекрутить всё · −1 💎
>
)}
);
}
// ─── 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} г углей
);
})}
{/* Extras */}
{!extraOpen ? (
setExtraOpen(true)}>
{Icon.warn}Сорвался — записать перекус
) : (
)}
{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 => (
setSlot(s)}
>
{SLOT_EMOJI[s]}
{SLOT_LABEL[s]}
{library[s]?.length > 0 && {library[s].length} }
))}
В библиотеке
{list.length}
{list.length === 0 ? (
{SLOT_EMOJI[slot]}
Пока пусто. Добавь первое блюдо.
) : (
{list.map(d => (
{d.name}
{d.carbs_g} г
del(d.id)}>{Icon.trash}
))}
)}
);
}
// ─── 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 (
);
})}
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;