// Main app controller const App = { consentGiven: false, isMobileMode: false, _preReg: null, // callback после согласия async init() { 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); } }; document.querySelectorAll('.tab').forEach(btn => { btn.onclick = () => this.showView(btn.dataset.view); }); if (TG_APP.isTelegram && TG_APP.user) { try { await API.Auth.loginWithTelegram(TG_APP.initData); await API.Auth.me(); await this.checkConsent(); this.maybeOnboard(); // fire-and-forget, не блокируем переход this.showView('map'); } catch (e) { this.showError('Не удалось войти через Telegram: ' + e.message); } } else if (API.Store.access) { try { await API.Auth.me(); await this.checkConsent(); this.showView('map'); } catch (e) { API.Store.clear(); this.showError('Сессия истекла: ' + e.message); } } else { this.showWelcome(); } // таббар виден только после авторизации this.updateTabbar(); 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 = '📱 Мобильный'; } }, updateTabbar() { // таббар виден только если залогинен (есть access token или мы в Telegram WebApp) const tb = document.getElementById('tabbar'); if (!tb) return; const loggedIn = !!(API.Store.access || API.Store.user || (TG_APP.isAvailable && TG_APP.user)); tb.hidden = !loggedIn; }, onLogin() { API.Store.load(); this.updateTabbar(); }, onLogout() { API.Store.clear(); this.updateTabbar(); this.showWelcome(); }, async checkConsent() { try { const s = await API.ConsentApi.status(); 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; }, showConsent() { const ov = document.createElement('div'); ov.id = 'consent-overlay'; ov.innerHTML = `

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

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

Перед тем как продолжить, прочти и прими:
Без принятия согласий пользование приложением невозможно.
`; // цепляемся к #mobile-frame если он есть const target = document.querySelector('body.mobile-mode #mobile-frame') || document.body; target.appendChild(ov); 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.ConsentApi.accept({ adult: true, terms: true, privacy: true, disclaimer: true }); ov.remove(); this.consentGiven = true; 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); } }; }, showError(msg) { document.getElementById('app').innerHTML = `
⚠️
${msg}
`; }, 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 = `
Нет аккаунта? Регистрация
`; this._initAuthForm(); this.loadRecentUsers(); }, async loadRecentUsers() { try { const r = await api('/api/v1/users/recent?limit=8'); const box = document.getElementById('recent-users-list'); if (!box) return; if (!r.users || !r.users.length) { box.innerHTML = '
Пока никого нет. Будь первым!
'; box.className = ''; return; } box.className = ''; box.innerHTML = r.users.map((u) => `
${escapeHtml((u.name || '?')[0].toUpperCase())}
${escapeHtml(u.name)}
${u.age ? u.age + ' лет' : ''}${u.city ? ' • ' + escapeHtml(u.city) : ''}
${u.registered_ago || ''}
`).join(''); } catch (e) { const box = document.getElementById('recent-users-list'); if (box) { box.innerHTML = ''; box.className = ''; } console.warn('loadRecentUsers:', e); } }, async emailLogin() { // старая функция — оставлена как fallback, реальный submit через _initAuthForm return this._submitAuth('in'); }, // === ОБНОВЛЁННЫЙ UX (от Grok) === _initAuthForm() { const app = this; let mode = 'in'; // 'in' | 'up' let busy = false; const $ = (id) => document.getElementById(id); function setMode(m) { mode = m; const up = m === 'up'; // табы $('tIn').style.background = up ? 'transparent' : 'var(--primary)'; $('tIn').style.color = up ? 'var(--text-dim)' : '#fff'; $('tUp').style.background = up ? 'var(--primary)' : 'transparent'; $('tUp').style.color = up ? '#fff' : 'var(--text-dim)'; // поля $('regBox').style.display = up ? '' : 'none'; $('regBox2').style.display = up ? '' : 'none'; $('b-pass').autocomplete = up ? 'new-password' : 'current-password'; // кнопка btnState('', up ? 'Создать аккаунт' : 'Войти'); // switch $('b-switch').innerHTML = up ? 'Уже есть аккаунт? Войти' : 'Нет аккаунта? Регистрация'; $('b-switch-link').onclick = (e) => { e.preventDefault(); setMode(up ? 'in' : 'up'); }; // фокус clearMarks(); validate(false); setTimeout(() => { $(up ? 'b-name' : 'b-email')?.focus(); }, 50); } function mark(inp, h, ok, msg) { inp.classList.remove('bad', 'good'); h.className = 'hint ' + (msg ? (ok ? 'ok' : 'err') : 'mute'); if (msg) { inp.classList.add(ok ? 'good' : 'bad'); h.textContent = (ok ? '✓ ' : '✗ ') + msg; } else h.textContent = ''; return ok; } function vName(show) { const v = $('b-name').value.trim(); if (!v) return mark($('b-name'), $('hName'), false, show ? 'Введите имя' : ''); if (v.length < 2) return mark($('b-name'), $('hName'), false, 'Минимум 2 буквы'); return mark($('b-name'), $('hName'), true, 'Отлично'); } function vEmail(show) { const v = $('b-email').value.trim(); if (!v) return mark($('b-email'), $('hEmail'), false, show ? 'Введите email' : ''); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return mark($('b-email'), $('hEmail'), false, 'Некорректный email'); return mark($('b-email'), $('hEmail'), true, 'Похоже верно'); } function vPass(show) { const v = $('b-pass').value; if (!v) return mark($('b-pass'), $('hPass'), false, show ? 'Введите пароль' : ''); if (v.length < 8) return mark($('b-pass'), $('hPass'), false, 'Ещё ' + (8 - v.length) + ' симв.'); return mark($('b-pass'), $('hPass'), true, 'Надёжно'); } function vPass2(show) { const v = $('b-pass2').value; if (!v) return mark($('b-pass2'), $('hPass2'), false, show ? 'Повторите пароль' : ''); if (v !== $('b-pass').value) return mark($('b-pass2'), $('hPass2'), false, 'Пароли не совпадают'); return mark($('b-pass2'), $('hPass2'), true, 'Совпадают'); } function clearMarks() { ['b-name', 'b-email', 'b-pass', 'b-pass2'].forEach((id) => $(id)?.classList.remove('bad', 'good')); ['hName', 'hEmail', 'hPass', 'hPass2'].forEach((id) => { const h = $(id); if (h) { h.className = 'hint mute'; h.textContent = ''; } }); } function validate(show) { let ok = vEmail(show) & vPass(show); if (mode === 'up') ok = vName(show) & ok & vPass2(show); return !!ok; } function btnState(s, txt) { const b = $('b-go'); b.className = 'btn' + (s ? ' ' + s : ''); b.textContent = txt; b.disabled = !!busy; } function showToast(msg) { let t = document.getElementById('b-toast'); if (!t) { t = document.createElement('div'); t.id = 'b-toast'; t.style.cssText = 'position:fixed;left:50%;bottom:88px;transform:translateX(-50%) translateY(80px);background:#222;color:#fff;padding:12px 18px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 8px 28px #0008;opacity:0;transition:.28s;z-index:99;max-width:90%;border:1px solid var(--border);'; document.body.appendChild(t); } t.textContent = msg; requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateX(-50%) translateY(0)'; }); setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(-50%) translateY(80px)'; }, 2200); } // bind $('tIn').onclick = () => setMode('in'); $('tUp').onclick = () => setMode('up'); $('b-switch-link').onclick = (e) => { e.preventDefault(); setMode(mode === 'up' ? 'in' : 'up'); }; ['input', 'blur'].forEach((ev) => { $('b-name')?.addEventListener(ev, () => { if (mode === 'up') vName(true); }); $('b-email')?.addEventListener(ev, () => vEmail(true)); $('b-pass')?.addEventListener(ev, () => { vPass(true); if (mode === 'up' && $('b-pass2').value) vPass2(true); }); $('b-pass2')?.addEventListener(ev, () => { if (mode === 'up') vPass2(true); }); }); $('b-auth-form').onsubmit = async (e) => { e.preventDefault(); if (busy) return; if (!validate(true)) { btnState('err', '⚠️ Проверьте поля'); setTimeout(() => btnState('', mode === 'up' ? 'Создать аккаунт' : 'Войти'), 1400); const bad = $('b-auth-form').querySelector('input.bad'); if (bad) bad.focus(); return; } busy = true; btnState('load', mode === 'up' ? '⏳ Создаём...' : '⏳ Входим...'); try { await app._submitAuth(mode); } catch (err) { busy = false; const msg = (err && err.message) || 'Ошибка'; btnState('err', '⚠️ ' + msg); const field = (err && err.data && err.data.field) || ''; if (field === 'email') { $('b-email').classList.add('bad'); mark($('b-email'), $('hEmail'), false, msg); $('b-email').focus(); } else if (field === 'pass') { $('b-pass').classList.add('bad'); mark($('b-pass'), $('hPass'), false, msg); $('b-pass').focus(); } else if (field === 'name') { $('b-name').classList.add('bad'); mark($('b-name'), $('hName'), false, msg); $('b-name').focus(); } setTimeout(() => btnState('', mode === 'up' ? 'Создать аккаунт' : 'Войти'), 2000); } // busy снимется в _submitAuth при успехе (showView уйдёт) }; // экспортируем для возможных других вызовов app._authSwitchMode = setMode; setMode('in'); }, async _submitAuth(mode) { const email = document.getElementById('b-email').value.trim(); const pass = document.getElementById('b-pass').value; const remember = document.getElementById('b-rem').checked; if (mode === 'up') { const name = document.getElementById('b-name').value.trim(); const pass2 = document.getElementById('b-pass2').value; if (!name || pass !== pass2) throw new Error('Проверьте поля'); const r = await API.Auth.registerEmail({ email, password: pass, name, consents: { adult: true, terms: true, privacy: true, disclaimer: true }, remember: remember, }); if (r && r.tokens) API.Store.save(r.tokens); const me = await API.Auth.me(); API.Store.user = me.user || me; const btn = document.getElementById('b-go'); btn.className = 'btn ok'; btn.textContent = '✓ Готово!'; this._toast('Добро пожаловать, ' + name + '!'); this.onLogin(); await this.maybeOnboard(); setTimeout(() => this.showView('map'), 700); } else { const r = await API.Auth.loginEmail(email, pass, remember); if (r && r.tokens) API.Store.save(r.tokens); const me = await API.Auth.me(); API.Store.user = me.user || me; const btn = document.getElementById('b-go'); btn.className = 'btn ok'; btn.textContent = '✓ Готово!'; const nm = me.user?.name || (me.user && me.user.email) || email.split('@')[0]; this._toast('С возвращением, ' + nm + '!'); this.onLogin(); setTimeout(() => this.showView('map'), 500); } }, _toast(msg, kind) { let t = document.getElementById('b-toast'); if (!t) { t = document.createElement('div'); t.id = 'b-toast'; t.className = 'toast-b'; document.body.appendChild(t); } t.className = 'toast-b' + (kind ? ' ' + kind : ''); t.textContent = msg; requestAnimationFrame(() => t.classList.add('show')); setTimeout(() => t.classList.remove('show'), 2200); }, showEmailRegister() { this.showEmailLogin(); this._authSwitchMode && this._authSwitchMode('up'); }, 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; } }, 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(); // обновить видимость таббара (не виден пока не залогинился) this.updateTabbar(); // мгновенный placeholder — чтоб UI не висел при тяжёлом render if (root) { root.innerHTML = '
'; } // defer на следующий тик — даём браузеру отрисовать таб/placeholder requestAnimationFrame(() => { 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 = '
Неизвестный экран
'; } }); }, 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // Debug-оверлей: показывает ошибки JS прямо на экране (для отладки в обычном браузере) window.addEventListener('error', (e) => { console.error('Global error:', e); showDebugError(e.message + ' @ ' + (e.filename || '?') + ':' + (e.lineno || '?')); }); window.addEventListener('unhandledrejection', (e) => { console.error('Unhandled rejection:', e); showDebugError('Promise: ' + (e.reason && e.reason.message ? e.reason.message : (e.reason || 'unknown'))); }); function showDebugError(msg) { try { let d = document.getElementById('debug-errors'); if (!d) { d = document.createElement('div'); d.id = 'debug-errors'; d.style.cssText = 'position:fixed;top:0;left:0;right:0;max-height:50vh;overflow:auto;background:#900;color:#fff;padding:12px;font:12px monospace;z-index:99999;white-space:pre-wrap;'; document.body.appendChild(d); } const line = document.createElement('div'); line.textContent = msg; d.appendChild(line); } catch {} } document.addEventListener('DOMContentLoaded', () => { App.init().catch((e) => { console.error('App.init failed:', e); showDebugError('App.init: ' + (e && e.message ? e.message : String(e))); }); });