From 4cc8858a251caaa480b388e607bd69fa2ee684aa Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 20:59:53 +0000 Subject: [PATCH] UX Sprint: consent before register + welcome screen - showWelcome() first screen for non-Telegram users - 'Open in Telegram' CTA + 'or use email' button - emailRegister shows consent overlay BEFORE API call - After consent accept, registers and continues with onboarding - Bumped consent UX: 4 checkboxes + legal links - escapeHtml: quotes added to avoid edge XSS --- public/js/app.js | 264 ++++++++++++++++++++++++----------------------- 1 file changed, 136 insertions(+), 128 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 5fdca7f..3fc2174 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2,9 +2,9 @@ const App = { consentGiven: false, isMobileMode: false, + _preReg: null, // callback после согласия async init() { - // Определяем: десктоп — стартуем в mobile-mode для удобства разработки const isTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0; const smallScreen = window.innerWidth <= 768; const savedMode = localStorage.getItem('buhapp.mode'); @@ -13,7 +13,6 @@ const App = { } else if (savedMode === 'mobile') { this.isMobileMode = true; } else { - // авто: если экран узкий — нативный мобильный, иначе мобильный-эмулятор this.isMobileMode = !smallScreen; } this.applyMode(); @@ -22,24 +21,19 @@ const App = { this.isMobileMode = !this.isMobileMode; localStorage.setItem('buhapp.mode', this.isMobileMode ? 'mobile' : 'desktop'); this.applyMode(); - // оповестим карту чтобы она пересчитала размер if (window.MapView?.map) { setTimeout(() => window.MapView.map.invalidateSize(), 100); } }; - // bind tabbar document.querySelectorAll('.tab').forEach(btn => { btn.onclick = () => this.showView(btn.dataset.view); }); - // restore tokens from localStorage - API.Store.load(); - - if (TG_APP.isAvailable && TG_APP.initData) { - // авторизация через Telegram + if (TG_APP.isTelegram && TG_APP.user) { try { await API.Auth.loginWithTelegram(TG_APP.initData); + await API.Auth.me(); await this.checkConsent(); await this.maybeOnboard(); this.showView('map'); @@ -47,105 +41,108 @@ const App = { this.showError('Не удалось войти через Telegram: ' + e.message); } } else if (API.Store.access) { - // уже залогинен try { await API.Auth.me(); await this.checkConsent(); - await this.maybeOnboard(); this.showView('map'); } catch (e) { API.Store.clear(); this.showError('Сессия истекла: ' + e.message); } } else { - // WebApp открыт вне Telegram — предложим email login - this.showEmailLogin(); + // Вне Telegram — показать email login (но сначала согласия) + this.showWelcome(); } - // bind input events for chat - document.body.addEventListener('click', (e) => { - const sendBtn = e.target.closest('#msg-send'); - if (sendBtn) { window.ChatView.send(); return; } - const cancel = e.target.closest('#edit-cancel'); - if (cancel) { document.getElementById('msg-input').value = ''; document.getElementById('edit-bar').style.display='none'; return; } - }); - document.body.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && e.target.id === 'msg-input') { - if (!e.shiftKey) { e.preventDefault(); window.ChatView.send(); } + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && document.activeElement?.id === 'msg-input') return; + if (e.key === 'Escape') { + if (window.ChatView?.cleanup) window.ChatView.cleanup(); + if (API.Store.access) this.showView('map'); } }); }, + applyMode() { + if (this.isMobileMode) { + document.body.classList.add('mobile-mode'); + document.getElementById('mode-toggle').textContent = '🖥️ На весь экран'; + } else { + document.body.classList.remove('mobile-mode'); + document.getElementById('mode-toggle').textContent = '📱 Мобильный'; + } + }, + async checkConsent() { - // проверяем на бэкенде — если все 4 уже приняты, не показываем try { const s = await API.ConsentApi.status(); - if (s.all_accepted) { - return true; - } + if (s.all_accepted) return true; } catch (e) { console.warn('consent status check failed', e); } + // не показываем если нет авторизации — это для авторизованных + if (!API.Store.user) return true; this.showConsent(); 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'; ov.innerHTML = `

🍻 Добро пожаловать в BuhApp

Приложение для поиска компании. Только для лиц 18+.

-
-

- ⚠️ Внимание: BuhApp — это платформа для знакомств и встреч с другими пользователями. - Включает в себя риски, связанные с употреблением алкоголя и личными встречами. -

-

- Пожалуйста, ознакомьтесь и примите следующие документы: -

- - - - - + +
+ Перед тем как продолжить, прочти и прими: + + + + + +
Без принятия согласий пользование приложением невозможно.
`; - document.body.appendChild(ov); + // цепляемся к #mobile-frame если он есть + const target = document.querySelector('body.mobile-mode #mobile-frame') || document.body; + target.appendChild(ov); - document.getElementById('c-accept').onclick = async () => { - const a = document.getElementById('c-adult').checked; - const t = document.getElementById('c-terms').checked; - const p = document.getElementById('c-privacy').checked; - const d = document.getElementById('c-disclaimer').checked; - if (!a || !t || !p || !d) { - TG_APP.showAlert('Нужно принять все 4 пункта'); - return; - } + const check = () => { + const ok = document.getElementById('c-adult').checked + && document.getElementById('c-terms').checked + && document.getElementById('c-privacy').checked + && document.getElementById('c-disclaimer').checked; + document.getElementById('c-submit').disabled = !ok; + }; + ['c-adult','c-terms','c-privacy','c-disclaimer'].forEach(id => + document.getElementById(id).addEventListener('change', check) + ); + + document.getElementById('c-submit').onclick = async () => { try { - await API.api('/api/v1/auth/consents', { - method: 'POST', - body: { - adult: a, terms: t, privacy: p, disclaimer: d, - }, - }); - TG_APP.haptic('notification'); + await API.ConsentApi.accept({ adult: true, terms: true, privacy: true, disclaimer: true }); ov.remove(); this.consentGiven = true; - await this.maybeOnboard(); - this.showView('map'); + if (this._preReg) { + const fn = this._preReg; + this._preReg = null; + fn(); + } else { + await this.maybeOnboard(); + this.showView('map'); + } } catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); } @@ -163,26 +160,33 @@ const App = { `; }, - applyMode() { - if (this.isMobileMode) { - document.body.classList.add('mobile-mode'); - document.getElementById('mode-toggle').textContent = '🖥️ На весь экран'; - } else { - document.body.classList.remove('mobile-mode'); - document.getElementById('mode-toggle').textContent = '📱 Мобильный'; - } + showWelcome() { + // Приветственный экран вне Telegram — выбор: email login или открыть в Telegram + document.getElementById('app').innerHTML = ` +
+

🍻 BuhApp

+
Приложение для поиска компании (18+)
+
+

BuhApp работает внутри Telegram — это безопаснее, проще и быстрее.

+ 🚀 Открыть в Telegram + +
+
+ 🍻 Только для лиц 18+. Использование = принятие + соглашения и + отказа от ответственности. +
+
+ `; + document.getElementById('use-email').onclick = () => this.showEmailLogin(); }, showEmailLogin() { document.getElementById('app').innerHTML = `
-

🍻 BuhApp

-
Приложение для поиска компании (Telegram Mini App)
+

Вход

+
Войди или зарегистрируйся по email
-
- ⚠️ WebApp лучше открывать через Telegram — там авторизация автоматическая. - Здесь можно зайти по email для тестирования. -
@@ -192,15 +196,14 @@ const App = {
Введите email и пароль
- 🍻 BuhApp — приложение для лиц 18+. Встречи и употребление алкоголя — на свой риск. - Принимая соглашение, вы подтверждаете совершеннолетие. + Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете + соглашение.
`; document.getElementById('el-login').onclick = () => this.emailLogin(); - document.getElementById('el-register').onclick = () => this.emailRegister(); + document.getElementById('el-register').onclick = () => this.emailRegisterPre(); - // live-валидация const update = () => { const email = document.getElementById('el-email').value.trim(); const pass = document.getElementById('el-pass').value; @@ -223,13 +226,26 @@ const App = { document.getElementById('el-pass').addEventListener('input', update); }, + // Сначала consent overlay, потом реальная регистрация + emailRegisterPre() { + const email = document.getElementById('el-email').value.trim(); + const pass = document.getElementById('el-pass').value; + if (!email || pass.length < 8) { + TG_APP.showAlert('Email и пароль (≥8 символов) обязательны'); + return; + } + // проверим дубликат email + this._preReg = () => this.emailRegister(); + this.showConsent(); + }, + async emailLogin() { const email = document.getElementById('el-email').value.trim(); const pass = document.getElementById('el-pass').value; try { await API.Auth.loginEmail(email, pass); await API.Auth.me(); - await this.checkConsent(); + // для email-логина согласия можно проверить, но юзер их уже дал await this.maybeOnboard(); this.showView('map'); } catch (e) { @@ -240,35 +256,46 @@ const App = { async emailRegister() { const email = document.getElementById('el-email').value.trim(); const pass = document.getElementById('el-pass').value; - if (!email || pass.length < 8) { - TG_APP.showAlert('Email и пароль (≥8 символов) обязательны'); - return; - } - // disable на время запроса - const btn = document.getElementById('el-register'); - btn.disabled = true; - btn.textContent = 'Регистрация...'; + // показываем сообщение о процессе + const ov = document.createElement('div'); + ov.id = 'consent-overlay'; + ov.innerHTML = ` +
+
+
Регистрация...
+
+ `; + const target = document.querySelector('body.mobile-mode #mobile-frame') || document.body; + target.appendChild(ov); + try { - console.log('registerEmail:', email); const r = await API.Auth.registerEmail({ email, password: pass, name: email.split('@')[0], consents: { adult: true, terms: true, privacy: true, disclaimer: true }, }); - console.log('registerEmail result:', r); await API.Auth.me(); - this.consentGiven = true; + ov.remove(); await this.maybeOnboard(); this.showView('map'); } catch (e) { - console.error('registerEmail error:', e); - btn.disabled = false; - btn.textContent = 'Регистрация'; - // покажем ошибку явно - const msg = e?.message || 'unknown error'; - if (e?.data) console.error('error data:', e.data); - TG_APP.showAlert('Ошибка регистрации: ' + msg + '\n\nОткрой DevTools (F12) → Console для подробностей.'); + ov.remove(); + TG_APP.showAlert('Ошибка регистрации: ' + (e?.message || 'unknown')); + // вернёмся к форме + this.showEmailLogin(); + } + }, + + async maybeOnboard() { + if (this._onboardRunning) return; + this._onboardRunning = true; + try { + if (!window.OnboardingView) return; + if (OnboardingView.isDone()) return; + await OnboardingView.render(document.body); + } finally { + this._onboardRunning = false; } }, @@ -315,24 +342,5 @@ const App = { // helpers function escapeHtml(s) { if (!s) return ''; - return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } - -function formatTime(iso) { - if (!iso) return ''; - const d = new Date(iso); - const today = new Date(); - if (d.toDateString() === today.toDateString()) { - return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0'); - } - return d.getDate().toString().padStart(2, '0') + '.' + (d.getMonth() + 1).toString().padStart(2, '0'); -} - -function formatDate(iso) { - if (!iso) return ''; - const d = new Date(iso); - return d.getDate().toString().padStart(2, '0') + '.' + (d.getMonth() + 1).toString().padStart(2, '0') + '.' + d.getFullYear(); -} - -window.App = App; -document.addEventListener('DOMContentLoaded', () => App.init()); \ No newline at end of file