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:
parent
2b34791ff0
commit
c5f8ac67ad
@ -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); }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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'); },
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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 style="font-size:13px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px;">${lastText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
${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>` : ''}
|
||||||
<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);
|
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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
window.ChatsView = ChatsView;
|
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;
|
||||||
|
|||||||
@ -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>
|
</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>
|
<div class="row-list" style="margin-top:14px;">
|
||||||
<h2>🎯 Чем занимаюсь</h2>
|
<label class="field-b">
|
||||||
<div id="p-activities"></div>
|
<span>Имя</span>
|
||||||
<h2>💡 Цели</h2>
|
<input class="ctrl" id="p-name" value="${escapeHtml(u.name || '')}" placeholder="Как тебя зовут">
|
||||||
<div id="p-purposes"></div>
|
<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="m" ${u.gender === 'm' ? 'selected' : ''}>Мужской</option>
|
||||||
|
<option value="f" ${u.gender === 'f' ? 'selected' : ''}>Женский</option>
|
||||||
|
<option value="o" ${u.gender === 'o' ? 'selected' : ''}>Другое</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn" id="p-save">Сохранить</button>
|
<!-- чипы (drink/activity/purpose) -->
|
||||||
|
<div class="row-list" style="margin-top:14px;">
|
||||||
|
<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-drinks"></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-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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
<div class="avatar-b">${escapeHtml(((u?.name || u?.email || '?')[0] || '?').toUpperCase())}</div>
|
||||||
<div class="card">
|
<div style="flex:1;min-width:0;">
|
||||||
<strong>Юридические документы</strong>
|
<div style="font-weight:600;font-size:16px;">${escapeHtml(u?.name || 'Без имени')}</div>
|
||||||
<div class="sub">
|
${u?.email ? `<div style="font-size:13px;color:var(--text-dim);margin-top:2px;">${escapeHtml(u.email)}</div>` : ''}
|
||||||
Версия 1.0 · 20.08.2026
|
</div>
|
||||||
</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;
|
|
||||||
Loading…
Reference in New Issue
Block a user