// 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;