buhapp-telegram/public/js/views/onboarding.js
root 4706203de8 Fix: onboarding overlay не сносит #app (#app удалялся root.innerHTML='')
- Убрал root.innerHTML='' в OnboardingView.render
- Оверлей теперь fixed/absolute поверх body — лейаут не страдает
- App.showView('map') после пропуска находит #app нормально
2026-08-20 21:21:14 +00:00

273 lines
10 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.

// Onboarding view — welcome-туториал для новых пользователей.
// Использование: OnboardingView.render(root).then(() => showView('map'))
// render() возвращает Promise, который резолвится когда пользователь
// завершил/пропустил онбординг.
//
// Читает /onboarding/slides.json — если файл вернул { slides: [...] } с полями
// { id, title, body, cta_primary, cta_skip, icon_svg_inline } — используются они
// (дизайнерский тёмный стиль). Если файла нет или схема не совпадает — fallback
// на встроенные 3 слайда.
const OnboardingView = {
STORAGE_KEY: 'buhapp.onboarded',
slides: null,
async loadSlides() {
if (this.slides) return this.slides;
try {
const r = await fetch('/onboarding/slides.json', { cache: 'no-cache' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
// поддерживаем два формата:
// A) {"slides":[...]} — designer-формат
// B) [...] — простой массив
const arr = Array.isArray(data) ? data : (Array.isArray(data.slides) ? data.slides : null);
if (arr && arr.length) {
this.slides = arr;
return this.slides;
}
throw new Error('bad schema');
} catch (e) {
console.warn('[onboarding] slides.json not available, using fallback', e?.message || e);
this.slides = this.fallback();
return this.slides;
}
},
fallback() {
return [
{
id: 'map',
title: 'Найди компанию рядом',
body: 'Открой карту — увидишь, кто из BuhApp находится поблизости.',
cta_primary: 'Дальше',
cta_skip: 'Пропустить',
icon_svg_inline: '🗺️',
},
{
id: 'chat',
title: 'Напиши первым',
body: 'В чате можно договориться о встрече. Сообщения доходят мгновенно.',
cta_primary: 'Дальше',
cta_skip: 'Пропустить',
icon_svg_inline: '💬',
},
{
id: 'review',
title: 'Оставь отзыв',
body: 'Поставь оценку и напиши, что понравилось. Это помогает другим.',
cta_primary: 'Готово',
cta_skip: 'Позже',
icon_svg_inline: '⭐',
},
];
},
isDone() {
try { return localStorage.getItem(this.STORAGE_KEY) === '1'; }
catch (e) { return false; }
},
markDone() {
try { localStorage.setItem(this.STORAGE_KEY, '1'); }
catch (e) { /* ignore */ }
},
reset() {
try { localStorage.removeItem(this.STORAGE_KEY); }
catch (e) { /* ignore */ }
},
// Главный API: показать онбординг. Возвращает Promise по завершении.
async render(root) {
const slides = await this.loadSlides();
return new Promise((resolve) => {
// НЕ трогаем root.innerHTML — иначе снесём #app, #tabbar и весь лейаут!
// Оверлей должен быть fixed/absolute поверх существующего UI.
const overlay = document.createElement('div');
overlay.className = 'onboarding-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-label', 'Обучение BuhApp');
root.appendChild(overlay);
const stage = document.createElement('div');
stage.className = 'onboarding-stage';
overlay.appendChild(stage);
// верх — пустой (для notch-эффекта; визуально не нагружаем)
const top = document.createElement('div');
top.className = 'onboarding-top';
stage.appendChild(top);
// контейнер слайдов (один активный, остальные absolute)
const slidesBox = document.createElement('div');
slidesBox.className = 'onboarding-slides';
stage.appendChild(slidesBox);
// нижняя панель — точки + кнопки
const bottom = document.createElement('div');
bottom.className = 'onboarding-bottom';
stage.appendChild(bottom);
const dots = document.createElement('div');
dots.className = 'onboarding-dots';
bottom.appendChild(dots);
const actions = document.createElement('div');
actions.className = 'onboarding-actions';
bottom.appendChild(actions);
const primaryBtn = document.createElement('button');
primaryBtn.type = 'button';
primaryBtn.className = 'onboarding-btn onboarding-btn-primary';
actions.appendChild(primaryBtn);
const skipBtn = document.createElement('button');
skipBtn.type = 'button';
skipBtn.className = 'onboarding-btn onboarding-btn-skip';
actions.appendChild(skipBtn);
// caption: X/Y
const caption = document.createElement('div');
caption.className = 'onboarding-caption';
bottom.appendChild(caption);
// ---------- helpers ----------
const renderDots = (idx) => {
dots.innerHTML = '';
slides.forEach((_, i) => {
const d = document.createElement('button');
d.type = 'button';
d.className = 'onboarding-dot' + (i === idx ? ' is-active' : '');
d.setAttribute('aria-label', `Слайд ${i + 1} из ${slides.length}`);
d.onclick = () => goTo(i);
dots.appendChild(d);
});
};
// рендер слайда. direction: 'next' | 'prev' | 'first'
const renderSlide = (idx, direction = 'first') => {
const s = slides[idx];
caption.textContent = `Шаг ${idx + 1} из ${slides.length}`;
primaryBtn.textContent = s.cta_primary || (idx === slides.length - 1 ? 'Готово' : 'Дальше');
skipBtn.textContent = s.cta_skip || 'Пропустить';
const slideEl = document.createElement('section');
slideEl.className = 'onboarding-slide' + (direction === 'first' ? ' is-active' : '');
slideEl.setAttribute('data-index', String(idx));
slideEl.setAttribute('aria-hidden', direction === 'first' ? 'false' : 'true');
// иконка — SVG (inline) или emoji fallback
const iconBox = document.createElement('div');
iconBox.className = 'onboarding-icon';
if (s.icon_svg_inline && s.icon_svg_inline.trim().startsWith('<')) {
iconBox.innerHTML = s.icon_svg_inline;
} else if (s.icon_svg_inline) {
iconBox.textContent = s.icon_svg_inline;
} else {
iconBox.textContent = s.icon || '•';
}
slideEl.appendChild(iconBox);
const title = document.createElement('h2');
title.className = 'onboarding-title';
title.textContent = s.title || '';
slideEl.appendChild(title);
const body = document.createElement('p');
body.className = 'onboarding-body';
body.textContent = s.body || '';
slideEl.appendChild(body);
slidesBox.appendChild(slideEl);
// анимация перехода — если не первый рендер
if (direction !== 'first') {
// пометить старый активный как leaving
const old = slidesBox.querySelector('.onboarding-slide.is-active');
if (old) {
old.classList.remove('is-active');
old.classList.add(direction === 'prev' ? 'to-right' : 'to-left');
old.setAttribute('aria-hidden', 'true');
setTimeout(() => old.remove(), 360);
}
// новый — start off-screen, animate in
slideEl.classList.add(direction === 'prev' ? 'from-left' : 'from-right');
requestAnimationFrame(() => requestAnimationFrame(() => {
slideEl.classList.remove('from-left', 'from-right');
slideEl.classList.add('is-active');
slideEl.setAttribute('aria-hidden', 'false');
}));
}
renderDots(idx);
};
let current = 0;
const goTo = (idx) => {
if (idx < 0 || idx >= slides.length || idx === current) return;
const dir = idx > current ? 'next' : 'prev';
current = idx;
renderSlide(current, dir);
if (window.TG_APP?.haptic) window.TG_APP.haptic('selection');
};
const finish = () => {
this.markDone();
if (window.TG_APP?.haptic) window.TG_APP.haptic('notification');
overlay.classList.add('leaving');
setTimeout(() => {
overlay.remove();
resolve();
}, 260);
};
primaryBtn.onclick = () => {
if (current === slides.length - 1) finish();
else goTo(current + 1);
};
skipBtn.onclick = finish;
// свайп
let touchStartX = null;
stage.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
}, { passive: true });
stage.addEventListener('touchend', (e) => {
if (touchStartX == null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) < 60) { touchStartX = null; return; }
if (dx < 0) goTo(current + 1); else goTo(current - 1);
touchStartX = null;
}, { passive: true });
// клавиатура (Esc = пропустить, Enter = дальше)
const onKey = (e) => {
if (e.key === 'Enter' || e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault(); primaryBtn.click();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault(); if (current > 0) goTo(current - 1);
} else if (e.key === 'Escape') {
e.preventDefault(); finish();
}
};
document.addEventListener('keydown', onKey);
// cleanup
const cleanup = () => {
document.removeEventListener('keydown', onKey);
};
const origResolve = resolve;
resolve = (v) => { cleanup(); origResolve(v); };
// первый рендер
renderSlide(0, 'first');
});
},
};
window.OnboardingView = OnboardingView;