- showView: requestAnimationFrame + мгновенный loader (UI не висит) - maybeOnboard на старте fire-and-forget (не блокирует init) - MapView: НЕ запрашивает геолокацию автоматически (только по кнопке) - MapView: правильно destroy map если container удалён (нет утечки Leaflet) - invalidateSize() перед setView чтобы тайлы 2GIS догрузились
386 lines
15 KiB
JavaScript
386 lines
15 KiB
JavaScript
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)));
|
||
});
|
||
});
|