ДИЗАЙН-СИСТЕМА в 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 кнопок.
210 lines
8.0 KiB
JavaScript
210 lines
8.0 KiB
JavaScript
// 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;
|