feat(assistant): Ф2 онбординг-тур + проактив «продолжи урок»

Ф2: коачмарк-тур новичка по разделам (сайдбар + сам помощник), офер на
дашборде пока не пройден/не закрыт, повтор из приветствия и Assistant.tour().
activeLesson: контекст-эндпоинт отдаёт начатый незавершённый урок (как
«продолжить чтение»), добавлено проактивное правило «Продолжи …» → /course.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 16:30:34 +03:00
parent 3f8009c59d
commit 9baaca7f68
2 changed files with 133 additions and 6 deletions
+20 -3
View File
@@ -85,6 +85,22 @@ function pendingHomework(uid) {
} catch (e) { return { overdue: null, dueSoon: null }; }
}
function activeLesson(uid, role) {
// Начатый, но не завершённый урок (как «продолжить чтение» на дашборде).
try {
const pub = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
const row = db.prepare(`
SELECT l.id AS lessonId, l.title AS lessonTitle, l.course_id AS courseId, c.title AS courseTitle
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN courses c ON l.course_id = c.id
WHERE lp.user_id = ? AND lp.completed = 0 ${pub}
ORDER BY lp.updated_at DESC LIMIT 1
`).get(uid);
return row || null;
} catch (e) { return null; }
}
/* ── GET /api/assistant/context ───────────────────────────────────────── */
function getContext(req, res) {
const uid = req.user.id;
@@ -97,10 +113,11 @@ function getContext(req, res) {
} catch (e) { /* table may be missing on a legacy instance */ }
res.json({
enabled: u ? u.assistant_enabled !== 0 : true,
enabled: u ? u.assistant_enabled !== 0 : true,
seen,
dueCards: dueCardsCount(uid),
homework: pendingHomework(uid),
dueCards: dueCardsCount(uid),
homework: pendingHomework(uid),
activeLesson: activeLesson(uid, req.user.role),
});
}
+113 -3
View File
@@ -91,6 +91,10 @@
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, 'карточка', 'карточки', 'карточек') + ' — освежим память?'; },
@@ -254,14 +258,16 @@
function greetHtml() {
return '<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">Привет! Я помогу разобраться в системе. Спроси, как что-то сделать.</div>' +
'<div class="asst-text">Привет! Я помогу разобраться в системе. Спроси, как что-то сделать, или пройди короткий тур.</div>' +
'<div class="asst-actions"><button class="asst-btn" data-a="ask">Спросить</button>' +
'<button class="asst-link" data-a="ok">Закрыть</button></div>';
'<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(); };
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
@@ -291,6 +297,98 @@
}).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');
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;
var r = el && vis(el) ? el.getBoundingClientRect() : null;
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, ty = Math.max(12, r.top);
if (tx + 290 > window.innerWidth) tx = Math.max(12, r.left - 294); // показать слева, если справа не влезает
tip.style.left = Math.min(tx, window.innerWidth - 290) + 'px';
tip.style.top = Math.min(ty, window.innerHeight - 160) + 'px';
tip.style.transform = '';
} else {
ring.style.display = 'none';
ov.style.background = 'rgba(15,12,30,.55)';
tip.style.left = '50%'; tip.style.top = '50%'; tip.style.transform = 'translate(-50%,-50%)';
}
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;
if (el && el.scrollIntoView) { try { el.scrollIntoView({ block: 'nearest' }); } catch (e) {} }
}
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();
@@ -309,6 +407,15 @@
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) {
@@ -342,5 +449,8 @@
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(); } };
window.Assistant = {
open: function () { if (root) root.querySelector('.asst-fab').click(); },
tour: function () { startTour(); },
};
})();