buhapp-telegram/public/js/app.js
root a2cc623756 Fix session persistence + полный рерайт регистрации
FIX SESSION:
- Store.save() теперь сохраняет в localStorage ('buhapp_session_v1')
- Store.load() восстанавливает access+refresh
- Store.clear() чистит и память и localStorage
- При загрузке api.js сразу Store.load() — сессия восстанавливается автоматически
- При refresh страницы init() видит Store.access и сразу делает me()

REGISTRATION REDO:
- Отдельный экран showEmailRegister() (не на том же экране что login)
- Поля: имя, email, пароль, пароль2, 18+, соглашения, запомнить
- Полная валидация в реальном времени (имя не пустое, email валидный,
  пароль ≥8, пароли совпадают, чекбоксы отмечены)
- Кнопка disabled пока форма не готова, hint показывает что не так
- После успешной регистрации: сразу показ 'Добро пожаловать!' + переход на карту
- Ошибка от сервера — показывается в hint + подсветка поля email красным
- Удалён старый emailRegisterPre и дублирующий emailRegister
2026-08-21 11:24:47 +00:00

528 lines
22 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">
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;font-size:14px;">
<input type="checkbox" id="el-remember" checked>
<span>Запомнить меня (30 дней)</span>
</label>
<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.showEmailRegister();
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();
},
// === ОТДЕЛЬНЫЙ ЭКРАН РЕГИСТРАЦИИ ===
showEmailRegister() {
document.getElementById('app').innerHTML = `
<div class="view">
<h1>Регистрация</h1>
<div class="sub">Создай аккаунт за 30 секунд</div>
<div class="card">
<label>Имя (как к тебе обращаться)</label>
<input class="input" id="rg-name" type="text" placeholder="Саша" autocomplete="given-name">
<label>Email</label>
<input class="input" id="rg-email" type="email" placeholder="you@example.com" autocomplete="email">
<label>Пароль (≥8 символов)</label>
<input class="input" id="rg-pass" type="password" placeholder="минимум 8 символов" autocomplete="new-password">
<label>Пароль ещё раз</label>
<input class="input" id="rg-pass2" type="password" placeholder="повтори пароль" autocomplete="new-password">
<label class="checkrow">
<input type="checkbox" id="rg-adult" checked>
<span>Мне уже есть 18 лет</span>
</label>
<label class="checkrow">
<input type="checkbox" id="rg-terms" checked>
<span>Принимаю <a href="/legal/TERMS.html" target="_blank">соглашение</a></span>
</label>
<label class="checkrow">
<input type="checkbox" id="rg-remember" checked>
<span>Запомнить меня (30 дней)</span>
</label>
<button class="btn" id="rg-submit">Создать аккаунт</button>
<button class="btn ghost" id="rg-back">← Назад ко входу</button>
<div class="sub" id="rg-hint" style="margin-top:8px;text-align:center;"></div>
</div>
</div>
`;
const update = () => {
const name = document.getElementById('rg-name').value.trim();
const email = document.getElementById('rg-email').value.trim();
const pass = document.getElementById('rg-pass').value;
const pass2 = document.getElementById('rg-pass2').value;
const adult = document.getElementById('rg-adult').checked;
const terms = document.getElementById('rg-terms').checked;
const btn = document.getElementById('rg-submit');
const hint = document.getElementById('rg-hint');
let msg = '';
let ready = true;
if (name.length < 1) { msg = 'Введите имя'; ready = false; }
else if (!email.includes('@') || !email.includes('.')) { msg = 'Некорректный email'; ready = false; }
else if (pass.length < 8) { msg = 'Пароль минимум 8 символов (' + pass.length + '/8)'; ready = false; }
else if (pass !== pass2) { msg = 'Пароли не совпадают'; ready = false; }
else if (!adult) { msg = 'Только для лиц 18+'; ready = false; }
else if (!terms) { msg = 'Примите соглашение'; ready = false; }
else { msg = '✓ Всё готово'; }
btn.disabled = !ready;
hint.textContent = msg;
hint.style.color = ready ? 'var(--success)' : (msg.startsWith('✓') ? 'var(--success)' : 'var(--text-dim)');
};
['rg-name','rg-email','rg-pass','rg-pass2','rg-adult','rg-terms'].forEach((id) => {
document.getElementById(id).addEventListener('input', update);
document.getElementById(id).addEventListener('change', update);
});
update();
document.getElementById('rg-submit').onclick = () => this.emailRegister();
document.getElementById('rg-back').onclick = () => this.showEmailLogin();
},
async emailRegister() {
const name = document.getElementById('rg-name').value.trim();
const email = document.getElementById('rg-email').value.trim();
const pass = document.getElementById('rg-pass').value;
const pass2 = document.getElementById('rg-pass2').value;
const adult = document.getElementById('rg-adult').checked;
const terms = document.getElementById('rg-terms').checked;
const remember = document.getElementById('rg-remember').checked;
const btn = document.getElementById('rg-submit');
const hint = document.getElementById('rg-hint');
// повторная проверка на стороне клиента
if (!name || !email.includes('@') || pass.length < 8 || pass !== pass2 || !adult || !terms) {
hint.textContent = 'Проверьте поля';
hint.style.color = 'var(--danger)';
return;
}
btn.disabled = true;
btn.textContent = '⏳ Создаём аккаунт…';
hint.textContent = 'Регистрация на сервере…';
hint.style.color = 'var(--text-dim)';
try {
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;
btn.textContent = '✓ Готово';
hint.textContent = 'Добро пожаловать, ' + name + '!';
hint.style.color = 'var(--success)';
this.onLogin();
await this.maybeOnboard();
this.showView('map');
} catch (e) {
btn.disabled = false;
btn.textContent = 'Создать аккаунт';
const msg = (e && e.message) || 'Неизвестная ошибка';
hint.textContent = '⚠️ ' + msg;
hint.style.color = 'var(--danger)';
if (msg.includes('email')) {
// подсветить поле email красным
const el = document.getElementById('rg-email');
if (el) el.style.borderColor = 'var(--danger)';
}
console.error('register failed:', e);
}
},
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() {
const email = document.getElementById('el-email').value.trim();
const pass = document.getElementById('el-pass').value;
const remember = document.getElementById('el-remember')?.checked ?? true;
try {
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;
this.onLogin();
this.maybeOnboard();
this.showView('map');
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
}
},
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)));
});
});