<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>ВСЕ УСПЕТЬ</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@700;900&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#0A0A0A;--fg:#F5F5F5;--red:#D71920;--mut:#BDBDBD;--line:#333;--field:#151515}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);font-family:Inter,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
body{padding:env(safe-area-inset-top,0px) 0 env(safe-area-inset-bottom,0px)}
.wrap{max-width:520px;margin:0 auto;padding:20px 18px 70px;display:flex;flex-direction:column;gap:18px}
.display,h1,h2{font-family:Unbounded,sans-serif;font-weight:900;margin:0;letter-spacing:-.5px}
h2{font-size:20px;font-weight:700;letter-spacing:0}
.top{display:flex;justify-content:space-between;align-items:center}
.logo{font-family:Unbounded,sans-serif;font-weight:900;font-size:22px;letter-spacing:-.5px}
.bar{height:4px;width:64px;background:var(--red)}
.tabs{display:flex;border:1px solid var(--fg);border-radius:999px;padding:4px}
.tabs button{flex:1;background:none;border:0;color:var(--fg);padding:10px;border-radius:999px;font:inherit;font-weight:600;cursor:pointer}
.tabs button.on{background:var(--fg);color:var(--bg)}
.card{background:var(--fg);color:var(--bg);border-radius:20px;padding:20px;display:flex;flex-direction:column;gap:12px}
.box{border:1px solid var(--line);border-radius:16px;padding:14px 16px}
.box.alert{border-color:var(--red)}
.row{display:flex;justify-content:space-between;align-items:center;gap:12px}
.col{display:flex;flex-direction:column;gap:4px}
.muted{color:var(--mut);font-size:13px}
.uc{font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase}
.btn{display:flex;align-items:center;justify-content:center;text-decoration:none;background:var(--red);color:var(--fg);border:0;border-radius:14px;height:52px;padding:0 18px;font:inherit;font-weight:700;font-size:16px;cursor:pointer;width:100%}
.btn.dark{background:var(--bg);color:var(--fg)}
.btn.light{background:var(--fg);color:var(--bg)}
.btn.sm{height:40px;width:auto;border-radius:12px;padding:0 14px;font-size:14px}
.btn.line{background:none;border:1px solid var(--fg);color:var(--fg)}
.tag{font-size:12px;font-weight:700;border:1px solid currentColor;border-radius:999px;padding:4px 10px;white-space:nowrap;align-self:flex-start}
.tag.red{color:var(--red)}
.done{color:#7a7a7a}
.done .title{text-decoration:line-through}
.check{width:26px;height:26px;border:2px solid currentColor;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;flex:none;background:none;cursor:pointer;color:var(--fg);padding:0}
.check.on{background:currentColor}
.check.on::after{content:"";width:7px;height:12px;border:solid var(--bg);border-width:0 3px 3px 0;transform:rotate(45deg) translate(-1px,-1px)}
.sec{display:flex;flex-direction:column;gap:10px}
.sec h2{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
.bars{display:flex;gap:4px}
.bars i{flex:1;height:8px;border-radius:4px;background:var(--line)}
.bars i.on{background:var(--fg)}
input,select,textarea{width:100%;background:var(--field);border:1px solid var(--line);color:var(--fg);border-radius:12px;padding:12px;font:inherit;font-size:16px}
label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--mut)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.empty{color:var(--mut);font-size:14px;padding:6px 0}
details summary{cursor:pointer;list-style:none;font-weight:700}
details summary::-webkit-details-marker{display:none}
details[open] > summary{margin-bottom:12px}
.topic{display:flex;justify-content:space-between;align-items:center;width:100%;background:none;border:0;border-top:1px solid var(--line);color:var(--fg);padding:12px 0;font:inherit;cursor:pointer;text-align:left}
.subnav{display:flex;gap:6px;flex-wrap:wrap}
.subnav button{flex:1 1 30%;background:none;border:1px solid var(--line);color:var(--fg);border-radius:10px;padding:9px 6px;font:inherit;font-size:13px;cursor:pointer}
.subnav button.on{background:var(--fg);color:var(--bg)}
.mat{display:flex;flex-direction:column;gap:6px;font-size:14px}
.mat a{color:var(--fg);text-decoration:underline}
#toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,140px);background:var(--fg);color:var(--bg);padding:12px 18px;border-radius:12px;font-weight:600;transition:transform .25s;pointer-events:none}
#toast.show{transform:translate(-50%,0)}
</style>
</head>
<body>
<div class="wrap">
<div class="top"><span class="logo">ВСЕ УСПЕТЬ</span><span class="tag" id="who"></span></div>
<div class="bar"></div>
<div class="tabs">
<button data-role="student">Ученик</button>
<button data-role="tutor">Репетитор</button>
</div>
<div id="app"></div>
</div>
<div id="toast"></div>
<script>
const KEY = 'vse-uspet-v1';
const TL = 'https://telemost.yandex.ru/j/';
const OGE_TOPICS = ['Древняя Русь','Монгольское нашествие','Московское государство','Смутное время','Петр I','Екатерина II','Александр I','Николай I','Реформы 1860–1870-х'];
const EGE_TOPICS = ['Древний мир','Средние века','Новое время: XVI–XVII','Новое время: XVIII в.','XIX век','Первая мировая','Гражданская война и СССР','Вторая мировая','Послевоенный СССР','Современная Россия'];
const p2 = n => String(n).padStart(2, '0');
const iso = d => d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate());
const addDays = n => { const d = new Date(); d.setDate(d.getDate() + n); return iso(d); };
const todayIso = () => iso(new Date());
const uid = () => Math.random().toString(36).slice(2, 9);
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const money = n => Number(n).toLocaleString('ru-RU') + ' ₽';
const lessonTs = l => new Date(l.date + 'T' + l.time + ':00').getTime();
const dayLabel = d => new Date(d + 'T00:00:00').toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' });
const shortDate = d => new Date(d + 'T00:00:00').toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
const SCHOOL_TOPICS = OGE_TOPICS; // заглушка: программа для «школьной» группы уточняется
function seed() {
const TLK = () => TL + Math.floor(1e10 + Math.random() * 9e10);
const L = (id, off, time, sid, topic, paid) => ({ id, date: addDays(off), time, dur: 60, studentId: sid, topic, paid, link: TLK() });
return {
role: 'student',
me: null,
students: [
{ id: 'aglaya', name: 'Аглая', exam: 'Школа', price: 800, username: 'dol9hin1fan', topics: SCHOOL_TOPICS, passed: [] },
{ id: 'anna', name: 'Анна', exam: 'Школа', price: 1000, username: '', topics: SCHOOL_TOPICS, passed: [] },
{ id: 'semen', name: 'Семен', exam: 'ОГЭ', price: 900, username: 'aily63', topics: OGE_TOPICS, passed: [] },
{ id: 'liza', name: 'Лиза', exam: 'ЕГЭ', price: 900, username: 'woman_in_track', topics: EGE_TOPICS, passed: [] }
],
lessons: [],
homework: []
};
}
const CFG = {
API_URL: 'https://szeobdwymbaegffcalgn.supabase.co/functions/v1/app',
TUTOR_USERNAME: 'kovvvliaaa'
};
const CFG_READY = !CFG.API_URL.includes('YOUR');
const TG = window.Telegram && window.Telegram.WebApp;
if (TG) { TG.ready(); TG.expand(); }
const ME_NAME = ((TG && TG.initDataUnsafe && TG.initDataUnsafe.user && TG.initDataUnsafe.user.username) || '').toLowerCase();
const ROLE = ME_NAME && ME_NAME === CFG.TUTOR_USERNAME.toLowerCase() ? 'tutor' : 'student';
let GUEST = false;
let NEW = false;
const HISTORY_FACTS = [
'Законы Хаммурапи записаны около 1754 года до н. э. на базальтовой стеле.',
'Битва при Марафоне произошла в 490 году до н. э.',
'Магна Карта подписана в 1215 году.',
'Санкт-Петербург основан Петром I в 1703 году.',
'Древние египтяне использовали папирус для письма, а не только камень.',
'Великая Китайская стена строилась на протяжении многих веков разными династиями.'
];
const dayIndex = n => Math.floor(Date.now() / 86400000) % n;
let S = seed();
let lastRemote = '';
function applyView(view, role) {
S = view;
if (role === 'tutor') { S.role = 'tutor'; }
else { S.role = 'student'; S.me = S.students[0] ? S.students[0].id : null; }
}
async function api(action, extra) {
const r = await fetch(CFG.API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ action, initData: TG ? TG.initData : '', state: action === 'save' ? S : undefined }, extra || {}))
});
if (r.status === 400) return { error: (await r.json()).error };
if (!r.ok) throw new Error(String(r.status));
return r.json();
}
async function pull(action) {
if (!CFG_READY || !TG) return;
try {
const j = await api(action || 'get');
if (j.error) { toast(j.error === 'need_username' ? 'Сначала задайте username в Telegram' : 'Заполните ФИО'); return; }
if (j.role === 'new') { NEW = true; render(); return; }
NEW = false;
if (j.role === 'tutor' && !j.state.students.length) { save(); return; } // первый запуск: отправить начальные данные
const txt = JSON.stringify(j.state);
if (txt !== lastRemote) { lastRemote = txt; applyView(j.state, j.role); render(); }
} catch (e) {
if (e.message === '401' || e.message === '403') { GUEST = true; render(); return; }
if (action === 'save') toast('Не сохранилось, проверьте связь');
}
}
function save() { pull('save'); }
if (ROLE !== 'tutor') S.role = 'student';
const studentOf = id => S.students.find(s => s.id === id);
const sortLessons = arr => arr.slice().sort((a, b) => lessonTs(a) - lessonTs(b));
const upcoming = sid => sortLessons(S.lessons.filter(l => (!sid || l.studentId === sid) && l.date >= todayIso() && lessonTs(l) + l.dur * 60000 >= Date.now()));
function toast(text) {
const el = document.getElementById('toast');
el.textContent = text;
el.classList.add('show');
clearTimeout(toast.t);
toast.t = setTimeout(() => el.classList.remove('show'), 1800);
}
let SCREEN = 'home';
let TAKING = null;
const matText = l => (l.materials || []).map(m => m.title + ' | ' + m.url).join('\n');
const parseMat = t => String(t || '').split('\n').map(x => x.trim()).filter(Boolean).map(x => {
const [title, ...rest] = x.split('|'); const url = rest.join('|').trim();
return { title: (title || '').trim() || 'Материал', url };
}).filter(m => /^https?:\/\//.test(m.url));
function studentView() {
const me = studentOf(S.me) || S.students[0];
if (!me) return '<div class="box"><div class="empty">Профиль не найден</div></div>';
const ups = upcoming(me.id);
const next = ups[0];
const unpaid = ups.filter(l => !l.paid);
const mine = S.lessons.filter(l => l.studentId === me.id);
const nav = [['home', 'Главная'], ['calendar', 'Календарь'], ['scripts', 'Скрипты'], ['hw', 'ДЗ'], ['tests', 'Тесты'], ['profile', 'Профиль']];
const navHtml = `<div class="subnav">${nav.map(([k, t]) => `<button class="
{k}">${t}</button>`).join('')}</div>`;
let body;
if (SCREEN === 'calendar') body = calendarScreen(sortLessons(mine.filter(l => l.date >= addDays(-7) && l.date <= addDays(13))));
else if (SCREEN === 'scripts') body = scriptsScreen(sortLessons(mine.filter(l => l.script)));
else if (SCREEN === 'hw') body = hwScreen(me, mine);
else if (SCREEN === 'tests') body = testsScreen();
else if (SCREEN === 'profile') body = profileScreen(me, unpaid);
else body = homeScreen(me, next, unpaid);
return navHtml + body;
}
function homeScreen(me, next, unpaid) {
const fact = HISTORY_FACTS[dayIndex(HISTORY_FACTS.length)];
const about = (S.teacherFacts || []).length ? S.teacherFacts[dayIndex(S.teacherFacts.length)] : '';
return `
<div class="col"><span class="uc" style="color:var(--mut)">Привет, ${esc(me.name)}</span><h1 style="font-size:30px">Главная</h1></div>
<button class="btn" data-screen="profile">Войти в профиль</button>
${next ? `
<section class="card">
<span class="uc" style="color:#3a3a3a">Ближайший урок</span>
<div class="display" style="font-size:36px;line-height:1">${next.time}</div>
<div style="font-weight:600">${dayLabel(next.date)} · ${esc(next.topic)}</div>
<a class="btn" href="${esc(next.link)}" target="_blank" rel="noopener">Войти на урок</a>
</section>` : '<div class="box"><div class="empty">Ближайших уроков нет</div></div>'}
${unpaid.length ? `<div class="box alert row"><span>Не оплачено уроков: ${unpaid.length}</span><button class="btn sm light" data-screen="profile">Оплатить</button></div>` : ''}
<div class="box"><span class="uc" style="color:var(--mut)">Факт дня</span><p style="margin:8px 0 0">${esc(fact)}</p></div>
${about ? `<div class="box"><span class="uc" style="color:var(--mut)">Об Алине</span><p style="margin:8px 0 0">${esc(about)}</p></div>` : ''}`;
}
function testsScreen() {
if (TAKING) {
const t = (S.tests || []).find(x => x.id === TAKING);
if (t) return `<div class="sec"><h2>${esc(t.title)}</h2>
<form data-form="takeTest" data-id="${t.id}" class="sec">
${t.questions.map((q, i) => `<div class="box col" style="gap:8px"><span style="font-weight:500">${i + 1}. ${esc(q.text)}</span>
${q.options.map((o, j) => `<label style="flex-direction:row;align-items:center;gap:8px;color:var(--fg)"><input type="radio" name="q
{j}" required style="width:auto">${esc(o)}</label>`).join('')}
</div>`).join('')}
<button class="btn" type="submit">Отправить ответы</button>
</form></div>`;
TAKING = null;
}
const tests = S.tests || [];
return `<div class="sec"><h2>Тесты</h2>
${tests.length ? tests.map(t => {
const a = (S.attempts || []).find(x => x.testId === t.id);
return `<div class="box row">
<div class="col" style="flex:1"><span style="font-weight:600">${esc(t.title)}</span><span class="muted">вопросов: ${t.questions.length}</span></div>
${a ? `<span class="tag">${a.score} из ${a.total}</span>` : `<button class="btn sm" data-act="start" data-id="${t.id}">Пройти</button>`}
</div>`;
}).join('') : '<div class="empty">Тестов пока нет</div>'}</div>`;
}
function profileScreen(me, unpaid) {
const paid = S.lessons.filter(l => l.studentId === me.id && l.paid).sort((a, b) => lessonTs(b) - lessonTs(a));
return `
${unpaid.length ? `<div class="box alert"><span>Есть неоплаченные уроки: ${unpaid.length}. Оплатить можно ниже.</span></div>` : '<div class="box"><span class="muted">Все уроки оплачены</span></div>'}
<details class="box" open>
<summary>Профиль</summary>
<form data-form="profile" data-id="${me.id}" class="sec" style="margin-top:12px">
<label>ФИО<input name="name" value="${esc(me.name)}" required></label>
<label>Телефон<input name="phone" type="tel" value="${esc(me.phone || '')}"></label>
<label>Почта<input name="email" type="email" value="${esc(me.email || '')}"></label>
<label>Класс<input name="grade" value="${esc(me.grade || '')}"></label>
<label>Цель подготовки<input name="goal" value="${esc(me.goal || '')}"></label>
<label>Username в Telegram<input value="${esc(me.username || '')}" disabled></label>
<button class="btn light" type="submit">Сохранить профиль</button>
</form>
</details>
<div class="sec"><h2>Оплата</h2>
${unpaid.map(l => `<div class="box row alert"><div class="col"><span style="font-weight:600">${shortDate(l.date)} · ${l.time} ·
{money(me.price)}</span></div><button class="btn sm" data-act="pay" data-id="${l.id}">Оплатить</button></div>`).join('') || '<div
class="empty">Неоплаченных уроков нет</div>'}
<span class="uc" style="color:var(--mut)">История оплат</span>
${paid.map(l => `<div class="box row"><span>${shortDate(l.date)} ·
{money(me.price)}</span></div>`).join('') || '<div class="empty">Оплат пока нет</div>'}
</div>`;
}
function calendarScreen(lessons) {
return `<div class="sec"><h2>Календарь</h2>
${lessons.length ? lessons.map(l => {
const past = lessonTs(l) < Date.now();
return `
<div class="box col ${past ? 'done' : ''}" style="gap:8px">
<div class="row"><span style="font-weight:600">${dayLabel(l.date)} · ${l.time}</span><span class="tag
{past ? 'Прошёл' : (l.paid ? 'Оплачен' : 'Не оплачен')}</span></div>
<span>${esc(l.topic)}</span>
${past ? '' : `<a class="btn sm" href="${esc(l.link)}" target="_blank" rel="noopener">Войти на урок</a>`}
${(l.materials || []).length ? `<div class="mat"><span class="muted">Материалы</span>${l.materials.map(m => `<a href="
{esc(m.title)}</a>`).join('')}</div>` : ''}
${l.homework ? `<div><span class="muted">Домашка${l.homeworkDue ? ' · до ' + shortDate(l.homeworkDue) : ''}</span><div style="white-space:pre-line">${esc(l.homework)}</div></div>` : ''}
</div>`;
}).join('') : '<div class="empty">На эти даты уроков нет</div>'}</div>`;
}
function scriptsScreen(lessons) {
return `<div class="sec"><h2>Скрипты уроков</h2>
${lessons.length ? lessons.map(l => `
<div class="box col" style="gap:6px"><span style="font-weight:600">${shortDate(l.date)} ·
{esc(l.script)}</div></div>`).join('')
: '<div class="empty">Скриптов пока нет. Алина добавит их к урокам.</div>'}</div>`;
}
function hwScreen(me, mine) {
const labels = { new: 'не выполнено', done: 'выполнено', checked: 'проверено' };
const standalone = S.homework.filter(h => h.studentId === me.id)
.sort((a, b) => (a.status === 'checked') - (b.status === 'checked') || a.due.localeCompare(b.due));
const lessonHw = sortLessons(mine.filter(l => l.homework));
return `<div class="sec"><h2>Домашка</h2>
${standalone.map(h => `
<div class="box row ${h.status === 'new' ? 'alert' : 'done'}">
<div class="col" style="flex:1"><span class="title">${esc(h.text)}</span><span class="muted">до ${shortDate(h.due)} · ${labels[h.status]}</span></div>
${h.status === 'checked' ? '<span class="tag">Проверено</span>' : `<button class="check
{h.id}" aria-label="Отметить выполненным"></button>`}
</div>`).join('')}
${lessonHw.map(l => `
<div class="box col"><span class="muted">${shortDate(l.date)} ·
{l.homeworkDue ? ' · до ' + shortDate(l.homeworkDue) : ''}</span><div style="white-space:pre-line">${esc(l.homework)}</div></div>`).join('')}
${!standalone.length && !lessonHw.length ? '<div class="empty">Домашки пока нет</div>' : ''}
</div>`;
}
function tutorView() {
const today = todayIso();
const todays = sortLessons(S.lessons.filter(l => l.date === today));
const unpaidCount = S.lessons.filter(l => !l.paid && l.date >= today).length;
const toCheck = S.homework.filter(h => h.status === 'done');
const studentOpts = S.students.map(s => `<option value="${s.id}">${esc(s.name)} · ${s.exam}</option>`).join('');
return `
<div class="col">
<span class="uc" style="color:var(--mut)">${dayLabel(today)}</span>
<h1 style="font-size:34px;margin-top:4px">Сегодня</h1>
</div>
<div class="grid2">
<div class="box"><div class="display" style="font-size:28px">${todays.length}</div><span class="muted">уроков сегодня</span></div>
<div class="box
{unpaidCount}</div><span class="muted">неоплаченных</span></div>
</div>
<div class="sec">
<h2>Уроки сегодня</h2>
${todays.length ? todays.map(l => {
const st = studentOf(l.studentId);
return `
<div class="box col" style="gap:10px">
<div class="row">
<div class="col">
<span class="display" style="font-size:20px">${l.time}</span>
<span style="font-weight:600">${st ? esc(st.name) : '—'} · ${esc(l.topic)}</span>
</div>
<button class="btn sm
{l.id}">${l.paid ? 'Оплачен' : 'Отметить оплату'}</button>
</div>
<button class="btn sm line" data-act="copy" data-id="${l.id}" style="width:100%">Скопировать ссылку на урок</button>
<details>
<summary>Сценарий урока</summary>
<textarea data-script="${l.id}" rows="5" placeholder="План и вопросы к уроку">${esc(l.script || '')}</textarea>
</details>
</div>`;
}).join('') : '<div class="empty">Сегодня уроков нет</div>'}
</div>
<div class="sec">
<h2>Домашка на проверку <span class="muted">${toCheck.length}</span></h2>
${toCheck.length ? toCheck.map(h => {
const st = studentOf(h.studentId);
return `
<div class="box row alert">
<div class="col" style="flex:1">
<span style="font-weight:600">${st ? esc(st.name) : '—'}</span>
<span class="muted">${esc(h.text)}</span>
</div>
<button class="btn sm light" data-act="checked" data-id="${h.id}">Проверено</button>
</div>`;
}).join('') : '<div class="empty">Всё проверено</div>'}
</div>
<div class="sec">
<h2>Ученики <span class="muted">${S.students.length}</span></h2>
${S.students.map(s => {
const lessons = sortLessons(S.lessons.filter(l => l.studentId === s.id));
const hw = S.homework.filter(h => h.studentId === s.id);
const labels = { new: 'не выполнено', done: 'выполнено', checked: 'проверено' };
return `
<details class="box">
<summary class="row">
<span>${esc(s.name)}</span>
<span class="muted">${s.exam} · ${money(s.price)} ·
{s.topics.length}</span>
</summary>
<div class="sec" style="margin-bottom:14px">
<span class="uc" style="color:var(--mut)">Профиль</span>
<form data-form="editStudent" data-id="${s.id}" class="sec">
<label>Имя<input name="name" value="${esc(s.name)}" required></label>
<div class="grid2">
<label>Экзамен<select name="exam">${['ОГЭ', 'ЕГЭ', 'Школа'].map(x => `<option
{x}</option>`).join('')}</select></label>
<label>Цена, ₽<input type="number" name="price" value="${s.price}" min="0" required></label>
</div>
<label>Username в Telegram (без @)<input name="username" value="${esc(s.username || '')}" placeholder="пусто, если ещё нет"></label>
<button class="btn sm light" type="submit" style="width:100%">Сохранить профиль</button>
</form>
<button class="btn sm line" style="width:100%" data-act="delStudent" data-id="${s.id}">Удалить ученика</button>
</div>
<div class="sec" style="margin-bottom:14px">
<span class="uc" style="color:var(--mut)">Уроки</span>
${lessons.length ? lessons.map(l => `
<div class="row" style="border-top:1px solid var(--line);padding-top:10px">
<span>${shortDate(l.date)} · ${l.time} · ${esc(l.topic)}</span>
<span class="row" style="gap:6px">
<button class="btn sm
{l.id}">${l.paid ? 'Оплачен' : 'Не оплачен'}</button>
<button class="btn sm line" data-act="delLesson" data-id="${l.id}">×</button>
</span>
</div>
<details><summary class="muted">Материалы, скрипт, ДЗ</summary>
<form data-form="lessonEdit" data-id="${l.id}" class="sec" style="margin-top:10px">
<label>Материалы (по строке: название | ссылка)<textarea name="materials" rows="3">${esc(matText(l))}</textarea></label>
<label>Скрипт урока<textarea name="script" rows="3">${esc(l.script || '')}</textarea></label>
<label>Домашка<textarea name="homework" rows="2">${esc(l.homework || '')}</textarea></label>
<label>Срок домашки<input type="date" name="homeworkDue" value="${l.homeworkDue || ''}"></label>
<button class="btn sm light" type="submit" style="width:100%">Сохранить урок</button>
</form>
</details>`).join('') : '<div class="empty">Уроков нет</div>'}
</div>
<div class="sec" style="margin-bottom:14px">
<span class="uc" style="color:var(--mut)">Домашка</span>
${hw.length ? hw.map(h => `
<div class="row" style="border-top:1px solid var(--line);padding-top:10px">
<span style="flex:1">${esc(h.text)} <span class="muted">· до ${shortDate(h.due)} · ${labels[h.status]}</span></span>
<button class="btn sm line" data-act="copyhw" data-id="${h.id}">Копировать</button>
<button class="btn sm line" data-act="delHw" data-id="${h.id}">×</button>
</div>`).join('') : '<div class="empty">Заданий нет</div>'}
</div>
<div class="sec">
<span class="uc" style="color:var(--mut)">Прогресс · темы</span>
${s.topics.map((t, i) => `
<button class="topic" data-act="topic" data-s="${s.id}" data-i="${i}">
<span>${esc(t)}</span>
<span class="check ${s.passed.includes(i) ? 'on' : ''}"></span>
</button>`).join('')}
</div>
</details>`;
}).join('')}
</div>
<div class="grid2">
<button class="btn sm line" style="width:100%" data-act="ics">Календарь (.ics)</button>
<button class="btn sm line" style="width:100%" data-act="hwdl">Выгрузить домашку</button>
</div>
<button class="btn sm line" style="width:100%" data-act="perm">Включить напоминания</button>
<div class="sec"><h2>Тесты</h2>
${(S.tests || []).map(t => {
const att = (S.attempts || []).filter(a => a.testId === t.id);
const who = t.assign === 'all' ? 'все ученики' : (studentOf(t.assign) ? studentOf(t.assign).name : '—');
return `<div class="box col" style="gap:6px">
<div class="row"><span style="font-weight:600">
{t.id}">×</button></div>
<span class="muted">${who} · вопросов: ${t.questions.length}</span>
${att.map(a => `<span>${esc(studentOf(a.studentId) ? studentOf(a.studentId).name : '—')}: ${a.score} из ${a.total}</span>`).join('') || '<span class="muted">Ответов пока нет</span>'}
</div>`;
}).join('') || '<div class="empty">Тестов пока нет</div>'}
</div>
<details class="box">
<summary>+ Создать тест</summary>
<form data-form="test" class="sec" style="margin-top:12px">
<label>Название<input name="title" required></label>
<label>Кому<select name="assign"><option value="all">Всем ученикам</option>${S.students.map(x => `<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></label>
<label>Вопросы, одна строка на вопрос: вопрос | вариант | вариант | номер верного<textarea name="questions" rows="6" required placeholder="Какой год битва при Марафоне? | 480 | 490 | 500 | 2"></textarea></label>
<button class="btn" type="submit">Создать тест</button>
</form>
</details>
<details class="box">
<summary>Факты о преподавателе</summary>
<form data-form="facts" class="sec" style="margin-top:12px">
<label>По одному факту на строку<textarea name="facts" rows="5">${esc((S.teacherFacts || []).join('\n'))}</textarea></label>
<button class="btn" type="submit">Сохранить</button>
</form>
</details>
<details class="box">
<summary>+ Создать урок</summary>
<form data-form="lesson" class="sec">
<label>Ученик<select name="studentId" required>${studentOpts}</select></label>
<div class="grid2">
<label>Дата<input type="date" name="date" value="${today}" required></label>
<label>Время<input type="time" name="time" value="18:00" required></label>
</div>
<label>Длительность
<select name="dur"><option value="60">60 минут</option><option value="90">90 минут</option></select>
</label>
<label>Тема<input type="text" name="topic" placeholder="Например, Средние века"></label>
<label>Сценарий урока<textarea name="script" rows="3" placeholder="План, вопросы, материалы"></textarea></label>
<button class="btn" type="submit">Создать урок</button>
</form>
</details>
<details class="box">
<summary>+ Создать домашку</summary>
<form data-form="hw" class="sec">
<label>Ученик<select name="studentId" required>${studentOpts}</select></label>
<label>Задание<textarea name="text" rows="3" required placeholder="Текст задания"></textarea></label>
<label>Срок<input type="date" name="due" value="${addDays(3)}" required></label>
<button class="btn" type="submit">Задать</button>
</form>
</details>
<details class="box">
<summary>+ Добавить ученика</summary>
<form data-form="student" class="sec">
<label>Имя<input type="text" name="name" required></label>
<div class="grid2">
<label>Экзамен<select name="exam"><option>ОГЭ</option><option>ЕГЭ</option><option>Школа</option></select></label>
<label>Username в Telegram<input type="text" name="username" placeholder="без @"></label>
<label>Цена урока, ₽<input type="number" name="price" value="1500" min="0" required></label>
</div>
<button class="btn" type="submit">Добавить</button>
</form>
</details>`;
}
function registerView() {
return `
<div class="col"><h1 style="font-size:28px">Регистрация</h1><span class="muted">Заполните анкету, профиль создастся автоматически.</span></div>
<form data-form="register" class="card" style="color:var(--fg)">
<label style="color:var(--bg)">ФИО<input name="name" required placeholder="Иванова Анна Петровна"></label>
<label style="color:var(--bg)">Класс
<select name="grade"><option>5</option><option>6</option><option>7</option><option>8</option><option>9</option><option>10</option><option>11</option><option>Выпускник</option></select>
</label>
<label style="color:var(--bg)">Цель подготовки
<select name="goal"><option>Школьная программа</option><option>ОГЭ</option><option>ЕГЭ</option></select>
</label>
<label style="color:var(--bg)">Телефон<input name="phone" type="tel" placeholder="+7…"></label>
<label style="color:var(--bg)">Почта<input name="email" type="email" placeholder="name@mail.ru"></label>
<button class="btn" type="submit">Создать профиль</button>
</form>`;
}
function render() {
if (NEW) {
document.querySelector('.tabs').style.display = 'none';
document.getElementById('who').textContent = '';
document.getElementById('app').innerHTML = registerView();
return;
}
if (GUEST) {
document.querySelector('.tabs').style.display = 'none';
document.getElementById('who').textContent = '';
document.getElementById('app').innerHTML = '<div class="box"><div class="empty">Вас нет в списке учеников. Напишите репетитору, он добавит вас.</div></div>';
return;
}
document.querySelector('.tabs').style.display = ROLE === 'tutor' ? '' : 'none';
document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('on', b.dataset.role === S.role));
document.getElementById('who').textContent = S.role === 'tutor' ? 'Репетитор' : 'Ученик';
document.getElementById('app').innerHTML = S.role === 'tutor' ? tutorView() : studentView();
}
const pad = n => String(n).padStart(2, '0');
async function saveFile(name, text, mime) {
try {
const dl = await window.claude.use('downloads');
if (dl) { await dl.save({ filename: name, data: text }); return; }
} catch (e) {}
if (TG) {
try { await navigator.clipboard.writeText(text); toast('Скопировано, вставьте в чат'); }
catch (e) { toast('Не удалось скопировать'); }
return;
}
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type: mime }));
a.download = name;
a.click();
}
function calendarText(lessons) {
const f = d => d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + 'T' + pad(d.getHours()) + pad(d.getMinutes()) + '00';
const out = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//VSE USPET//RU', 'CALSCALE:GREGORIAN'];
lessons.forEach(l => {
const st = studentOf(l.studentId);
const start = new Date(l.date + 'T' + l.time + ':00');
const end = new Date(start.getTime() + l.dur * 60000);
out.push('BEGIN:VEVENT', 'UID:' + l.id + '@vse-uspet', 'DTSTAMP:' + f(new Date()), 'DTSTART:' + f(start), 'DTEND:' + f(end),
'SUMMARY:' + (st ? st.name + ' · ' : '') + l.topic, 'DESCRIPTION:Ссылка: ' + l.link,
'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Урок через 2 часа', 'TRIGGER:-PT120M', 'END:VALARM',
'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Урок через 1 час', 'TRIGGER:-PT60M', 'END:VALARM', 'END:VEVENT');
});
out.push('END:VCALENDAR');
return out.join('\r\n');
}
function homeworkText() {
const label = { new: 'не выполнено', done: 'выполнено', checked: 'проверено' };
return S.students.map(s => {
const hw = S.homework.filter(h => h.studentId === s.id);
return s.name + ' (' + s.exam + ')\n' + (hw.length ? hw.map(h => '— ' + h.text + ' · до ' + h.due + ' · ' + label[h.status]).join('\n') : '— заданий нет');
}).join('\n\n');
}
function notify(text) {
if ('Notification' in window && Notification.permission === 'granted') new Notification('ВСЕ УСПЕТЬ', { body: text });
else toast(text);
}
const notified = new Set();
function remind() {
const now = Date.now();
const list = S.role === 'tutor' ? S.lessons : S.lessons.filter(l => l.studentId === S.me);
list.forEach(l => {
const mins = Math.round((lessonTs(l) - now) / 60000);
[120, 60].forEach(m => {
const key = l.id + ':' + m;
if (mins <= m && mins > m - 2 && !notified.has(key)) {
notified.add(key);
let text = 'Через ' + (m === 60 ? '1 час' : '2 часа') + ' урок в ' + l.time + '. Ссылка в приложении';
if (!l.paid) text += '. Урок ещё не оплачен, оплатить можно в приложении';
notify(text);
}
});
});
}
document.addEventListener('click', async e => {
const scr = e.target.closest('[data-screen]');
if (scr) { SCREEN = scr.dataset.screen; render(); window.scrollTo(0, 0); return; }
const roleBtn = e.target.closest('[data-role]');
if (roleBtn) { S.role = roleBtn.dataset.role; save(); render(); return; }
const t = e.target.closest('[data-act]');
if (!t) return;
const act = t.dataset.act;
const id = t.dataset.id;
if (act === 'pay') {
try {
const j = await api('invoice', { lessonId: id });
if (j.error || !j.url) { toast('Оплата сейчас недоступна, попробуйте позже'); return; }
if (TG && TG.openInvoice) TG.openInvoice(j.url, status => { if (status === 'paid') { toast('Оплачено'); pull(); } });
else window.open(j.url, '_blank');
} catch (err) { toast('Нет связи, попробуйте позже'); }
return;
} else if (act === 'paytoggle') {
const l = S.lessons.find(x => x.id === id); if (l) l.paid = !l.paid;
} else if (act === 'hw') {
const h = S.homework.find(x => x.id === id);
if (h && h.status !== 'checked') h.status = h.status === 'new' ? 'done' : 'new';
} else if (act === 'checked') {
const h = S.homework.find(x => x.id === id); if (h) h.status = 'checked';
toast('Проверено');
} else if (act === 'topic') {
const s = studentOf(t.dataset.s); const i = Number(t.dataset.i);
if (s) s.passed = s.passed.includes(i) ? s.passed.filter(x => x !== i) : [...s.passed, i];
} else if (act === 'ics') {
const list = S.role === 'tutor' ? S.lessons : S.lessons.filter(l => l.studentId === S.me);
await saveFile('lessons.ics', calendarText(sortLessons(list)), 'text/calendar');
return;
} else if (act === 'hwdl') {
await saveFile('homework.txt', homeworkText(), 'text/plain');
return;
} else if (act === 'perm') {
if ('Notification' in window) {
const r = await Notification.requestPermission();
toast(r === 'granted' ? 'Напоминания включены' : 'Браузер не разрешил уведомления');
} else toast('Уведомления недоступны в этом браузере');
return;
} else if (act === 'delStudent') {
if (!confirm('Удалить ученика вместе с его уроками и домашкой?')) return;
S.students = S.students.filter(x => x.id !== id);
S.lessons = S.lessons.filter(x => x.studentId !== id);
S.homework = S.homework.filter(x => x.studentId !== id);
} else if (act === 'delLesson') {
S.lessons = S.lessons.filter(x => x.id !== id);
} else if (act === 'delHw') {
S.homework = S.homework.filter(x => x.id !== id);
} else if (act === 'copyhw') {
const h = S.homework.find(x => x.id === id);
const st = h && studentOf(h.studentId);
const text = (st ? st.name + ', ' : '') + 'домашка: ' + h.text + '. Срок: ' + shortDate(h.due) + '.';
try { await navigator.clipboard.writeText(text); toast('Скопировано, вставьте в чат'); }
catch (err) { toast('Не удалось скопировать'); }
return;
} else if (act === 'start') {
TAKING = id; SCREEN = 'tests'; render(); window.scrollTo(0, 0); return;
} else if (act === 'delTest') {
S.tests = (S.tests || []).filter(x => x.id !== id);
} else if (act === 'copy') {
const l = S.lessons.find(x => x.id === id);
try { await navigator.clipboard.writeText(l.link); toast('Ссылка скопирована'); }
catch (err) { toast(l.link); }
return;
}
save(); render();
});
document.addEventListener('change', e => {
if (e.target.matches('[data-script]')) {
const l = S.lessons.find(x => x.id === e.target.dataset.script);
if (l) { l.script = e.target.value; save(); toast('Сценарий сохранён'); }
return;
}
if (e.target.matches('[data-field="me"]')) { S.me = e.target.value; save(); render(); }
});
document.addEventListener('submit', e => {
const form = e.target.closest('[data-form]');
if (!form) return;
e.preventDefault();
const f = new FormData(form);
const kind = form.dataset.form;
if (kind === 'lesson') {
S.lessons.push({ id: uid(), date: f.get('date'), time: f.get('time'), dur: Number(f.get('dur')),
studentId: f.get('studentId'), topic: (f.get('topic') || '').trim() || 'Без темы', script: String(f.get('script') || '').trim(), paid: false,
link: TL + Math.floor(1e10 + Math.random() * 9e10) });
} else if (kind === 'hw') {
S.homework.push({ id: uid(), studentId: f.get('studentId'), text: String(f.get('text')).trim(),
due: f.get('due'), status: 'new' });
} else if (kind === 'register') {
api('register', { profile: { name: f.get('name'), grade: f.get('grade'), goal: f.get('goal'), phone: f.get('phone'), email: f.get('email') } })
.then(j => { if (j.error) { toast(j.error === 'need_username' ? 'Сначала задайте username в Telegram' : 'Заполните ФИО'); return; } NEW = false; applyView(j.state, 'student'); lastRemote = JSON.stringify(j.state); render(); toast('Профиль создан'); });
return;
} else if (kind === 'profile') {
const st = studentOf(form.dataset.id);
if (st) { st.name = String(f.get('name')).trim(); st.phone = f.get('phone'); st.email = f.get('email'); st.grade = f.get('grade'); st.goal = f.get('goal'); }
} else if (kind === 'lessonEdit') {
const l = S.lessons.find(x => x.id === form.dataset.id);
if (l) { l.materials = parseMat(f.get('materials')); l.script = String(f.get('script') || '').trim(); l.homework = String(f.get('homework') || '').trim(); l.homeworkDue = f.get('homeworkDue') || ''; }
} else if (kind === 'takeTest') {
const t = (S.tests || []).find(x => x.id === form.dataset.id);
const answers = t ? t.questions.map((_, i) => Number(f.get('q' + i))) : [];
api('submitTest', { testId: form.dataset.id, answers }).then(j => {
if (j.error) { toast(j.error === 'done' ? 'Тест уже пройден' : 'Не удалось отправить'); return; }
applyView(j.state, 'student'); lastRemote = JSON.stringify(j.state); TAKING = null;
toast('Результат: ' + j.result.score + ' из ' + j.result.total); render();
});
return;
} else if (kind === 'test') {
const questions = String(f.get('questions') || '').split('\n').map(x => x.split('|').map(y => y.trim())).map(parts => {
const correct = parseInt(parts[parts.length - 1], 10) - 1;
const options = parts.slice(1, -1).filter(Boolean);
return parts[0] && options.length >= 2 && correct >= 0 && correct < options.length ? { text: parts[0], options, correct } : null;
}).filter(Boolean);
if (!questions.length) { toast('Нет корректных вопросов. Формат: вопрос | вариант | вариант | номер верного'); return; }
S.tests = [...(S.tests || []), { id: uid(), title: String(f.get('title')).trim(), assign: f.get('assign'), questions }];
} else if (kind === 'facts') {
S.teacherFacts = String(f.get('facts') || '').split('\n').map(x => x.trim()).filter(Boolean);
} else if (kind === 'editStudent') {
const st = studentOf(form.dataset.id);
if (st) {
const exam = f.get('exam');
const newTopics = exam === 'ЕГЭ' ? EGE_TOPICS : (exam === 'ОГЭ' ? OGE_TOPICS : SCHOOL_TOPICS);
if (exam !== st.exam) { st.passed = []; st.topics = newTopics; }
st.name = String(f.get('name')).trim();
st.exam = exam;
st.price = Number(f.get('price'));
st.username = String(f.get('username') || '').replace('@', '').trim();
}
} else if (kind === 'student') {
const exam = f.get('exam');
S.students.push({ id: 's' + uid(), name: String(f.get('name')).trim(), exam, price: Number(f.get('price')),
username: String(f.get('username') || '').replace('@', '').trim(),
topics: exam === 'ЕГЭ' ? EGE_TOPICS : (exam === 'ОГЭ' ? OGE_TOPICS : SCHOOL_TOPICS), passed: [] });
}
save(); render(); toast('Сохранено');
});
render();
pull();
setInterval(() => pull(), 15000);
setInterval(remind, 60000);
remind();
</script>
</body>
</html>