250 lines
8.6 KiB
JavaScript
250 lines
8.6 KiB
JavaScript
'use strict';
|
|
|
|
const form = document.getElementById('chat-form');
|
|
const message = document.getElementById('chat-message');
|
|
const submit = document.getElementById('chat-submit');
|
|
const feedback = document.getElementById('chat-feedback');
|
|
const messages = document.getElementById('chat-log');
|
|
const chatWindow = document.getElementById('chat-window');
|
|
const chatEmpty = document.getElementById('chat-empty');
|
|
const ticketLabel = document.getElementById('chat-ticket');
|
|
const newConversation = document.getElementById('chat-new');
|
|
const chatView = document.getElementById('chat-view');
|
|
const exitView = document.getElementById('chat-exit-view');
|
|
const exitButton = document.getElementById('chat-exit');
|
|
const returnButton = document.getElementById('chat-return');
|
|
const staff = location.pathname === '/staff-chat';
|
|
const channel = staff ? 'staff' : 'users';
|
|
const sessionsUrl = `/api/chat/${channel}/sessions`;
|
|
const storageKey = `ege-support.chat.${channel}`;
|
|
const sessionIdPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu;
|
|
const roleLabels = { customer: 'Вы', staff: 'Вы', moderator: 'Модерация', operator: 'Поддержка' };
|
|
const timeFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
|
|
|
let conversation = loadConversation();
|
|
let attempt;
|
|
let sending = false;
|
|
let renderedCount = 0;
|
|
let exited = false;
|
|
let generation = 0;
|
|
let pollTimer;
|
|
|
|
function isCurrent(expected) {
|
|
return !exited && generation === expected;
|
|
}
|
|
|
|
const title = staff ? 'Чат сотрудников' : 'Чат пользователей';
|
|
document.getElementById('chat-title').textContent = title;
|
|
document.title = `${title} · Онлайн-школа ЕГЭ`;
|
|
|
|
function loadConversation() {
|
|
if (staff) {
|
|
const params = new URLSearchParams(location.hash.slice(1));
|
|
if (params.has('session')) {
|
|
const id = params.get('session');
|
|
history.replaceState(null, '', location.pathname + location.search);
|
|
if (sessionIdPattern.test(id)) {
|
|
const issued = { id, fromStaffSite: true };
|
|
storeConversation(issued);
|
|
return issued;
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(storageKey));
|
|
if (typeof saved?.id === 'string' && sessionIdPattern.test(saved.id)) return saved;
|
|
} catch {
|
|
// The chat can still be used for the current tab when storage is unavailable.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function storeConversation(value) {
|
|
try {
|
|
if (value) localStorage.setItem(storageKey, JSON.stringify(value));
|
|
else localStorage.removeItem(storageKey);
|
|
} catch {
|
|
// Keep the active conversation in memory when storage is unavailable.
|
|
}
|
|
}
|
|
|
|
function setFeedback(text = '', error = false) {
|
|
feedback.textContent = text;
|
|
feedback.dataset.error = error;
|
|
}
|
|
|
|
async function request(url, body) {
|
|
const options = body === undefined ? {} : {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
};
|
|
let response;
|
|
try {
|
|
response = await fetch(url, options);
|
|
} catch {
|
|
throw new Error('Не удалось связаться с сервером. Повторите попытку.');
|
|
}
|
|
const data = await response.json().catch(() => null);
|
|
if (!response.ok || !data) {
|
|
const error = new Error(data?.error || 'Не удалось выполнить действие. Повторите попытку.');
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function renderConversation(data, follow = false) {
|
|
if (data.id !== conversation?.id || data.messages.length < renderedCount) return;
|
|
const wasAtBottom = chatWindow.scrollHeight - chatWindow.scrollTop - chatWindow.clientHeight < 40;
|
|
const fragment = document.createDocumentFragment();
|
|
for (const item of data.messages.slice(renderedCount)) {
|
|
const bubble = document.createElement('li');
|
|
bubble.dataset.role = item.role;
|
|
const meta = document.createElement('div');
|
|
meta.className = 'chat-meta';
|
|
const author = document.createElement('strong');
|
|
author.textContent = roleLabels[item.role] || 'Участник';
|
|
const time = document.createElement('time');
|
|
time.dateTime = item.createdAt;
|
|
time.textContent = timeFormat.format(new Date(item.createdAt));
|
|
const text = document.createElement('p');
|
|
text.textContent = item.text;
|
|
meta.append(author, time);
|
|
bubble.append(meta, text);
|
|
fragment.append(bubble);
|
|
}
|
|
messages.append(fragment);
|
|
renderedCount = data.messages.length;
|
|
messages.hidden = renderedCount === 0;
|
|
chatEmpty.hidden = renderedCount > 0;
|
|
ticketLabel.textContent = data.ticketId ? `Обращение №${data.ticketId}` : 'Новое обращение';
|
|
newConversation.hidden = !data.ticketId || Boolean(conversation.fromStaffSite);
|
|
if (follow || wasAtBottom) chatWindow.scrollTop = chatWindow.scrollHeight;
|
|
}
|
|
|
|
function resetConversation() {
|
|
conversation = null;
|
|
attempt = null;
|
|
renderedCount = 0;
|
|
storeConversation(null);
|
|
messages.replaceChildren();
|
|
messages.hidden = true;
|
|
chatEmpty.hidden = false;
|
|
newConversation.hidden = true;
|
|
ticketLabel.textContent = 'Новое обращение';
|
|
}
|
|
|
|
async function refreshConversation() {
|
|
if (exited || !conversation || sending) return;
|
|
const current = conversation;
|
|
try {
|
|
const data = await request(`${sessionsUrl}/${current.id}`);
|
|
if (conversation === current) renderConversation(data);
|
|
} catch (error) {
|
|
if (conversation !== current || sending) return;
|
|
if (error.status === 404) {
|
|
resetConversation();
|
|
setFeedback(current.fromStaffSite
|
|
? 'Откройте новую ссылку на чат в разделе поддержки сотрудников.'
|
|
: 'Предыдущая переписка недоступна. Можно начать новое обращение.', true);
|
|
} else if (!renderedCount) {
|
|
setFeedback(error.message, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pollConversation() {
|
|
const expected = generation;
|
|
await refreshConversation();
|
|
if (isCurrent(expected)) pollTimer = window.setTimeout(pollConversation, 2000);
|
|
}
|
|
|
|
function setSending(value) {
|
|
sending = value;
|
|
submit.disabled = value;
|
|
message.disabled = value;
|
|
newConversation.disabled = value;
|
|
form.setAttribute('aria-busy', String(value));
|
|
submit.textContent = value ? 'Отправляем…' : 'Отправить';
|
|
}
|
|
|
|
newConversation.addEventListener('click', () => {
|
|
resetConversation();
|
|
message.value = '';
|
|
setFeedback();
|
|
message.focus();
|
|
});
|
|
|
|
exitButton.addEventListener('click', () => {
|
|
exited = true;
|
|
generation += 1;
|
|
window.clearTimeout(pollTimer);
|
|
resetConversation();
|
|
message.value = '';
|
|
setFeedback();
|
|
setSending(false);
|
|
chatView.hidden = true;
|
|
exitView.hidden = false;
|
|
exitButton.hidden = true;
|
|
returnButton.focus();
|
|
});
|
|
|
|
returnButton.addEventListener('click', () => {
|
|
if (!exited) return;
|
|
exited = false;
|
|
chatView.hidden = false;
|
|
exitView.hidden = true;
|
|
exitButton.hidden = false;
|
|
message.focus();
|
|
pollConversation();
|
|
});
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
if (exited) return;
|
|
const expected = generation;
|
|
const text = message.value.trim();
|
|
if (!text || sending) {
|
|
if (!text) setFeedback('Введите сообщение.', true);
|
|
return;
|
|
}
|
|
if (!attempt || attempt.text !== text) attempt = { requestId: crypto.randomUUID(), text };
|
|
setSending(true);
|
|
setFeedback();
|
|
try {
|
|
if (!conversation) {
|
|
const created = await request(sessionsUrl, {});
|
|
if (!isCurrent(expected)) return;
|
|
conversation = { id: created.id };
|
|
storeConversation(conversation);
|
|
}
|
|
const updated = await request(`${sessionsUrl}/${conversation.id}/messages`, attempt);
|
|
if (!isCurrent(expected)) return;
|
|
renderConversation(updated, true);
|
|
message.value = '';
|
|
attempt = null;
|
|
setFeedback('Сообщение отправлено.');
|
|
} catch (error) {
|
|
if (!isCurrent(expected)) return;
|
|
if (error.status === 404 && conversation) {
|
|
const needsLink = conversation.fromStaffSite;
|
|
resetConversation();
|
|
setFeedback(needsLink
|
|
? 'Откройте новую ссылку на чат в разделе поддержки сотрудников.'
|
|
: 'Предыдущая переписка недоступна. Повторите отправку, чтобы начать новую.', true);
|
|
} else if (staff && (error.status === 401 || error.status === 403)) {
|
|
setFeedback('Откройте чат через раздел поддержки сотрудников на сайте.', true);
|
|
} else {
|
|
setFeedback(error.message, true);
|
|
}
|
|
} finally {
|
|
if (isCurrent(expected)) {
|
|
setSending(false);
|
|
message.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
pollConversation();
|