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:
parent
81f586d24e
commit
21d281a1c6
42
README.md
42
README.md
@ -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
238
public/css/app.css
Normal 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
44
public/index.html
Normal 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
151
public/js/api.js
Normal 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
183
public/js/app.js
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
33
public/js/tg.js
Normal 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
152
public/js/views/chat.js
Normal 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
41
public/js/views/chats.js
Normal 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
127
public/js/views/map.js
Normal 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
115
public/js/views/profile.js
Normal 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
116
public/js/views/review.js
Normal 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
115
public/js/views/settings.js
Normal 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;
|
||||||
63
public/legal/DISCLAIMER.html
Normal file
63
public/legal/DISCLAIMER.html
Normal 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
60
public/legal/PRIVACY.html
Normal 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
65
public/legal/TERMS.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user