// 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 = `
`;
},
showWelcome() {
// Приветственный экран вне Telegram — выбор: email login или открыть в Telegram
document.getElementById('app').innerHTML = `
🍻 BuhApp
Приложение для поиска компании (18+)
BuhApp работает внутри Telegram — это безопаснее, проще и быстрее.
🚀 Открыть в Telegram
`;
document.getElementById('use-email').onclick = () => this.showEmailLogin();
},
showEmailLogin() {
document.getElementById('app').innerHTML = `
🍻 BuhApp
Учёт · просто и быстро
`;
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) {
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);
},
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)));
});
});