buhapp-telegram/public/js/views/chat.js
root c5f8ac67ad 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 кнопок.
2026-08-21 11:42:16 +00:00

210 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Single chat view
const ChatView = {
chatId: null,
otherId: null,
otherName: null,
ws: null,
async render(root, params) {
this.chatId = params.chatId;
this.otherId = params.otherId;
this.otherName = params.otherName;
root.innerHTML = `
<div class="view" style="display:flex;flex-direction:column;height:calc(100vh - 100px);padding:0;">
<!-- header -->
<div style="padding:12px 16px;border-bottom:1px solid var(--border);background:var(--bg-elev);display:flex;align-items:center;gap:8px;">
<button class="btn-b ghost tiny" id="chat-back" style="padding:6px 10px;">←</button>
<div class="avatar-b sm" style="${API.Store.user?.other_photo_for_other ? '' : ''}">${escapeHtml(((this.otherName || '?')[0] || '?').toUpperCase())}</div>
<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>
<!-- messages -->
<div id="messages" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:6px;"></div>
<!-- edit bar -->
<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>
<!-- composer -->
<div class="composer-b">
<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>
`;
document.getElementById('chat-back').onclick = () => App.showView('chats');
document.getElementById('chat-profile').onclick = () => App.openUserProfile(this.otherId, this.otherName);
document.getElementById('chat-review').onclick = () => App.openReview(this.chatId, this.otherId, this.otherName);
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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.send();
}
});
document.getElementById('msg-send').onclick = () => this.send();
this.loadMessages();
this.setupWS();
this.markRead();
},
async loadMessages() {
try {
const r = await API.ChatApi.listMessages(this.chatId);
const box = document.getElementById('messages');
box.innerHTML = '';
r.messages.forEach((m) => this.appendMessage(m));
box.scrollTop = box.scrollHeight;
} catch (e) {
console.warn(e);
}
},
appendMessage(m) {
const box = document.getElementById('messages');
const me = API.Store.user && m.sender_id === (API.Store.user.id || API.Store.user.user_id);
const div = document.createElement('div');
div.className = 'bubble-b ' + (me ? 'out' : 'in');
div.dataset.id = m.id;
if (m.deleted) {
div.innerHTML = '<em style="color:var(--text-mute);">Удалено</em>';
} else {
const readMark = me
? (m.read_at ? '<span class="ticks read" title="прочитано">✓✓</span>' : '<span class="ticks" title="доставлено">✓</span>')
: '';
div.innerHTML = `
<div>${escapeHtml(m.body || '')}</div>
<div class="meta">
<span>${formatTime(m.created_at)}</span>
${m.edited ? '<span style="font-size:10px;margin:0 4px;">ред.</span>' : ''}
${readMark}
</div>
`;
if (me) {
div.onclick = () => this.onMessageClick(m);
}
}
box.appendChild(div);
box.scrollTop = box.scrollHeight;
},
onMessageClick(m) {
TG_APP.showConfirm('Удалить сообщение?').then((ok) => {
if (ok) this.deleteMessage(m);
});
},
async deleteMessage(m) {
try {
await API.ChatApi.deleteMessage(m.id);
const el = document.querySelector(`.bubble[data-id="${m.id}"]`);
if (el) el.innerHTML = '<em>Удалено</em>';
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
}
},
setupWS() {
if (!API.Store.access) return;
const url = (window.API_BASE || 'https://api.buhapp.mygoodservice.ru').replace(/^http/, 'ws') + '/ws?token=' + API.Store.access;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => console.log('WS connected');
this.ws.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.type === 'message' && d.payload.chat_id === this.chatId) {
this.appendMessage(d.payload);
} else if (d.type === 'message_edited' && d.payload.chat_id === this.chatId) {
this.updateMessage(d.payload);
} else if (d.type === 'message_read' && d.payload.chat_id === this.chatId) {
this.markMessagesRead();
}
} catch {}
};
this.ws.onclose = () => setTimeout(() => this.setupWS(), 3000);
this.ws.onerror = (e) => console.warn('WS error', e);
} catch (e) {
console.warn(e);
}
},
async markMessagesRead() {
try {
const r = await API.ChatApi.listMessages(this.chatId);
r.messages.forEach((m) => {
if (m.read_at) {
const el = document.querySelector(`.bubble[data-id="${m.id}"] .read-tick`);
if (el) { el.classList.add('read'); el.textContent = '✓✓'; }
}
});
} catch {}
},
updateMessage(p) {
const el = document.querySelector(`.bubble[data-id="${p.id}"]`);
if (el) {
el.querySelector('div:first-child') && (el.querySelector('div:first-child').textContent = p.body);
const meta = el.querySelector('.meta');
if (meta && !meta.querySelector('.edited')) {
const ed = document.createElement('span');
ed.className = 'edited';
ed.textContent = 'ред.';
meta.appendChild(ed);
}
}
},
async send() {
const inp = document.getElementById('msg-input');
const btn = document.getElementById('msg-send');
const body = inp.value.trim();
if (!body) { App._toast('Введите сообщение', 'err'); btn.classList.add('err'); setTimeout(() => btn.classList.remove('err'), 600); return; }
btn.classList.add('load');
try {
await API.ChatApi.sendMessage(this.chatId, body);
inp.value = '';
btn.classList.remove('load');
} catch (e) {
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');
}
},
async markRead() {
try { await API.ChatApi.markRead(this.chatId); } catch {}
},
cleanup() {
if (this.ws) { this.ws.close(); this.ws = null; }
},
};
window.ChatView = ChatView;