Files
f7support/js/main.js
T

1043 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const root = document.getElementById("f7support-app");
if (!root) return;
const username = root.dataset.username;
const serverAddress = root.dataset.serverAddress;
const apiBase = root.dataset.supportApiBase;
const RASTER_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif"]);
const RASTER_IMAGE_MIME = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/x-tiff",
"image/heic",
"image/heif",
]);
function isRasterImageAtt(a) {
const m = (a.mime_type || "").toLowerCase().split(";")[0].trim();
if (RASTER_IMAGE_MIME.has(m)) return true;
const parts = (a.filename || "").split(".");
const ext = parts.length > 1 ? parts.pop().toLowerCase() : "";
return RASTER_IMAGE_EXT.has(ext);
}
function openOrDownloadBlob(blob, filename, mime) {
const m = (mime || blob.type || "").toLowerCase().split(";")[0].trim();
const url = URL.createObjectURL(blob);
const tryInline =
RASTER_IMAGE_MIME.has(m) ||
m === "application/pdf" ||
(m.startsWith("text/") && m !== "text/html");
if (tryInline) {
const w = window.open(url, "_blank", "noopener,noreferrer");
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 180000);
return;
}
}
const a = document.createElement("a");
a.href = url;
a.download = filename || "file";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 120000);
}
const state = {
tickets: [],
currentTicket: null,
messages: [],
chatModalOpen: false,
blobUrls: [],
ticketSocket: null,
wsReconnectTimer: null,
pendingFile: null,
};
function clientIdentityHeaders() {
return {
"X-F7cloud-User": username,
"X-F7cloud-Server": serverAddress,
};
}
function revokeAttachmentUrls() {
for (const u of state.blobUrls) {
try {
URL.revokeObjectURL(u);
} catch (_) {}
}
state.blobUrls = [];
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatBytes(n) {
const v = Number(n) || 0;
if (v < 1024) return `${v} Б`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} КБ`;
return `${(v / (1024 * 1024)).toFixed(1)} МБ`;
}
function formatChatTime(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false });
} catch (_) {
return "";
}
}
function formatTicketCardTime(iso) {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const day = d.getDate();
const month = d.toLocaleString("ru-RU", { month: "long" });
const hm = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false });
return `${day} ${month}, ${hm}`;
} catch (_) {
return "";
}
}
function fileExtBadge(filename) {
let ext = (filename || "").split(".").pop() || "";
ext = ext.toLowerCase().replace(/[^a-z0-9]/g, "");
if (!ext) ext = "file";
const label = ext.length <= 5 ? ext : ext.slice(0, 4) + "…";
const slug = ext.replace(/[^a-z0-9]/g, "").slice(0, 12) || "file";
return { label, slug };
}
function nonImageAttachmentHtml(m, a) {
const outgoing = m.author_role === "client";
const wrapCls = outgoing ? "chat-file-bubble-wrap chat-file-bubble-wrap--out" : "chat-file-bubble-wrap chat-file-bubble-wrap--in";
const bubbleCls = outgoing ? "chat-file-bubble chat-file-bubble--out" : "chat-file-bubble chat-file-bubble--in";
const { label, slug } = fileExtBadge(a.filename);
const time = formatChatTime(m.created_at);
const ticks = outgoing ? '<span class="chat-file-bubble__ticks" aria-hidden="true">✓✓</span>' : "";
return `<div class="${wrapCls}">
<div class="${bubbleCls}" role="button" tabindex="0" data-f7-att-file="${a.id}" data-f7-filename="${escapeHtml(a.filename)}" data-f7-mime="${escapeHtml(a.mime_type || "")}">
<div class="chat-file-bubble__row">
<div class="chat-file-bubble__icon chat-file-bubble__icon--${escapeHtml(slug)}"><span class="chat-file-bubble__icon-text">${escapeHtml(label)}</span></div>
<div class="chat-file-bubble__body">
<div class="chat-file-bubble__name">${escapeHtml(a.filename)}</div>
<div class="chat-file-bubble__size">${formatBytes(a.size_bytes)}</div>
</div>
</div>
<div class="chat-file-bubble__foot">
<span class="chat-file-bubble__time">${escapeHtml(time)}</span>
${ticks}
</div>
</div>
</div>`;
}
function wsUrlForTicket(ticketNumber) {
try {
const u = new URL(apiBase);
const wsProto = u.protocol === "https:" ? "wss:" : "ws:";
return `${wsProto}//${u.host}/ws/tickets/${encodeURIComponent(ticketNumber)}`;
} catch (_) {
return null;
}
}
function disconnectTicketSocket() {
if (state.wsReconnectTimer) {
clearTimeout(state.wsReconnectTimer);
state.wsReconnectTimer = null;
}
if (state.ticketSocket) {
try {
state.ticketSocket.close();
} catch (_) {}
state.ticketSocket = null;
}
}
function connectTicketSocket(ticketNumber) {
disconnectTicketSocket();
const url = wsUrlForTicket(ticketNumber);
if (!url) return;
let socket;
try {
socket = new WebSocket(url);
} catch (_) {
return;
}
state.ticketSocket = socket;
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (!payload || !payload.data) return;
if (!state.chatModalOpen || state.currentTicket !== ticketNumber) return;
if (payload.event === "new_message" || payload.event === "message_updated") {
applyIncomingMessage(payload.data);
fetchTickets().catch(() => {});
}
} catch (_) {}
};
socket.onclose = () => {
state.ticketSocket = null;
if (state.chatModalOpen && state.currentTicket === ticketNumber) {
state.wsReconnectTimer = setTimeout(() => connectTicketSocket(ticketNumber), 2500);
}
};
socket.onerror = () => {
try {
socket.close();
} catch (_) {}
};
}
function messageBlockHtml(m) {
const atts = m.attachments || [];
const attHtml = atts
.map((a) => {
if (isRasterImageAtt(a)) {
return `<div class="f7support-att" data-f7-att-preview="${a.id}"><span style="color:#666;font-size:0.9em;">Загрузка…</span></div>`;
}
return nonImageAttachmentHtml(m, a);
})
.join("");
return `<div class="f7support-msg" data-f7-msg-id="${m.id}" style="margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid #eee;">
<div><b>${escapeHtml(m.author)}</b> (${escapeHtml(m.author_role)}): ${escapeHtml(m.text)}</div>
${attHtml ? `<div style="margin-top:6px;">${attHtml}</div>` : ""}
</div>`;
}
function applyIncomingMessage(msg) {
const id = msg.id;
const idx = state.messages.findIndex((m) => m.id === id);
if (idx === -1) state.messages.push(msg);
else state.messages[idx] = msg;
state.messages.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
const el = messageList.querySelector(`[data-f7-msg-id="${id}"]`);
const html = messageBlockHtml(msg);
if (el) {
el.outerHTML = html;
} else {
messageList.insertAdjacentHTML("beforeend", html);
}
const node = messageList.querySelector(`[data-f7-msg-id="${id}"]`);
if (node) hydrateClientAttachments(node);
messageList.scrollTop = messageList.scrollHeight;
if (msg.author_role === "support" && state.currentTicket) {
markClientTicketRead(state.currentTicket).catch(() => {});
}
}
async function markClientTicketRead(ticketNumber) {
const res = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/read`, {
method: "POST",
headers: clientIdentityHeaders(),
});
if (!res.ok) return;
const row = state.tickets.find((x) => x.ticket_number === ticketNumber);
if (row) row.has_unread = false;
renderTickets();
}
root.innerHTML = `
<div class="f7support-wrap">
<h2>f7support</h2>
<button type="button" id="create-ticket-btn">Создать токен</button>
<p id="error-box" class="f7support-error"></p>
<div class="f7support-main">
<h3>Мои обращения</h3>
<div class="f7-board" id="f7-ticket-board">
<div class="f7-column">
<h4 class="f7-column-title">Новые</h4>
<ul id="ticket-col-new" class="f7-column-list"></ul>
</div>
<div class="f7-column">
<h4 class="f7-column-title">В работе</h4>
<ul id="ticket-col-progress" class="f7-column-list"></ul>
</div>
<div class="f7-column">
<h4 class="f7-column-title">Архив</h4>
<ul id="ticket-col-closed" class="f7-column-list"></ul>
</div>
</div>
</div>
</div>
<div id="create-ticket-modal" class="f7support-modal" aria-hidden="true">
<div class="f7support-modal-panel">
<div class="f7support-modal-head">
<h3>Создание токена</h3>
<button type="button" id="close-modal-btn" class="f7support-icon-btn" aria-label="Закрыть">✕</button>
</div>
<input id="subject-input" maxlength="255" placeholder="Тема" class="f7support-input" />
<textarea id="body-input" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)" class="f7support-textarea"></textarea>
<button type="button" id="submit-ticket-btn">Отправить</button>
</div>
</div>
<div id="chat-modal" class="f7support-modal" aria-hidden="true">
<div class="f7support-modal-panel f7support-chat-panel">
<div class="f7support-modal-head">
<h3 id="chat-modal-title">Чат</h3>
<button type="button" id="chat-modal-close-btn" class="f7support-icon-btn" aria-label="Закрыть чат">✕</button>
</div>
<div id="message-list" class="f7support-message-list f7support-dnd-target" title="Перетащите файл сюда"></div>
<div class="f7support-composer">
<button type="button" id="attachment-clip-btn" class="f7support-clip-btn" title="Прикрепить файл" aria-label="Прикрепить файл">📎</button>
<input type="file" id="attachment-input" class="f7support-hidden-file" />
<textarea id="message-input" maxlength="4000" placeholder="Сообщение (до 4000 символов). Файл — скрепка или перетаскивание." class="f7support-textarea f7support-dnd-target"></textarea>
</div>
<div id="pending-attachment-row" class="f7support-pending-row" hidden>
<span id="pending-attachment-label"></span>
<button type="button" id="pending-attachment-clear" class="f7support-icon-btn" aria-label="Убрать файл">×</button>
</div>
<div class="f7support-chat-actions">
<button type="button" id="send-message-btn">Отправить</button>
</div>
</div>
</div>
<style>
.f7support-wrap { padding: 16px; font-family: sans-serif; max-width: min(1200px, 100%); box-sizing: border-box; }
.f7support-error { color: #b00020; min-height: 1.25em; margin: 8px 0; }
.f7support-main { margin-top: 12px; }
.f7-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
align-items: stretch;
}
@media (max-width: 900px) {
.f7-board { grid-template-columns: 1fr; }
}
.f7-column {
border: 1px solid #ddd;
border-radius: 8px;
background: #fafafa;
padding: 10px;
min-height: 200px;
display: flex;
flex-direction: column;
}
.f7-column-title { margin: 0 0 8px 0; font-size: 1rem; font-weight: 600; }
.f7-column-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-height: 160px;
max-height: min(56vh, 520px);
overflow-y: auto;
}
.f7-ticket-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
background: #fff;
cursor: pointer;
overflow: hidden;
}
.f7-ticket-card:hover { filter: brightness(0.985); border-color: #c8c8c8; }
.f7-ticket-card--active { outline: 2px solid #1a4fa3; }
.f7-ticket-card__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
background: #f5f5f5;
margin: 8px;
padding: 8px 10px;
border-radius: 6px;
}
.f7-ticket-card__title { font-size: 0.92rem; color: #333; }
.f7-ticket-card__title b { font-weight: 700; }
.f7-ticket-card__num { font-weight: 400; color: #777; margin-left: 4px; }
.f7-ticket-card__status {
flex-shrink: 0;
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
font-weight: 600;
color: #fff;
}
.f7-ticket-card__status--new { background: #2d9cdb; }
.f7-ticket-card__status--progress { background: #f2994a; }
.f7-ticket-card__status--closed { background: #9e9e9e; }
.f7-ticket-card__body {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 4px 14px 6px;
}
.f7-ticket-card__preview {
flex: 1;
margin: 0;
color: #666;
font-size: 0.88rem;
line-height: 1.4;
min-height: 2.8em;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.f7-unread-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #6fcf97;
margin-top: 5px;
}
.f7-ticket-card__foot {
display: flex;
justify-content: flex-end;
padding: 0 14px 10px;
}
.f7-ticket-card__time {
font-size: 0.75rem;
color: #333;
background: #f0f0f0;
padding: 2px 10px;
border-radius: 999px;
}
.f7support-modal {
display: none;
position: fixed;
inset: 0;
z-index: 2000;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.45);
padding: 16px;
box-sizing: border-box;
}
.f7support-modal.f7support-modal-visible { display: flex; }
.f7support-modal-panel {
background: #fff;
border-radius: 8px;
padding: 16px;
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}
.f7support-chat-panel {
max-width: 720px;
display: flex;
flex-direction: column;
max-height: 88vh;
}
.f7support-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.f7support-modal-head h3 { margin: 0; font-size: 1.1rem; }
.f7support-icon-btn {
border: none;
background: transparent;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 4px 8px;
color: #444;
}
.f7support-icon-btn:hover { color: #000; background: #eee; border-radius: 4px; }
.f7support-input, .f7support-textarea {
width: 100%;
box-sizing: border-box;
margin-top: 8px;
}
.f7support-textarea { min-height: 72px; resize: vertical; }
.f7support-message-list {
flex: 1;
min-height: 200px;
max-height: 45vh;
overflow: auto;
border: 1px solid #ccc;
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
background: #fff;
}
.f7support-message-list.f7support-dnd-dragover { outline: 2px dashed #1a4fa3; background: #f4f8ff; }
.f7support-composer {
display: flex;
align-items: flex-end;
gap: 8px;
margin-bottom: 6px;
}
.f7support-composer .f7support-textarea { flex: 1; margin-top: 0; min-height: 72px; }
.f7support-clip-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
border: 1px solid #ccc;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0;
}
.f7support-clip-btn:hover { background: #eee; }
.f7support-hidden-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.f7support-pending-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: #f5f5f5;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.9rem;
}
.chat-file-bubble-wrap {
display: flex;
width: 100%;
margin-top: 8px;
}
.chat-file-bubble-wrap--out { justify-content: flex-end; }
.chat-file-bubble-wrap--in { justify-content: flex-start; }
.chat-file-bubble {
max-width: min(100%, 340px);
padding: 10px 12px 8px;
color: #fff;
cursor: pointer;
text-align: left;
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
position: relative;
transition: filter 0.15s ease, transform 0.15s ease;
}
.chat-file-bubble:hover { filter: brightness(1.05); }
.chat-file-bubble:active { transform: scale(0.99); }
.chat-file-bubble--out {
background: linear-gradient(165deg, #c4b5e8 0%, #9b86d8 45%, #8b7cc8 100%);
border-radius: 14px 14px 6px 14px;
}
.chat-file-bubble--out::after {
content: "";
position: absolute;
right: -4px;
bottom: 2px;
width: 14px;
height: 14px;
background: #8b7cc8;
border-radius: 0 0 10px 0;
clip-path: polygon(0 100%, 100% 0, 100% 100%);
}
.chat-file-bubble--in {
background: linear-gradient(165deg, #6b7280 0%, #4b5563 100%);
border-radius: 14px 14px 14px 6px;
}
.chat-file-bubble--in::after {
content: "";
position: absolute;
left: -4px;
bottom: 2px;
width: 14px;
height: 14px;
background: #4b5563;
border-radius: 0 0 0 10px;
clip-path: polygon(0 0, 0 100%, 100% 100%);
}
.chat-file-bubble__row { display: flex; gap: 10px; align-items: flex-start; }
.chat-file-bubble__icon {
flex-shrink: 0;
width: 44px;
height: 52px;
border-radius: 8px;
background: linear-gradient(180deg, #3b9eff 0%, #1e6fd9 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.12);
}
.chat-file-bubble__icon::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 14px 14px 0;
border-color: transparent rgba(255,255,255,0.35) transparent transparent;
}
.chat-file-bubble__icon-text {
font-size: 10px;
font-weight: 700;
text-transform: lowercase;
color: #fff;
letter-spacing: 0.02em;
max-width: 36px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-file-bubble__body { flex: 1; min-width: 0; }
.chat-file-bubble__name {
font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
word-break: break-word;
}
.chat-file-bubble__size {
font-size: 0.8rem;
opacity: 0.92;
margin-top: 4px;
}
.chat-file-bubble__foot {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 0.72rem;
opacity: 0.92;
}
.chat-file-bubble__ticks { font-size: 0.78rem; letter-spacing: -3px; opacity: 0.95; }
.f7support-chat-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 4px; }
.f7support-dnd-target.f7support-dnd-dragover { outline: 2px dashed #1a4fa3; }
.chat-inline-img-wrap {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
margin: 4px 0;
}
.chat-inline-img {
display: block;
max-width: min(100%, min(520px, 92vw));
max-height: min(280px, 42vh);
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
</style>
`;
const messageList = document.getElementById("message-list");
const chatModalTitle = document.getElementById("chat-modal-title");
const errorBox = document.getElementById("error-box");
const createModal = document.getElementById("create-ticket-modal");
const chatModal = document.getElementById("chat-modal");
bindClientChatDnDOnce();
function showError(message) {
errorBox.textContent = message || "";
}
function setModalVisible(el, visible) {
el.classList.toggle("f7support-modal-visible", visible);
el.setAttribute("aria-hidden", visible ? "false" : "true");
}
function updatePendingFileUi() {
const row = document.getElementById("pending-attachment-row");
const label = document.getElementById("pending-attachment-label");
if (!row || !label) return;
if (state.pendingFile) {
label.textContent = `Вложение: ${state.pendingFile.name}`;
row.hidden = false;
} else {
row.hidden = true;
label.textContent = "";
}
}
function setPendingFile(file) {
if (!file) return;
state.pendingFile = file;
updatePendingFileUi();
}
function clearPendingFile() {
state.pendingFile = null;
const inp = document.getElementById("attachment-input");
if (inp) inp.value = "";
updatePendingFileUi();
}
let clientChatDndBound = false;
function bindClientChatDnDOnce() {
if (clientChatDndBound) return;
clientChatDndBound = true;
document.querySelectorAll(".f7support-dnd-target").forEach((el) => {
el.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.add("f7support-dnd-dragover");
});
el.addEventListener("dragleave", (e) => {
e.preventDefault();
el.classList.remove("f7support-dnd-dragover");
});
el.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.remove("f7support-dnd-dragover");
const f = e.dataTransfer?.files?.[0];
if (f) setPendingFile(f);
});
});
}
function openChatModal(ticket) {
state.chatModalOpen = true;
chatModalTitle.textContent = `Чат #${ticket.ticket_number}${ticket.subject}`;
setModalVisible(chatModal, true);
}
function closeChatModal() {
disconnectTicketSocket();
revokeAttachmentUrls();
state.chatModalOpen = false;
state.currentTicket = null;
state.messages = [];
messageList.innerHTML = "";
const msgInput = document.getElementById("message-input");
if (msgInput) msgInput.value = "";
clearPendingFile();
setModalVisible(chatModal, false);
showError("");
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
fetchTickets().catch(() => {});
}
async function fetchTickets() {
const response = await fetch(`${apiBase}/api/client/tickets`, {
headers: {
"X-F7cloud-User": username,
"X-F7cloud-Server": serverAddress,
},
});
if (!response.ok) throw new Error("Не удалось получить токены");
state.tickets = await response.json();
renderTickets();
}
function partitionClientTickets(list) {
const buckets = { newT: [], progress: [], closed: [] };
for (const t of list) {
const s = String(t.status || "");
if (s === "Закрыт") buckets.closed.push(t);
else if (s === "В работе") buckets.progress.push(t);
else if (s === "Новый") buckets.newT.push(t);
else buckets.newT.push(t);
}
const byDate = (a, b) =>
String(b.activity_at || b.created_at || "").localeCompare(String(a.activity_at || a.created_at || ""));
buckets.newT.sort(byDate);
buckets.progress.sort(byDate);
buckets.closed.sort(byDate);
return buckets;
}
function clientTicketStatusClass(status) {
const s = String(status || "");
if (s === "Новый") return "f7-ticket-card__status--new";
if (s === "В работе") return "f7-ticket-card__status--progress";
return "f7-ticket-card__status--closed";
}
function renderTicketCardHtml(ticket) {
const previewRaw = ticket.preview_text != null ? ticket.preview_text : "";
const preview = escapeHtml(previewRaw);
const activity = ticket.activity_at || ticket.created_at;
const time = escapeHtml(formatTicketCardTime(activity));
const hasUnread = Boolean(ticket.has_unread);
const unread = hasUnread
? '<span class="f7-unread-dot" title="Новое сообщение" aria-label="Новое сообщение"></span>'
: "";
const tn = escapeHtml(ticket.ticket_number);
const st = escapeHtml(ticket.status);
const stCls = clientTicketStatusClass(ticket.status);
return `<li class="f7-ticket-card" data-ticket-number="${escapeHtml(ticket.ticket_number)}">
<div class="f7-ticket-card__head">
<span class="f7-ticket-card__title"><b>Тема</b><span class="f7-ticket-card__num">#${tn}</span></span>
<span class="f7-ticket-card__status ${stCls}">${st}</span>
</div>
<div class="f7-ticket-card__body">
<p class="f7-ticket-card__preview">${preview || "—"}</p>
${unread}
</div>
<div class="f7-ticket-card__foot">
<span class="f7-ticket-card__time">${time}</span>
</div>
</li>`;
}
async function tryOpenTicket(ticketNumber) {
const ticket = state.tickets.find((t) => t.ticket_number === ticketNumber);
if (!ticket) return;
if (state.chatModalOpen) {
if (state.currentTicket === ticketNumber) return;
showError("Чтобы открыть другое обращение, сначала закройте окно чата (крестик справа сверху).");
return;
}
state.currentTicket = ticketNumber;
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`);
if (card) card.classList.add("f7-ticket-card--active");
openChatModal(ticket);
try {
await fetchMessages();
await markClientTicketRead(ticket.ticket_number);
connectTicketSocket(ticket.ticket_number);
} catch (e) {
showError(e.message || "Не удалось загрузить сообщения");
disconnectTicketSocket();
closeChatModal();
}
}
function renderTickets() {
const { newT, progress, closed } = partitionClientTickets(state.tickets);
const cols = [
document.getElementById("ticket-col-new"),
document.getElementById("ticket-col-progress"),
document.getElementById("ticket-col-closed"),
];
const data = [newT, progress, closed];
cols.forEach((ul, i) => {
if (!ul) return;
ul.innerHTML = data[i].map(renderTicketCardHtml).join("");
ul.querySelectorAll(".f7-ticket-card").forEach((li) => {
li.onclick = () => tryOpenTicket(li.getAttribute("data-ticket-number"));
});
});
}
async function fetchMessages() {
if (!state.currentTicket) return;
const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, {
headers: clientIdentityHeaders(),
});
if (!response.ok) throw new Error("Не удалось загрузить сообщения");
state.messages = await response.json();
renderMessages();
}
async function fetchClientAttachmentBlob(attachmentId) {
const url = `${apiBase}/api/client/tickets/${state.currentTicket}/attachments/${attachmentId}`;
const res = await fetch(url, { headers: clientIdentityHeaders() });
if (!res.ok) throw new Error("attachment");
return res.blob();
}
function hydrateClientAttachments(scope) {
const root = scope || messageList;
if (!root || !state.currentTicket) return;
root.querySelectorAll("[data-f7-att-preview]").forEach((wrap) => {
const id = wrap.getAttribute("data-f7-att-preview");
(async () => {
try {
const blob = await fetchClientAttachmentBlob(id);
const objUrl = URL.createObjectURL(blob);
state.blobUrls.push(objUrl);
const frame = document.createElement("div");
frame.className = "chat-inline-img-wrap";
const img = document.createElement("img");
img.className = "chat-inline-img";
img.src = objUrl;
img.alt = "";
img.loading = "lazy";
frame.appendChild(img);
wrap.replaceChildren(frame);
} catch (_) {
wrap.textContent = "Не удалось показать файл";
}
})();
});
root.querySelectorAll("[data-f7-att-file]").forEach((row) => {
const handler = async () => {
const id = row.getAttribute("data-f7-att-file");
const fn = row.getAttribute("data-f7-filename") || "file";
const mime = row.getAttribute("data-f7-mime") || "";
try {
const blob = await fetchClientAttachmentBlob(id);
openOrDownloadBlob(blob, fn, mime);
} catch (_) {
showError("Не удалось открыть файл");
}
};
row.onclick = handler;
row.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handler();
}
};
});
}
function renderMessages() {
revokeAttachmentUrls();
messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join("");
hydrateClientAttachments(messageList);
}
async function submitTicketWithRetry(payload) {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const duplicate = attempt > 1 ? 1 : 0;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 180000);
try {
const response = await fetch(`${apiBase}/api/client/tickets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, duplicate }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) throw new Error("Send failed");
return await response.json();
} catch (e) {
clearTimeout(timeout);
if (attempt === maxAttempts) {
throw new Error("Сервер поддержки недоступен, пожалуйста обратитесь к администратору.");
}
}
}
throw new Error("Unknown error");
}
document.getElementById("create-ticket-btn").onclick = () => {
setModalVisible(createModal, true);
showError("");
};
document.getElementById("close-modal-btn").onclick = () => {
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
setModalVisible(createModal, false);
};
document.getElementById("chat-modal-close-btn").onclick = () => {
closeChatModal();
};
document.getElementById("attachment-clip-btn").onclick = () => {
document.getElementById("attachment-input")?.click();
};
document.getElementById("attachment-input").onchange = (e) => {
const f = e.target.files?.[0];
if (f) setPendingFile(f);
};
document.getElementById("pending-attachment-clear").onclick = () => {
clearPendingFile();
};
const messageInputEl = document.getElementById("message-input");
messageInputEl.addEventListener("paste", (e) => {
const files = e.clipboardData?.files;
if (files && files[0]) {
e.preventDefault();
setPendingFile(files[0]);
}
});
document.getElementById("submit-ticket-btn").onclick = async () => {
try {
const subject = document.getElementById("subject-input").value.trim();
const body = document.getElementById("body-input").value.trim();
if (!subject || !body) {
showError("Тема и обращение обязательны");
return;
}
const result = await submitTicketWithRetry({
server_address: serverAddress,
username,
subject,
body,
});
setModalVisible(createModal, false);
showError(`Токен создан: ${result.ticket_number}`);
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
await fetchTickets();
} catch (e) {
showError(e.message || "Ошибка отправки");
}
};
document.getElementById("send-message-btn").onclick = async () => {
if (!state.currentTicket) {
showError("Сначала выберите токен");
return;
}
let text = document.getElementById("message-input").value.trim();
const file = state.pendingFile;
if (!text && !file) {
showError("Введите сообщение или прикрепите файл");
return;
}
if (!text && file) text = `📎 ${file.name}`;
const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...clientIdentityHeaders(),
},
body: JSON.stringify({ text }),
});
if (!response.ok) {
showError("Не удалось отправить сообщение");
return;
}
const created = await response.json();
if (file) {
const form = new FormData();
form.append("message_id", String(created.id));
form.append("file", file);
const attRes = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/attachments`, {
method: "POST",
headers: clientIdentityHeaders(),
body: form,
});
if (!attRes.ok) {
const errText = await attRes.text().catch(() => "");
showError(errText || "Не удалось отправить файл");
return;
}
clearPendingFile();
}
document.getElementById("message-input").value = "";
await fetchMessages();
};
queueMicrotask(() => {
fetchTickets().catch((e) => showError(e.message));
});
})();