buhapp-telegram/public/js/app.js
root 06c8f8b593 Fix: tab switching hangs on iOS Safari
- showView: requestAnimationFrame + мгновенный loader (UI не висит)
- maybeOnboard на старте fire-and-forget (не блокирует init)
- MapView: НЕ запрашивает геолокацию автоматически (только по кнопке)
- MapView: правильно destroy map если container удалён (нет утечки Leaflet)
- invalidateSize() перед setView чтобы тайлы 2GIS догрузились
2026-08-20 21:28:33 +00:00

386 lines
15 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 {
// Вне Telegram — показать email login (но сначала согласия)
this.showWelcome();
}
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() {
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">
<h1>Вход</h1>
<div class="sub">Войди или зарегистрируйся по email</div>
<div class="card">
<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);">
Регистрируясь, вы подтверждаете совершеннолетие (18+) и принимаете
<a href="/legal/TERMS.html" target="_blank">соглашение</a>.
</div>
</div>
`;
document.getElementById('el-login').onclick = () => this.emailLogin();
document.getElementById('el-register').onclick = () => this.emailRegisterPre();
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);
},
// Сначала 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();
// для email-логина согласия можно проверить, но юзер их уже дал
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;
// показываем сообщение о процессе
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 {
const r = await API.Auth.registerEmail({
email,
password: pass,
name: email.split('@')[0],
consents: { adult: true, terms: true, privacy: true, disclaimer: true },
});
await API.Auth.me();
ov.remove();
await this.maybeOnboard();
this.showView('map');
} catch (e) {
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;
}
},
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();
// мгновенный 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)));
});
});