Add EGE support prototype v0.1.0
This commit is contained in:
1 parent
9944a41cca
commit
24cae23262
19 files changed
+2581
-2
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user