UX overhaul: единый стиль BuhApp на всех экранах

ДИЗАЙН-СИСТЕМА в app.css:
- --primary #FF6B35, --success #3dd68c, --danger #ff5c5c
- --bg #0f0f12, --bg-elev #1a1a22, --bg-input #121218
- Компоненты: .card-b, .row-b, .field-b, .btn-b, .switch-b, .chip, .avatar-b, .bubble-b, .composer-b, .toast-b, .stars-b, .pulse-b, .empty-b

ПЕРЕПИСАНЫ на единый стиль:

Settings:
- Карточка с аватаром пользователя
- Card-row для юр.документов (📄 / 🔒 / ⚠️)
- 'Выйти' — danger btn-b с state machine (idle→load→ok)

Profile:
- Карточка аватара + кнопка 'Загрузить фото' через multipart /api/v1/me/photo
- Поля: field-b (ctrl + hint)
- Чипы в chip-list, новый стиль с .on
- Счётчик bio символов
- 'Сохранить' со state machine + toast

Review (создание):
- Звёзды в stars-b с center hint
- textarea с counter
- switch-b 'Анонимно' (вместо checkbox)
- Submit со state machine + toast 'Спасибо за отзыв!'

UserReviews:
- Средний рейтинг + звёзды
- Список карточек с отзывами

Chats:
- Список с avatar-b + last message + время + badge непрочитанных
- Пустое состояние с эмодзи
- formatChatTime() — 'сейчас' / '5м' / '3ч' / '2д'

Chat (один):
- Header с аватаром и кнопками 👤 ⭐
- composer-b: 📎 + input + ➤ (sticky bottom)
- bubble-b .in / .out с .ticks.read
- Send со state + toast
- sendPhoto() для фоток в чате

App.js _toast(msg, kind):
- Единый toast-b с классами ok / err

Всё в одном стиле. CSS variables, padding 16, radius 14, no disabled кнопок.
This commit is contained in:
root 2026-08-21 11:42:16 +00:00
parent 2b34791ff0
commit c5f8ac67ad
8 changed files with 664 additions and 231 deletions

View File

@ -512,3 +512,312 @@ input.good { border-color: var(--success) !important; }
.hint.ok { color: var(--success); } .hint.ok { color: var(--success); }
.hint.err { color: var(--danger); } .hint.err { color: var(--danger); }
.hint.mute { color: var(--text-dim); } .hint.mute { color: var(--text-dim); }
/* ====================================
BuhApp Design System (от Grok)
Единый стиль для всех экранов
==================================== */
:root {
--primary: #FF6B35;
--primary-soft: rgba(255, 107, 53, 0.15);
--success: #3dd68c;
--danger: #ff5c5c;
--warn: #ffb800;
--bg: #0f0f12;
--bg-elev: #1a1a22;
--bg-input: #121218;
--border: #2a2a36;
--border-strong: #3a3a48;
--text: #f2f2f5;
--text-dim: #8b8b9a;
--text-mute: #5a5a6a;
--r-card: 14px;
--r-field: 10px;
--r-btn: 12px;
--safe: env(safe-area-inset-bottom, 0px);
}
/* Card */
.card-b {
background: var(--bg-elev);
border: 1.5px solid var(--border);
border-radius: var(--r-card);
padding: 16px;
}
/* Stack of card-rows (для Settings и Chats) */
.row-list { display: flex; flex-direction: column; gap: 12px; }
/* Row внутри card */
.row-b {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--bg-input);
border-radius: 12px;
border: 1px solid var(--border);
min-height: 52px;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.row-b:active { background: var(--bg-elev); }
.row-b .title { flex: 1; font-size: 15px; font-weight: 500; }
.row-b .value { font-size: 13px; color: var(--text-dim); }
.row-b .chev { color: var(--text-mute); font-size: 16px; }
/* Field */
.field-b { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field-b label { font-size: 12px; font-weight: 600; color: var(--text-dim); letter-spacing: 0.02em; }
.field-b .ctrl {
width: 100%;
background: var(--bg-input);
border: 1.5px solid var(--border);
border-radius: var(--r-field);
color: var(--text);
font: 15px/1.2 inherit;
padding: 13px 14px;
outline: 0;
-webkit-appearance: none;
}
.field-b .ctrl:focus { border-color: var(--primary); }
.field-b .ctrl.bad { border-color: var(--danger); }
.field-b .ctrl.good { border-color: var(--success); }
.field-b .hint { min-height: 18px; font-size: 12px; }
.field-b .hint.ok { color: var(--success); }
.field-b .hint.err { color: var(--danger); }
.field-b .hint.mute { color: var(--text-dim); }
/* Button */
.btn-b {
width: 100%;
border: 0;
border-radius: var(--r-btn);
padding: 15px;
font: 700 16px/1 inherit;
cursor: pointer;
background: var(--primary);
color: #fff;
transition: opacity 0.15s, background 0.15s, transform 0.1s;
}
.btn-b:active { transform: scale(0.98); }
.btn-b.load { opacity: 0.85; pointer-events: none; }
.btn-b.ok { background: var(--success); }
.btn-b.err { background: var(--danger); }
.btn-b.ghost {
background: transparent;
border: 1.5px solid var(--border-strong);
color: var(--text);
}
.btn-b.ghost:active { border-color: var(--primary); }
.btn-b.danger { background: var(--danger); }
.btn-b.tiny {
width: auto;
padding: 8px 14px;
font-size: 13px;
border-radius: 10px;
}
/* Switch (track + thumb) */
.switch-b {
position: relative;
width: 44px;
height: 26px;
background: var(--border);
border-radius: 13px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.switch-b.on { background: var(--primary); }
.switch-b::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
transition: left 0.15s;
}
.switch-b.on::after { left: 21px; }
/* Chip (tag) */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg-input);
border: 1.5px solid var(--border);
border-radius: 999px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.chip:active { transform: scale(0.96); }
.chip.on {
background: var(--primary-soft);
border-color: var(--primary);
color: var(--primary);
}
.chip .x { font-size: 11px; opacity: 0.7; }
/* List of chips (drink/activity/purpose) */
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; }
/* Toast (bottom) — общий для всего приложения */
.toast-b {
position: fixed;
left: 50%;
bottom: 88px;
transform: translateX(-50%) translateY(80px);
background: #222;
color: #fff;
padding: 12px 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.28s, transform 0.28s;
z-index: 999;
max-width: 90%;
border: 1px solid var(--border-strong);
pointer-events: none;
}
.toast-b.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-b.err { background: var(--danger); }
.toast-b.ok { background: var(--success); }
/* Empty state */
.empty-b {
text-align: center;
padding: 32px 16px;
color: var(--text-dim);
}
.empty-b .em { font-size: 48px; margin-bottom: 12px; }
/* Avatar circle */
.avatar-b {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary-soft);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 20px;
flex-shrink: 0;
}
.avatar-b.sm { width: 32px; height: 32px; font-size: 14px; }
.avatar-b.lg { width: 88px; height: 88px; font-size: 36px; }
/* Chat bubble */
.bubble-b {
max-width: 75%;
padding: 10px 14px;
border-radius: 14px;
word-wrap: break-word;
font-size: 15px;
line-height: 1.35;
}
.bubble-b.in {
background: var(--bg-elev);
border: 1px solid var(--border);
align-self: flex-start;
}
.bubble-b.out {
background: var(--primary-soft);
border: 1px solid var(--primary);
align-self: flex-end;
color: var(--text);
}
.bubble-b .meta {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
margin-top: 4px;
font-size: 11px;
color: var(--text-mute);
}
.bubble-b.out .meta { color: var(--text-dim); }
.bubble-b .ticks.read { color: var(--primary); }
.bubble-b.err {
border-color: var(--danger);
background: rgba(255, 92, 92, 0.1);
}
/* Composer (чат) */
.composer-b {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px calc(8px + var(--safe));
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.composer-b input {
flex: 1;
background: var(--bg-input);
border: 1.5px solid var(--border);
border-radius: 20px;
padding: 10px 14px;
color: var(--text);
font: 15px/1.2 inherit;
outline: 0;
-webkit-appearance: none;
}
.composer-b input:focus { border-color: var(--primary); }
.composer-b .send-b {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary);
color: #fff;
border: 0;
cursor: pointer;
font-size: 18px;
flex-shrink: 0;
}
.composer-b .send-b.load { opacity: 0.6; pointer-events: none; }
.composer-b .attach-b {
width: 40px;
height: 40px;
border-radius: 50%;
background: transparent;
color: var(--text-dim);
border: 0;
cursor: pointer;
font-size: 20px;
flex-shrink: 0;
}
.composer-b .attach-b:active { color: var(--primary); }
/* Pulse animation (для важных idle-кнопок) */
@keyframes pulse-b {
0%, 100% { box-shadow: 0 0 0 0 var(--primary-soft); }
50% { box-shadow: 0 0 0 12px transparent; }
}
.pulse-b { animation: pulse-b 2s ease-in-out infinite; }
/* Star rating */
.stars-b {
display: flex;
justify-content: center;
gap: 8px;
margin: 12px 0;
}
.stars-b .star {
font-size: 36px;
color: var(--border-strong);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.stars-b .star.on { color: var(--warn); }

View File

@ -8,7 +8,7 @@
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"> <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/css/app.css?v=24">
</head> </head>
<body> <body>
<button id="mode-toggle" title="Переключить режим">📱 Мобильный</button> <button id="mode-toggle" title="Переключить режим">📱 Мобильный</button>
@ -34,15 +34,15 @@
</button> </button>
</nav> </nav>
</div> </div>
<script src="/js/tg.js?v=23"></script> <script src="/js/tg.js?v=24"></script>
<script src="/js/api.js?v=23"></script> <script src="/js/api.js?v=24"></script>
<script src="/js/views/map.js?v=23"></script> <script src="/js/views/map.js?v=24"></script>
<script src="/js/views/chats.js?v=23"></script> <script src="/js/views/chats.js?v=24"></script>
<script src="/js/views/chat.js?v=23"></script> <script src="/js/views/chat.js?v=24"></script>
<script src="/js/views/profile.js?v=23"></script> <script src="/js/views/profile.js?v=24"></script>
<script src="/js/views/settings.js?v=23"></script> <script src="/js/views/settings.js?v=24"></script>
<script src="/js/views/onboarding.js?v=23"></script> <script src="/js/views/onboarding.js?v=24"></script>
<script src="/js/views/review.js?v=23"></script> <script src="/js/views/review.js?v=24"></script>
<script src="/js/app.js?v=23"></script> <script src="/js/app.js?v=24"></script>
</body> </body>
</html> </html>

View File

@ -471,17 +471,18 @@ const App = {
} }
}, },
_toast(msg) { _toast(msg, kind) {
let t = document.getElementById('b-toast'); let t = document.getElementById('b-toast');
if (!t) { if (!t) {
t = document.createElement('div'); t = document.createElement('div');
t.id = 'b-toast'; t.id = 'b-toast';
t.style.cssText = 'position:fixed;left:50%;bottom:88px;transform:translateX(-50%) translateY(80px);background:#222;color:#fff;padding:12px 18px;border-radius:12px;font-size:14px;font-weight:600;box-shadow:0 8px 28px #0008;opacity:0;transition:.28s;z-index:99;max-width:90%;border:1px solid var(--border);'; t.className = 'toast-b';
document.body.appendChild(t); document.body.appendChild(t);
} }
t.className = 'toast-b' + (kind ? ' ' + kind : '');
t.textContent = msg; t.textContent = msg;
requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateX(-50%) translateY(0)'; }); requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(-50%) translateY(80px)'; }, 2200); setTimeout(() => t.classList.remove('show'), 2200);
}, },
showEmailRegister() { this.showEmailLogin(); this._authSwitchMode && this._authSwitchMode('up'); }, showEmailRegister() { this.showEmailLogin(); this._authSwitchMode && this._authSwitchMode('up'); },

View File

@ -11,20 +11,26 @@ const ChatView = {
this.otherName = params.otherName; this.otherName = params.otherName;
root.innerHTML = ` root.innerHTML = `
<div class="view" style="display:flex;flex-direction:column;height:calc(100vh - 100px);padding:0;"> <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;"> <!-- header -->
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-back">←</button> <div style="padding:12px 16px;border-bottom:1px solid var(--border);background:var(--bg-elev);display:flex;align-items:center;gap:8px;">
<div class="name" style="flex:1;font-weight:600;">${escapeHtml(this.otherName || '')}</div> <button class="btn-b ghost tiny" id="chat-back" style="padding:6px 10px;">←</button>
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-profile">👤</button> <div class="avatar-b sm" style="${API.Store.user?.other_photo_for_other ? '' : ''}">${escapeHtml(((this.otherName || '?')[0] || '?').toUpperCase())}</div>
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-review">⭐</button> <div style="flex:1;font-weight:600;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(this.otherName || '')}</div>
<button class="btn-b ghost tiny" id="chat-profile" style="padding:6px 10px;">👤</button>
<button class="btn-b ghost tiny" id="chat-review" style="padding:6px 10px;">⭐</button>
</div> </div>
<div id="messages" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;"></div> <!-- messages -->
<div id="edit-bar" style="display:none;padding:6px 12px;background:var(--bg-elev);border-top:1px solid var(--border);font-size:13px;"> <div id="messages" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:6px;"></div>
<span>Редактирование</span> <!-- edit bar -->
<button id="edit-cancel" style="float:right;background:none;border:none;color:var(--danger);font-weight:600;">Отмена</button> <div id="edit-bar" style="display:none;padding:8px 16px;background:var(--bg-elev);border-top:1px solid var(--border);font-size:13px;color:var(--text-dim);">
<span>✎ Редактирование</span>
<button id="edit-cancel" style="float:right;background:none;border:none;color:var(--danger);font-weight:600;cursor:pointer;">Отмена</button>
</div> </div>
<div class="chat-input"> <!-- composer -->
<input id="msg-input" class="input" placeholder="Сообщение..." autocomplete="off" enterkeyhint="send" style="margin:0;"> <div class="composer-b">
<button class="btn" id="msg-send">➤</button> <button class="attach-b" id="msg-attach" title="Прикрепить фото">📎</button>
<input id="msg-input" placeholder="Сообщение..." autocomplete="off" enterkeyhint="send">
<button class="send-b" id="msg-send" title="Отправить">➤</button>
</div> </div>
</div> </div>
`; `;
@ -32,6 +38,12 @@ const ChatView = {
document.getElementById('chat-back').onclick = () => App.showView('chats'); document.getElementById('chat-back').onclick = () => App.showView('chats');
document.getElementById('chat-profile').onclick = () => App.openUserProfile(this.otherId, this.otherName); document.getElementById('chat-profile').onclick = () => App.openUserProfile(this.otherId, this.otherName);
document.getElementById('chat-review').onclick = () => App.openReview(this.chatId, this.otherId, this.otherName); document.getElementById('chat-review').onclick = () => App.openReview(this.chatId, this.otherId, this.otherName);
document.getElementById('msg-attach').onclick = () => {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = () => this.sendPhoto(inp.files[0]);
inp.click();
};
document.getElementById('msg-input').addEventListener('keydown', (e) => { document.getElementById('msg-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -60,22 +72,21 @@ const ChatView = {
appendMessage(m) { appendMessage(m) {
const box = document.getElementById('messages'); const box = document.getElementById('messages');
const me = API.Store.user && m.sender_id === API.Store.user.id; const me = API.Store.user && m.sender_id === (API.Store.user.id || API.Store.user.user_id);
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'bubble ' + (me ? 'mine' : 'theirs'); div.className = 'bubble-b ' + (me ? 'out' : 'in');
div.dataset.id = m.id; div.dataset.id = m.id;
if (m.deleted) { if (m.deleted) {
div.innerHTML = '<em>Удалено</em>'; div.innerHTML = '<em style="color:var(--text-mute);">Удалено</em>';
} else { } else {
// ✓✓ если прочитано собеседником, ✓ если только доставлено
const readMark = me const readMark = me
? (m.read_at ? '<span class="read-tick read">✓✓</span>' : '<span class="read-tick">✓</span>') ? (m.read_at ? '<span class="ticks read" title="прочитано">✓✓</span>' : '<span class="ticks" title="доставлено">✓</span>')
: ''; : '';
div.innerHTML = ` div.innerHTML = `
<div>${escapeHtml(m.body)}</div> <div>${escapeHtml(m.body || '')}</div>
<div class="meta"> <div class="meta">
<span>${formatTime(m.created_at)}</span> <span>${formatTime(m.created_at)}</span>
${m.edited ? '<span class="edited">ред.</span>' : ''} ${m.edited ? '<span style="font-size:10px;margin:0 4px;">ред.</span>' : ''}
${readMark} ${readMark}
</div> </div>
`; `;
@ -156,13 +167,33 @@ const ChatView = {
async send() { async send() {
const inp = document.getElementById('msg-input'); const inp = document.getElementById('msg-input');
const btn = document.getElementById('msg-send');
const body = inp.value.trim(); const body = inp.value.trim();
if (!body) return; if (!body) { App._toast('Введите сообщение', 'err'); btn.classList.add('err'); setTimeout(() => btn.classList.remove('err'), 600); return; }
inp.value = ''; btn.classList.add('load');
try { try {
await API.ChatApi.sendMessage(this.chatId, body); await API.ChatApi.sendMessage(this.chatId, body);
inp.value = '';
btn.classList.remove('load');
} catch (e) { } catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message); btn.classList.remove('load');
App._toast('⚠️ ' + (e.message || 'Не отправлено'), 'err');
}
},
async sendPhoto(file) {
if (!file) return;
if (file.size > 8 * 1024 * 1024) { App._toast('Файл больше 8 МБ', 'err'); return; }
App._toast('⏳ Загружаю фото…', 'ok');
const fd = new FormData();
fd.append('photo', file);
try {
const r = await fetch(API_BASE + '/api/v1/chats/' + this.chatId + '/photo', { method: 'POST', body: fd, credentials: 'include' });
if (!r.ok) throw new Error((await r.json()).error || 'HTTP ' + r.status);
App._toast('✓ Фото отправлено', 'ok');
this.loadMessages();
} catch (e) {
App._toast('⚠️ ' + (e.message || 'Ошибка'), 'err');
} }
}, },

View File

@ -1,41 +1,63 @@
// Chats list view // Chats list — единый стиль BuhApp
const ChatsView = { const ChatsView = {
async render(root) { async render(root) {
root.innerHTML = ` root.innerHTML = `
<div class="view"> <div class="view">
<h1>💬 Чаты</h1> <h1 style="margin:0 0 14px;font-size:22px;">💬 Чаты</h1>
<div id="chats-list" class="loader">Загрузка...</div> <div id="chats-list" class="empty-b">⏳ Загрузка…</div>
</div> </div>
`; `;
try { try {
const r = await API.ChatApi.listChats(); const r = await API.ChatApi.listChats();
const list = document.getElementById('chats-list'); const list = document.getElementById('chats-list');
if (!r.chats || r.chats.length === 0) { if (!r.chats || r.chats.length === 0) {
list.innerHTML = '<div class="empty">Нет чатов. Открой карту и найди кого-нибудь!</div>'; list.innerHTML = '<div class="empty-b"><div class="em">💬</div>Пока нет чатов<br><small>Открой карту и напиши кому-нибудь!</small></div>';
return; return;
} }
list.className = 'row-list';
list.innerHTML = ''; list.innerHTML = '';
list.className = '';
r.chats.forEach((item) => { r.chats.forEach((item) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'list-row'; row.className = 'card-b row-list-item';
row.style.padding = '12px';
row.style.cursor = 'pointer';
row.style.display = 'flex';
row.style.gap = '12px';
row.style.alignItems = 'center';
const init = (item.other_name || '?')[0].toUpperCase();
const lastText = item.last_message ? escapeHtml(item.last_message) : '<em style="color:var(--text-mute);">Нет сообщений</em>';
row.innerHTML = ` row.innerHTML = `
<div class="avatar" style="${item.other_photo ? `background-image:url(${item.other_photo})` : ''}"> <div class="avatar-b" style="${item.other_photo ? `background-image:url(${item.other_photo});background-size:cover;color:transparent;` : ''}">${item.other_photo ? '' : init}</div>
${item.other_photo ? '' : escapeHtml((item.other_name || '?')[0])} <div style="flex:1;min-width:0;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-weight:600;font-size:15px;">${escapeHtml(item.other_name || 'Без имени')}</div>
${item.last_ts ? `<div style="font-size:11px;color:var(--text-mute);">${formatChatTime(item.last_ts)}</div>` : ''}
</div> </div>
<div class="body"> <div style="font-size:13px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px;">${lastText}</div>
<div class="name">${escapeHtml(item.other_name)}</div>
<div class="last">${escapeHtml(item.last_message || 'Нет сообщений')}</div>
</div> </div>
${item.unread_count > 0 ? `<div class="badge">${item.unread_count}</div>` : ''} ${item.unread_count > 0 ? `<div style="background:var(--primary);color:#fff;font-size:11px;font-weight:700;min-width:20px;height:20px;padding:0 6px;border-radius:10px;display:flex;align-items:center;justify-content:center;">${item.unread_count}</div>` : ''}
`; `;
row.onclick = () => App.openChat(item.chat.id, item.other_id, item.other_name); row.onclick = () => App.openChat(item.chat.id, item.other_id, item.other_name);
list.appendChild(row); list.appendChild(row);
}); });
} catch (e) { } catch (e) {
document.getElementById('chats-list').innerHTML = '<div class="empty">Ошибка: ' + e.message + '</div>'; document.getElementById('chats-list').innerHTML = '<div class="empty-b"><div class="em">⚠️</div>Ошибка загрузки<br><small>Попробуй позже</small></div>';
App._toast('Не удалось загрузить чаты', 'err');
} }
}, },
}; };
function formatChatTime(ts) {
try {
const d = new Date(ts);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'сейчас';
if (diff < 3600) return Math.floor(diff / 60) + 'м';
if (diff < 86400) return Math.floor(diff / 3600) + 'ч';
if (diff < 604800) return Math.floor(diff / 86400) + 'д';
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
} catch { return ''; }
}
window.ChatsView = ChatsView; window.ChatsView = ChatsView;

View File

@ -38,34 +38,63 @@ const ProfileView = {
renderForm(root, u) { renderForm(root, u) {
root.innerHTML = ` root.innerHTML = `
<div class="view"> <div class="view">
<h1>👤 Профиль</h1> <h1 style="margin:0 0 14px;font-size:22px;">👤 Мой профиль</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>` : ''} <div class="card-b" style="display:flex;align-items:center;gap:14px;">
${u.telegram_id ? '' : `<div class="sub">${escapeHtml(u.email || '')}</div>`} <div class="avatar-b lg" id="p-avatar">${escapeHtml(((u.name || u.email || '?')[0] || '?').toUpperCase())}</div>
<div style="flex:1;">
<div style="font-weight:600;font-size:16px;">${escapeHtml(u.name || 'Без имени')}</div>
${u.telegram_id ? `<div style="font-size:13px;color:var(--text-dim);">@${escapeHtml(u.username || '')}</div>` : `<div style="font-size:13px;color:var(--text-dim);">${escapeHtml(u.email || '')}</div>`}
<input type="file" id="p-photo-input" accept="image/jpeg,image/png,image/webp" style="display:none;">
<button type="button" class="btn-b ghost tiny" id="p-upload" style="margin-top:8px;">📷 Загрузить фото</button>
</div> </div>
<label>Имя</label> </div>
<input class="input" id="p-name" value="${escapeHtml(u.name || '')}">
<label>Город</label> <!-- поля -->
<input class="input" id="p-city" placeholder="Москва" value="${escapeHtml(u.city || '')}"> <div class="row-list" style="margin-top:14px;">
<label>О себе</label> <label class="field-b">
<textarea class="input" id="p-bio" rows="3" placeholder="Расскажи о себе" style="resize:none;">${escapeHtml(u.bio || '')}</textarea> <span>Имя</span>
<label>Пол</label> <input class="ctrl" id="p-name" value="${escapeHtml(u.name || '')}" placeholder="Как тебя зовут">
<select class="input" id="p-gender"> <div class="hint mute" id="h-name"></div>
</label>
<label class="field-b">
<span>Город</span>
<input class="ctrl" id="p-city" placeholder="Москва" value="${escapeHtml(u.city || '')}">
</label>
<label class="field-b">
<span>О себе</span>
<textarea class="ctrl" id="p-bio" rows="3" placeholder="Пару слов о себе" style="resize:none;">${escapeHtml(u.bio || '')}</textarea>
<div class="hint mute" id="h-bio" style="text-align:right;font-size:11px;"></div>
</label>
<label class="field-b">
<span>Пол</span>
<select class="ctrl" id="p-gender" style="font:15px/1.2 inherit;padding:13px 14px;">
<option value="">Не указан</option> <option value="">Не указан</option>
<option value="m" ${u.gender === 'm' ? 'selected' : ''}>Мужской</option> <option value="m" ${u.gender === 'm' ? 'selected' : ''}>Мужской</option>
<option value="f" ${u.gender === 'f' ? 'selected' : ''}>Женский</option> <option value="f" ${u.gender === 'f' ? 'selected' : ''}>Женский</option>
<option value="o" ${u.gender === 'o' ? 'selected' : ''}>Другое</option> <option value="o" ${u.gender === 'o' ? 'selected' : ''}>Другое</option>
</select> </select>
</label>
</div>
<h2>🍻 Что пью</h2> <!-- чипы (drink/activity/purpose) -->
<div id="p-drinks"></div> <div class="row-list" style="margin-top:14px;">
<h2>🎯 Чем занимаюсь</h2> <div class="card-b">
<div id="p-activities"></div> <div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:10px;">🍻 Что пью</div>
<h2>💡 Цели</h2> <div class="chip-list" id="p-drinks"></div>
<div id="p-purposes"></div> </div>
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:10px;">🎯 Чем занимаюсь</div>
<div class="chip-list" id="p-activities"></div>
</div>
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:10px;">💡 Цели</div>
<div class="chip-list" id="p-purposes"></div>
</div>
</div>
<button class="btn" id="p-save">Сохранить</button> <button class="btn-b" id="p-save" style="margin-top:14px;">Сохранить</button>
</div> </div>
`; `;
@ -73,21 +102,57 @@ const ProfileView = {
this.renderChips('p-activities', this.ACTIVITIES, this.activities); this.renderChips('p-activities', this.ACTIVITIES, this.activities);
this.renderChips('p-purposes', this.PURPOSES, this.purposes); this.renderChips('p-purposes', this.PURPOSES, this.purposes);
// upload photo
document.getElementById('p-upload').onclick = () => document.getElementById('p-photo-input').click();
document.getElementById('p-photo-input').onchange = async (e) => {
const f = e.target.files[0];
if (!f) return;
if (f.size > 8 * 1024 * 1024) { App._toast('Файл больше 8 МБ', 'err'); return; }
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) { App._toast('Только JPEG/PNG/WebP', 'err'); return; }
const fd = new FormData();
fd.append('photo', f);
const btn = document.getElementById('p-upload');
const orig = btn.textContent;
btn.textContent = '⏳ Загружаем…';
btn.classList.add('load');
try {
const r = await fetch(API_BASE + '/api/v1/me/photo', { method: 'POST', body: fd, credentials: 'include' });
if (!r.ok) throw new Error((await r.json()).error || 'HTTP ' + r.status);
App._toast('✓ Фото загружено', 'ok');
} catch (e) {
App._toast('⚠️ ' + e.message, 'err');
} finally {
btn.textContent = orig;
btn.classList.remove('load');
}
};
// live counters
const bio = document.getElementById('p-bio');
const counter = document.getElementById('h-bio');
function updBio() {
const n = bio.value.length;
counter.textContent = n + ' / 500';
counter.className = 'hint ' + (n > 450 ? 'err' : 'mute');
}
bio.addEventListener('input', updBio);
updBio();
document.getElementById('p-save').onclick = () => this.save(); document.getElementById('p-save').onclick = () => this.save();
}, },
renderChips(elId, items, selected) { renderChips(elId, items, selected) {
const el = document.getElementById(elId); const el = document.getElementById(elId);
if (!el) { console.error('renderChips: no element', elId); return; } if (!el) return;
items.forEach(([key, label]) => { items.forEach(([key, label]) => {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'chip' + (selected.has(key) ? ' active' : ''); chip.className = 'chip' + (selected.has(key) ? ' on' : '');
chip.textContent = label; chip.textContent = label;
chip.onclick = () => { chip.onclick = () => {
try { try {
if (selected.has(key)) selected.delete(key); else selected.add(key); if (selected.has(key)) selected.delete(key); else selected.add(key);
chip.classList.toggle('active'); chip.classList.toggle('on');
TG_APP.haptic('selection'); TG_APP.haptic?.('selection');
} catch (e) { console.warn('chip click err', e); } } catch (e) { console.warn('chip click err', e); }
}; };
el.appendChild(chip); el.appendChild(chip);
@ -96,8 +161,9 @@ const ProfileView = {
async save() { async save() {
const btn = document.getElementById('p-save'); const btn = document.getElementById('p-save');
const orig = btn ? btn.textContent : null; const orig = btn.textContent;
if (btn) { btn.disabled = true; btn.textContent = '⏳ Сохраняю…'; } btn.className = 'btn-b load';
btn.textContent = '⏳ Сохраняем…';
try { try {
await API.Auth.updateMe({ await API.Auth.updateMe({
name: document.getElementById('p-name').value.trim(), name: document.getElementById('p-name').value.trim(),
@ -110,14 +176,24 @@ const ProfileView = {
activities: [...this.activities], activities: [...this.activities],
purposes: [...this.purposes], purposes: [...this.purposes],
}); });
try { TG_APP.haptic('notification'); } catch {} TG_APP.haptic?.('notification');
App.toast('✓ Сохранено'); btn.className = 'btn-b ok';
if (btn) { btn.textContent = '✓ Сохранено'; setTimeout(() => { btn.textContent = orig || 'Сохранить'; btn.disabled = false; }, 1500); } btn.textContent = '✓ Готово!';
App._toast('Профиль обновлён', 'ok');
setTimeout(() => {
btn.className = 'btn-b';
btn.textContent = orig;
}, 1500);
} catch (e) { } catch (e) {
console.error('save profile:', e); btn.className = 'btn-b err';
App.toast('⚠️ ' + e.message); btn.textContent = '⚠️ ' + (e.message || 'Ошибка');
TG_APP.showAlert('Ошибка сохранения: ' + e.message); App._toast('Не удалось сохранить', 'err');
if (btn) { btn.disabled = false; btn.textContent = orig || 'Сохранить'; } const bad = (e.message || '').includes('name') ? 'p-name' : (e.message || '').includes('gender') ? 'p-gender' : null;
if (bad) document.getElementById(bad)?.classList.add('bad');
setTimeout(() => {
btn.className = 'btn-b';
btn.textContent = orig;
}, 2000);
} }
}, },
}; };

View File

@ -12,36 +12,68 @@ const ReviewView = {
this.rating = 5; this.rating = 5;
root.innerHTML = ` root.innerHTML = `
<div class="view"> <div class="view">
<h1>⭐ Отзыв</h1> <h1 style="margin:0 0 6px;font-size:22px;">⭐ Отзыв</h1>
<div class="sub">О ${escapeHtml(this.otherName || 'пользователе')}</div> <div style="color:var(--text-dim);font-size:13px;margin-bottom:14px;">О ${escapeHtml(this.otherName || 'пользователе')}</div>
<div class="card">
<label>Ваша оценка</label> <div class="card-b">
<div class="stars" id="stars"> <div style="font-size:12px;font-weight:600;color:var(--text-dim);">Ваша оценка</div>
${[1,2,3,4,5].map(n => `<span data-n="${n}" class="${n<=5?'on':''}">★</span>`).join('')} <div class="stars-b" id="stars">
${[1,2,3,4,5].map(n => `<span class="star on" data-n="${n}">★</span>`).join('')}
</div> </div>
<label style="margin-top:12px;">Комментарий (необязательно)</label> <div class="hint mute" id="r-rating-hint" style="text-align:center;color:var(--success);font-size:13px;margin-bottom:14px;">5 из 5</div>
<textarea class="input" id="r-body" rows="4" placeholder="Как прошла встреча?" maxlength="2000" style="resize:none;"></textarea>
<div class="row"> <label class="field-b" style="margin-top:6px;">
<label>Анонимно</label> <span style="font-size:12px;font-weight:600;color:var(--text-dim);">Комментарий (необязательно)</span>
<input type="checkbox" id="r-anon" style="width:24px;height:24px;"> <textarea class="ctrl" id="r-body" rows="4" placeholder="Как прошла встреча?" maxlength="2000" style="resize:none;"></textarea>
</div> <div class="hint mute" id="r-counter" style="text-align:right;font-size:11px;">0 / 2000</div>
<button class="btn" id="r-submit">Отправить</button> </label>
<label class="row-b" style="margin-top:4px;">
<div class="title" style="font-size:14px;font-weight:500;">Анонимный отзыв</div>
<input type="checkbox" id="r-anon" style="display:none;">
<div class="switch-b" id="r-anon-sw"></div>
</label>
<button class="btn-b" id="r-submit" style="margin-top:16px;">Отправить</button>
</div> </div>
</div> </div>
`; `;
document.querySelectorAll('#stars span').forEach(s => { // звёзды
document.querySelectorAll('#stars .star').forEach(s => {
s.onclick = () => { s.onclick = () => {
this.rating = parseInt(s.dataset.n, 10); this.rating = parseInt(s.dataset.n, 10);
document.querySelectorAll('#stars span').forEach((x, i) => { document.querySelectorAll('#stars .star').forEach((x, i) => {
x.classList.toggle('on', i < this.rating); x.classList.toggle('on', i < this.rating);
}); });
const h = document.getElementById('r-rating-hint');
h.textContent = this.rating + ' из 5';
h.style.color = 'var(--success)';
TG_APP.haptic('selection'); TG_APP.haptic('selection');
}; };
}); });
// чекбокс как switch
document.getElementById('r-anon-sw').onclick = () => {
const sw = document.getElementById('r-anon-sw');
const cb = document.getElementById('r-anon');
const newVal = !cb.checked;
cb.checked = newVal;
sw.classList.toggle('on', newVal);
};
// счётчик textarea
document.getElementById('r-body').addEventListener('input', (e) => {
const c = document.getElementById('r-counter');
const n = e.target.value.length;
c.textContent = n + ' / 2000';
c.className = 'hint ' + (n > 1800 ? 'err' : 'mute');
});
document.getElementById('r-submit').onclick = () => this.submit(); document.getElementById('r-submit').onclick = () => this.submit();
}, },
async submit() { async submit() {
const btn = document.getElementById('r-submit');
const orig = btn.textContent;
btn.className = 'btn-b load';
btn.textContent = '⏳ Отправляем…';
try { try {
await API.ReviewApi.create( await API.ReviewApi.create(
this.chatId, this.chatId,
@ -50,10 +82,22 @@ const ReviewView = {
document.getElementById('r-anon').checked document.getElementById('r-anon').checked
); );
TG_APP.haptic('notification'); TG_APP.haptic('notification');
App.toast('Отзыв отправлен ✓'); btn.className = 'btn-b ok';
setTimeout(() => App.showView('chats'), 800); btn.textContent = '✓ Готово!';
App._toast('Спасибо за отзыв!', 'ok');
setTimeout(() => {
btn.className = 'btn-b';
btn.textContent = orig;
App.showView('chats');
}, 1200);
} catch (e) { } catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message); btn.className = 'btn-b err';
btn.textContent = '⚠️ ' + (e.message || 'Ошибка');
App._toast('Не удалось отправить', 'err');
setTimeout(() => {
btn.className = 'btn-b';
btn.textContent = orig;
}, 2000);
} }
}, },
}; };
@ -64,8 +108,9 @@ const UserReviewsView = {
const userName = params.userName || 'Пользователь'; const userName = params.userName || 'Пользователь';
root.innerHTML = ` root.innerHTML = `
<div class="view"> <div class="view">
<h1>⭐ Отзывы</h1> <h1 style="margin:0 0 14px;font-size:22px;">⭐ Отзывы о ${escapeHtml(userName)}</h1>
<div id="ur-header" class="loader">Загрузка...</div> <div id="ur-header" class="empty-b">⏳ Загрузка…</div>
<div id="ur-list" class="row-list" style="margin-top:14px;"></div>
</div> </div>
`; `;
try { try {
@ -73,41 +118,40 @@ const UserReviewsView = {
API.ReviewApi.stats(userId), API.ReviewApi.stats(userId),
API.ReviewApi.listForUser(userId), API.ReviewApi.listForUser(userId),
]); ]);
const avg = stats.rating_avg ? Number(stats.rating_avg).toFixed(1) : '—';
const header = document.getElementById('ur-header'); const header = document.getElementById('ur-header');
header.className = 'card'; header.className = 'card-b';
header.innerHTML = ` header.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;"> <div style="display:flex;align-items:center;gap:14px;">
<div class="rating-big">${stats.rating_avg ? stats.rating_avg.toFixed(1) : '—'}</div> <div style="font-size:36px;font-weight:700;color:var(--warn);">${avg}</div>
<div> <div style="flex:1;">
<div class="stars" style="font-size:18px;"> <div style="font-size:18px;color:var(--warn);">${[1,2,3,4,5].map(n => `<span style="color:${n<=Math.round(stats.rating_avg||0)?'var(--warn)':'var(--border-strong)'}">★</span>`).join('')}</div>
${[1,2,3,4,5].map(n => `<span class="${n<=Math.round(stats.rating_avg||0)?'on':''}">★</span>`).join('')} <div style="font-size:13px;color:var(--text-dim);margin-top:2px;">${stats.rating_count || 0} отзывов</div>
</div>
<div class="sub">${stats.rating_count} отзывов</div>
</div> </div>
</div> </div>
`; `;
const v = document.createElement('div'); const box = document.getElementById('ur-list');
v.innerHTML = '<h2>' + escapeHtml(userName) + '</h2>';
if (!list.reviews || list.reviews.length === 0) { if (!list.reviews || list.reviews.length === 0) {
v.innerHTML += '<div class="empty">Пока нет отзывов</div>'; box.innerHTML = '<div class="empty-b">Пока нет отзывов</div>';
} else { } else {
list.reviews.forEach((r) => { list.reviews.forEach((r) => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card'; card.className = 'card-b';
card.style.padding = '14px';
card.innerHTML = ` card.innerHTML = `
<div style="display:flex;justify-content:space-between;"> <div style="display:flex;justify-content:space-between;align-items:center;">
<div class="stars" style="font-size:14px;">${[1,2,3,4,5].map(n=>`<span class="${n<=r.rating?'on':''}">★</span>`).join('')}</div> <div style="font-size:16px;color:var(--warn);">${[1,2,3,4,5].map(n=>`<span style="color:${n<=r.rating?'var(--warn)':'var(--border-strong)'}">★</span>`).join('')}</div>
<div class="sub" style="margin:0;">${formatDate(r.created_at)}</div> <div style="font-size:11px;color:var(--text-mute);">${formatDate(r.created_at)}</div>
</div> </div>
<div class="sub">${r.anonymous ? 'Аноним' : 'ID: ' + r.reviewer_id.slice(0,8)}</div> <div style="font-size:12px;color:var(--text-dim);margin:4px 0 6px;">${r.anonymous ? 'Анонимно' : 'ID: ' + (r.reviewer_id || '').slice(0,8)}</div>
${r.body ? '<div style="margin-top:6px;">' + escapeHtml(r.body) + '</div>' : ''} ${r.body ? '<div style="font-size:14px;">' + escapeHtml(r.body) + '</div>' : ''}
`; `;
v.appendChild(card); box.appendChild(card);
}); });
} }
header.parentElement.appendChild(v);
} catch (e) { } catch (e) {
document.getElementById('ur-header').innerHTML = '<div class="empty">Ошибка: ' + e.message + '</div>'; document.getElementById('ur-header').innerHTML = '<div class="empty-b">⚠️ Ошибка загрузки</div>';
App._toast('Не удалось загрузить отзывы', 'err');
} }
}, },
}; };

View File

@ -1,117 +1,67 @@
// Settings view + consent gate + user profile (public) // Settings view — единый стиль BuhApp
const SettingsView = { const SettingsView = {
async render(root) { async render(root) {
const u = API.Store.user; const u = API.Store.user;
root.innerHTML = ` root.innerHTML = `
<div class="view"> <div class="view">
<h1>⚙️ Настройки</h1> <h1 style="margin:0 0 14px;font-size:22px;">⚙️ Настройки</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>` : ''} <div class="card-b">
${u?.email ? `<div class="sub">${escapeHtml(u.email)}</div>` : ''} <div style="display:flex;align-items:center;gap:14px;">
<div class="avatar-b">${escapeHtml(((u?.name || u?.email || '?')[0] || '?').toUpperCase())}</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:16px;">${escapeHtml(u?.name || 'Без имени')}</div>
${u?.email ? `<div style="font-size:13px;color:var(--text-dim);margin-top:2px;">${escapeHtml(u.email)}</div>` : ''}
</div> </div>
<div class="card">
<strong>Юридические документы</strong>
<div class="sub">
Версия 1.0 · 20.08.2026
</div> </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> </div>
<button class="btn danger" id="logout">Выйти</button>
<!-- Юридические документы -->
<div class="row-list" style="margin-top:14px;">
<div class="card-b" style="padding:0;">
<a href="/legal/TERMS.html" target="_blank" style="text-decoration:none;color:inherit;">
<div class="row-b" style="border:0;background:transparent;">
<div class="title">📄 Пользовательское соглашение</div>
<div class="chev">›</div>
</div>
</a>
<a href="/legal/PRIVACY.html" target="_blank" style="text-decoration:none;color:inherit;">
<div class="row-b" style="border:0;background:transparent;border-top:1px solid var(--border);">
<div class="title">🔒 Политика конфиденциальности</div>
<div class="chev">›</div>
</div>
</a>
<a href="/legal/DISCLAIMER.html" target="_blank" style="text-decoration:none;color:inherit;">
<div class="row-b" style="border:0;background:transparent;border-top:1px solid var(--border);">
<div class="title">⚠️ Отказ от ответственности</div>
<div class="chev">›</div>
</div>
</a>
</div>
</div>
<div style="text-align:center;color:var(--text-mute);font-size:11px;margin:20px 0 8px;">v 0.7.0 · BuhApp</div>
<!-- Выйти — danger -->
<button class="btn-b danger" id="logout" style="margin-top:8px;">Выйти</button>
</div> </div>
`; `;
document.getElementById('logout').onclick = async () => { document.getElementById('logout').onclick = async () => {
const ok = await TG_APP.showConfirm('Выйти из BuhApp?'); const ok = await TG_APP.showConfirm('Выйти из BuhApp?');
if (!ok) return; if (!ok) return;
// очистим cookie на бэкенде (best-effort, без await) const btn = document.getElementById('logout');
try { await fetch('https://api.buhapp.mygoodservice.ru/api/v1/auth/logout', { method: 'POST', credentials: 'include' }); } catch {} const orig = btn.textContent;
btn.className = 'btn-b danger load';
btn.textContent = '⏳ Выходим…';
try {
await fetch('https://buhapp.mygoodservice.ru/api/v1/auth/logout', { method: 'POST', credentials: 'include' });
} catch {}
btn.className = 'btn-b ok';
btn.textContent = '✓ Готово!';
App.onLogout(); App.onLogout();
location.reload(); setTimeout(() => location.reload(), 500);
}; };
}, },
}; };
// 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;