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
This commit is contained in:
root 2026-08-20 20:59:53 +00:00
parent 7fa2dc83e4
commit 4cc8858a25

View File

@ -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 = `
<h1>🍻 Добро пожаловать в BuhApp</h1>
<p class="sub">Приложение для поиска компании. Только для лиц 18+.</p>
<div class="card" style="background:var(--bg-elev);">
<p style="margin-bottom:12px;font-size:13px;">
<strong>Внимание</strong>: BuhApp это платформа для знакомств и встреч с другими пользователями.
Включает в себя риски, связанные с употреблением алкоголя и личными встречами.
</p>
<p style="font-size:13px;">
Пожалуйста, ознакомьтесь и примите следующие документы:
</p>
<div class="consent-line"><input type="checkbox" id="c-adult"><label for="c-adult">Мне исполнилось 18 лет (обязательно)</label></div>
<div class="consent-line"><input type="checkbox" id="c-terms"><label for="c-terms">Пользовательское соглашение (v1.0)</label></div>
<div class="consent-line"><input type="checkbox" id="c-privacy"><label for="c-privacy">Политика конфиденциальности (152-ФЗ, v1.0)</label></div>
<div class="consent-line"><input type="checkbox" id="c-disclaimer"><label for="c-disclaimer">Отказ от ответственности (v1.0)</label></div>
<button class="btn" id="c-accept" style="margin-top:16px;">Принять и продолжить</button>
<div class="card">
<strong>Перед тем как продолжить, прочти и прими:</strong>
<div class="consent-line">
<input type="checkbox" id="c-adult">
<label for="c-adult">Мне уже исполнилось 18 лет</label>
</div>
<div class="consent-line">
<input type="checkbox" id="c-terms">
<label for="c-terms">Согласен с <a href="/legal/TERMS.html" target="_blank">Пользовательским соглашением</a></label>
</div>
<div class="consent-line">
<input type="checkbox" id="c-privacy">
<label for="c-privacy">Согласен с <a href="/legal/PRIVACY.html" target="_blank">Политикой конфиденциальности</a></label>
</div>
<div class="consent-line">
<input type="checkbox" id="c-disclaimer">
<label for="c-disclaimer">Принимаю <a href="/legal/DISCLAIMER.html" target="_blank">Отказ от ответственности</a></label>
</div>
<button class="btn" id="c-submit" disabled>Принять и продолжить</button>
<div class="privacy-note" style="margin-top:12px;">Без принятия согласий пользование приложением невозможно.</div>
</div>
`;
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 = `
<div class="view">
<h1>🍻 BuhApp</h1>
<div class="sub">Приложение для поиска компании (18+)</div>
<div class="card">
<p>BuhApp работает внутри Telegram это безопаснее, проще и быстрее.</p>
<a class="btn" href="https://t.me/buhoiapp_bot" target="_blank">🚀 Открыть в Telegram</a>
<button class="btn ghost" id="use-email">Или войти по email</button>
</div>
<div class="card" style="font-size:12px;color:var(--text-dim);">
🍻 Только для лиц 18+. Использование = принятие
<a href="/legal/TERMS.html" target="_blank">соглашения</a> и
<a href="/legal/DISCLAIMER.html" target="_blank">отказа от ответственности</a>.
</div>
</div>
`;
document.getElementById('use-email').onclick = () => this.showEmailLogin();
},
showEmailLogin() {
document.getElementById('app').innerHTML = `
<div class="view">
<h1>🍻 BuhApp</h1>
<div class="sub">Приложение для поиска компании (Telegram Mini App)</div>
<h1>Вход</h1>
<div class="sub">Войди или зарегистрируйся по email</div>
<div class="card">
<div style="background:var(--warning);color:#000;padding:8px 12px;border-radius:8px;margin-bottom:12px;font-size:13px;">
WebApp лучше открывать через Telegram там авторизация автоматическая.
Здесь можно зайти по email для тестирования.
</div>
<label>Email</label>
<input class="input" id="el-email" type="email" placeholder="you@example.com" autocomplete="email">
<label>Пароль (8 символов)</label>
@ -192,15 +196,14 @@ const App = {
<div class="sub" id="el-hint" style="margin-top:8px;text-align:center;">Введите email и пароль</div>
</div>
<div class="card" style="font-size:12px;color:var(--text-dim);">
🍻 BuhApp приложение для лиц 18+. Встречи и употребление алкоголя на свой риск.
Принимая соглашение, вы подтверждаете совершеннолетие.
Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете
<a href="/legal/TERMS.html" target="_blank">соглашение</a>.
</div>
</div>
`;
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 = `
<div style="text-align:center;padding:48px 16px;">
<div style="font-size:32px;margin-bottom:12px;"></div>
<div>Регистрация...</div>
</div>
`;
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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());