- Убрал root.innerHTML='' в OnboardingView.render
- Оверлей теперь fixed/absolute поверх body — лейаут не страдает
- App.showView('map') после пропуска находит #app нормально
273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
// 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;
|