ДИЗАЙН-СИСТЕМА в app.css: - --primary #FF6B35, --success #3dd68c, --danger #ff5c5c - --bg #0f0f12, --bg-elev #1a1a22, --bg-input #121218 - Компоненты: .card-b, .row-b, .field-b, .btn-b, .switch-b, .chip, .avatar-b, .bubble-b, .composer-b, .toast-b, .stars-b, .pulse-b, .empty-b ПЕРЕПИСАНЫ на единый стиль: Settings: - Карточка с аватаром пользователя - Card-row для юр.документов (📄 / 🔒 / ⚠️) - 'Выйти' — danger btn-b с state machine (idle→load→ok) Profile: - Карточка аватара + кнопка 'Загрузить фото' через multipart /api/v1/me/photo - Поля: field-b (ctrl + hint) - Чипы в chip-list, новый стиль с .on - Счётчик bio символов - 'Сохранить' со state machine + toast Review (создание): - Звёзды в stars-b с center hint - textarea с counter - switch-b 'Анонимно' (вместо checkbox) - Submit со state machine + toast 'Спасибо за отзыв!' UserReviews: - Средний рейтинг + звёзды - Список карточек с отзывами Chats: - Список с avatar-b + last message + время + badge непрочитанных - Пустое состояние с эмодзи - formatChatTime() — 'сейчас' / '5м' / '3ч' / '2д' Chat (один): - Header с аватаром и кнопками 👤 ⭐ - composer-b: 📎 + input + ➤ (sticky bottom) - bubble-b .in / .out с .ticks.read - Send со state + toast - sendPhoto() для фоток в чате App.js _toast(msg, kind): - Единый toast-b с классами ok / err Всё в одном стиле. CSS variables, padding 16, radius 14, no disabled кнопок.
589 lines
27 KiB
JavaScript
589 lines
27 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 {
|
||
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, kind) {
|
||
let t = document.getElementById('b-toast');
|
||
if (!t) {
|
||
t = document.createElement('div');
|
||
t.id = 'b-toast';
|
||
t.className = 'toast-b';
|
||
document.body.appendChild(t);
|
||
}
|
||
t.className = 'toast-b' + (kind ? ' ' + kind : '');
|
||
t.textContent = msg;
|
||
requestAnimationFrame(() => t.classList.add('show'));
|
||
setTimeout(() => t.classList.remove('show'), 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)));
|
||
});
|
||
});
|