Files
Learn_System/frontend/js/assistant.js
T
Maxim Dolgolyov 2f3fd7475b fix(assistant): тур не залипает на нижних шагах
Подсказка тура уезжала за край на нижних пунктах сайдбара (Питомец), а оверлей
ловил клики → «ничего не сделать». Теперь: scrollIntoView ДО замера позиции,
подсказка жёстко клампится в пределах экрана (меряем реальный размер), и клик
по затемнённому фону закрывает тур (escape-hatch вдобавок к Esc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:53:33 +03:00

471 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* assistant.js — «Квантик-ассистент»: плавающий компаньон на всех страницах.
* Подсказки по контексту + проактивные напоминания + поздравления + «Спроси».
* Правиловый движок (без модели). Состояние «видел» — на сервере (assistant_seen),
* дневной лимит/детект событий — в localStorage. Лицо = pet-sprite.js, данные —
* /api/assistant/context и /api/pet. Гейт фичи 'pet' проверяется на сервере.
* Грузится через sidebar.js (app-страницы) и серверный inject (учебник). */
(function () {
if (window.__assistantBooted) return;
window.__assistantBooted = true;
if (window.parent !== window) return; // не в iframe/embed
if (!window.LS || !LS.getToken || !LS.getToken()) return; // только залогиненным
var DAILY_CAP = 2; // консервативно: не больше 2 авто-подсказок в день
var AUTO_DELAY = 7000; // показать подсказку через 7с на странице
var reduceMotion = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
var SRV = null, PET = null, picked = null, root = null, bubble = null, openState = false;
/* ── helpers ─────────────────────────────────────────────────────────── */
function esc(s) { return (window.LS && LS.escapeHtml) ? LS.escapeHtml(String(s == null ? '' : s)) : String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function todayKey() { return new Date().toISOString().slice(0, 10); }
function pageId() {
var p = location.pathname.replace(/\/+$/, '') || '/';
if (p === '/' || p === '/dashboard') return 'dashboard';
if (p.indexOf('/textbook') === 0) return 'textbook';
if (p === '/classroom') return 'classroom';
if (p === '/board') return 'board';
if (p.indexOf('/exam') === 0) return 'exam';
if (p === '/flashcards') return 'flashcards';
if (p === '/my-materials') return 'materials';
if (p === '/lab') return 'lab';
if (p === '/theory' || p.indexOf('/course') === 0 || p.indexOf('/lesson') === 0) return 'theory';
return 'other';
}
var PAGE = pageId();
var SUPPRESS_PAGE = (PAGE === 'classroom'); // не мешаем на живом уроке
function quest(undoneOnly) {
var qs = (PET && PET.quests) || [];
for (var i = 0; i < qs.length; i++) if (!undoneOnly || !qs[i].done) return qs[i];
return null;
}
function activeToday() {
if (!PET) return true;
if (PET.daysSinceLogin === 0) {
var q = (PET.quests || []).find ? (PET.quests || []).find(function (x) { return x.id === 'xp30'; }) : null;
return q ? (q.progress || 0) > 0 : true; // есть прогресс сегодня
}
return false;
}
/* ── каталог правил ──────────────────────────────────────────────────── */
// scope: page | proactive | celebration. when(C) → bool. action(C) → {label,url}|null.
var RULES = [
// — контекстные —
{ id: 'p-textbook', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'textbook'; },
text: function () { return 'Любой кусок страницы можно вырезать картинкой в «Мои материалы» — кнопка «Вырезать область» внизу.'; },
action: function () { return null; } },
{ id: 'p-exam', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'exam'; },
text: function () { return 'Три режима: экзамен (как на ЦТ/ЦЭ), тренировка (с разбором) и случайный. Выбирай под свою цель.'; },
action: function () { return null; } },
{ id: 'p-flashcards', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'flashcards'; },
text: function () { return 'Формулы в карточках вводятся через KaTeX-палитру, а ещё можно добавить картинку.'; },
action: function () { return null; } },
{ id: 'p-materials', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'materials'; },
text: function () { return 'Раскладывай материалы по папкам, а поверх фото можно рисовать — кнопка с карандашом.'; },
action: function () { return null; } },
{ id: 'p-lab', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'lab'; },
text: function () { return 'Симуляции запускаются прямо в браузере — ничего ставить не нужно.'; },
action: function () { return null; } },
{ id: 'p-dashboard', scope: 'page', cooldownDays: 30, maxShows: 1,
when: function () { return PAGE === 'dashboard'; },
text: function () { return 'Виджеты на дашборде можно включать и переставлять под себя.'; },
action: function () { return null; } },
// — проактивные (из реальных данных) —
{ id: 'hw-overdue', scope: 'proactive', cooldownDays: 1, maxShows: 30,
when: function () { return !!(SRV && SRV.homework && SRV.homework.overdue); },
text: function () { return 'Просрочена домашка: «' + (SRV.homework.overdue.title || 'задание') + '». Загляни в раздел.'; },
action: function () { return { label: 'К домашке', url: '/homework' }; } },
{ id: 'hw-soon', scope: 'proactive', cooldownDays: 1, maxShows: 30,
when: function () { return !!(SRV && SRV.homework && SRV.homework.dueSoon); },
text: function () { return 'Скоро дедлайн: «' + (SRV.homework.dueSoon.title || 'задание') + '».'; },
action: function () { return { label: 'К домашке', url: '/homework' }; } },
{ id: 'lesson-continue', scope: 'proactive', cooldownDays: 1, maxShows: 60,
when: function () { return !!(SRV && SRV.activeLesson && SRV.activeLesson.courseId); },
text: function () { return 'Продолжи: «' + (SRV.activeLesson.courseTitle || SRV.activeLesson.lessonTitle || 'урок') + '».'; },
action: function () { return { label: 'Продолжить', url: '/course?id=' + SRV.activeLesson.courseId }; } },
{ id: 'cards-due', scope: 'proactive', cooldownDays: 1, maxShows: 60,
when: function () { return !!(SRV && SRV.dueCards > 0); },
text: function () { return 'К повторению ' + SRV.dueCards + ' ' + plural(SRV.dueCards, 'карточка', 'карточки', 'карточек') + ' — освежим память?'; },
action: function () { return { label: 'Повторить', url: '/flashcards' }; } },
{ id: 'streak-risk', scope: 'proactive', cooldownDays: 1, maxShows: 60,
when: function () { return !!(PET && PET.streakCurrent >= 1 && !activeToday() && new Date().getHours() >= 18); },
text: function () { return 'Серия ' + PET.streakCurrent + ' ' + plural(PET.streakCurrent, 'день', 'дня', 'дней') + ' под угрозой — позанимайся сегодня, чтобы не потерять.'; },
action: function () { return { label: 'Заниматься', url: '/exam-prep' }; } },
{ id: 'quest', scope: 'proactive', cooldownDays: 1, maxShows: 90,
when: function () { return !!(quest(true) && new Date().getHours() >= 16); },
text: function () { var q = quest(true); return 'Остался квест дня: «' + (q.label || 'задание') + '».'; },
action: function () { return null; } },
];
function plural(n, one, few, many) {
var m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
/* ── выбор подсказки ─────────────────────────────────────────────────── */
function eligible(rule) {
if (SUPPRESS_PAGE && rule.scope !== 'celebration') return false;
var s = (SRV && SRV.seen && SRV.seen[rule.id]) || null;
if (s && s.dismissed) return false;
if (s && rule.maxShows && s.count >= rule.maxShows) return false;
if (s && s.lastAt && rule.cooldownDays) {
var days = (Date.now() - Date.parse(s.lastAt + 'Z')) / 86400000;
if (days < rule.cooldownDays) return false;
}
try { return !!rule.when(); } catch (e) { return false; }
}
function pickRule() {
var order = { celebration: 3, proactive: 2, page: 1 };
var cands = RULES.filter(eligible).sort(function (a, b) { return (order[b.scope] || 0) - (order[a.scope] || 0); });
return cands[0] || null;
}
/* ── поздравления (детект по дельте, localStorage) ───────────────────── */
function celebration() {
if (!PET) return null;
var lvl = PET.petLevel || 1;
var prevLvl = parseInt(lsGet('asst_lvl') || '', 10);
if (!isNaN(prevLvl) && lvl > prevLvl) {
lsSet('asst_lvl', String(lvl));
return { id: 'cel-level', mood: 'ecstatic', text: 'Ура! ' + (PET.petName || 'Квантик') + ' дорос до уровня ' + lvl + '! Так держать.' };
}
if (isNaN(prevLvl)) lsSet('asst_lvl', String(lvl));
var ms = [3, 7, 14, 30, 60, 100];
var cur = PET.streakCurrent || 0;
var prevMs = parseInt(lsGet('asst_streak_ms') || '0', 10) || 0;
var hit = 0;
for (var i = 0; i < ms.length; i++) if (cur >= ms[i]) hit = ms[i];
if (hit > prevMs) {
lsSet('asst_streak_ms', String(hit));
return { id: 'cel-streak', mood: 'ecstatic', text: 'Серия ' + hit + ' ' + plural(hit, 'день', 'дня', 'дней') + ' подряд! Огонь.' };
}
if (hit > 0 && prevMs === 0) lsSet('asst_streak_ms', String(hit));
return null;
}
/* ── дневной лимит авто-показов ──────────────────────────────────────── */
function dayCount() {
if (lsGet('asst_day') !== todayKey()) { lsSet('asst_day', todayKey()); lsSet('asst_day_n', '0'); }
return parseInt(lsGet('asst_day_n') || '0', 10) || 0;
}
function bumpDay() { lsSet('asst_day', todayKey()); lsSet('asst_day_n', String(dayCount() + 1)); }
/* ── PetSprite ───────────────────────────────────────────────────────── */
function ensurePet(cb) {
if (window.PetSprite && PetSprite.render) return cb();
var s = document.createElement('script'); s.src = '/js/pet-sprite.js';
s.onload = cb; s.onerror = cb; document.head.appendChild(s);
}
function faceSVG(mood) {
try {
if (window.PetSprite && PetSprite.render) {
return PetSprite.render(PET ? (PET.petLevel || 1) : 1, mood || (PET && PET.mood) || 'happy',
(PET && PET.accessories) || [], (PET && PET.petColor) || 'purple', (PET && PET.streakCurrent) || 0);
}
} catch (e) {}
return '<svg viewBox="0 0 100 100"><circle cx="55" cy="58" r="34" fill="#9B5DE5"/></svg>';
}
/* ── styles ──────────────────────────────────────────────────────────── */
function ensureStyles() {
if (document.getElementById('asst-style')) return;
var s = document.createElement('style'); s.id = 'asst-style';
s.textContent = [
'.asst-root{position:fixed;left:18px;bottom:18px;z-index:8500;font-family:Manrope,system-ui,sans-serif;transition:left .28s cubic-bezier(.4,0,.2,1);}',
// в приложении сдвигаем правее сайдбара (230/62px), чтобы не перекрывать профиль в сайдбаре
'.app-layout ~ .asst-root{left:248px;}',
'.app-layout.sb-collapsed ~ .asst-root{left:80px;}',
'.asst-fab{width:54px;height:54px;border-radius:50%;border:none;background:#fff;cursor:pointer;padding:4px;',
' box-shadow:0 8px 24px rgba(139,92,246,.32);transition:transform .15s;position:relative;display:block;}',
'.asst-fab:hover{transform:translateY(-2px) scale(1.04);}',
'.asst-fab svg{width:100%;height:100%;display:block;}',
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
'.asst-bubble{position:absolute;left:0;bottom:64px;width:300px;max-width:78vw;background:#fff;border-radius:16px;',
' box-shadow:0 18px 50px rgba(15,23,42,.22);padding:14px 16px;border:1px solid rgba(15,23,42,.07);',
' opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .18s,transform .18s;}',
'.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}',
'.asst-x{position:absolute;top:8px;right:8px;width:26px;height:26px;border:none;background:transparent;color:#8a94a6;',
' cursor:pointer;border-radius:7px;font-size:18px;line-height:1;}',
'.asst-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}',
'.asst-name{font-size:.7rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px;}',
'.asst-text{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;white-space:pre-line;}',
'.asst-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}',
'.asst-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:99px;border:none;cursor:pointer;',
' font:700 .78rem Manrope,sans-serif;background:#9B5DE5;color:#fff;text-decoration:none;}',
'.asst-btn:hover{background:#7e3eca;}',
'.asst-link{background:none;border:none;color:#8a94a6;cursor:pointer;font:600 .76rem Manrope,sans-serif;padding:4px 2px;text-decoration:none;}',
'.asst-link:hover{color:#9B5DE5;}',
'.asst-ask-in{width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #e2e8f0;border-radius:10px;font:inherit;font-size:.84rem;margin-bottom:10px;}',
'.asst-ans{font-size:.82rem;line-height:1.5;color:#28324a;border-top:1px solid rgba(15,23,42,.06);padding:9px 0;}',
'.asst-ans:first-of-type{border-top:none;}',
'.asst-ans-q{font-weight:700;color:#0F172A;margin-bottom:2px;}',
'.asst-ans-link{display:inline-block;margin-top:4px;color:#9B5DE5;font-weight:700;font-size:.78rem;text-decoration:none;}',
'.asst-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}',
// на мобиле сайдбар — выезжающая шторка, контент во всю ширину → к левому краю
'@media(max-width:768px){.asst-root,.app-layout ~ .asst-root,.app-layout.sb-collapsed ~ .asst-root{left:12px;bottom:18px;}.asst-fab{width:48px;height:48px;}}',
].join('');
document.head.appendChild(s);
}
/* ── рендер ──────────────────────────────────────────────────────────── */
function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); }
function openBubble(html, opts) {
opts = opts || {};
bubble.innerHTML = '<button class="asst-x" aria-label="Закрыть">&times;</button>' + html;
bubble.querySelector('.asst-x').onclick = closeBubble;
bubble.classList.add('open');
openState = true;
root.querySelector('.asst-fab').classList.remove('pulse');
root.querySelector('.asst-dot') && root.querySelector('.asst-dot').remove();
if (opts.mood) setFace(opts.mood);
}
function closeBubble() { bubble.classList.remove('open'); openState = false; setFace(); }
function hintHtml(rule) {
var act = null; try { act = rule.action(); } catch (e) {}
var actHtml = act && act.url ? '<a class="asst-btn" href="' + esc(act.url) + '">' + esc(act.label || 'Открыть') + '</a>' : '';
var dismiss = (rule.scope !== 'celebration') ? '<button class="asst-link" data-a="dismiss">Не показывать</button>' : '';
return '<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">' + esc(rule.text ? rule.text() : rule.text) + '</div>' +
'<div class="asst-actions">' + actHtml +
'<button class="asst-link" data-a="ok">Понятно</button>' + dismiss +
'<button class="asst-link" data-a="ask" style="margin-left:auto">Спросить</button></div>';
}
function showRule(rule, isCelebration) {
openBubble(hintHtml(rule), { mood: rule.mood });
// отметить показ (для page/proactive — на сервере; celebration — локально через дельту)
if (!isCelebration) { try { LS.assistantSeen(rule.id); } catch (e) {} bumpDay(); }
bubble.querySelector('[data-a="ok"]').onclick = closeBubble;
var dz = bubble.querySelector('[data-a="dismiss"]');
if (dz) dz.onclick = function () { try { LS.assistantDismiss(rule.id); } catch (e) {} closeBubble(); };
bubble.querySelector('[data-a="ask"]').onclick = openAsk;
}
function greetHtml() {
return '<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">Привет! Я помогу разобраться в системе. Спроси, как что-то сделать, или пройди короткий тур.</div>' +
'<div class="asst-actions"><button class="asst-btn" data-a="ask">Спросить</button>' +
'<button class="asst-link" data-a="tour">Тур по системе</button>' +
'<button class="asst-link" data-a="ok" style="margin-left:auto">Закрыть</button></div>';
}
function showGreet() {
openBubble(greetHtml(), {});
bubble.querySelector('[data-a="ok"]').onclick = closeBubble;
bubble.querySelector('[data-a="ask"]').onclick = openAsk;
bubble.querySelector('[data-a="tour"]').onclick = function () { startTour(); };
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
function openAsk() {
openBubble(
'<div class="asst-name">Спроси Квантика</div>' +
'<input class="asst-ask-in" type="text" placeholder="Например: как сохранить кусок учебника" maxlength="200" />' +
'<div class="asst-ans-box"></div>', {});
var inp = bubble.querySelector('.asst-ask-in');
var box = bubble.querySelector('.asst-ans-box');
inp.focus();
var t = null;
inp.addEventListener('input', function () { clearTimeout(t); t = setTimeout(function () { runAsk(inp.value, box); }, 350); });
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { clearTimeout(t); runAsk(inp.value, box); } });
}
function runAsk(q, box) {
q = (q || '').trim();
if (q.length < 3) { box.innerHTML = ''; return; }
box.innerHTML = '<div class="asst-empty">Ищу…</div>';
LS.assistantAsk(q).then(function (r) {
var ans = (r && r.answers) || [];
if (!ans.length) { box.innerHTML = '<div class="asst-empty">Не нашёл точного ответа. Попробуй переформулировать.</div>'; return; }
box.innerHTML = ans.map(function (a) {
return '<div class="asst-ans"><div class="asst-ans-q">' + esc(a.q) + '</div>' + esc(a.a) +
(a.url ? '<br><a class="asst-ans-link" href="' + esc(a.url) + '">Открыть</a>' : '') + '</div>';
}).join('');
}).catch(function () { box.innerHTML = '<div class="asst-empty">Не удалось получить ответ.</div>'; });
}
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
var TOUR = [
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
{ sel: '#app-sidebar a[href="/exam-prep/math9"]', title: 'Подготовка к экзамену', text: 'Тесты по темам, режимы экзамена и тренировки.' },
{ sel: '#app-sidebar a[href="/theory"]', title: 'Теория', text: 'Курсы и уроки. Можно создать и быстрый одиночный урок.' },
{ sel: '#app-sidebar a[href="/flashcards"]', title: 'Флэшкарты', text: 'Карточки для повторения — система сама напомнит, что освежить.' },
{ sel: '#app-sidebar a[href="/my-materials"]', title: 'Мои материалы', text: 'Сюда сохраняются вырезки из учебника, заметки и рисунки.' },
{ sel: '#app-sidebar a[href="/pet"]', title: 'Питомец', text: 'Квантик растёт от занятий и серий — заглядывай к нему.' },
{ sel: '.asst-fab', title: 'Я рядом', text: 'Нажми на меня в любой момент, чтобы спросить «как сделать…».' },
];
function vis(el) { return !!(el && el.getBoundingClientRect && el.getBoundingClientRect().width > 0 && el.offsetParent !== null); }
function ensureTourStyles() {
if (document.getElementById('asst-tour-style')) return;
var s = document.createElement('style'); s.id = 'asst-tour-style';
s.textContent = [
'.asst-tour-ov{position:fixed;inset:0;z-index:9600;}',
'.asst-tour-ring{position:absolute;border-radius:10px;border:2px solid #9B5DE5;box-shadow:0 0 0 9999px rgba(15,12,30,.55);transition:all .2s ease;pointer-events:none;}',
'.asst-tour-tip{position:fixed;width:280px;max-width:84vw;background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 18px 50px rgba(15,23,42,.3);font-family:Manrope,system-ui,sans-serif;}',
'.asst-tour-h{font-size:.78rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px;}',
'.asst-tour-t{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;}',
'.asst-tour-nav{display:flex;align-items:center;gap:8px;}',
'.asst-tour-pg{font-size:.72rem;color:#8a94a6;font-weight:700;}',
].join('');
document.head.appendChild(s);
}
function startTour() {
closeBubble();
ensureTourStyles();
var steps = TOUR.filter(function (st) { return !st.sel || vis(document.querySelector(st.sel)); });
if (!steps.length) return;
var i = 0;
var ov = document.createElement('div'); ov.className = 'asst-tour-ov'; ov.setAttribute('data-h2c-ignore', '');
ov.innerHTML = '<div class="asst-tour-ring"></div><div class="asst-tour-tip"></div>';
document.body.appendChild(ov);
var ring = ov.querySelector('.asst-tour-ring');
var tip = ov.querySelector('.asst-tour-tip');
ov.addEventListener('click', function (e) { if (e.target === ov) finish(); }); // клик по фону — выход
function finish() {
ov.remove();
window.removeEventListener('resize', render);
document.removeEventListener('keydown', onKey);
try { LS.assistantDismiss('onboarding'); } catch (e) {}
}
function onKey(e) { if (e.key === 'Escape') finish(); }
function render() {
var st = steps[i];
var el = st.sel ? document.querySelector(st.sel) : null;
// сперва проматываем элемент в зону видимости, ПОТОМ меряем (иначе позиция стухшая)
if (el && vis(el) && el.scrollIntoView) { try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (e) {} }
var r = el && vis(el) ? el.getBoundingClientRect() : null;
// контент сначала — чтобы измерить реальный размер подсказки и привязать кнопки
tip.innerHTML =
'<div class="asst-tour-h">' + esc(st.title) + '</div>' +
'<div class="asst-tour-t">' + esc(st.text) + '</div>' +
'<div class="asst-tour-nav"><span class="asst-tour-pg">' + (i + 1) + ' / ' + steps.length + '</span>' +
'<span style="margin-left:auto"></span>' +
(i > 0 ? '<button class="asst-link" data-a="back">Назад</button>' : '') +
'<button class="asst-link" data-a="skip">Пропустить</button>' +
'<button class="asst-btn" data-a="next">' + (i === steps.length - 1 ? 'Готово' : 'Далее') + '</button></div>';
tip.querySelector('[data-a="next"]').onclick = function () { if (i === steps.length - 1) finish(); else { i++; render(); } };
var b = tip.querySelector('[data-a="back"]'); if (b) b.onclick = function () { i--; render(); };
tip.querySelector('[data-a="skip"]').onclick = finish;
// позиционирование: подсказка ВСЕГДА целиком в пределах экрана
var W = window.innerWidth, H = window.innerHeight;
var tw = tip.offsetWidth || 280, th = tip.offsetHeight || 150;
tip.style.transform = '';
if (r) {
var pad = 6;
ring.style.display = 'block';
ring.style.left = (r.left - pad) + 'px'; ring.style.top = (r.top - pad) + 'px';
ring.style.width = (r.width + pad * 2) + 'px'; ring.style.height = (r.height + pad * 2) + 'px';
ov.style.background = '';
var tx = r.right + 14;
if (tx + tw > W - 12) tx = r.left - tw - 14; // не влезает справа — показать слева
tx = Math.max(12, Math.min(tx, W - tw - 12));
var ty = Math.max(12, Math.min(r.top, H - th - 12));
tip.style.left = tx + 'px'; tip.style.top = ty + 'px';
} else {
ring.style.display = 'none';
ov.style.background = 'rgba(15,12,30,.55)';
tip.style.left = Math.max(12, (W - tw) / 2) + 'px';
tip.style.top = Math.max(12, (H - th) / 2) + 'px';
}
}
window.addEventListener('resize', render);
document.addEventListener('keydown', onKey);
render();
}
function onboardingOffer() {
openBubble(
'<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">Привет! Я ' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '. Показать за минуту, что где в системе?</div>' +
'<div class="asst-actions"><button class="asst-btn" data-a="tour">Показать тур</button>' +
'<button class="asst-link" data-a="later">Позже</button></div>', {});
try { LS.assistantSeen('onboarding'); } catch (e) {}
bubble.querySelector('[data-a="tour"]').onclick = function () { startTour(); };
bubble.querySelector('[data-a="later"]').onclick = closeBubble;
}
/* ── монтирование ────────────────────────────────────────────────────── */
function mount() {
ensureStyles();
root = document.createElement('div');
root.className = 'asst-root';
root.setAttribute('data-h2c-ignore', ''); // не попадать в скриншоты учебника
root.innerHTML =
'<div class="asst-bubble" role="dialog" aria-live="polite"></div>' +
'<button class="asst-fab" aria-label="Помощник Квантик"><span class="asst-face">' + faceSVG() + '</span></button>';
document.body.appendChild(root);
bubble = root.querySelector('.asst-bubble');
var fab = root.querySelector('.asst-fab');
fab.onclick = function () {
if (openState) return closeBubble();
if (picked) showRule(picked, picked.scope === 'celebration');
else showGreet();
};
// Онбординг новичка — приоритетно на дашборде, пока не пройден/не закрыт
var ob = (SRV.seen && SRV.seen['onboarding']) || {};
if (PAGE === 'dashboard' && !ob.dismissed && (ob.count || 0) < 3) {
var d0 = document.createElement('span'); d0.className = 'asst-dot'; fab.appendChild(d0);
fab.classList.add('pulse');
setTimeout(function () { if (!openState) onboardingOffer(); }, 1500);
return;
}
// поздравление — сразу; иначе подсказка — через паузу (с учётом дневного лимита)
var cel = celebration();
if (cel) {
picked = { id: cel.id, scope: 'celebration', text: cel.text, mood: cel.mood, action: function () { return null; } };
var dot = document.createElement('span'); dot.className = 'asst-dot'; fab.appendChild(dot);
fab.classList.add('pulse');
setTimeout(function () { if (!openState) showRule(picked, true); }, 1200);
return;
}
picked = pickRule();
if (picked && dayCount() < DAILY_CAP) {
var d = document.createElement('span'); d.className = 'asst-dot'; fab.appendChild(d);
fab.classList.add('pulse');
setTimeout(function () { if (!openState && picked) showRule(picked, false); }, AUTO_DELAY);
}
}
/* ── boot ────────────────────────────────────────────────────────────── */
function boot() {
if (!document.body) { return setTimeout(boot, 200); }
LS.assistantContext().then(function (ctx) {
SRV = ctx || {};
if (SRV.enabled === false) return; // выключено пользователем
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
PET = pet || null;
ensurePet(mount);
});
}).catch(function () { /* фича выключена / нет доступа — тихо выходим */ });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 400); });
else setTimeout(boot, 400);
window.Assistant = {
open: function () { if (root) root.querySelector('.asst-fab').click(); },
tour: function () { startTour(); },
};
})();