From 21d281a1c6ff20a1debe1c89faca4ea5256163c4 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 17:43:16 +0000 Subject: [PATCH] Telegram WebApp: Mini App with map, chats, profile, reviews - index.html with bottom tabbar (Map/Chats/Profile/Settings) - Telegram WebApp SDK auth via /api/v1/auth/telegram - Consent gate on first login (4 checkboxes) - Map view with Leaflet (OSM) + nearby users + radius selector - Chat list + single chat with WebSocket realtime - Profile editing (drinks, activities, purposes) - Review screen with stars + anonymous toggle - User reviews list with stats - User profile (public) with rating, write/block/report - Legal pages (TERMS / PRIVACY / DISCLAIMER) --- README.md | 42 ++++++- public/css/app.css | 238 +++++++++++++++++++++++++++++++++++ public/index.html | 44 +++++++ public/js/api.js | 151 ++++++++++++++++++++++ public/js/app.js | 183 +++++++++++++++++++++++++++ public/js/tg.js | 33 +++++ public/js/views/chat.js | 152 ++++++++++++++++++++++ public/js/views/chats.js | 41 ++++++ public/js/views/map.js | 127 +++++++++++++++++++ public/js/views/profile.js | 115 +++++++++++++++++ public/js/views/review.js | 116 +++++++++++++++++ public/js/views/settings.js | 115 +++++++++++++++++ public/legal/DISCLAIMER.html | 63 ++++++++++ public/legal/PRIVACY.html | 60 +++++++++ public/legal/TERMS.html | 65 ++++++++++ 15 files changed, 1543 insertions(+), 2 deletions(-) create mode 100644 public/css/app.css create mode 100644 public/index.html create mode 100644 public/js/api.js create mode 100644 public/js/app.js create mode 100644 public/js/tg.js create mode 100644 public/js/views/chat.js create mode 100644 public/js/views/chats.js create mode 100644 public/js/views/map.js create mode 100644 public/js/views/profile.js create mode 100644 public/js/views/review.js create mode 100644 public/js/views/settings.js create mode 100644 public/legal/DISCLAIMER.html create mode 100644 public/legal/PRIVACY.html create mode 100644 public/legal/TERMS.html diff --git a/README.md b/README.md index 168858e..4d3bc3a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ -# buhapp-telegram +# BuhApp Telegram WebApp -Telegram bot + Mini App (WebApp) \ No newline at end of file +Mini App (WebApp) для Telegram, открывается по кнопке в боте @BuhAppBot. + +## Запуск + +Статика (HTML/JS) раздаётся с `https://app.buhapp.mygoodservice.ru/`. + +## Структура + +``` +buhapp-telegram/ +├── public/ +│ ├── index.html — главная страница (карта/чаты/профиль/настройки) +│ ├── login.html — онбординг (если запустили без Telegram) +│ ├── css/ +│ │ └── app.css — стили +│ └── js/ +│ ├── api.js — HTTP клиент + TG SDK +│ ├── tg.js — обёртки Telegram WebApp SDK +│ ├── app.js — главный контроллер +│ └── views/ +│ ├── map.js +│ ├── chats.js +│ ├── chat.js +│ ├── profile.js +│ ├── review.js +│ └── settings.js +└── README.md +``` + +## Telegram Bot + +- Создай бота через [@BotFather](https://t.me/BotFather) → `/newbot` +- Установи WebApp URL: `/setdomain` → `app.buhapp.mygoodservice.ru` +- Скопируй токен → `TELEGRAM_BOT_TOKEN` в `.env` бэкенда + +## Деплой + +Все файлы из `public/` монтируются в nginx (location /). +Бэкенд читает `initData` от Telegram и авторизует через `POST /api/v1/auth/telegram`. \ No newline at end of file diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..768853a --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,238 @@ +:root { + --primary: #FF6B35; + --primary-dark: #E64A19; + --secondary: #1E88E5; + --bg: #FFFFFF; + --bg-elev: #F5F5F7; + --bg-dark: #1C1C1E; + --text: #111114; + --text-dim: #6B6B73; + --text-inverse: #FFFFFF; + --border: #E0E0E5; + --danger: #E53935; + --success: #4CAF50; + --warning: #FFB300; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1C1C1E; + --bg-elev: #2C2C2E; + --text: #FFFFFF; + --text-dim: #98989D; + --border: #38383A; + } +} + +* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } +html, body { height: 100%; overflow: hidden; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro Display', Roboto, sans-serif; + font-size: 15px; + color: var(--text); + background: var(--bg); + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + -webkit-user-select: none; + user-select: none; +} + +#app { + height: calc(100vh - 60px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} + +#tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--bg-elev); + border-top: 1px solid var(--border); + display: flex; + z-index: 100; +} +.tab { + flex: 1; + background: none; + border: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + color: var(--text-dim); + font-size: 11px; + cursor: pointer; + position: relative; +} +.tab svg { fill: currentColor; } +.tab.active { color: var(--primary); } +.tab .badge { + position: absolute; + top: 4px; + right: calc(50% - 18px); + background: var(--primary); + color: var(--text-inverse); + font-size: 10px; + font-weight: 700; + border-radius: 999px; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +.view { padding: 16px; } +.view h1 { font-size: 28px; font-weight: 700; margin-bottom: 12px; } +.view h2 { font-size: 18px; font-weight: 600; margin: 12px 0 8px; } +.view .sub { color: var(--text-dim); margin-bottom: 16px; } + +.card { + background: var(--bg-elev); + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; +} +.card.clickable { cursor: pointer; } + +.btn { + display: block; + width: 100%; + background: var(--primary); + color: var(--text-inverse); + border: none; + border-radius: 10px; + padding: 12px 16px; + font-size: 16px; + font-weight: 600; + margin-top: 8px; + cursor: pointer; +} +.btn.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} +.btn.danger { background: var(--danger); } +.btn:disabled { opacity: 0.4; } + +.input { + width: 100%; + background: var(--bg-elev); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + font-size: 15px; + margin-bottom: 12px; + font-family: inherit; +} + +label { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; display: block; } + +.chip { + display: inline-block; + padding: 6px 12px; + border-radius: 999px; + background: var(--bg-elev); + border: 1px solid var(--border); + margin: 0 6px 6px 0; + font-size: 13px; + cursor: pointer; +} +.chip.active { background: var(--primary); color: var(--text-inverse); border-color: var(--primary); } + +.list-row { + display: flex; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--primary); + color: var(--text-inverse); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + margin-right: 12px; + flex-shrink: 0; + overflow: hidden; + background-size: cover; +} +.list-row .body { flex: 1; min-width: 0; } +.list-row .name { font-weight: 600; } +.list-row .last { color: var(--text-dim); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.bubble { + max-width: 75%; + padding: 8px 12px; + border-radius: 16px; + margin-bottom: 6px; + word-wrap: break-word; +} +.bubble.mine { background: var(--primary); color: var(--text-inverse); align-self: flex-end; border-bottom-right-radius: 4px; } +.bubble.theirs { background: var(--bg-elev); color: var(--text); align-self: flex-start; border-bottom-left-radius: 4px; } +.bubble .meta { font-size: 11px; opacity: 0.7; margin-top: 2px; display: flex; gap: 6px; } +.bubble .edited { font-style: italic; } + +.chat-input { + display: flex; + padding: 8px; + background: var(--bg); + border-top: 1px solid var(--border); + position: sticky; + bottom: 0; +} +.chat-input input { flex: 1; margin: 0 8px 0 0; } +.chat-input button { width: 40px; padding: 0; margin: 0; } + +#map { height: calc(100vh - 60px - 32px); width: 100%; } + +.stars { display: flex; gap: 4px; font-size: 32px; } +.stars span { cursor: pointer; color: var(--border); } +.stars span.on { color: var(--warning); } + +.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } + +#consent-overlay { + position: fixed; inset: 0; + background: var(--bg); + z-index: 200; + padding: 20px; + overflow-y: auto; +} + +.empty { color: var(--text-dim); text-align: center; padding: 32px 16px; } + +.toast { + position: fixed; + top: 16px; left: 16px; right: 16px; + background: var(--bg-dark); + color: var(--text-inverse); + padding: 12px; + border-radius: 10px; + z-index: 1000; + text-align: center; + font-size: 14px; +} + +.loader { text-align: center; padding: 32px; color: var(--text-dim); } + +.consent-line { display: flex; align-items: center; padding: 8px 0; gap: 8px; } +.consent-line input[type="checkbox"] { width: 20px; height: 20px; } + +.rating-big { font-size: 36px; font-weight: 700; } + +.privacy-note { font-size: 12px; color: var(--text-dim); margin-top: 4px; } \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a14d766 --- /dev/null +++ b/public/index.html @@ -0,0 +1,44 @@ + + + + + + + BuhApp + + + + + + +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..92aa11b --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,151 @@ +// API клиент +const API_BASE = window.API_BASE || 'https://api.buhapp.mygoodservice.ru'; + +const Store = { + access: null, + refresh: null, + user: null, + + save(tokens) { + this.access = tokens.access; + this.refresh = tokens.refresh; + try { localStorage.setItem('buhapp', JSON.stringify({ access, refresh, user: this.user })); } catch {} + }, + + load() { + try { + const s = JSON.parse(localStorage.getItem('buhapp') || 'null'); + if (s) { this.access = s.access; this.refresh = s.refresh; this.user = s.user; } + } catch {} + return this.access; + }, + + clear() { + this.access = null; + this.refresh = null; + this.user = null; + try { localStorage.removeItem('buhapp'); } catch {} + }, +}; + +async function api(path, opts = {}) { + opts.headers = opts.headers || {}; + if (Store.access && !opts.headers.Authorization) { + opts.headers.Authorization = `Bearer ${Store.access}`; + } + if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(opts.body); + } + let r = await fetch(API_BASE + path, opts); + if (r.status === 401 && Store.refresh) { + // refresh + const r2 = await fetch(API_BASE + '/api/v1/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh: Store.refresh }), + }); + if (r2.ok) { + const tokens = await r2.json(); + Store.save(tokens); + opts.headers.Authorization = `Bearer ${tokens.access}`; + r = await fetch(API_BASE + path, opts); + } else { + Store.clear(); + } + } + const text = await r.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = text; } + if (!r.ok) { + const msg = (data && data.error) || `HTTP ${r.status}`; + const err = new Error(msg); + err.status = r.status; + err.data = data; + throw err; + } + return data; +} + +// === Auth === + +const Auth = { + async loginWithTelegram(initData) { + const r = await api('/api/v1/auth/telegram', { + method: 'POST', + body: { init_data: initData }, + }); + Store.save(r.tokens); + Store.user = r.user; + return r; + }, + + async registerEmail(data) { + const r = await api('/api/v1/auth/register', { method: 'POST', body: data }); + Store.save(r.tokens); + Store.user = r.user; + return r; + }, + + async loginEmail(email, password) { + const r = await api('/api/v1/auth/login', { method: 'POST', body: { email, password } }); + Store.save(r.tokens); + Store.user = r.user; + return r; + }, + + async me() { + const r = await api('/api/v1/me'); + Store.user = r.user; + return r; + }, + + async updateMe(p) { + return api('/api/v1/me', { method: 'PUT', body: p }); + }, + + async getPrefs() { return api('/api/v1/me/prefs'); }, + async updatePrefs(p) { return api('/api/v1/me/prefs', { method: 'PUT', body: p }); }, + async updateLocation(lat, lng, visible) { + return api('/api/v1/me/location', { method: 'PUT', body: { lat, lng, visible } }); + }, + async setVisibility(v) { return api('/api/v1/me/visibility', { method: 'PUT', body: { visible: v } }); }, + async searchNearby(lat, lng, radiusKm) { + return api('/api/v1/search/nearby?lat=' + lat + '&lng=' + lng + '&radius=' + radiusKm); + }, + async getUser(id) { return api('/api/v1/users/' + id); }, +}; + +// === Chat === + +const ChatApi = { + async ensureChat(otherId) { return api('/api/v1/chats', { method: 'POST', body: { other_id: otherId } }); }, + async listChats() { return api('/api/v1/chats'); }, + async listMessages(chatId) { return api('/api/v1/chats/' + chatId + '/messages'); }, + async sendMessage(chatId, body, photoUrl) { + return api('/api/v1/chats/' + chatId + '/messages', { method: 'POST', body: { body, photo_url: photoUrl } }); + }, + async editMessage(msgId, body) { + return api('/api/v1/messages/' + msgId, { method: 'PUT', body: { body } }); + }, + async deleteMessage(msgId) { + return api('/api/v1/messages/' + msgId, { method: 'DELETE' }); + }, + async markRead(chatId) { + return api('/api/v1/chats/' + chatId + '/read', { method: 'PUT', body: {} }); + }, + async block(userId) { return api('/api/v1/blocks', { method: 'POST', body: { user_id: userId } }); }, + async report(type, id, reason) { return api('/api/v1/reports', { method: 'POST', body: { target_type: type, target_id: id, reason } }); }, +}; + +// === Reviews === + +const ReviewApi = { + async create(chatId, rating, body, anonymous) { + return api('/api/v1/reviews', { method: 'POST', body: { chat_id: chatId, rating, body, anonymous } }); + }, + async listForUser(userId) { return api('/api/v1/users/' + userId + '/reviews'); }, + async stats(userId) { return api('/api/v1/users/' + userId + '/stats'); }, +}; + +window.API = { api, Auth, ChatApi, ReviewApi, Store }; \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..394d872 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,183 @@ +// Main app controller +const App = { + consentGiven: false, + + async init() { + // 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(); + 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.showError('Откройте BuhApp через бота @buhoiapp_bot'); + } + + // 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() { + // показываем согласия при первом входе + if (API.Store.user && !API.Store.user.consents_given) { + this.showConsent(); + return false; + } + return true; + }, + + showConsent() { + const ov = document.createElement('div'); + ov.id = 'consent-overlay'; + ov.innerHTML = ` +

🍻 Добро пожаловать в BuhApp

+

Приложение для поиска компании. Только для лиц 18+.

+
+

+ ⚠️ Внимание: BuhApp — это платформа для знакомств и встреч с другими пользователями. + Включает в себя риски, связанные с употреблением алкоголя и личными встречами. +

+

+ Пожалуйста, ознакомьтесь и примите следующие документы: +

+ + + + + +
+ `; + 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; + this.showView('map'); + } catch (e) { + TG_APP.showAlert('Ошибка: ' + e.message); + } + }; + }, + + showError(msg) { + document.getElementById('app').innerHTML = ` +
+
+
⚠️
+
${msg}
+
+
+ `; + }, + + 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 = '
Неизвестный экран
'; + } + }, + + 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()); \ No newline at end of file diff --git a/public/js/tg.js b/public/js/tg.js new file mode 100644 index 0000000..22c8aa6 --- /dev/null +++ b/public/js/tg.js @@ -0,0 +1,33 @@ +// Telegram WebApp SDK обёртки +const TG = window.Telegram?.WebApp; +if (TG) { + TG.ready(); + TG.expand(); +} + +window.TG_APP = { + initData: TG?.initData || '', + initDataUnsafe: TG?.initDataUnsafe || {}, + user: TG?.initDataUnsafe?.user || null, + colorScheme: TG?.colorScheme || 'light', + + isAvailable: !!TG, + + close: () => TG?.close(), + sendData: (data) => TG?.sendData(JSON.stringify(data)), + haptic: (type) => { + try { + TG?.HapticFeedback?.impactOccurred(type || 'light'); + } catch (e) {} + }, + showAlert: (msg) => { + if (TG?.showAlert) TG.showAlert(msg); + else alert(msg); + }, + showConfirm: (msg) => { + if (TG?.showConfirm) return new Promise((r) => TG.showConfirm(msg, r)); + return Promise.resolve(confirm(msg)); + }, + + theme: () => TG?.themeParams || {}, +}; \ No newline at end of file diff --git a/public/js/views/chat.js b/public/js/views/chat.js new file mode 100644 index 0000000..0f85d3a --- /dev/null +++ b/public/js/views/chat.js @@ -0,0 +1,152 @@ +// Single chat view +const ChatView = { + chatId: null, + otherId: null, + otherName: null, + ws: null, + + async render(root, params) { + this.chatId = params.chatId; + this.otherId = params.otherId; + this.otherName = params.otherName; + root.innerHTML = ` +
+
+ +
${escapeHtml(this.otherName || '')}
+ + +
+
+ +
+ + +
+
+ `; + + document.getElementById('chat-back').onclick = () => App.showView('chats'); + document.getElementById('chat-profile').onclick = () => App.openUserProfile(this.otherId, this.otherName); + document.getElementById('chat-review').onclick = () => App.openReview(this.chatId, this.otherId, this.otherName); + + this.loadMessages(); + this.setupWS(); + this.markRead(); + }, + + async loadMessages() { + try { + const r = await API.ChatApi.listMessages(this.chatId); + const box = document.getElementById('messages'); + box.innerHTML = ''; + r.messages.forEach((m) => this.appendMessage(m)); + box.scrollTop = box.scrollHeight; + } catch (e) { + console.warn(e); + } + }, + + appendMessage(m) { + const box = document.getElementById('messages'); + const me = API.Store.user && m.sender_id === API.Store.user.id; + const div = document.createElement('div'); + div.className = 'bubble ' + (me ? 'mine' : 'theirs'); + div.dataset.id = m.id; + if (m.deleted) { + div.innerHTML = 'Удалено'; + } else { + div.innerHTML = ` +
${escapeHtml(m.body)}
+
+ ${formatTime(m.created_at)} + ${m.edited ? 'ред.' : ''} +
+ `; + if (me) { + div.onclick = () => this.onMessageClick(m); + } + } + box.appendChild(div); + box.scrollTop = box.scrollHeight; + }, + + onMessageClick(m) { + TG_APP.showConfirm('Удалить сообщение?').then((ok) => { + if (ok) this.deleteMessage(m); + }); + }, + + async deleteMessage(m) { + try { + await API.ChatApi.deleteMessage(m.id); + const el = document.querySelector(`.bubble[data-id="${m.id}"]`); + if (el) el.innerHTML = 'Удалено'; + } catch (e) { + TG_APP.showAlert('Ошибка: ' + e.message); + } + }, + + setupWS() { + if (!API.Store.access) return; + const url = (window.API_BASE || 'https://api.buhapp.mygoodservice.ru').replace(/^http/, 'ws') + '/ws?token=' + API.Store.access; + try { + this.ws = new WebSocket(url); + this.ws.onopen = () => console.log('WS connected'); + this.ws.onmessage = (ev) => { + try { + const d = JSON.parse(ev.data); + if (d.type === 'message' && d.payload.chat_id === this.chatId) { + this.appendMessage(d.payload); + } else if (d.type === 'message_edited' && d.payload.chat_id === this.chatId) { + this.updateMessage(d.payload); + } + } catch {} + }; + this.ws.onclose = () => setTimeout(() => this.setupWS(), 3000); + this.ws.onerror = (e) => console.warn('WS error', e); + } catch (e) { + console.warn(e); + } + }, + + updateMessage(p) { + const el = document.querySelector(`.bubble[data-id="${p.id}"]`); + if (el) { + el.querySelector('div:first-child') && (el.querySelector('div:first-child').textContent = p.body); + const meta = el.querySelector('.meta'); + if (meta && !meta.querySelector('.edited')) { + const ed = document.createElement('span'); + ed.className = 'edited'; + ed.textContent = 'ред.'; + meta.appendChild(ed); + } + } + }, + + async send() { + const inp = document.getElementById('msg-input'); + const body = inp.value.trim(); + if (!body) return; + inp.value = ''; + try { + await API.ChatApi.sendMessage(this.chatId, body); + // WS пришлёт обратно + } catch (e) { + TG_APP.showAlert('Ошибка: ' + e.message); + } + }, + + async markRead() { + try { await API.ChatApi.markRead(this.chatId); } catch {} + }, + + cleanup() { + if (this.ws) { this.ws.close(); this.ws = null; } + }, +}; + +window.ChatView = ChatView; \ No newline at end of file diff --git a/public/js/views/chats.js b/public/js/views/chats.js new file mode 100644 index 0000000..8563f99 --- /dev/null +++ b/public/js/views/chats.js @@ -0,0 +1,41 @@ +// Chats list view +const ChatsView = { + async render(root) { + root.innerHTML = ` +
+

💬 Чаты

+
Загрузка...
+
+ `; + try { + const r = await API.ChatApi.listChats(); + const list = document.getElementById('chats-list'); + if (!r.chats || r.chats.length === 0) { + list.innerHTML = '
Нет чатов. Открой карту и найди кого-нибудь!
'; + return; + } + list.innerHTML = ''; + list.className = ''; + r.chats.forEach((item) => { + const row = document.createElement('div'); + row.className = 'list-row'; + row.innerHTML = ` +
+ ${item.other_photo ? '' : escapeHtml((item.other_name || '?')[0])} +
+
+
${escapeHtml(item.other_name)}
+
${escapeHtml(item.last_message || 'Нет сообщений')}
+
+ ${item.unread_count > 0 ? `
${item.unread_count}
` : ''} + `; + row.onclick = () => App.openChat(item.chat.id, item.other_id, item.other_name); + list.appendChild(row); + }); + } catch (e) { + document.getElementById('chats-list').innerHTML = '
Ошибка: ' + e.message + '
'; + } + }, +}; + +window.ChatsView = ChatsView; \ No newline at end of file diff --git a/public/js/views/map.js b/public/js/views/map.js new file mode 100644 index 0000000..5884474 --- /dev/null +++ b/public/js/views/map.js @@ -0,0 +1,127 @@ +// Map view — главный экран +const MapView = { + map: null, + markers: [], + radius: 5, + visible: true, + nearby: [], + + async render(root) { + root.innerHTML = ` +
+

🗺️ Карта

+
+
+
+ Я на карте +
Другие пользователи видят твоё местоположение ±300м
+
+ +
+
+
+
+ + +
+
+
+ `; + + if (!this.map) { + this.map = L.map('map').setView([55.7558, 37.6173], 12); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19, + }).addTo(this.map); + } + setTimeout(() => this.map.invalidateSize(), 100); + + document.getElementById('visible-toggle').addEventListener('change', async (e) => { + this.visible = e.target.checked; + try { + await API.Auth.setVisibility(this.visible); + this.refreshLocation(); + } catch (err) { + API.Store && console.warn(err); + TG_APP.showAlert('Не удалось обновить видимость'); + } + }); + + document.getElementById('radius-sel').addEventListener('change', (e) => { + this.radius = parseInt(e.target.value, 10); + document.getElementById('radius-label').textContent = this.radius + ' км'; + this.refreshNearby(); + }); + + this.refreshLocation(); + }, + + async refreshLocation() { + if (!navigator.geolocation) { + document.getElementById('nearby-count').textContent = 'Геолокация недоступна'; + return; + } + navigator.geolocation.getCurrentPosition( + async (pos) => { + const lat = pos.coords.latitude; + const lng = pos.coords.longitude; + try { + await API.Auth.updateLocation(lat, lng, this.visible); + } catch (e) { + console.warn(e); + } + this.map.setView([lat, lng], 13); + L.circle([lat, lng], { + radius: this.radius * 1000, + color: '#FF6B35', fillColor: '#FF6B35', fillOpacity: 0.1, weight: 2, + }).addTo(this.map); + this.refreshNearby(); + }, + (err) => { + document.getElementById('nearby-count').textContent = 'Нет доступа к GPS: ' + err.message; + }, + { enableHighAccuracy: true, timeout: 15000 } + ); + }, + + async refreshNearby() { + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition(async (pos) => { + try { + const r = await API.Auth.searchNearby(pos.coords.latitude, pos.coords.longitude, this.radius); + this.nearby = r.results || []; + document.getElementById('nearby-count').textContent = this.nearby.length + ' чел. рядом'; + + // удалить старые маркеры (кроме своего круга) + this.markers.forEach(m => this.map.removeLayer(m)); + this.markers = []; + + this.nearby.forEach((n) => { + const icon = L.divIcon({ + className: 'custom-marker', + html: `
${escapeHtml((n.name || '?')[0])}
`, + iconSize: [36, 36], + }); + const m = L.marker([n.lat, n.lng], { icon }).addTo(this.map); + m.bindTooltip(n.name + ' • ' + (n.distance_m / 1000).toFixed(1) + ' км', { direction: 'top' }); + m.on('click', () => App.openUserProfile(n.user_id, n.name)); + this.markers.push(m); + }); + } catch (e) { + console.warn(e); + } + }); + }, +}; + +window.MapView = MapView; \ No newline at end of file diff --git a/public/js/views/profile.js b/public/js/views/profile.js new file mode 100644 index 0000000..852796a --- /dev/null +++ b/public/js/views/profile.js @@ -0,0 +1,115 @@ +// Profile view (свой профиль) +const ProfileView = { + drinks: new Set(), + activities: new Set(), + purposes: new Set(), + anonymous: false, + + DRINKS: [ + ['beer', '🍺 Пиво'], ['wine', '🍷 Вино'], ['whiskey', '🥃 Виски'], ['vodka', '🍸 Водка'], + ['cocktail', '🍹 Коктейль'], ['rum', '🥃 Ром'], ['gin', '🍸 Джин'], ['tequila', '🌵 Текила'], + ['champagne', '🍾 Шампанское'], ['non_alcoholic', '🥤 Безалкогольное'], + ], + ACTIVITIES: [ + ['walk', '🚶 Прогулка'], ['dinner', '🍽️ Ужин'], ['bar', '🍻 Бар'], ['cafe', '☕ Кафе'], + ['travel', '✈️ Путешествие'], ['movie', '🎬 Кино'], ['concert', '🎵 Концерт'], + ['sport', '⚽ Спорт'], ['gaming', '🎮 Игры'], ['reading', '📚 Чтение'], + ], + PURPOSES: [ + ['chat', '💬 Пообщаться'], ['drink', '🍻 Выпить'], ['walk', '🌳 Погулять'], + ['dinner', '🍽️ Поужинать'], ['travel', '✈️ Поехать'], ['friendship', '🤝 Дружба'], + ['relationship', '❤️ Отношения'], + ], + + async render(root) { + try { + const me = await API.Auth.me(); + API.Store.user = me.user; + const prefs = me.prefs || {}; + this.drinks = new Set(prefs.drinks || []); + this.activities = new Set(prefs.activities || []); + this.purposes = new Set(prefs.purposes || []); + this.renderForm(root, me.user); + } catch (e) { + root.innerHTML = '
Не удалось загрузить: ' + e.message + '
'; + } + }, + + renderForm(root, u) { + root.innerHTML = ` +
+

👤 Профиль

+
+
${escapeHtml(u.name || '')}
+ ${u.telegram_id ? `
@${escapeHtml(u.username || '')}
` : ''} + ${u.telegram_id ? '' : `
${escapeHtml(u.email || '')}
`} +
+ + + + + + + + + +

🍻 Что пью

+
+

🎯 Чем занимаюсь

+
+

💡 Цели

+
+ + +
+ `; + + this.renderChips('p-drinks', this.DRINKS, this.drinks); + this.renderChips('p-activities', this.ACTIVITIES, this.activities); + this.renderChips('p-purposes', this.PURPOSES, this.purposes); + + document.getElementById('p-save').onclick = () => this.save(); + }, + + renderChips(elId, items, selected) { + const el = document.getElementById(elId); + items.forEach(([key, label]) => { + const chip = document.createElement('span'); + chip.className = 'chip' + (selected.has(key) ? ' active' : ''); + chip.textContent = label; + chip.onclick = () => { + if (selected.has(key)) selected.delete(key); else selected.add(key); + chip.classList.toggle('active'); + TG_APP.haptic('selection'); + }; + el.appendChild(chip); + }); + }, + + async save() { + try { + await API.Auth.updateMe({ + name: document.getElementById('p-name').value.trim(), + city: document.getElementById('p-city').value.trim(), + bio: document.getElementById('p-bio').value.trim(), + gender: document.getElementById('p-gender').value, + }); + await API.Auth.updatePrefs({ + drinks: [...this.drinks], + activities: [...this.activities], + purposes: [...this.purposes], + }); + TG_APP.haptic('notification'); + App.toast('Сохранено ✓'); + } catch (e) { + TG_APP.showAlert('Ошибка: ' + e.message); + } + }, +}; + +window.ProfileView = ProfileView; \ No newline at end of file diff --git a/public/js/views/review.js b/public/js/views/review.js new file mode 100644 index 0000000..d96206c --- /dev/null +++ b/public/js/views/review.js @@ -0,0 +1,116 @@ +// Review screens — create + user-reviews list +const ReviewView = { + rating: 5, + chatId: null, + otherId: null, + otherName: null, + + async render(root, params) { + this.chatId = params.chatId; + this.otherId = params.otherId; + this.otherName = params.otherName; + this.rating = 5; + root.innerHTML = ` +
+

⭐ Отзыв

+
О ${escapeHtml(this.otherName || 'пользователе')}
+
+ +
+ ${[1,2,3,4,5].map(n => ``).join('')} +
+ + +
+ + +
+ +
+
+ `; + document.querySelectorAll('#stars span').forEach(s => { + s.onclick = () => { + this.rating = parseInt(s.dataset.n, 10); + document.querySelectorAll('#stars span').forEach((x, i) => { + x.classList.toggle('on', i < this.rating); + }); + TG_APP.haptic('selection'); + }; + }); + document.getElementById('r-submit').onclick = () => this.submit(); + }, + + async submit() { + try { + await API.ReviewApi.create( + this.chatId, + this.rating, + document.getElementById('r-body').value.trim(), + document.getElementById('r-anon').checked + ); + TG_APP.haptic('notification'); + App.toast('Отзыв отправлен ✓'); + setTimeout(() => App.showView('chats'), 800); + } catch (e) { + TG_APP.showAlert('Ошибка: ' + e.message); + } + }, +}; + +const UserReviewsView = { + async render(root, params) { + const userId = params.userId; + const userName = params.userName || 'Пользователь'; + root.innerHTML = ` +
+

⭐ Отзывы

+
Загрузка...
+
+ `; + try { + const [stats, list] = await Promise.all([ + API.ReviewApi.stats(userId), + API.ReviewApi.listForUser(userId), + ]); + const header = document.getElementById('ur-header'); + header.className = 'card'; + header.innerHTML = ` +
+
${stats.rating_avg ? stats.rating_avg.toFixed(1) : '—'}
+
+
+ ${[1,2,3,4,5].map(n => ``).join('')} +
+
${stats.rating_count} отзывов
+
+
+ `; + const v = document.createElement('div'); + v.innerHTML = '

' + escapeHtml(userName) + '

'; + if (!list.reviews || list.reviews.length === 0) { + v.innerHTML += '
Пока нет отзывов
'; + } else { + list.reviews.forEach((r) => { + const card = document.createElement('div'); + card.className = 'card'; + card.innerHTML = ` +
+
${[1,2,3,4,5].map(n=>``).join('')}
+
${formatDate(r.created_at)}
+
+
${r.anonymous ? 'Аноним' : 'ID: ' + r.reviewer_id.slice(0,8)}
+ ${r.body ? '
' + escapeHtml(r.body) + '
' : ''} + `; + v.appendChild(card); + }); + } + header.parentElement.appendChild(v); + } catch (e) { + document.getElementById('ur-header').innerHTML = '
Ошибка: ' + e.message + '
'; + } + }, +}; + +window.ReviewView = ReviewView; +window.UserReviewsView = UserReviewsView; \ No newline at end of file diff --git a/public/js/views/settings.js b/public/js/views/settings.js new file mode 100644 index 0000000..ec4a772 --- /dev/null +++ b/public/js/views/settings.js @@ -0,0 +1,115 @@ +// Settings view + consent gate + user profile (public) +const SettingsView = { + async render(root) { + const u = API.Store.user; + root.innerHTML = ` +
+

⚙️ Настройки

+
+
${escapeHtml(u?.name || '')}
+ ${u?.telegram_id ? `
Telegram ID: ${u.telegram_id}
` : ''} + ${u?.email ? `
${escapeHtml(u.email)}
` : ''} +
+ + +
+ `; + document.getElementById('logout').onclick = async () => { + const ok = await TG_APP.showConfirm('Выйти из BuhApp?'); + if (!ok) return; + API.Store.clear(); + location.reload(); + }; + }, +}; + +// Public user profile (включая рейтинг и кнопку "написать") +const UserProfileView = { + async render(root, params) { + const userId = params.userId; + const name = params.name || ''; + root.innerHTML = '
Загрузка...
'; + try { + const [profile, stats] = await Promise.all([ + API.Auth.getUser(userId), + API.ReviewApi.stats(userId), + ]); + root.innerHTML = ` +
+ +
+
+ ${profile.photo ? `
` : + `
${escapeHtml((profile.name||'?')[0])}
`} +
+
${escapeHtml(profile.name || '')}
+ ${profile.city ? `
📍 ${escapeHtml(profile.city)}
` : ''} + ${profile.age ? `
${profile.age} лет
` : ''} +
+
+
+
${stats.rating_avg ? stats.rating_avg.toFixed(1) : '—'}
+
+
+ ${[1,2,3,4,5].map(n=>``).join('')} +
+
${stats.rating_count || 0} отзывов
+
+
+
+ + ${profile.bio ? `
О себе
${escapeHtml(profile.bio)}
` : ''} + + ${profile.prefs ? this.renderPrefs(profile.prefs) : ''} + + + + + +
+ `; + + document.getElementById('up-back').onclick = () => history.back(); + document.getElementById('up-chat').onclick = async () => { + try { + const chat = await API.ChatApi.ensureChat(userId); + App.openChat(chat.id, userId, profile.name); + } catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); } + }; + document.getElementById('up-reviews').onclick = () => App.showView('user-reviews', { userId, userName: profile.name }); + document.getElementById('up-block').onclick = async () => { + const ok = await TG_APP.showConfirm('Заблокировать ' + profile.name + '?'); + if (!ok) return; + try { await API.ChatApi.block(userId); TG_APP.showAlert('Заблокировано'); history.back(); } + catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); } + }; + document.getElementById('up-report').onclick = async () => { + const reason = prompt('Причина жалобы:'); + if (!reason) return; + try { await API.ChatApi.report('user', userId, reason); TG_APP.showAlert('Жалоба отправлена'); } + catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); } + }; + } catch (e) { + root.innerHTML = '
Ошибка: ' + e.message + '
'; + } + }, + + renderPrefs(p) { + const blocks = []; + if (p.drinks?.length) { + blocks.push(`
Предпочтения
${p.drinks.map(d => `${escapeHtml(d)}`).join('')}
`); + } + return blocks.join(''); + }, +}; + +window.SettingsView = SettingsView; +window.UserProfileView = UserProfileView; \ No newline at end of file diff --git a/public/legal/DISCLAIMER.html b/public/legal/DISCLAIMER.html new file mode 100644 index 0000000..cfdc047 --- /dev/null +++ b/public/legal/DISCLAIMER.html @@ -0,0 +1,63 @@ + + + + + + Отказ от ответственности — BuhApp + + + + +
+ ← Назад +

Отказ от ответственности (Disclaimer)

+

Версия: 1.0 от 20.08.2026

+ +

1. Назначение документа

+

Настоящий документ устанавливает пределы ответственности Администрации Сервиса BuhApp.

+ +

2. Отказ от гарантий

+
    +
  1. Сервис предоставляется «как есть», без каких-либо гарантий.
  2. +
  3. Администрация не гарантирует, что Пользователи будут соблюдать правила, закон или быть адекватными.
  4. +
+ +

3. Алкоголь

+
    +
  1. Сервис не продаёт и не доставляет алкоголь.
  2. +
  3. Пользователи самостоятельно несут ответственность за своё поведение при употреблении алкоголя.
  4. +
  5. Администрация не несёт ответственности за вред, причинённый в состоянии алкогольного опьянения.
  6. +
+ +

4. Личные встречи

+
    +
  1. Сервис является исключительно платформой для знакомств.
  2. +
  3. Администрация не организует, не контролирует и не отвечает за личные встречи Пользователей.
  4. +
  5. Пользователи самостоятельно оценивают риски и принимают решения о встречах.
  6. +
  7. Администрация не является стороной отношений между Пользователями.
  8. +
+ +

5. Безопасность

+
    +
  1. Сервис предоставляет базовые инструменты безопасности (жалобы, блокировки).
  2. +
  3. Пользователи обязаны сообщать о нарушениях через кнопку «Пожаловаться».
  4. +
  5. В экстренных ситуациях Пользователь обязан обратиться в полицию (112).
  6. +
+ +

6. Ограничение ответственности

+
    +
  1. Администрация не несёт ответственности за любой ущерб, причинённый Пользователями друг другу.
  2. +
  3. Совокупная ответственность Администрации ограничивается суммой, уплаченной Пользователем за платные услуги (при наличии).
  4. +
+ +

Полный текст: github

+
+ + \ No newline at end of file diff --git a/public/legal/PRIVACY.html b/public/legal/PRIVACY.html new file mode 100644 index 0000000..8ac130b --- /dev/null +++ b/public/legal/PRIVACY.html @@ -0,0 +1,60 @@ + + + + + + Политика конфиденциальности — BuhApp + + + + +
+ ← Назад +

Политика конфиденциальности BuhApp

+

Версия: 1.0 от 20.08.2026

+ +

1. Какие данные мы собираем

+ + +

2. Цели обработки

+ + +

3. Хранение данных

+
    +
  1. Данные хранятся на серверах в РФ.
  2. +
  3. Данные хранятся до удаления аккаунта Пользователем.
  4. +
+ +

4. Передача третьим лицам

+
    +
  1. Мы не передаём данные третьим лицам, кроме случаев, предусмотренных законом.
  2. +
+ +

5. Права Пользователя

+ + +

Полный текст: github

+
+ + \ No newline at end of file diff --git a/public/legal/TERMS.html b/public/legal/TERMS.html new file mode 100644 index 0000000..e483fb4 --- /dev/null +++ b/public/legal/TERMS.html @@ -0,0 +1,65 @@ + + + + + + Пользовательское соглашение — BuhApp + + + + +
+ ← Назад +

Пользовательское соглашение BuhApp

+

Версия: 1.0 от 20.08.2026

+ +

1. Общие положения

+
    +
  1. Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между Пользователем и Сервисом BuhApp (далее — «Сервис»).
  2. +
  3. Сервис предоставляет возможность совершеннолетним пользователям искать друг друга для совместного времяпрепровождения и общения.
  4. +
  5. Использование Сервиса означает безусловное согласие Пользователя с настоящим Соглашением.
  6. +
+ +

2. Возрастные ограничения

+
    +
  1. Сервис предназначен исключительно для лиц, достигших 18 лет.
  2. +
  3. Регистрация в Сервисе подтверждает совершеннолетие Пользователя.
  4. +
  5. Администрация оставляет за собой право запросить подтверждение возраста в любой момент.
  6. +
+ +

3. Безопасность и поведение

+
    +
  1. Пользователь обязуется соблюдать законодательство РФ.
  2. +
  3. Запрещены: оскорбления, угрозы, спам, мошенничество, распространение запрещённых материалов.
  4. +
  5. В случае опасности Пользователь может обратиться в полицию, а также воспользоваться функцией SOS.
  6. +
+ +

4. Персональные данные

+
    +
  1. Обработка персональных данных осуществляется в соответствии с Политикой конфиденциальности и ФЗ-152.
  2. +
  3. Пользователь даёт согласие на обработку указанных данных для целей работы Сервиса.
  4. +
+ +

5. Встречи и риски

+
    +
  1. Сервис не несёт ответственности за поведение Пользователей при личных встречах.
  2. +
  3. Пользователь осознаёт и принимает все риски, связанные с личными встречами.
  4. +
+ +

6. Права Администрации

+
    +
  1. Администрация вправе заблокировать Пользователя за нарушение Соглашения.
  2. +
  3. Администрация вправе изменить Соглашение, уведомив Пользователя.
  4. +
+ +

Полный текст: github

+
+ + \ No newline at end of file