'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();