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:
@@ -85,6 +85,22 @@ function pendingHomework(uid) {
|
|||||||
} catch (e) { return { overdue: null, dueSoon: null }; }
|
} 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 ───────────────────────────────────────── */
|
/* ── GET /api/assistant/context ───────────────────────────────────────── */
|
||||||
function getContext(req, res) {
|
function getContext(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
@@ -97,10 +113,11 @@ function getContext(req, res) {
|
|||||||
} catch (e) { /* table may be missing on a legacy instance */ }
|
} catch (e) { /* table may be missing on a legacy instance */ }
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
enabled: u ? u.assistant_enabled !== 0 : true,
|
enabled: u ? u.assistant_enabled !== 0 : true,
|
||||||
seen,
|
seen,
|
||||||
dueCards: dueCardsCount(uid),
|
dueCards: dueCardsCount(uid),
|
||||||
homework: pendingHomework(uid),
|
homework: pendingHomework(uid),
|
||||||
|
activeLesson: activeLesson(uid, req.user.role),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-3
@@ -91,6 +91,10 @@
|
|||||||
when: function () { return !!(SRV && SRV.homework && SRV.homework.dueSoon); },
|
when: function () { return !!(SRV && SRV.homework && SRV.homework.dueSoon); },
|
||||||
text: function () { return 'Скоро дедлайн: «' + (SRV.homework.dueSoon.title || 'задание') + '».'; },
|
text: function () { return 'Скоро дедлайн: «' + (SRV.homework.dueSoon.title || 'задание') + '».'; },
|
||||||
action: function () { return { label: 'К домашке', url: '/homework' }; } },
|
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,
|
{ id: 'cards-due', scope: 'proactive', cooldownDays: 1, maxShows: 60,
|
||||||
when: function () { return !!(SRV && SRV.dueCards > 0); },
|
when: function () { return !!(SRV && SRV.dueCards > 0); },
|
||||||
text: function () { return 'К повторению ' + SRV.dueCards + ' ' + plural(SRV.dueCards, 'карточка', 'карточки', 'карточек') + ' — освежим память?'; },
|
text: function () { return 'К повторению ' + SRV.dueCards + ' ' + plural(SRV.dueCards, 'карточка', 'карточки', 'карточек') + ' — освежим память?'; },
|
||||||
@@ -254,14 +258,16 @@
|
|||||||
|
|
||||||
function greetHtml() {
|
function greetHtml() {
|
||||||
return '<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
|
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>' +
|
'<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() {
|
function showGreet() {
|
||||||
openBubble(greetHtml(), {});
|
openBubble(greetHtml(), {});
|
||||||
bubble.querySelector('[data-a="ok"]').onclick = closeBubble;
|
bubble.querySelector('[data-a="ok"]').onclick = closeBubble;
|
||||||
bubble.querySelector('[data-a="ask"]').onclick = openAsk;
|
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>'; });
|
}).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() {
|
function mount() {
|
||||||
ensureStyles();
|
ensureStyles();
|
||||||
@@ -309,6 +407,15 @@
|
|||||||
else showGreet();
|
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();
|
var cel = celebration();
|
||||||
if (cel) {
|
if (cel) {
|
||||||
@@ -342,5 +449,8 @@
|
|||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 400); });
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 400); });
|
||||||
else 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(); },
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user