buhapp-telegram/public/js/views/onboarding.js
BuhApp Dev 149f31d25d Add user onboarding flow (3-slide welcome) for new signups
- New OnboardingView module (public/js/views/onboarding.js):
  • Reads /onboarding/slides.json (designer schema with inline SVG icons,
    per-slide cta_primary/cta_skip labels) — falls back to 3 emoji slides
    if the JSON is unavailable.
  • Renders a Telegram-style dark overlay inside #mobile-frame with a
    220px floating SVG icon, title, body, dot indicator (expandable
    active dot), primary CTA, and a ghost Skip button.
  • Prev/Next via primary CTA; dot click jumps to slide; arrow keys and
    horizontal swipe also navigate.
  • Haptic feedback via TG_APP on slide change + finish.
  • markDone() writes buhapp.onboarded='1' to localStorage so the flow
    never repeats. isDone()/reset() exposed for QA.

- App integration (public/js/app.js):
  • New App.maybeOnboard() helper — awaited after every successful auth
    path: Telegram login, restored session, email login, email register,
    and consent accept. Skips silently if already onboarded.

- UI assets (public/onboarding/):
  • slides.json with 3 designer slides (Map / Chat / Review).
  • Inline-SVG icons for map, chat, and review (icon-*.svg kept for
    designer reference, preview.html too).

- Styles (public/css/app.css): new .onboarding-* dark-theme rules.
- Index (public/index.html): loads onboarding.js before app.js.
2026-08-20 20:20:54 +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 = '';
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;