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)
This commit is contained in:
root 2026-08-20 17:43:16 +00:00
parent 81f586d24e
commit 21d281a1c6
15 changed files with 1543 additions and 2 deletions

View File

@ -1,3 +1,41 @@
# buhapp-telegram # BuhApp Telegram WebApp
Telegram bot + Mini App (WebApp) 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`.

238
public/css/app.css Normal file
View File

@ -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; }

44
public/index.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#FF6B35">
<title>BuhApp</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<div id="app"></div>
<nav id="tabbar">
<button class="tab" data-view="map">
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/></svg>
<span>Карта</span>
</button>
<button class="tab" data-view="chats">
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>
<span>Чаты</span>
<span class="badge" id="chats-badge" hidden></span>
</button>
<button class="tab" data-view="profile">
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
<span>Профиль</span>
</button>
<button class="tab" data-view="settings">
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z"/></svg>
<span>Настройки</span>
</button>
</nav>
<script src="/js/tg.js"></script>
<script src="/js/api.js"></script>
<script src="/js/views/map.js"></script>
<script src="/js/views/chats.js"></script>
<script src="/js/views/chat.js"></script>
<script src="/js/views/profile.js"></script>
<script src="/js/views/review.js"></script>
<script src="/js/views/settings.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

151
public/js/api.js Normal file
View File

@ -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 };

183
public/js/app.js Normal file
View File

@ -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 = `
<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;
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>
`;
},
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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());

33
public/js/tg.js Normal file
View File

@ -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 || {},
};

152
public/js/views/chat.js Normal file
View File

@ -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 = `
<div class="view" style="display:flex;flex-direction:column;height:calc(100vh - 100px);padding:0;">
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;">
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-back"></button>
<div class="name" style="flex:1;font-weight:600;">${escapeHtml(this.otherName || '')}</div>
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-profile">👤</button>
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-review"></button>
</div>
<div id="messages" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;"></div>
<div id="edit-bar" style="display:none;padding:6px 12px;background:var(--bg-elev);border-top:1px solid var(--border);font-size:13px;">
<span>Редактирование</span>
<button id="edit-cancel" style="float:right;background:none;border:none;color:var(--danger);font-weight:600;">Отмена</button>
</div>
<div class="chat-input">
<input id="msg-input" class="input" placeholder="Сообщение..." style="margin:0;">
<button class="btn" id="msg-send"></button>
</div>
</div>
`;
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 = '<em>Удалено</em>';
} else {
div.innerHTML = `
<div>${escapeHtml(m.body)}</div>
<div class="meta">
<span>${formatTime(m.created_at)}</span>
${m.edited ? '<span class="edited">ред.</span>' : ''}
</div>
`;
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 = '<em>Удалено</em>';
} 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;

41
public/js/views/chats.js Normal file
View File

@ -0,0 +1,41 @@
// Chats list view
const ChatsView = {
async render(root) {
root.innerHTML = `
<div class="view">
<h1>💬 Чаты</h1>
<div id="chats-list" class="loader">Загрузка...</div>
</div>
`;
try {
const r = await API.ChatApi.listChats();
const list = document.getElementById('chats-list');
if (!r.chats || r.chats.length === 0) {
list.innerHTML = '<div class="empty">Нет чатов. Открой карту и найди кого-нибудь!</div>';
return;
}
list.innerHTML = '';
list.className = '';
r.chats.forEach((item) => {
const row = document.createElement('div');
row.className = 'list-row';
row.innerHTML = `
<div class="avatar" style="${item.other_photo ? `background-image:url(${item.other_photo})` : ''}">
${item.other_photo ? '' : escapeHtml((item.other_name || '?')[0])}
</div>
<div class="body">
<div class="name">${escapeHtml(item.other_name)}</div>
<div class="last">${escapeHtml(item.last_message || 'Нет сообщений')}</div>
</div>
${item.unread_count > 0 ? `<div class="badge">${item.unread_count}</div>` : ''}
`;
row.onclick = () => App.openChat(item.chat.id, item.other_id, item.other_name);
list.appendChild(row);
});
} catch (e) {
document.getElementById('chats-list').innerHTML = '<div class="empty">Ошибка: ' + e.message + '</div>';
}
},
};
window.ChatsView = ChatsView;

127
public/js/views/map.js Normal file
View File

@ -0,0 +1,127 @@
// Map view — главный экран
const MapView = {
map: null,
markers: [],
radius: 5,
visible: true,
nearby: [],
async render(root) {
root.innerHTML = `
<div class="view">
<h1>🗺 Карта</h1>
<div class="card" id="my-info">
<div class="row">
<div>
<strong>Я на карте</strong>
<div class="privacy-note">Другие пользователи видят твоё местоположение ±300м</div>
</div>
<label class="switch">
<input type="checkbox" id="visible-toggle" ${this.visible ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</div>
<div id="map" style="height:400px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
<div class="row">
<label>Радиус: <strong id="radius-label">${this.radius} км</strong></label>
<select id="radius-sel" style="background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px;">
<option value="1">1 км</option>
<option value="5" selected>5 км</option>
<option value="10">10 км</option>
<option value="25">25 км</option>
<option value="50">50 км</option>
</select>
</div>
<div id="nearby-count" class="sub"></div>
</div>
`;
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: `<div style="width:36px;height:36px;border-radius:50%;background:#FF6B35;color:white;display:flex;align-items:center;justify-content:center;font-weight:700;border:2px solid white;">${escapeHtml((n.name || '?')[0])}</div>`,
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;

115
public/js/views/profile.js Normal file
View File

@ -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 = '<div class="view"><div class="empty">Не удалось загрузить: ' + e.message + '</div></div>';
}
},
renderForm(root, u) {
root.innerHTML = `
<div class="view">
<h1>👤 Профиль</h1>
<div class="card">
<div style="font-size:22px;font-weight:700;">${escapeHtml(u.name || '')}</div>
${u.telegram_id ? `<div class="sub">@${escapeHtml(u.username || '')}</div>` : ''}
${u.telegram_id ? '' : `<div class="sub">${escapeHtml(u.email || '')}</div>`}
</div>
<label>Имя</label>
<input class="input" id="p-name" value="${escapeHtml(u.name || '')}">
<label>Город</label>
<input class="input" id="p-city" placeholder="Москва" value="${escapeHtml(u.city || '')}">
<label>О себе</label>
<textarea class="input" id="p-bio" rows="3" placeholder="Расскажи о себе" style="resize:none;">${escapeHtml(u.bio || '')}</textarea>
<label>Пол</label>
<select class="input" id="p-gender">
<option value="">Не указан</option>
<option value="m" ${u.gender === 'm' ? 'selected' : ''}>Мужской</option>
<option value="f" ${u.gender === 'f' ? 'selected' : ''}>Женский</option>
<option value="o" ${u.gender === 'o' ? 'selected' : ''}>Другое</option>
</select>
<h2>🍻 Что пью</h2>
<div id="p-drinks"></div>
<h2>🎯 Чем занимаюсь</h2>
<div id="p-activities"></div>
<h2>💡 Цели</h2>
<div id="p-purposes"></div>
<button class="btn" id="p-save">Сохранить</button>
</div>
`;
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;

116
public/js/views/review.js Normal file
View File

@ -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 = `
<div class="view">
<h1> Отзыв</h1>
<div class="sub">О ${escapeHtml(this.otherName || 'пользователе')}</div>
<div class="card">
<label>Ваша оценка</label>
<div class="stars" id="stars">
${[1,2,3,4,5].map(n => `<span data-n="${n}" class="${n<=5?'on':''}">★</span>`).join('')}
</div>
<label style="margin-top:12px;">Комментарий (необязательно)</label>
<textarea class="input" id="r-body" rows="4" placeholder="Как прошла встреча?" maxlength="2000" style="resize:none;"></textarea>
<div class="row">
<label>Анонимно</label>
<input type="checkbox" id="r-anon" style="width:24px;height:24px;">
</div>
<button class="btn" id="r-submit">Отправить</button>
</div>
</div>
`;
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 = `
<div class="view">
<h1> Отзывы</h1>
<div id="ur-header" class="loader">Загрузка...</div>
</div>
`;
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 = `
<div style="display:flex;align-items:center;gap:12px;">
<div class="rating-big">${stats.rating_avg ? stats.rating_avg.toFixed(1) : '—'}</div>
<div>
<div class="stars" style="font-size:18px;">
${[1,2,3,4,5].map(n => `<span class="${n<=Math.round(stats.rating_avg||0)?'on':''}">★</span>`).join('')}
</div>
<div class="sub">${stats.rating_count} отзывов</div>
</div>
</div>
`;
const v = document.createElement('div');
v.innerHTML = '<h2>' + escapeHtml(userName) + '</h2>';
if (!list.reviews || list.reviews.length === 0) {
v.innerHTML += '<div class="empty">Пока нет отзывов</div>';
} else {
list.reviews.forEach((r) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div style="display:flex;justify-content:space-between;">
<div class="stars" style="font-size:14px;">${[1,2,3,4,5].map(n=>`<span class="${n<=r.rating?'on':''}">★</span>`).join('')}</div>
<div class="sub" style="margin:0;">${formatDate(r.created_at)}</div>
</div>
<div class="sub">${r.anonymous ? 'Аноним' : 'ID: ' + r.reviewer_id.slice(0,8)}</div>
${r.body ? '<div style="margin-top:6px;">' + escapeHtml(r.body) + '</div>' : ''}
`;
v.appendChild(card);
});
}
header.parentElement.appendChild(v);
} catch (e) {
document.getElementById('ur-header').innerHTML = '<div class="empty">Ошибка: ' + e.message + '</div>';
}
},
};
window.ReviewView = ReviewView;
window.UserReviewsView = UserReviewsView;

115
public/js/views/settings.js Normal file
View File

@ -0,0 +1,115 @@
// Settings view + consent gate + user profile (public)
const SettingsView = {
async render(root) {
const u = API.Store.user;
root.innerHTML = `
<div class="view">
<h1> Настройки</h1>
<div class="card">
<div style="font-weight:600;">${escapeHtml(u?.name || '')}</div>
${u?.telegram_id ? `<div class="sub">Telegram ID: ${u.telegram_id}</div>` : ''}
${u?.email ? `<div class="sub">${escapeHtml(u.email)}</div>` : ''}
</div>
<div class="card">
<strong>Юридические документы</strong>
<div class="sub">
Версия 1.0 · 20.08.2026
</div>
<a class="btn ghost" href="/legal/TERMS.html" target="_blank" style="margin-top:8px;">📄 Пользовательское соглашение</a>
<a class="btn ghost" href="/legal/PRIVACY.html" target="_blank">🔒 Политика конфиденциальности</a>
<a class="btn ghost" href="/legal/DISCLAIMER.html" target="_blank"> Отказ от ответственности</a>
</div>
<button class="btn danger" id="logout">Выйти</button>
</div>
`;
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 = '<div class="view"><div class="loader">Загрузка...</div></div>';
try {
const [profile, stats] = await Promise.all([
API.Auth.getUser(userId),
API.ReviewApi.stats(userId),
]);
root.innerHTML = `
<div class="view">
<button class="btn ghost" id="up-back" style="width:auto;margin-bottom:12px;"> Назад</button>
<div class="card">
<div style="display:flex;align-items:center;gap:16px;">
${profile.photo ? `<div class="avatar" style="width:80px;height:80px;background-image:url(${profile.photo});"></div>` :
`<div class="avatar" style="width:80px;height:80px;font-size:32px;">${escapeHtml((profile.name||'?')[0])}</div>`}
<div>
<div style="font-size:22px;font-weight:700;">${escapeHtml(profile.name || '')}</div>
${profile.city ? `<div class="sub">📍 ${escapeHtml(profile.city)}</div>` : ''}
${profile.age ? `<div class="sub">${profile.age} лет</div>` : ''}
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;margin-top:12px;">
<div class="rating-big">${stats.rating_avg ? stats.rating_avg.toFixed(1) : '—'}</div>
<div>
<div class="stars" style="font-size:18px;">
${[1,2,3,4,5].map(n=>`<span class="${n<=Math.round(stats.rating_avg||0)?'on':''}">★</span>`).join('')}
</div>
<div class="sub">${stats.rating_count || 0} отзывов</div>
</div>
</div>
</div>
${profile.bio ? `<div class="card"><strong>О себе</strong><div style="margin-top:6px;">${escapeHtml(profile.bio)}</div></div>` : ''}
${profile.prefs ? this.renderPrefs(profile.prefs) : ''}
<button class="btn" id="up-chat">💬 Написать</button>
<button class="btn ghost" id="up-reviews"> Все отзывы (${stats.rating_count || 0})</button>
<button class="btn danger" id="up-block">🚫 Заблокировать</button>
<button class="btn ghost" id="up-report"> Пожаловаться</button>
</div>
`;
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 = '<div class="view"><div class="empty">Ошибка: ' + e.message + '</div></div>';
}
},
renderPrefs(p) {
const blocks = [];
if (p.drinks?.length) {
blocks.push(`<div class="card"><strong>Предпочтения</strong><div style="margin-top:6px;">${p.drinks.map(d => `<span class="chip">${escapeHtml(d)}</span>`).join('')}</div></div>`);
}
return blocks.join('');
},
};
window.SettingsView = SettingsView;
window.UserProfileView = UserProfileView;

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отказ от ответственности — BuhApp</title>
<link rel="stylesheet" href="/css/app.css">
<style>
.doc { padding: 24px 16px; line-height: 1.6; max-width: 800px; margin: 0 auto; }
.doc h1 { font-size: 24px; margin-bottom: 16px; }
.doc h2 { font-size: 18px; margin: 20px 0 8px; color: var(--primary); }
.doc p, .doc li { margin-bottom: 8px; }
.doc ol { padding-left: 24px; margin-bottom: 12px; }
.doc .back { display: inline-block; margin-bottom: 16px; color: var(--primary); text-decoration: none; }
</style>
</head>
<body>
<div class="doc">
<a href="javascript:history.back()" class="back">← Назад</a>
<h1>Отказ от ответственности (Disclaimer)</h1>
<p><em>Версия: 1.0 от 20.08.2026</em></p>
<h2>1. Назначение документа</h2>
<p>Настоящий документ устанавливает пределы ответственности Администрации Сервиса BuhApp.</p>
<h2>2. Отказ от гарантий</h2>
<ol>
<li>Сервис предоставляется «как есть», без каких-либо гарантий.</li>
<li>Администрация не гарантирует, что Пользователи будут соблюдать правила, закон или быть адекватными.</li>
</ol>
<h2>3. Алкоголь</h2>
<ol>
<li>Сервис не продаёт и не доставляет алкоголь.</li>
<li>Пользователи самостоятельно несут ответственность за своё поведение при употреблении алкоголя.</li>
<li>Администрация не несёт ответственности за вред, причинённый в состоянии алкогольного опьянения.</li>
</ol>
<h2>4. Личные встречи</h2>
<ol>
<li>Сервис является исключительно платформой для знакомств.</li>
<li>Администрация не организует, не контролирует и не отвечает за личные встречи Пользователей.</li>
<li>Пользователи самостоятельно оценивают риски и принимают решения о встречах.</li>
<li>Администрация не является стороной отношений между Пользователями.</li>
</ol>
<h2>5. Безопасность</h2>
<ol>
<li>Сервис предоставляет базовые инструменты безопасности (жалобы, блокировки).</li>
<li>Пользователи обязаны сообщать о нарушениях через кнопку «Пожаловаться».</li>
<li>В экстренных ситуациях Пользователь обязан обратиться в полицию (112).</li>
</ol>
<h2>6. Ограничение ответственности</h2>
<ol>
<li>Администрация не несёт ответственности за любой ущерб, причинённый Пользователями друг другу.</li>
<li>Совокупная ответственность Администрации ограничивается суммой, уплаченной Пользователем за платные услуги (при наличии).</li>
</ol>
<p style="margin-top:24px;"><small>Полный текст: <a href="https://git.buhapp.mygoodservice.ru/ga/buhapp-docs/raw/branch/main/legal/DISCLAIMER.md">github</small></a></p>
</div>
</body>
</html>

60
public/legal/PRIVACY.html Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика конфиденциальности — BuhApp</title>
<link rel="stylesheet" href="/css/app.css">
<style>
.doc { padding: 24px 16px; line-height: 1.6; max-width: 800px; margin: 0 auto; }
.doc h1 { font-size: 24px; margin-bottom: 16px; }
.doc h2 { font-size: 18px; margin: 20px 0 8px; color: var(--primary); }
.doc p, .doc li { margin-bottom: 8px; }
.doc ol { padding-left: 24px; margin-bottom: 12px; }
.doc .back { display: inline-block; margin-bottom: 16px; color: var(--primary); text-decoration: none; }
</style>
</head>
<body>
<div class="doc">
<a href="javascript:history.back()" class="back">← Назад</a>
<h1>Политика конфиденциальности BuhApp</h1>
<p><em>Версия: 1.0 от 20.08.2026</em></p>
<h2>1. Какие данные мы собираем</h2>
<ul>
<li>Данные Telegram (ID, имя, фото) — при авторизации</li>
<li>Геолокация — если Пользователь разрешил</li>
<li>Сообщения в чатах — между Пользователями</li>
<li>Отзывы и рейтинги</li>
<li>Журнал действий (audit log) — IP, user-agent, время</li>
</ul>
<h2>2. Цели обработки</h2>
<ul>
<li>Предоставление работы Сервиса</li>
<li>Безопасность и предотвращение нарушений</li>
<li>Улучшение Сервиса</li>
</ul>
<h2>3. Хранение данных</h2>
<ol>
<li>Данные хранятся на серверах в РФ.</li>
<li>Данные хранятся до удаления аккаунта Пользователем.</li>
</ol>
<h2>4. Передача третьим лицам</h2>
<ol>
<li>Мы не передаём данные третьим лицам, кроме случаев, предусмотренных законом.</li>
</ol>
<h2>5. Права Пользователя</h2>
<ul>
<li>Запросить свои данные</li>
<li>Удалить аккаунт и все данные</li>
<li>Отозвать согласие на обработку</li>
</ul>
<p style="margin-top:24px;"><small>Полный текст: <a href="https://git.buhapp.mygoodservice.ru/ga/buhapp-docs/raw/branch/main/legal/PRIVACY.md">github</small></a></p>
</div>
</body>
</html>

65
public/legal/TERMS.html Normal file
View File

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Пользовательское соглашение — BuhApp</title>
<link rel="stylesheet" href="/css/app.css">
<style>
.doc { padding: 24px 16px; line-height: 1.6; max-width: 800px; margin: 0 auto; }
.doc h1 { font-size: 24px; margin-bottom: 16px; }
.doc h2 { font-size: 18px; margin: 20px 0 8px; color: var(--primary); }
.doc p, .doc li { margin-bottom: 8px; }
.doc ol { padding-left: 24px; margin-bottom: 12px; }
.doc .back { display: inline-block; margin-bottom: 16px; color: var(--primary); text-decoration: none; }
</style>
</head>
<body>
<div class="doc">
<a href="javascript:history.back()" class="back">← Назад</a>
<h1>Пользовательское соглашение BuhApp</h1>
<p><em>Версия: 1.0 от 20.08.2026</em></p>
<h2>1. Общие положения</h2>
<ol>
<li>Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между Пользователем и Сервисом BuhApp (далее — «Сервис»).</li>
<li>Сервис предоставляет возможность совершеннолетним пользователям искать друг друга для совместного времяпрепровождения и общения.</li>
<li>Использование Сервиса означает безусловное согласие Пользователя с настоящим Соглашением.</li>
</ol>
<h2>2. Возрастные ограничения</h2>
<ol>
<li>Сервис предназначен исключительно для лиц, достигших 18 лет.</li>
<li>Регистрация в Сервисе подтверждает совершеннолетие Пользователя.</li>
<li>Администрация оставляет за собой право запросить подтверждение возраста в любой момент.</li>
</ol>
<h2>3. Безопасность и поведение</h2>
<ol>
<li>Пользователь обязуется соблюдать законодательство РФ.</li>
<li>Запрещены: оскорбления, угрозы, спам, мошенничество, распространение запрещённых материалов.</li>
<li>В случае опасности Пользователь может обратиться в полицию, а также воспользоваться функцией SOS.</li>
</ol>
<h2>4. Персональные данные</h2>
<ol>
<li>Обработка персональных данных осуществляется в соответствии с Политикой конфиденциальности и ФЗ-152.</li>
<li>Пользователь даёт согласие на обработку указанных данных для целей работы Сервиса.</li>
</ol>
<h2>5. Встречи и риски</h2>
<ol>
<li>Сервис не несёт ответственности за поведение Пользователей при личных встречах.</li>
<li>Пользователь осознаёт и принимает все риски, связанные с личными встречами.</li>
</ol>
<h2>6. Права Администрации</h2>
<ol>
<li>Администрация вправе заблокировать Пользователя за нарушение Соглашения.</li>
<li>Администрация вправе изменить Соглашение, уведомив Пользователя.</li>
</ol>
<p style="margin-top:24px;"><small>Полный текст: <a href="https://git.buhapp.mygoodservice.ru/ga/buhapp-docs/raw/branch/main/legal/TERMS.md">github</small></a></p>
</div>
</body>
</html>