diff --git a/public/css/app.css b/public/css/app.css index 7137326..78901de 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -309,4 +309,177 @@ label { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; display: bl .leaflet-container { position: relative !important; z-index: 1; +} + +/* ===== Onboarding (welcome flow) — dark Telegram-style ===== */ +.onboarding-overlay { + position: absolute; + inset: 0; + background: #1C1C1E; + color: #fff; + z-index: 300; + display: flex; + flex-direction: column; + overflow: hidden; + animation: ob-fade-in 260ms ease-out both; + border-radius: inherit; + background: radial-gradient(1200px 600px at 20% 0%, #2A2A2E 0%, #1C1C1E 60%, #141416 100%); +} +.onboarding-overlay.leaving { animation: ob-fade-out 240ms ease-in both; } + +@keyframes ob-fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes ob-fade-out { from { opacity: 1; } to { opacity: 0; } } + +.onboarding-stage { + flex: 1; + display: flex; + flex-direction: column; + padding: 8px 28px 24px; + position: relative; +} + +.onboarding-top { + height: 44px; /* место под status-bar / notch */ + flex-shrink: 0; +} + +.onboarding-slides { + flex: 1; + position: relative; + min-height: 0; +} + +.onboarding-slide { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 8px 0 0; + opacity: 0; + transform: translateX(40px); + transition: opacity 320ms ease, transform 360ms cubic-bezier(.2,.7,.2,1); + pointer-events: none; + will-change: opacity, transform; +} +.onboarding-slide.from-left { transform: translateX(-40px); } +.onboarding-slide.from-right { transform: translateX(40px); } +.onboarding-slide.is-active { + opacity: 1; + transform: translateX(0); + pointer-events: auto; +} +.onboarding-slide.to-left { opacity: 0; transform: translateX(-40px); } +.onboarding-slide.to-right { opacity: 0; transform: translateX(40px); } + +.onboarding-icon { + width: 220px; + height: 220px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 28px; + filter: drop-shadow(0 16px 36px rgba(0,0,0,0.35)); + animation: ob-float 6s ease-in-out infinite; +} +.onboarding-icon svg { width: 100%; height: 100%; display: block; } +@keyframes ob-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +.onboarding-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.4px; + margin-bottom: 12px; + max-width: 320px; + color: #fff; + line-height: 1.2; +} +.onboarding-body { + font-size: 15px; + line-height: 1.45; + color: rgba(255,255,255,0.75); + max-width: 300px; +} + +.onboarding-bottom { + display: flex; + flex-direction: column; + gap: 0; + flex-shrink: 0; + padding-bottom: env(safe-area-inset-bottom); +} + +.onboarding-dots { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 18px; + margin-bottom: 18px; +} +.onboarding-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255,255,255,0.25); + border: 0; + padding: 0; + cursor: pointer; + transition: width 240ms ease, background 240ms ease; +} +.onboarding-dot.is-active { + width: 22px; + border-radius: 4px; + background: var(--primary); +} + +.onboarding-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.onboarding-btn { + appearance: none; + border: 0; + cursor: pointer; + font-family: inherit; + font-size: 16px; + font-weight: 600; + border-radius: 14px; + padding: 16px 20px; + transition: transform 120ms ease, background 200ms ease, opacity 200ms ease; +} +.onboarding-btn:active { transform: scale(0.98); } + +.onboarding-btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 6px 16px rgba(255,107,53,0.35); +} +.onboarding-btn-primary:hover { background: var(--primary-dark); } + +.onboarding-btn-skip { + background: transparent; + color: rgba(255,255,255,0.65); +} +.onboarding-btn-skip:hover { color: #fff; } + +.onboarding-caption { + margin-top: 14px; + color: rgba(255,255,255,0.5); + font-size: 12px; + text-align: center; +} + +@media (prefers-reduced-motion: reduce) { + .onboarding-overlay, + .onboarding-slide, + .onboarding-icon, + .onboarding-dot { animation: none !important; transition: none !important; } + .onboarding-slide { transform: none; opacity: 1; pointer-events: auto; } } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 41115a6..b580725 100644 --- a/public/index.html +++ b/public/index.html @@ -42,6 +42,7 @@ + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 68798c3..5fdca7f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -41,6 +41,7 @@ const App = { try { await API.Auth.loginWithTelegram(TG_APP.initData); await this.checkConsent(); + await this.maybeOnboard(); this.showView('map'); } catch (e) { this.showError('Не удалось войти через Telegram: ' + e.message); @@ -50,6 +51,7 @@ const App = { try { await API.Auth.me(); await this.checkConsent(); + await this.maybeOnboard(); this.showView('map'); } catch (e) { API.Store.clear(); @@ -88,6 +90,18 @@ const App = { return false; }, + // Показываем welcome-flow новому пользователю ОДИН раз. + // Возвращает Promise, который резолвится когда пользователь прошёл/пропустил онбординг. + async maybeOnboard() { + if (!window.OnboardingView) { + console.warn('[onboarding] OnboardingView not loaded'); + return; + } + if (OnboardingView.isDone()) return; + const root = document.getElementById('app'); + await OnboardingView.render(root); + }, + showConsent() { const ov = document.createElement('div'); ov.id = 'consent-overlay'; @@ -130,6 +144,7 @@ const App = { TG_APP.haptic('notification'); ov.remove(); this.consentGiven = true; + await this.maybeOnboard(); this.showView('map'); } catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); @@ -215,6 +230,7 @@ const App = { await API.Auth.loginEmail(email, pass); await API.Auth.me(); await this.checkConsent(); + await this.maybeOnboard(); this.showView('map'); } catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); @@ -243,6 +259,7 @@ const App = { console.log('registerEmail result:', r); await API.Auth.me(); this.consentGiven = true; + await this.maybeOnboard(); this.showView('map'); } catch (e) { console.error('registerEmail error:', e); diff --git a/public/js/views/onboarding.js b/public/js/views/onboarding.js new file mode 100644 index 0000000..f41865a --- /dev/null +++ b/public/js/views/onboarding.js @@ -0,0 +1,272 @@ +// 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; diff --git a/public/onboarding/icon-chat.svg b/public/onboarding/icon-chat.svg new file mode 100644 index 0000000..e1fae7e --- /dev/null +++ b/public/onboarding/icon-chat.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/onboarding/icon-map.svg b/public/onboarding/icon-map.svg new file mode 100644 index 0000000..9d636ba --- /dev/null +++ b/public/onboarding/icon-map.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/onboarding/icon-review.svg b/public/onboarding/icon-review.svg new file mode 100644 index 0000000..2d3b124 --- /dev/null +++ b/public/onboarding/icon-review.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/onboarding/preview.html b/public/onboarding/preview.html new file mode 100644 index 0000000..851e44c --- /dev/null +++ b/public/onboarding/preview.html @@ -0,0 +1,491 @@ + + + + + + + BuhApp · Онбординг (превью) + + + +
+ BuhApp · Онбординг — превью в стиле Telegram WebApp + mobile · iOS-like +
+ +
+
+
+ 9:41 + + + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Найди компанию рядом

Открой карту — увидишь, кто из BuhApp находится поблизости. Геолокация работает прямо в Telegram, без отдельных приложений.

+ + +
+ + + +
+ + +
+
+
+ +

Нажми «Далее» или точки снизу, чтобы переключать слайды.

+ + + + diff --git a/public/onboarding/slides.json b/public/onboarding/slides.json new file mode 100644 index 0000000..0aad888 --- /dev/null +++ b/public/onboarding/slides.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "brand": { + "primary": "#FF6B35", + "bg_dark": "#1C1C1E" + }, + "slides": [ + { + "id": "map", + "order": 1, + "title": "Найди компанию рядом", + "body": "Открой карту — увидишь, кто из BuhApp находится поблизости. Геолокация работает прямо в Telegram, без отдельных приложений.", + "cta_primary": "Показать на карте", + "cta_skip": "Пропустить", + "icon_svg_inline": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n\n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n\n" + }, + { + "id": "chat", + "order": 2, + "title": "Напиши первым", + "body": "В чате можно задать вопрос, договориться о встрече или обменяться контактами. Сообщения доходят мгновенно — как в обычном мессенджере.", + "cta_primary": "Открыть чаты", + "cta_skip": "Пропустить", + "icon_svg_inline": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n\n" + }, + { + "id": "review", + "order": 3, + "title": "Оставь отзыв", + "body": "Поставь от 1 до 5 звёзд и напиши, что понравилось. Твои отзывы помогают другим выбрать надёжную компанию.", + "cta_primary": "Готово", + "cta_skip": "Позже", + "icon_svg_inline": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n" + } + ] +} \ No newline at end of file