- New OnboardingView module (public/js/views/onboarding.js):
• Reads /onboarding/slides.json (designer schema with inline SVG icons,
per-slide cta_primary/cta_skip labels) — falls back to 3 emoji slides
if the JSON is unavailable.
• Renders a Telegram-style dark overlay inside #mobile-frame with a
220px floating SVG icon, title, body, dot indicator (expandable
active dot), primary CTA, and a ghost Skip button.
• Prev/Next via primary CTA; dot click jumps to slide; arrow keys and
horizontal swipe also navigate.
• Haptic feedback via TG_APP on slide change + finish.
• markDone() writes buhapp.onboarded='1' to localStorage so the flow
never repeats. isDone()/reset() exposed for QA.
- App integration (public/js/app.js):
• New App.maybeOnboard() helper — awaited after every successful auth
path: Telegram login, restored session, email login, email register,
and consent accept. Skips silently if already onboarded.
- UI assets (public/onboarding/):
• slides.json with 3 designer slides (Map / Chat / Review).
• Inline-SVG icons for map, chat, and review (icon-*.svg kept for
designer reference, preview.html too).
- Styles (public/css/app.css): new .onboarding-* dark-theme rules.
- Index (public/index.html): loads onboarding.js before app.js.
338 lines
14 KiB
JavaScript
338 lines
14 KiB
JavaScript
// Main app controller
|
||
const App = {
|
||
consentGiven: false,
|
||
isMobileMode: false,
|
||
|
||
async init() {
|
||
// Определяем: десктоп — стартуем в mobile-mode для удобства разработки
|
||
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);
|
||
}
|
||
};
|
||
|
||
// bind tabbar
|
||
document.querySelectorAll('.tab').forEach(btn => {
|
||
btn.onclick = () => this.showView(btn.dataset.view);
|
||
});
|
||
|
||
// restore tokens from localStorage
|
||
API.Store.load();
|
||
|
||
if (TG_APP.isAvailable && TG_APP.initData) {
|
||
// авторизация через Telegram
|
||
try {
|
||
await API.Auth.loginWithTelegram(TG_APP.initData);
|
||
await this.checkConsent();
|
||
await this.maybeOnboard();
|
||
this.showView('map');
|
||
} catch (e) {
|
||
this.showError('Не удалось войти через Telegram: ' + e.message);
|
||
}
|
||
} else if (API.Store.access) {
|
||
// уже залогинен
|
||
try {
|
||
await API.Auth.me();
|
||
await this.checkConsent();
|
||
await this.maybeOnboard();
|
||
this.showView('map');
|
||
} catch (e) {
|
||
API.Store.clear();
|
||
this.showError('Сессия истекла: ' + e.message);
|
||
}
|
||
} else {
|
||
// WebApp открыт вне Telegram — предложим email login
|
||
this.showEmailLogin();
|
||
}
|
||
|
||
// bind input events for chat
|
||
document.body.addEventListener('click', (e) => {
|
||
const sendBtn = e.target.closest('#msg-send');
|
||
if (sendBtn) { window.ChatView.send(); return; }
|
||
const cancel = e.target.closest('#edit-cancel');
|
||
if (cancel) { document.getElementById('msg-input').value = ''; document.getElementById('edit-bar').style.display='none'; return; }
|
||
});
|
||
document.body.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && e.target.id === 'msg-input') {
|
||
if (!e.shiftKey) { e.preventDefault(); window.ChatView.send(); }
|
||
}
|
||
});
|
||
},
|
||
|
||
async checkConsent() {
|
||
// проверяем на бэкенде — если все 4 уже приняты, не показываем
|
||
try {
|
||
const s = await API.ConsentApi.status();
|
||
if (s.all_accepted) {
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.warn('consent status check failed', e);
|
||
}
|
||
this.showConsent();
|
||
return false;
|
||
},
|
||
|
||
// Показываем welcome-flow новому пользователю ОДИН раз.
|
||
// Возвращает Promise, который резолвится когда пользователь прошёл/пропустил онбординг.
|
||
async maybeOnboard() {
|
||
if (!window.OnboardingView) {
|
||
console.warn('[onboarding] OnboardingView not loaded');
|
||
return;
|
||
}
|
||
if (OnboardingView.isDone()) return;
|
||
const root = document.getElementById('app');
|
||
await OnboardingView.render(root);
|
||
},
|
||
|
||
showConsent() {
|
||
const ov = document.createElement('div');
|
||
ov.id = 'consent-overlay';
|
||
ov.innerHTML = `
|
||
<h1>🍻 Добро пожаловать в BuhApp</h1>
|
||
<p class="sub">Приложение для поиска компании. Только для лиц 18+.</p>
|
||
<div class="card" style="background:var(--bg-elev);">
|
||
<p style="margin-bottom:12px;font-size:13px;">
|
||
⚠️ <strong>Внимание</strong>: BuhApp — это платформа для знакомств и встреч с другими пользователями.
|
||
Включает в себя риски, связанные с употреблением алкоголя и личными встречами.
|
||
</p>
|
||
<p style="font-size:13px;">
|
||
Пожалуйста, ознакомьтесь и примите следующие документы:
|
||
</p>
|
||
<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">Пользовательское соглашение (v1.0)</label></div>
|
||
<div class="consent-line"><input type="checkbox" id="c-privacy"><label for="c-privacy">Политика конфиденциальности (152-ФЗ, v1.0)</label></div>
|
||
<div class="consent-line"><input type="checkbox" id="c-disclaimer"><label for="c-disclaimer">Отказ от ответственности (v1.0)</label></div>
|
||
<button class="btn" id="c-accept" style="margin-top:16px;">Принять и продолжить</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(ov);
|
||
|
||
document.getElementById('c-accept').onclick = async () => {
|
||
const a = document.getElementById('c-adult').checked;
|
||
const t = document.getElementById('c-terms').checked;
|
||
const p = document.getElementById('c-privacy').checked;
|
||
const d = document.getElementById('c-disclaimer').checked;
|
||
if (!a || !t || !p || !d) {
|
||
TG_APP.showAlert('Нужно принять все 4 пункта');
|
||
return;
|
||
}
|
||
try {
|
||
await API.api('/api/v1/auth/consents', {
|
||
method: 'POST',
|
||
body: {
|
||
adult: a, terms: t, privacy: p, disclaimer: d,
|
||
},
|
||
});
|
||
TG_APP.haptic('notification');
|
||
ov.remove();
|
||
this.consentGiven = true;
|
||
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>
|
||
`;
|
||
},
|
||
|
||
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 = '📱 Мобильный';
|
||
}
|
||
},
|
||
|
||
showEmailLogin() {
|
||
document.getElementById('app').innerHTML = `
|
||
<div class="view">
|
||
<h1>🍻 BuhApp</h1>
|
||
<div class="sub">Приложение для поиска компании (Telegram Mini App)</div>
|
||
<div class="card">
|
||
<div style="background:var(--warning);color:#000;padding:8px 12px;border-radius:8px;margin-bottom:12px;font-size:13px;">
|
||
⚠️ WebApp лучше открывать через Telegram — там авторизация автоматическая.
|
||
Здесь можно зайти по email для тестирования.
|
||
</div>
|
||
<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);">
|
||
🍻 BuhApp — приложение для лиц 18+. Встречи и употребление алкоголя — на свой риск.
|
||
Принимая соглашение, вы подтверждаете совершеннолетие.
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.getElementById('el-login').onclick = () => this.emailLogin();
|
||
document.getElementById('el-register').onclick = () => this.emailRegister();
|
||
|
||
// live-валидация
|
||
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);
|
||
},
|
||
|
||
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();
|
||
await this.checkConsent();
|
||
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;
|
||
if (!email || pass.length < 8) {
|
||
TG_APP.showAlert('Email и пароль (≥8 символов) обязательны');
|
||
return;
|
||
}
|
||
// disable на время запроса
|
||
const btn = document.getElementById('el-register');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Регистрация...';
|
||
try {
|
||
console.log('registerEmail:', email);
|
||
const r = await API.Auth.registerEmail({
|
||
email,
|
||
password: pass,
|
||
name: email.split('@')[0],
|
||
consents: { adult: true, terms: true, privacy: true, disclaimer: true },
|
||
});
|
||
console.log('registerEmail result:', r);
|
||
await API.Auth.me();
|
||
this.consentGiven = true;
|
||
await this.maybeOnboard();
|
||
this.showView('map');
|
||
} catch (e) {
|
||
console.error('registerEmail error:', e);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Регистрация';
|
||
// покажем ошибку явно
|
||
const msg = e?.message || 'unknown error';
|
||
if (e?.data) console.error('error data:', e.data);
|
||
TG_APP.showAlert('Ошибка регистрации: ' + msg + '\n\nОткрой DevTools (F12) → Console для подробностей.');
|
||
}
|
||
},
|
||
|
||
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();
|
||
|
||
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]));
|
||
}
|
||
|
||
function formatTime(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const today = new Date();
|
||
if (d.toDateString() === today.toDateString()) {
|
||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||
}
|
||
return d.getDate().toString().padStart(2, '0') + '.' + (d.getMonth() + 1).toString().padStart(2, '0');
|
||
}
|
||
|
||
function formatDate(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
return d.getDate().toString().padStart(2, '0') + '.' + (d.getMonth() + 1).toString().padStart(2, '0') + '.' + d.getFullYear();
|
||
}
|
||
|
||
window.App = App;
|
||
document.addEventListener('DOMContentLoaded', () => App.init()); |