buhapp-telegram/public/js/app.js
root 8fce24104d Fix Safari: Connection:close + SameSite=None + cache:no-store + 150ms delay
Анализ Grok: Safari зависает на ответе fetch после POST с Set-Cookie из-за keep-alive/HTTP2 reuse.
- backend Login/Register: Connection: close, Cache-Control: no-store
- backend cookie: SameSite=None (cross-subdomain в Safari)
- frontend api(): cache:'no-store', mode:'cors', timeout 20s
- frontend emailLogin: 150ms пауза после login перед следующим fetch
2026-08-21 10:57:09 +00:00

449 lines
18 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">
<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">Регистрация</button>
<div class="sub" id="el-hint" style="margin-top:8px;text-align:center;">Введите email и пароль (≥8 символов)</div>
</div>
<div class="card" id="recent-users-card">
<div class="sub">👥 Недавно зарегистрировались:</div>
<div id="recent-users-list" class="loader" style="min-height:60px;padding:8px 0;">Загрузка...</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;
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 if (pass.length > 0) {
hint.textContent = 'Введите email';
hint.style.color = 'var(--text-dim)';
} else {
hint.textContent = 'Введите email и пароль (≥8 символов)';
hint.style.color = 'var(--text-dim)';
}
};
document.getElementById('el-email').addEventListener('input', update);
document.getElementById('el-pass').addEventListener('input', update);
// подгрузим недавно зарегистрированных
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);
}
},
// Сначала 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);
// Safari: дать применить Set-Cookie перед следующим fetch
await new Promise((r) => setTimeout(r, 150));
await API.Auth.me();
this.onLogin();
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();
// обновить видимость таббара (не виден пока не залогинился)
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)));
});
});