buhapp-telegram/public/js/app.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

338 lines
14 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.

// Main app controller
const App = {
consentGiven: false,
isMobileMode: false,
async init() {
// Определяем: десктоп — стартуем в mobile-mode для удобства разработки
const isTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
const smallScreen = window.innerWidth <= 768;
const savedMode = localStorage.getItem('buhapp.mode');
if (savedMode === 'desktop') {
this.isMobileMode = false;
} else if (savedMode === 'mobile') {
this.isMobileMode = true;
} else {
// авто: если экран узкий — нативный мобильный, иначе мобильный-эмулятор
this.isMobileMode = !smallScreen;
}
this.applyMode();
document.getElementById('mode-toggle').onclick = () => {
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
try {
await API.Auth.loginWithTelegram(TG_APP.initData);
await this.checkConsent();
await this.maybeOnboard();
this.showView('map');
} catch (e) {
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();
}
// 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(); }
}
});
},
async checkConsent() {
// проверяем на бэкенде — если все 4 уже приняты, не показываем
try {
const s = await API.ConsentApi.status();
if (s.all_accepted) {
return true;
}
} catch (e) {
console.warn('consent status check failed', e);
}
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>
`;
document.body.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;
}
try {
await API.api('/api/v1/auth/consents', {
method: 'POST',
body: {
adult: a, terms: t, privacy: p, disclaimer: d,
},
});
TG_APP.haptic('notification');
ov.remove();
this.consentGiven = true;
await this.maybeOnboard();
this.showView('map');
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
}
};
},
showError(msg) {
document.getElementById('app').innerHTML = `
<div class="view">
<div class="card" style="text-align:center;padding:32px 16px;">
<div style="font-size:48px;margin-bottom:16px;">⚠️</div>
<div>${msg}</div>
</div>
</div>
`;
},
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 = '📱 Мобильный';
}
},
showEmailLogin() {
document.getElementById('app').innerHTML = `
<div class="view">
<h1>🍻 BuhApp</h1>
<div class="sub">Приложение для поиска компании (Telegram Mini App)</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>
<input class="input" id="el-pass" type="password" placeholder="минимум 8 символов" autocomplete="current-password">
<button class="btn" id="el-login" disabled>Войти</button>
<button class="btn ghost" id="el-register" disabled>Регистрация</button>
<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+. Встречи и употребление алкоголя — на свой риск.
Принимая соглашение, вы подтверждаете совершеннолетие.
</div>
</div>
`;
document.getElementById('el-login').onclick = () => this.emailLogin();
document.getElementById('el-register').onclick = () => this.emailRegister();
// live-валидация
const update = () => {
const email = document.getElementById('el-email').value.trim();
const pass = document.getElementById('el-pass').value;
const ok = email.length > 0 && pass.length >= 8;
document.getElementById('el-login').disabled = !ok;
document.getElementById('el-register').disabled = !ok;
const hint = document.getElementById('el-hint');
if (pass.length > 0 && pass.length < 8) {
hint.textContent = 'Пароль слишком короткий (' + pass.length + '/8)';
hint.style.color = 'var(--danger)';
} else if (ok) {
hint.textContent = '✓ Готово';
hint.style.color = 'var(--success)';
} else {
hint.textContent = 'Введите email и пароль';
hint.style.color = 'var(--text-dim)';
}
};
document.getElementById('el-email').addEventListener('input', update);
document.getElementById('el-pass').addEventListener('input', update);
},
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();
await this.maybeOnboard();
this.showView('map');
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
}
},
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 = 'Регистрация...';
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;
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 для подробностей.');
}
},
showView(name, params = {}) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.view === name));
const root = document.getElementById('app');
if (window.ChatView?.cleanup) window.ChatView.cleanup();
switch (name) {
case 'map': MapView.render(root); break;
case 'chats': ChatsView.render(root); break;
case 'chat': ChatView.render(root, params); break;
case 'profile': ProfileView.render(root); break;
case 'settings': SettingsView.render(root); break;
case 'review': ReviewView.render(root, params); break;
case 'user-reviews': UserReviewsView.render(root, params); break;
case 'user-profile': UserProfileView.render(root, params); break;
default: root.innerHTML = '<div class="view">Неизвестный экран</div>';
}
},
openChat(chatId, otherId, otherName) {
this.showView('chat', { chatId, otherId, otherName });
},
openReview(chatId, otherId, otherName) {
this.showView('review', { chatId, otherId, otherName });
},
openUserProfile(userId, name) {
this.showView('user-profile', { userId, name });
},
toast(msg) {
const t = document.createElement('div');
t.className = 'toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
},
};
// helpers
function escapeHtml(s) {
if (!s) return '';
return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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());