/* ═══════════════════════════════════════════════════════════
Dating Coach — Telegram Mini App
Single-file build для Babel-in-browser. Все экраны и логика тут,
чтобы не зависеть от порядка подгрузки внешних JSX-скриптов.
═══════════════════════════════════════════════════════════ */
const { useState, useEffect, useCallback, useMemo, useRef } = React;
const TG = window.Telegram && window.Telegram.WebApp;
/* ───────── Icon set (1.7px stroke, 24 viewBox) ───────── */
function Icon({ name, size = 18, style }) {
const common = {
width: size, height: size, viewBox: "0 0 24 24",
fill: "none", stroke: "currentColor",
strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round",
style,
};
const P = {
home: <>>,
book: <>>,
trophy: <>>,
chart: <>>,
user: <>>,
flame: <>>,
bolt: <>>,
play: <>>,
arrow: <>>,
back: <>>,
check: <>>,
heart: <>>,
sparkle: <>>,
mic: <>>,
eye: <>>,
lock: <>>,
msg: <>>,
users: <>>,
cal: <>>,
bell: <>>,
settings: <>>,
target: <>>,
clock: <>>,
crown: <>>,
chev: <>>,
moon: <>>,
smile: <>>,
lang: <>>,
logout: <>>,
trend: <>>,
medal: <>>,
gift: <>>,
fire: <>>,
info: <>>,
palette: <>>,
shield: <>>,
};
return ;
}
/* ───────── API layer ───────── */
let _initData = "";
function authHeaders() {
const h = { "Content-Type": "application/json" };
if (_initData) h["X-Telegram-Init-Data"] = _initData;
return h;
}
async function apiGet(url) {
const r = await fetch(url, { credentials: "include", headers: authHeaders() });
if (!r.ok) { const e = new Error(r.status); e.status = r.status; throw e; }
return r.json();
}
async function apiPost(url, body) {
const r = await fetch(url, {
method: "POST", credentials: "include", headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) { const e = new Error(r.status); e.status = r.status; throw e; }
return r.json();
}
async function apiPatch(url, body) {
const r = await fetch(url, {
method: "PATCH", credentials: "include", headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) { const e = new Error(r.status); e.status = r.status; throw e; }
return r.json();
}
/* ───────── helpers ───────── */
const LEVEL_META = {
beginner: { name: "Начальный", short: "Начальный", idx: "I", sub: "Фундамент уверенности" },
intermediate: { name: "Средний", short: "Средний", idx: "II", sub: "Техники и отношения" },
advanced: { name: "Продвинутый", short: "Продвинутый", idx: "III", sub: "Мастерство и рост" },
};
const LEVEL_ORDER = ["beginner", "intermediate", "advanced"];
const POINTS_TO_NEXT = { beginner: 450, intermediate: 600, advanced: 0 };
function levelMeta(id) { return LEVEL_META[id] || { name: id, short: id, idx: "?", sub: "" }; }
function makeInitials(profile) {
if (!profile) return "?";
const fn = (profile.first_name || "").trim();
const ln = (profile.last_name || "").trim();
if (fn || ln) return ((fn[0] || "") + (ln[0] || "")).toUpperCase() || "?";
const un = (profile.username || "").trim();
return un ? un[0].toUpperCase() : "?";
}
function displayName(profile) {
if (!profile) return "Студент";
return profile.first_name || profile.username || "Студент";
}
function greeting() {
const h = new Date().getHours();
if (h < 6) return "Доброй ночи";
if (h < 12) return "Доброе утро";
if (h < 18) return "Добрый день";
return "Добрый вечер";
}
function haptic(kind = "light") {
try {
if (TG && TG.HapticFeedback) {
if (kind === "success") TG.HapticFeedback.notificationOccurred("success");
else TG.HapticFeedback.impactOccurred(kind);
}
} catch {}
}
/* Группировка ачивок по префиксу id (бэкенд хранит без category) */
function groupAchievements(list) {
const groups = {
lessons: { name: "Уроки", items: [] },
streak: { name: "Серии", items: [] },
points: { name: "Баллы", items: [] },
level: { name: "Уровни", items: [] },
special: { name: "Особые", items: [] },
};
list.forEach(a => {
if (a.id.startsWith("lessons_") || a.id === "first_lesson") groups.lessons.items.push(a);
else if (a.id.startsWith("streak_")) groups.streak.items.push(a);
else if (a.id.startsWith("points_")) groups.points.items.push(a);
else if (a.id.startsWith("level_")) groups.level.items.push(a);
else groups.special.items.push(a);
});
return Object.values(groups).filter(g => g.items.length > 0);
}
/* первый незавершённый урок текущего уровня */
function findCurrentLesson(structure, profileLevel) {
if (!structure) return null;
const levelOrderId = profileLevel || "beginner";
// приоритет — начать с текущего уровня, потом проверить разблокированные предыдущие
const prefer = [levelOrderId, ...LEVEL_ORDER.filter(x => x !== levelOrderId)];
for (const lvId of prefer) {
const lv = structure.find(l => l.id === lvId);
if (!lv || !lv.unlocked) continue;
let idx = 0;
for (const m of lv.modules) {
for (const les of m.lessons) {
idx++;
if (!les.completed) {
return { level: lv, module: m, lesson: les, lessonIndex: idx };
}
}
}
}
return null;
}
/* ───────── Toast ───────── */
const ToastCtx = React.createContext(() => {});
function ToastProvider({ children }) {
const [msg, setMsg] = useState(null);
const tRef = useRef(null);
const show = useCallback((m) => {
setMsg(m);
clearTimeout(tRef.current);
tRef.current = setTimeout(() => setMsg(null), 2800);
}, []);
return (
{children}
{msg && {msg}
}
);
}
const useToast = () => React.useContext(ToastCtx);
/* ───────── App-wide data store (минимальный) ───────── */
const DataCtx = React.createContext(null);
function DataProvider({ profile, setProfile, children }) {
const [progress, setProgress] = useState(null);
const [structure, setStructure] = useState(null);
const [structureLoading, setStructureLoading] = useState(false);
const loadProgress = useCallback(async () => {
try { setProgress(await apiGet("/api/users/me/progress")); } catch {}
}, []);
const loadStructure = useCallback(async (force = false) => {
if (structure && !force) return structure;
if (structureLoading) return null;
setStructureLoading(true);
try {
const s = await apiGet("/api/lessons/structure");
setStructure(s);
return s;
} catch {
return null;
} finally {
setStructureLoading(false);
}
}, [structure, structureLoading]);
const invalidate = useCallback(() => {
setStructure(null);
loadProgress();
}, [loadProgress]);
return (
{children}
);
}
const useData = () => React.useContext(DataCtx);
/* ───────── Router (стек экранов) ───────── */
const NavCtx = React.createContext(null);
function NavProvider({ children, initial = { id: "home" } }) {
const [stack, setStack] = useState([initial]);
const top = stack[stack.length - 1];
const push = useCallback((screen) => {
setStack(s => [...s, screen]);
}, []);
const replace = useCallback((screen) => {
setStack([screen]);
}, []);
const back = useCallback(() => {
setStack(s => s.length > 1 ? s.slice(0, -1) : s);
}, []);
const goTab = useCallback((tabId) => {
setStack([{ id: tabId }]);
}, []);
// Telegram BackButton
useEffect(() => {
if (!TG || !TG.BackButton) return;
if (stack.length > 1) {
TG.BackButton.show();
const onClick = () => back();
TG.BackButton.onClick(onClick);
return () => { try { TG.BackButton.offClick(onClick); } catch {} };
} else {
TG.BackButton.hide();
}
}, [stack.length, back]);
return (
{children}
);
}
const useNav = () => React.useContext(NavCtx);
/* ───────── Reusable: TabBar ───────── */
function TabBar({ active }) {
const nav = useNav();
const tabs = [
{ id: "home", icon: "home", label: "Главная" },
{ id: "learn", icon: "book", label: "Уроки" },
{ id: "ach", icon: "trophy", label: "Награды" },
{ id: "stats", icon: "chart", label: "Статистика" },
];
return (
{tabs.map(t => (
{ haptic("light"); nav.goTab(t.id); }}>
{t.label}
))}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 1: Auth
═══════════════════════════════════════════════════════════ */
function AuthScreen({ onAuthed }) {
const [phase, setPhase] = useState("loading"); // loading | needLogin
const toast = useToast();
useEffect(() => {
let cancelled = false;
(async () => {
// 1) JWT cookie
try {
const s = await apiGet("/api/auth/session");
if (s && s.authenticated && !cancelled) { onAuthed(s.user); return; }
} catch {}
// 2) Portal cookie
try {
const p = await apiGet("/api/auth/platform/auto");
if (p && p.ok && !cancelled) { onAuthed(p.user); return; }
} catch {}
// 3) Telegram WebApp initData
if (TG && TG.initData) {
try {
const t = await apiPost("/api/auth/telegram", { initData: TG.initData });
if (t && t.ok && !cancelled) { onAuthed(t.user); return; }
} catch {}
}
if (!cancelled) setPhase("needLogin");
})();
return () => { cancelled = true; };
}, [onAuthed]);
return (
TG Play · Dating Coach
Коуч по общению и уверенности
36 уроков, реальные сценарии и ежедневная практика.
3 уровня — от основ до продвинутых техник общения
17 достижений и прогресс, который видно
Ежедневная серия — 5 минут в день достаточно
{phase === "loading"
? "Подгружаем твой профиль…"
: "Авторизуйся на tgplay.ru и вернись сюда"}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 2: Home / Dashboard
═══════════════════════════════════════════════════════════ */
function HomeScreen() {
const { profile, progress, structure, loadStructure } = useData();
const nav = useNav();
useEffect(() => { loadStructure(); }, [loadStructure]);
const initials = makeInitials(profile);
const name = displayName(profile);
const streak = progress?.current_streak ?? 0;
const completed = progress?.completed_lessons ?? 0;
const total = progress?.total_lessons ?? 36;
const points = progress?.points ?? profile?.points ?? 0;
// Неделя: восстановим последние 7 дней по стрику.
// Локальный понедельник как старт недели.
const today = new Date();
const dow = (today.getDay() + 6) % 7; // 0=Mon...6=Sun
const weekLabels = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];
const week = weekLabels.map((d, i) => {
if (i < dow) {
// прошедший день недели — закрашен, если стрик «дотягивается» назад
const daysBack = dow - i;
return { d, done: streak >= (daysBack + 1) };
}
if (i === dow) return { d, today: true, done: streak >= 1 };
return { d };
});
const cur = useMemo(() => findCurrentLesson(structure, profile?.level), [structure, profile]);
// continue card — индекс урока «X из 36»
const lessonNo = cur ? (() => {
let n = 0;
for (const lv of structure || []) {
for (const m of lv.modules) {
for (const l of m.lessons) {
n++;
if (lv.id === cur.level.id && m.id === cur.module.id && l.id === cur.lesson.id) return n;
}
}
}
return n;
})() : 0;
return (
{initials}
{/* streak hero */}
Серия
{streak}
{streak === 1 ? "день" : streak >= 2 && streak <= 4 ? "дня подряд" : "дней подряд"}
{streak === 0 ? "Начни сегодня — открой первый урок"
: streak < 3 ? "Хорошее начало, не теряй темп"
: streak < 7 ? "Уже три дня! Продолжай"
: "Дисциплина — это сила"}
{week.map((x, i) => (
{x.done ? : x.today ? : null}
{x.d}
))}
{/* continue lesson */}
{cur ? (
nav.push({
id: "lesson",
levelId: cur.level.id, moduleId: cur.module.id, lessonId: cur.lesson.id,
})}>
Урок {String(lessonNo).padStart(2,"0")} из {total}
{cur.lesson.title}
{cur.module.name.replace(/^\W+\s*/, "")}
·
{levelMeta(cur.level.id).short}
) : (
nav.goTab("ach")}>
Курс пройден
Посмотри свои достижения
{progress?.achievements_count || 0}/17 наград
)}
{/* фокус — реальные числа */}
Твой прогресс
{points}
Очков
+10 за урок
{total ? Math.round(completed/total*100) : 0}%
Курс пройден
{completed} из {total}
{progress?.achievements_count || 0}
Достижений
из 17
);
}
/* ═══════════════════════════════════════════════════════════
Экран 3: Learning — список уровней + модули
═══════════════════════════════════════════════════════════ */
function LearningScreen() {
const { profile, progress, structure, loadStructure } = useData();
const nav = useNav();
const [openLevel, setOpenLevel] = useState(profile?.level || "beginner");
useEffect(() => { loadStructure(); }, [loadStructure]);
if (!structure) {
return (
);
}
const currentLevel = structure.find(l => l.id === (profile?.level || "beginner")) || structure[0];
const curStats = (() => {
let total = 0, done = 0;
currentLevel.modules.forEach(m => m.lessons.forEach(l => { total++; if (l.completed) done++; }));
return { total, done, pct: total ? Math.round(done/total*100) : 0 };
})();
const points = progress?.points ?? profile?.points ?? 0;
const meta = levelMeta(currentLevel.id);
const opened = structure.find(l => l.id === openLevel);
return (
Твой текущий уровень
{meta.short} — {meta.sub.toLowerCase()}
Ты освоил {curStats.done} {curStats.done === 1 ? "урок" : "уроков"} из {curStats.total}.
{curStats.total - curStats.done > 0 && ` Осталось ${curStats.total - curStats.done}, чтобы открыть следующий.`}
{curStats.done}/{curStats.total}
Уроков
{progress?.current_streak || 0}д
Серия
{structure.map(lv => {
let total = 0, done = 0;
lv.modules.forEach(m => m.lessons.forEach(l => { total++; if (l.completed) done++; }));
const pct = total ? Math.round(done/total*100) : 0;
const state = !lv.unlocked ? "locked"
: (lv.id === (profile?.level || "beginner") ? "current" : "done");
const lm = levelMeta(lv.id);
return (
lv.unlocked && setOpenLevel(lv.id)}>
{state === "locked" ? : lm.idx}
{lm.name} уровень
{lm.sub}
);
})}
Модули — {levelMeta(opened.id).short}
{(() => {
let t=0, d=0;
opened.modules.forEach(m => m.lessons.forEach(l => { t++; if (l.completed) d++; }));
return
{d}/{t};
})()}
{!opened.unlocked && (
🔒 Этот уровень ещё закрыт. Пройди уроки текущего уровня.
)}
{opened.modules.map((m, mi) => {
const total = m.lessons.length;
const done = m.lessons.filter(l => l.completed).length;
const state = done === total ? "done" : (done > 0 ? "current" : "todo");
return (
{String(mi+1).padStart(2,"0")}
{m.name}
{done}/{total}
{m.lessons.map((les, li) => {
const prevCompleted = li === 0 ? true : m.lessons[li-1].completed;
const lstate = les.completed ? "done"
: (!opened.unlocked ? "lock"
: prevCompleted ? "current" : "todo");
return (
opened.unlocked && nav.push({
id: "lesson", levelId: opened.id, moduleId: m.id, lessonId: les.id,
})}>
{lstate === "done" ?
: lstate === "current" ?
: lstate === "lock" ? : null}
{les.title}
);
})}
);
})}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 4: Lesson reader
═══════════════════════════════════════════════════════════ */
function LessonScreen({ levelId, moduleId, lessonId }) {
const { structure, loadStructure, invalidate } = useData();
const nav = useNav();
const toast = useToast();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [completing, setCompleting] = useState(false);
useEffect(() => { loadStructure(); }, [loadStructure]);
useEffect(() => {
let cancelled = false;
setData(null);
setError(null);
(async () => {
try {
const d = await apiGet(`/api/lessons/${levelId}/${moduleId}/${lessonId}`);
if (!cancelled) setData(d);
} catch (e) {
if (!cancelled) setError(e.status === 403 ? "locked" : "error");
}
})();
return () => { cancelled = true; };
}, [levelId, moduleId, lessonId]);
// Хлебные крошки и индекс
const crumbs = (() => {
if (!structure) return null;
const lv = structure.find(l => l.id === levelId);
if (!lv) return null;
const m = lv.modules.find(x => x.id === moduleId);
if (!m) return null;
const lessonIdx = m.lessons.findIndex(l => l.id === lessonId);
return { lv, m, lessonIdx, lvMeta: levelMeta(lv.id) };
})();
// Глобальный номер урока для прогресс-бара чтения
const globalIdx = useMemo(() => {
if (!structure) return null;
let n = 0, total = 0;
let cur = 0;
for (const lv of structure) {
for (const m of lv.modules) {
for (const l of m.lessons) {
n++;
total++;
if (lv.id === levelId && m.id === moduleId && l.id === lessonId) cur = n;
}
}
}
return { cur, total };
}, [structure, levelId, moduleId, lessonId]);
const onComplete = async () => {
if (completing) return;
setCompleting(true);
try {
const r = await apiPost(`/api/lessons/${levelId}/${moduleId}/${lessonId}/complete`);
haptic("success");
invalidate();
// celebration
nav.replace({
id: "celeb",
result: r,
levelId, moduleId, lessonId,
title: data?.title || "",
});
} catch {
toast("Не удалось завершить урок");
} finally {
setCompleting(false);
}
};
return (
{globalIdx ? `Урок ${String(globalIdx.cur).padStart(2,"0")}` : "Урок"}
{globalIdx && (
)}
{error === "locked" ? (
🔒 Этот уровень ещё заблокирован.
Пройди все уроки текущего уровня.
) : error ? (
Ошибка загрузки урока
) : !data ? (
) : (
<>
{crumbs && (
{crumbs.lvMeta.short} · {crumbs.m.name.replace(/^\W+\s*/, "")}
)}
{data.title}
+{data.points} очков
{data.completed && (
Пройден
)}
{data.completed ? (
) : (
)}
>
)}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 5: Celebration
═══════════════════════════════════════════════════════════ */
function CelebrationScreen({ result, levelId, moduleId, lessonId, title }) {
const { structure, profile, progress } = useData();
const nav = useNav();
// Найти следующий урок
const next = useMemo(() => {
if (!structure) return null;
for (const lv of structure) {
for (const m of lv.modules) {
for (let i = 0; i < m.lessons.length; i++) {
const l = m.lessons[i];
if (lv.id === levelId && m.id === moduleId && l.id === lessonId) {
// следующий — может быть в этом же модуле
if (i + 1 < m.lessons.length) {
return { level: lv, module: m, lesson: m.lessons[i+1] };
}
// или первый незавершённый дальше по структуре
return findCurrentLesson(structure, profile?.level);
}
}
}
}
return null;
}, [structure, profile, levelId, moduleId, lessonId]);
// % прохождения текущего уровня
const pct = useMemo(() => {
if (!structure) return 0;
const lvId = result?.level_up || profile?.level || "beginner";
const lv = structure.find(l => l.id === lvId);
if (!lv) return 0;
let t = 0, d = 0;
lv.modules.forEach(m => m.lessons.forEach(l => { t++; if (l.completed) d++; }));
return t ? Math.round(d/t*100) : 0;
}, [structure, profile, result]);
const newAch = result?.new_achievements?.[0];
const wasAlready = result?.already_completed;
return (
{result?.level_up ? "Новый уровень!" : wasAlready ? "Урок уже пройден" : "Урок завершён"}
{result?.level_up
? `Открыт ${levelMeta(result.level_up).name.toLowerCase()} уровень`
: "Ещё один шаг к уверенности"}
{wasAlready
? `Урок «${title}» был пройден ранее.`
: `Ты прошёл урок «${title}». Молодец — не останавливайся.`}
+{wasAlready ? 0 : 10}
Очков
{result?.current_streak ?? progress?.current_streak ?? 0}
Серия
{newAch && (
{newAch.emoji || "🏆"}
Новое достижение
{newAch.name}
)}
{next ? (
) : (
)}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 6: Achievements
═══════════════════════════════════════════════════════════ */
const ACH_ICON_MAP = {
first_lesson:"target", lessons_5:"book", lessons_10:"medal",
lessons_20:"crown", lessons_all:"trophy",
streak_3:"fire", streak_7:"flame", streak_14:"bolt", streak_30:"shield",
level_intermediate:"bolt", level_advanced:"crown",
points_50:"bolt", points_100:"sparkle", points_200:"gift", points_500:"crown",
early_bird:"sparkle", completionist:"crown",
};
function AchievementsScreen() {
const [list, setList] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await apiGet("/api/achievements/");
if (!cancelled) setList(data);
} catch {
if (!cancelled) setError(true);
}
})();
return () => { cancelled = true; };
}, []);
if (error) {
return (
Не удалось загрузить достижения
);
}
if (!list) {
return (
);
}
const total = list.length;
const earned = list.filter(a => a.unlocked).length;
const groups = groupAchievements(list);
const r = 30, c = 2 * Math.PI * r;
const pct = total ? earned / total : 0;
const renderAch = (a) => (
{a.unlocked
?
: }
{a.name}
);
return (
{earned === total ? "Все достижения собраны!"
: earned === 0 ? "Начни путь — пройди первый урок"
: "На пути к перфекционисту"}
{earned === total ? "Невероятно — ты собрал всё"
: `Осталось ${total - earned} достижений до полного набора`}
{groups.map(g => {
const gEarned = g.items.filter(a => a.unlocked).length;
return (
{g.name}
{gEarned}/{g.items.length}
{g.items.map(renderAch)}
);
})}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 7: Stats
═══════════════════════════════════════════════════════════ */
function StatsScreen() {
const { profile, progress, structure, loadStructure } = useData();
useEffect(() => { loadStructure(); }, [loadStructure]);
const initials = makeInitials(profile);
const name = displayName(profile);
const lvm = levelMeta(profile?.level);
const points = progress?.points ?? profile?.points ?? 0;
const completed = progress?.completed_lessons ?? 0;
const total = progress?.total_lessons ?? 36;
const streak = progress?.current_streak ?? 0;
const best = progress?.best_streak ?? 0;
const ach = progress?.achievements_count ?? 0;
const curLevel = structure?.find(l => l.id === (profile?.level || "beginner"));
const curStats = (() => {
if (!curLevel) return { done: 0, total: 0, pct: 0 };
let t = 0, d = 0;
curLevel.modules.forEach(m => m.lessons.forEach(l => { t++; if (l.completed) d++; }));
return { done: d, total: t, pct: t ? Math.round(d/t*100) : 0 };
})();
const toNext = POINTS_TO_NEXT[profile?.level || "beginner"];
const remaining = toNext > 0 ? Math.max(0, toNext - points) : 0;
// Heatmap не выводим — нет API daily activity. Вместо неё — разбивка по модулям текущего уровня.
return (
{initials}
{name}
{lvm.name.toUpperCase()} · {curStats.done}/{curStats.total} УРОКОВ
{toNext > 0 && (
до {profile?.level === "beginner" ? "среднего" : "продвинутого"}: {remaining}
)}
Серия
{streak} дн.
▲ лучшая {best} дн.
Уроки
{completed}/{total}
{total ? Math.round(completed/total*100) : 0}% курса
Достижения
{ach}/17
{Math.round(ach/17*100)}% открыто
Очки
{points}
+10 за урок
{curLevel && (
Разбивка — {lvm.short} уровень
{curLevel.modules.map(m => {
const t = m.lessons.length;
const d = m.lessons.filter(l => l.completed).length;
const pct = t ? Math.round(d/t*100) : 0;
return (
{m.name.replace(/^\W+\s*/, "")}
{d}/{t}
);
})}
)}
);
}
/* ═══════════════════════════════════════════════════════════
Экран 8: Settings
═══════════════════════════════════════════════════════════ */
function SettingsScreen() {
const { profile, setProfile } = useData();
const nav = useNav();
const toast = useToast();
const [s, setS] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await apiGet("/api/settings/");
if (!cancelled) setS(data);
} catch {}
})();
return () => { cancelled = true; };
}, []);
const Switch = ({ on, onClick, disabled }) => (