buhapp-telegram/public/js/app.js

588 lines
27 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,
_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 = `
<h1>🍻 Добро пожаловать в BuhApp</h1>
<p class="sub">Приложение для поиска компании. Только для лиц 18+.</p>
<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>
`;
// цепляемся к #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 = `
<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>
`;
},
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">
<div class="wrap" style="padding:0;">
<div class="logo" style="text-align:center;margin:12px 0 18px;">
<b style="font-size:26px;color:var(--primary);">🍻 BuhApp</b>
<p style="color:var(--text-dim);font-size:13px;margin-top:2px;">Учёт · просто и быстро</p>
</div>
<div class="tabs" style="display:flex;background:var(--bg-elev);border-radius:12px;padding:4px;margin-bottom:18px;border:1px solid var(--border);">
<button class="tab on" id="tIn" type="button" style="flex:1;border:0;background:var(--primary);color:#fff;font:600 14px/1 inherit;padding:12px;border-radius:9px;cursor:pointer;">Вход</button>
<button class="tab" id="tUp" type="button" style="flex:1;border:0;background:0;color:var(--text-dim);font:600 14px/1 inherit;padding:12px;border-radius:9px;cursor:pointer;">Регистрация</button>
</div>
<div class="card">
<form id="b-auth-form" novalidate>
<div id="regBox" style="display:none;">
<div class="field" style="margin-bottom:14px;">
<label for="b-name" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Имя</label>
<input id="b-name" type="text" autocomplete="name" placeholder="Как к вам обращаться" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="hint mute" id="hName" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
</div>
<div class="field" style="margin-bottom:14px;">
<label for="b-email" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Email</label>
<input id="b-email" type="email" autocomplete="email" inputmode="email" placeholder="you@mail.com" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="hint mute" id="hEmail" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
<div class="field" style="margin-bottom:14px;">
<label for="b-pass" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Пароль</label>
<input id="b-pass" type="password" autocomplete="current-password" placeholder="Минимум 8 символов" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="hint mute" id="hPass" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
<div id="regBox2" style="display:none;">
<div class="field" style="margin-bottom:14px;">
<label for="b-pass2" style="display:block;font-size:12px;font-weight:600;color:var(--text-dim);margin:0 0 6px;">Ещё раз пароль</label>
<input id="b-pass2" type="password" autocomplete="new-password" placeholder="Повторите пароль" style="width:100%;background:var(--bg-elev);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font:15px/1.2 inherit;padding:13px 14px;outline:0;-webkit-appearance:none;">
<div class="hint mute" id="hPass2" style="min-height:18px;font-size:12px;margin-top:5px;color:var(--text-dim);"></div>
</div>
</div>
<label class="row" style="display:flex;align-items:center;gap:10px;margin:6px 0 16px;padding:12px;background:var(--bg-elev);border-radius:10px;border:1px solid var(--border);cursor:pointer;">
<input type="checkbox" id="b-rem" checked style="width:20px;height:20px;accent-color:var(--primary);flex-shrink:0;">
<span style="font-size:14px;font-weight:600;">Запомнить меня (30 дней)</span>
</label>
<button class="btn" id="b-go" type="submit" style="width:100%;border:0;border-radius:12px;padding:15px;font:700 16px/1 inherit;cursor:pointer;background:var(--primary);color:#fff;">Войти</button>
</form>
<div id="b-switch" style="text-align:center;margin-top:14px;font-size:13px;color:var(--text-dim);">Нет аккаунта? <a href="#" id="b-switch-link" style="color:var(--primary);font-weight:600;text-decoration:none;">Регистрация</a></div>
</div>
</div>
</div>
`;
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 = '<div class="empty" style="font-size:13px;">Пока никого нет. Будь первым!</div>';
box.className = '';
return;
}
box.className = '';
box.innerHTML = r.users.map((u) => `
<div style="display:flex;align-items:center;gap:10px;padding:6px 0;border-top:1px solid var(--border);">
<div class="avatar" style="width:32px;height:32px;border-radius:50%;background:var(--primary);color:white;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex-shrink:0;">${escapeHtml((u.name || '?')[0].toUpperCase())}</div>
<div style="flex:1;min-width:0;">
<div style="font-size:14px;font-weight:500;">${escapeHtml(u.name)}</div>
<div class="privacy-note" style="font-size:11px;">${u.age ? u.age + ' лет' : ''}${u.city ? ' • ' + escapeHtml(u.city) : ''}</div>
</div>
<div class="privacy-note" style="font-size:11px;">${u.registered_ago || ''}</div>
</div>
`).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
? 'Уже есть аккаунт? <a href="#" id="b-switch-link" style="color:var(--primary);font-weight:600;text-decoration:none;">Войти</a>'
: 'Нет аккаунта? <a href="#" id="b-switch-link" style="color:var(--primary);font-weight:600;text-decoration:none;">Регистрация</a>';
$('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 = '<div class="view" style="display:flex;justify-content:center;padding:40px;"><div class="loader"></div></div>';
}
// 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 = '<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;', "'": '&#39;' }[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)));
});
});