Add EGE support prototype v0.1.0

This commit is contained in:
alexglados1 committed 2026-11-13 14:14:31 +03:00
1 parent 9944a41cca
commit 24cae23262
19 files changed
+2581 -2

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
'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();