1595 lines
64 KiB
JavaScript
1595 lines
64 KiB
JavaScript
(function () {
|
||
const root = document.getElementById("f7support-app");
|
||
if (!root) return;
|
||
|
||
const username = root.dataset.username;
|
||
const userEmail = (root.dataset.userEmail || "").trim();
|
||
const serverAddress = root.dataset.serverAddress;
|
||
const apiBase = root.dataset.supportApiBase;
|
||
const supportWsBaseOverride = (root.dataset.supportWsBase || "").trim();
|
||
/** Включается в F7cloud: `occ config:app:set f7support client_read_receipts --value=1` после выката API `POST .../read`. */
|
||
const clientReadReceiptsEnabled = root.dataset.clientReadReceipts === "1";
|
||
const ticketsPollMsRaw = parseInt(
|
||
String(root.dataset.ticketsPollMs || root.dataset.messagesPollMs || "3000").trim(),
|
||
10
|
||
);
|
||
const ticketsPollMsParsed = Number.isFinite(ticketsPollMsRaw) && ticketsPollMsRaw >= 0 ? ticketsPollMsRaw : 3000;
|
||
/** Интервал опроса GET /api/client/tickets (мс). По умолчанию 3000 (3 с), если атрибут не задан. 0 — выключить. Иначе 3000…120000. */
|
||
const ticketsPollIntervalMs =
|
||
ticketsPollMsParsed === 0 ? 0 : Math.min(120000, Math.max(3000, ticketsPollMsParsed));
|
||
|
||
const RASTER_IMAGE_EXT = new Set([
|
||
"jpg",
|
||
"jpeg",
|
||
"png",
|
||
"gif",
|
||
"webp",
|
||
"bmp",
|
||
"tif",
|
||
"tiff",
|
||
"heic",
|
||
"heif",
|
||
"svg",
|
||
]);
|
||
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",
|
||
"image/svg+xml",
|
||
]);
|
||
|
||
function isRasterImageAtt(a) {
|
||
const m = (a.mime_type || "").toLowerCase().split(";")[0].trim();
|
||
if (RASTER_IMAGE_MIME.has(m)) return true;
|
||
return RASTER_IMAGE_EXT.has(fileExt(a.filename));
|
||
}
|
||
|
||
function fileExt(filename) {
|
||
const parts = String(filename || "").split(".");
|
||
return parts.length > 1 ? parts.pop().toLowerCase() : "";
|
||
}
|
||
|
||
function f7sAppImgUrl(name) {
|
||
const path = `/apps/f7support/img/${name}`;
|
||
if (typeof OC !== "undefined" && OC.generateUrl) {
|
||
return OC.generateUrl(path);
|
||
}
|
||
return path;
|
||
}
|
||
|
||
const FILE_ICON = {
|
||
pdf: "file-pdf.svg",
|
||
doc: "file-doc-docx.svg",
|
||
txt: "file-txt.svg",
|
||
sheet: "file-sheet.svg",
|
||
presentation: "file-presentation.svg",
|
||
code: "file-code.svg",
|
||
fonts: "file-fonts.svg",
|
||
application: "file-application.svg",
|
||
default: "file-default.svg",
|
||
};
|
||
|
||
function attachmentIconFile(att) {
|
||
const mime = (att.mime_type || "").toLowerCase().split(";")[0].trim();
|
||
const ext = fileExt(att.filename);
|
||
|
||
if (mime === "application/pdf" || ext === "pdf") return FILE_ICON.pdf;
|
||
|
||
if (
|
||
["doc", "docx", "odt"].includes(ext) ||
|
||
mime.includes("msword") ||
|
||
mime.includes("wordprocessingml")
|
||
) {
|
||
return FILE_ICON.doc;
|
||
}
|
||
|
||
if (
|
||
["txt", "log", "md", "markdown", "rtf"].includes(ext) ||
|
||
mime === "text/plain" ||
|
||
mime === "text/markdown" ||
|
||
mime === "application/rtf"
|
||
) {
|
||
return FILE_ICON.txt;
|
||
}
|
||
|
||
if (
|
||
["xls", "xlsx", "csv", "ods"].includes(ext) ||
|
||
mime.includes("spreadsheet") ||
|
||
mime.includes("excel")
|
||
) {
|
||
return FILE_ICON.sheet;
|
||
}
|
||
|
||
if (
|
||
["ppt", "pptx", "odp", "key"].includes(ext) ||
|
||
mime.includes("presentation") ||
|
||
mime.includes("powerpoint")
|
||
) {
|
||
return FILE_ICON.presentation;
|
||
}
|
||
|
||
if (
|
||
[
|
||
"js",
|
||
"ts",
|
||
"jsx",
|
||
"tsx",
|
||
"mjs",
|
||
"cjs",
|
||
"py",
|
||
"html",
|
||
"htm",
|
||
"css",
|
||
"scss",
|
||
"less",
|
||
"json",
|
||
"xml",
|
||
"yaml",
|
||
"yml",
|
||
"php",
|
||
"java",
|
||
"c",
|
||
"cpp",
|
||
"h",
|
||
"hpp",
|
||
"go",
|
||
"rs",
|
||
"rb",
|
||
"sh",
|
||
"bash",
|
||
"sql",
|
||
"vue",
|
||
"swift",
|
||
"kt",
|
||
"cs",
|
||
].includes(ext) ||
|
||
mime.startsWith("text/x-") ||
|
||
mime.includes("javascript") ||
|
||
mime.includes("json") ||
|
||
mime.includes("xml")
|
||
) {
|
||
return FILE_ICON.code;
|
||
}
|
||
|
||
if (
|
||
["ttf", "otf", "woff", "woff2", "eot"].includes(ext) ||
|
||
mime.includes("font")
|
||
) {
|
||
return FILE_ICON.fonts;
|
||
}
|
||
|
||
if (
|
||
["exe", "msi", "dmg", "apk", "deb", "rpm", "appimage", "bin", "pkg", "app"].includes(ext) ||
|
||
mime.includes("executable") ||
|
||
mime === "application/vnd.android.package-archive" ||
|
||
mime === "application/x-msdownload"
|
||
) {
|
||
return FILE_ICON.application;
|
||
}
|
||
|
||
return FILE_ICON.default;
|
||
}
|
||
|
||
const MSG_ATTACH_NAME_MAX_LEN = 24;
|
||
|
||
function formatMsgAttachDisplayName(filename) {
|
||
const name = filename || "";
|
||
if (name.length <= MSG_ATTACH_NAME_MAX_LEN) return name;
|
||
return name.slice(0, MSG_ATTACH_NAME_MAX_LEN) + "...";
|
||
}
|
||
|
||
function messageAttachmentsFrom(m) {
|
||
return Array.isArray(m.attachments) ? m.attachments : [];
|
||
}
|
||
|
||
function messageIdKey(id) {
|
||
if (id == null || id === "") return null;
|
||
return String(id);
|
||
}
|
||
|
||
/** Один id — одно сообщение (WS иногда шлёт number/string по-разному). */
|
||
function dedupeMessagesById(messages) {
|
||
if (!Array.isArray(messages)) return [];
|
||
const byId = new Map();
|
||
const noId = [];
|
||
for (const m of messages) {
|
||
const key = messageIdKey(m.id);
|
||
if (key == null) {
|
||
noId.push(m);
|
||
continue;
|
||
}
|
||
const prev = byId.get(key);
|
||
if (!prev) {
|
||
byId.set(key, m);
|
||
continue;
|
||
}
|
||
const prevN = messageAttachmentsFrom(prev).length;
|
||
const nextN = messageAttachmentsFrom(m).length;
|
||
if (nextN >= prevN) byId.set(key, m);
|
||
}
|
||
const merged = [...byId.values(), ...noId];
|
||
merged.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
||
return merged;
|
||
}
|
||
|
||
function findMessageIndexById(messages, id) {
|
||
const key = messageIdKey(id);
|
||
if (key == null) return -1;
|
||
return messages.findIndex((m) => messageIdKey(m.id) === key);
|
||
}
|
||
|
||
function isMessageBodyPlaceholder(text) {
|
||
const t = String(text ?? "").trim();
|
||
if (!t || t === MESSAGE_BODY_PLACEHOLDER || t === TICKET_CREATE_BODY_PLACEHOLDER) return true;
|
||
return /^📎\s/.test(t);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/** Сервер без маршрута `POST .../read` (404) — не дёргать повторно, меньше шума в консоли. */
|
||
let skipClientReadReceiptPost = false;
|
||
|
||
const state = {
|
||
tickets: [],
|
||
currentTicket: null,
|
||
messages: [],
|
||
chatModalOpen: false,
|
||
blobUrls: [],
|
||
ticketSocket: null,
|
||
wsReconnectTimer: null,
|
||
ticketsPollTimer: null,
|
||
/** @type {File[]} */
|
||
createTicketAttachmentFiles: [],
|
||
/** @type {File[]} */
|
||
chatAttachmentFiles: [],
|
||
/** @type {Record<string, string>} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */
|
||
ticketSeenFingerprint: {},
|
||
/** @type {Record<string, string>} сигнатура последнего сообщения (id+время) по тикету — список тикетов часто не меняет preview/activity */
|
||
ticketLastMsgSig: Object.create(null),
|
||
/** @type {Record<string, boolean>} новое сообщение по сравнению с последним опросом /messages */
|
||
ticketUnreadByLastMsg: Object.create(null),
|
||
/** Смещение пачки опросов /messages (не грузим все тикеты за один раз) */
|
||
msgSigPollIndex: 0,
|
||
/** Идёт отправка сообщения + upload вложений — не мержить WS (иначе дубли в списке). */
|
||
chatOutboundBusy: false,
|
||
};
|
||
|
||
function clientIdentityHeaders() {
|
||
const headers = {
|
||
"X-F7cloud-User": username,
|
||
"X-F7cloud-Server": serverAddress,
|
||
};
|
||
if (userEmail) {
|
||
headers["X-F7cloud-Email"] = userEmail;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
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, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
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 wsUrlForTicket(ticketNumber) {
|
||
const tail = `/tickets/${encodeURIComponent(ticketNumber)}`;
|
||
if (supportWsBaseOverride) {
|
||
try {
|
||
const raw = supportWsBaseOverride.replace(/\/$/, "");
|
||
const u = new URL(raw.includes("://") ? raw : `wss://${raw}`);
|
||
const wsProto = u.protocol === "https:" ? "wss:" : u.protocol === "http:" ? "ws:" : u.protocol;
|
||
let path = (u.pathname || "").replace(/\/$/, "");
|
||
if (path === "" || path === "/") {
|
||
path = "/ws";
|
||
}
|
||
return `${wsProto}//${u.host}${path}${tail}`;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
try {
|
||
const u = new URL(apiBase);
|
||
const wsProto = u.protocol === "https:" ? "wss:" : "ws:";
|
||
return `${wsProto}//${u.host}/ws${tail}`;
|
||
} 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 applyIncomingMessage(msg) {
|
||
if (state.chatOutboundBusy) return;
|
||
const idx = findMessageIndexById(state.messages, msg.id);
|
||
if (idx === -1) state.messages.push(msg);
|
||
else state.messages[idx] = msg;
|
||
state.messages = dedupeMessagesById(state.messages);
|
||
void renderMessages();
|
||
if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) {
|
||
markClientTicketRead(state.currentTicket).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function markClientTicketRead(ticketNumber) {
|
||
if (!clientReadReceiptsEnabled || skipClientReadReceiptPost || !ticketNumber) return;
|
||
let res;
|
||
try {
|
||
res = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/read`, {
|
||
method: "POST",
|
||
headers: clientIdentityHeaders(),
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
if (res.status === 404) {
|
||
skipClientReadReceiptPost = true;
|
||
return;
|
||
}
|
||
if (!res.ok) return;
|
||
const row = state.tickets.find((x) => x.ticket_number === ticketNumber);
|
||
if (row) row.has_unread = false;
|
||
delete state.ticketUnreadByLastMsg[String(ticketNumber)];
|
||
renderTickets();
|
||
reapplyActiveTicketCard(ticketNumber);
|
||
}
|
||
|
||
function reapplyActiveTicketCard(ticketNumber) {
|
||
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");
|
||
}
|
||
|
||
const F7S_CREATE_BTN_PLUS = `<svg class="f7s-create-ticket-btn__icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><g clip-path="url(#clip0_f7s_create)"><path d="M1.5 8L14.5 8" stroke="white" stroke-width="1.5" stroke-linecap="round"/><path d="M8 1.5L8 14.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/></g><defs><clipPath id="clip0_f7s_create"><rect width="16" height="16" fill="white"/></clipPath></defs></svg>`;
|
||
const F7S_FILE_CLIP_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M5.45616 12.1089L11.0009 6.80099C11.1585 6.65293 11.2841 6.47416 11.3699 6.2757C11.4558 6.07724 11.5001 5.8633 11.5001 5.64707C11.5001 5.43083 11.4558 5.21689 11.3699 5.01843C11.2841 4.81997 11.1585 4.6412 11.0009 4.49314C10.6747 4.18623 10.2436 4.01535 9.79568 4.01535C9.34776 4.01535 8.91671 4.18623 8.59045 4.49314L3.08506 9.76241C2.78552 10.0437 2.5468 10.3834 2.38361 10.7605C2.22042 11.1376 2.13623 11.5441 2.13623 11.955C2.13623 12.3659 2.22042 12.7725 2.38361 13.1496C2.5468 13.5267 2.78552 13.8663 3.08506 14.1476C4.35002 15.3585 6.40066 15.3585 7.66562 14.1476L13.2518 8.80104C15.1155 7.01604 15.1155 4.12349 13.2518 2.33849C11.3881 0.553486 8.36487 0.554189 6.50045 2.33849L2 6.64639" stroke="#151515" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
||
const F7S_CHIP_REMOVE_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M2.62598 2.625L13.374 13.373" stroke="#151515" stroke-width="1.5" stroke-linecap="round"/><path d="M2.62598 13.373L13.374 2.625" stroke="#151515" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
||
const F7S_SUBMIT_BTN_ICON = `<svg class="f7s-submit-ticket-btn__icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M4 8L2 14L14 8L2 2L4 8ZM4 8H8" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||
|
||
const CHAT_MSG_READ_SVG = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M7.14062 9.44319L10.8811 4.17969" stroke="#808080" stroke-width="1.2" stroke-linecap="round"/><path d="M8.11499 4.17969L4.44303 9.34673C4.40718 9.39718 4.33457 9.40328 4.29081 9.35951L1.11914 6.18774" stroke="#808080" stroke-width="1.2" stroke-linecap="round"/></svg>`;
|
||
|
||
const CHAT_MSG_UNREAD_SVG = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M9.4978 4.17969L5.82584 9.34673C5.78999 9.39718 5.71738 9.40328 5.67362 9.35951L2.50195 6.18774" stroke="#808080" stroke-width="1.2" stroke-linecap="round"/></svg>`;
|
||
|
||
function formatMsgTime(iso) {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function messageIsOutgoing(m) {
|
||
const r = String(m.author_role || "").toLowerCase();
|
||
if (r === "client" || r === "user") return true;
|
||
const a = String(m.author || "").toLowerCase();
|
||
const u = String(username || "").toLowerCase();
|
||
return Boolean(u && a === u);
|
||
}
|
||
|
||
function supportSenderLabel(m) {
|
||
const author = String(m.author || "").trim();
|
||
if (author) return author;
|
||
return "Поддержка";
|
||
}
|
||
|
||
function incomingSenderKey(m) {
|
||
const r = String(m.author_role || "").toLowerCase();
|
||
const a = String(m.author || "").trim().toLowerCase();
|
||
return `${r}\u0001${a}`;
|
||
}
|
||
|
||
function messageIsRenderable(m) {
|
||
const text = String(m.text ?? "").trim();
|
||
const attachments = messageAttachmentsFrom(m);
|
||
const showText = text && !isMessageBodyPlaceholder(text);
|
||
return showText || attachments.length > 0;
|
||
}
|
||
|
||
function messagesShareGroup(prev, curr) {
|
||
if (!prev || !curr) return false;
|
||
const outPrev = messageIsOutgoing(prev);
|
||
const outCurr = messageIsOutgoing(curr);
|
||
if (outPrev !== outCurr) return false;
|
||
if (outCurr) return true;
|
||
return incomingSenderKey(prev) === incomingSenderKey(curr);
|
||
}
|
||
|
||
/** Позиция в цепочке подряд идущих сообщений одного автора (скругление углов как в макете). */
|
||
function buildRenderableMessageRows(messages) {
|
||
const visible = dedupeMessagesById(messages).filter(messageIsRenderable);
|
||
return visible.map((m, i) => {
|
||
const prev = i > 0 ? visible[i - 1] : null;
|
||
const next = i < visible.length - 1 ? visible[i + 1] : null;
|
||
const sharesPrev = messagesShareGroup(prev, m);
|
||
const sharesNext = messagesShareGroup(m, next);
|
||
let groupPos = "single";
|
||
if (sharesPrev && sharesNext) groupPos = "middle";
|
||
else if (sharesPrev && !sharesNext) groupPos = "last";
|
||
else if (!sharesPrev && sharesNext) groupPos = "first";
|
||
const outgoing = messageIsOutgoing(m);
|
||
const showSender = !outgoing && !sharesPrev;
|
||
return { m, groupPos, showSender, outgoing };
|
||
});
|
||
}
|
||
|
||
function clientMessageIsRead(m) {
|
||
if (m == null || typeof m !== "object") return false;
|
||
if (m.is_read === true || m.read === true) return true;
|
||
if (m.is_read === 1 || m.read === 1) return true;
|
||
const ra = m.read_at ?? m.read_at_utc ?? m.seen_at ?? m.read_at_iso;
|
||
if (ra != null && String(ra).trim() !== "") return true;
|
||
const s = String(m.status ?? m.delivery_status ?? "").toLowerCase();
|
||
if (s === "read" || s === "seen" || s === "delivered_read") return true;
|
||
return false;
|
||
}
|
||
|
||
const F7S_TICKETS_ICON = `<svg class="f7s-section-title-icon" width="77" height="77" viewBox="0 0 77 77" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><g clip-path="url(#f7sTicketsIconClip)"><g filter="url(#f7sTicketsIconFilter)"><circle cx="38.5" cy="38.5" r="38.5" fill="#FDFDFD"/><circle cx="38.5" cy="38.5" r="37.4" stroke="url(#f7sTicketsIconGrad)" stroke-opacity="0.2" stroke-width="2.2"/></g><path d="M33.7256 50.4785L33.2539 50.1992C31.8543 49.3678 30.6591 48.4534 29.5439 47.7002V47.6992L29.2246 47.4717C27.6644 46.297 26.6148 44.5603 26.29 42.6182L25.6826 38.9834L25.5479 38.1797H24.7334C23.3617 38.1797 22.254 37.0719 22.2539 35.7002C22.2539 34.6806 22.8649 33.8068 23.7422 33.4268L24.6201 33.0469L24.2451 32.166C23.2105 29.7403 22.4507 27.168 21.9941 24.5293V24.5283C21.7008 22.837 21.5701 21.2154 22.0049 19.7871V19.7861C22.4438 18.3424 23.5134 17.3675 24.4863 17.332L24.6367 17.3428L25.2061 17.3818L25.5137 16.9014C26.3106 15.6569 27.5502 14.7775 28.9297 14.2275L29.207 14.1221C30.713 13.5754 32.3204 13.3829 34.042 13.1846L34.043 13.1855C34.7109 13.109 35.3709 13.031 36.0322 12.9648L36.0332 12.9658C39.0918 12.6715 42.3218 12.612 45.3154 13.4453C48.2972 14.276 51.0954 16.1166 52.5537 18.9678L52.5547 18.9688C53.6473 21.1027 53.9456 23.7045 53.7236 26.2793L53.6719 26.7939C53.4676 28.6089 53.0309 30.3927 52.5049 32.1895L52.25 33.0605L53.1035 33.3662C54.0599 33.7088 54.7422 34.6231 54.7422 35.7002C54.7421 37.0722 53.6358 38.1797 52.2627 38.1797H51.4482L51.3135 38.9834L50.7061 42.6182C50.3596 44.6901 49.189 46.528 47.4531 47.6992C46.3342 48.4538 45.1429 49.3672 43.7422 50.1992L43.2715 50.4785V53.6113L43.9443 53.8232L55.3389 57.4219C59.5186 58.7414 62.7311 61.9091 64.1953 65.8535C63.7645 66.2584 63.3249 66.6539 62.875 67.0381L62.874 67.0391C61.8304 67.9331 60.7364 68.7698 59.5977 69.5449L59.1074 69.873C53.1922 73.7666 46.1107 76.0312 38.498 76.0312C31.1234 76.0312 24.2475 73.9059 18.4473 70.2334L17.8887 69.873C16.573 69.0074 15.3147 68.0607 14.1221 67.0391L14.1211 67.0381C13.671 66.6537 13.2308 66.2585 12.7998 65.8535C14.2632 61.9085 17.4772 58.7408 21.6562 57.4219H21.6572L33.0527 53.8232L33.7256 53.6113V50.4785Z" fill="#F5F5F5" stroke="black" stroke-width="1.925"/><path d="M39.8973 53.8984H36.8425C36.5773 53.8984 36.3915 54.1602 36.479 54.4105L37.2448 56.6005C37.2988 56.7549 37.4445 56.8584 37.6082 56.8584H39.1316C39.2952 56.8584 39.441 56.7549 39.495 56.6005L40.2607 54.4105C40.3482 54.1602 40.1625 53.8984 39.8973 53.8984Z" fill="#70B62B"/><path d="M34.6853 70.7889L37.282 56.9468C37.3161 56.7647 37.4751 56.6328 37.6604 56.6328H39.0867C39.274 56.6328 39.4341 56.7676 39.4661 56.9521L41.8637 70.7983C41.8833 70.9116 41.8513 71.0276 41.7764 71.1148L38.6597 74.7449C38.5089 74.9205 38.2384 74.9244 38.0827 74.7531L34.7789 71.1189C34.6977 71.0296 34.6631 70.9075 34.6853 70.7889Z" fill="#70B62B"/><path d="M50.7923 37.7304C49.8964 37.7503 49.8893 37.7502 49.8878 37.9124C49.8851 38.2016 49.5223 39.2986 49.2346 39.9025C48.8246 40.7592 48.2961 41.4667 47.5206 42.186C46.5317 43.1079 45.5463 43.6419 44.2242 43.9823C43.6797 44.1254 43.4892 44.1307 39.4125 44.1141L36.2174 44.1057C35.632 44.1042 35.1551 44.5754 35.1496 45.1608C35.1442 45.744 35.6146 46.2201 36.1978 46.2217L39.4987 46.2309C44.2455 46.2467 44.1679 46.253 45.5121 45.814C47.7689 45.0802 49.845 43.2938 50.9869 41.0966C51.3889 40.3386 51.7875 39.1855 51.9214 38.432C51.9734 38.1574 52.0253 37.8828 52.04 37.8124C52.055 37.7209 52.02 37.6924 51.8788 37.7052C51.7729 37.7112 51.2861 37.7279 50.7923 37.7304Z" fill="black"/><rect x="49.3921" y="27.5444" width="7.04" height="11.44" rx="1.32" transform="rotate(0.530608 49.3921 27.5444)" fill="#F5F5F5" stroke="black" stroke-width="1.76"/><rect x="20.7921" y="27.2865" width="7.04" height="11.44" rx="1.32" transform="rotate(0.530608 20.7921 27.2865)" fill="#F5F5F5" stroke="black" stroke-width="1.76"/><path d="M33.6445 44.9813C33.6445 43.7662 34.6295 42.7812 35.8445 42.7812H38.0445C39.2596 42.7812 40.2445 43.7662 40.2445 44.9813C40.2445 46.1963 39.2596 47.1813 38.0445 47.1813H35.8445C34.6295 47.1813 33.6445 46.1963 33.6445 44.9813Z" fill="black"/></g><defs><filter id="f7sTicketsIconFilter" x="-4.4" y="-2.2" width="85.8" height="85.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dy="2.2"/><feGaussianBlur stdDeviation="2.2"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix type="matrix" values="0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 1 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3753_46963"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset dy="-0.55"/><feGaussianBlur stdDeviation="3.3"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/><feBlend mode="normal" in2="shape" result="effect2_innerShadow_3753_46963"/></filter><linearGradient id="f7sTicketsIconGrad" x1="2.40625" y1="3.5" x2="77" y2="3.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C0FF7B"/><stop offset="0.65625" stop-color="#70B62B"/></linearGradient><clipPath id="f7sTicketsIconClip"><rect width="77" height="77" fill="white"/></clipPath></defs></svg>`;
|
||
|
||
|
||
async function formatApiError(response, fallback) {
|
||
try {
|
||
const data = await response.clone().json();
|
||
const d = data?.detail;
|
||
if (Array.isArray(d)) {
|
||
return d.map((e) => e.msg || JSON.stringify(e)).join("; ") || fallback;
|
||
}
|
||
if (typeof d === "string") return d;
|
||
} catch (_) {}
|
||
return fallback;
|
||
}
|
||
|
||
/** OpenAPI TicketCreate / MessageCreate требуют text minLength:1. */
|
||
const TICKET_CREATE_BODY_PLACEHOLDER = ".";
|
||
const MESSAGE_BODY_PLACEHOLDER = ".";
|
||
|
||
function fileDedupeKey(f) {
|
||
return `${f.name}|${f.size}|${f.lastModified}`;
|
||
}
|
||
|
||
const FILE_CHIP_NAME_MAX_LEN = 8;
|
||
|
||
function formatFileChipDisplayName(filename) {
|
||
const name = filename || "";
|
||
if (name.length <= FILE_CHIP_NAME_MAX_LEN) return name;
|
||
return name.slice(0, FILE_CHIP_NAME_MAX_LEN) + "...";
|
||
}
|
||
|
||
function applyCreateTicketFilesToInput() {
|
||
const input = document.getElementById("create-ticket-attachment-input");
|
||
if (!input) return;
|
||
const dt = new DataTransfer();
|
||
for (const f of state.createTicketAttachmentFiles) {
|
||
dt.items.add(f);
|
||
}
|
||
input.files = dt.files;
|
||
}
|
||
|
||
function clearCreateTicketAttachments() {
|
||
state.createTicketAttachmentFiles = [];
|
||
const input = document.getElementById("create-ticket-attachment-input");
|
||
if (input) input.value = "";
|
||
syncCreateTicketAttachmentsUI();
|
||
}
|
||
|
||
function removeCreateTicketFileAt(index) {
|
||
if (index < 0 || index >= state.createTicketAttachmentFiles.length) return;
|
||
state.createTicketAttachmentFiles.splice(index, 1);
|
||
applyCreateTicketFilesToInput();
|
||
syncCreateTicketAttachmentsUI();
|
||
}
|
||
|
||
function renderCreateTicketAttachmentChips() {
|
||
const host = document.getElementById("create-ticket-attachment-chips");
|
||
if (!host) return;
|
||
host.innerHTML = "";
|
||
state.createTicketAttachmentFiles.forEach((file, index) => {
|
||
const chip = document.createElement("div");
|
||
chip.className = "f7s-file-chip";
|
||
const name = document.createElement("span");
|
||
name.className = "f7s-file-chip__name";
|
||
name.textContent = formatFileChipDisplayName(file.name);
|
||
if (file.name.length > FILE_CHIP_NAME_MAX_LEN) name.title = file.name;
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "f7s-file-chip-remove";
|
||
btn.setAttribute("aria-label", "Удалить файл");
|
||
btn.innerHTML = F7S_CHIP_REMOVE_SVG;
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
removeCreateTicketFileAt(index);
|
||
});
|
||
chip.append(name, btn);
|
||
host.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function syncCreateTicketAttachmentsUI() {
|
||
const row = document.getElementById("create-attach-row");
|
||
const n = state.createTicketAttachmentFiles.length;
|
||
if (row) row.classList.toggle("f7s-has-attachments", n > 0);
|
||
renderCreateTicketAttachmentChips();
|
||
}
|
||
|
||
function onCreateTicketAttachmentChange() {
|
||
const input = document.getElementById("create-ticket-attachment-input");
|
||
if (!input) return;
|
||
addFilesToZone("create", input.files);
|
||
applyCreateTicketFilesToInput();
|
||
}
|
||
|
||
function applyChatFilesToInput() {
|
||
const input = document.getElementById("attachment-input");
|
||
if (!input) return;
|
||
const dt = new DataTransfer();
|
||
for (const f of state.chatAttachmentFiles) {
|
||
dt.items.add(f);
|
||
}
|
||
input.files = dt.files;
|
||
}
|
||
|
||
function clearChatAttachments() {
|
||
state.chatAttachmentFiles = [];
|
||
const input = document.getElementById("attachment-input");
|
||
if (input) input.value = "";
|
||
syncChatAttachmentsUI();
|
||
}
|
||
|
||
function removeChatAttachmentAt(index) {
|
||
if (index < 0 || index >= state.chatAttachmentFiles.length) return;
|
||
state.chatAttachmentFiles.splice(index, 1);
|
||
applyChatFilesToInput();
|
||
syncChatAttachmentsUI();
|
||
}
|
||
|
||
function renderChatAttachmentChips() {
|
||
const host = document.getElementById("chat-attachment-chips");
|
||
if (!host) return;
|
||
host.innerHTML = "";
|
||
state.chatAttachmentFiles.forEach((file, index) => {
|
||
const chip = document.createElement("div");
|
||
chip.className = "f7s-file-chip";
|
||
const name = document.createElement("span");
|
||
name.className = "f7s-file-chip__name";
|
||
name.textContent = formatFileChipDisplayName(file.name);
|
||
if (file.name.length > FILE_CHIP_NAME_MAX_LEN) name.title = file.name;
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "f7s-file-chip-remove";
|
||
btn.setAttribute("aria-label", "Удалить файл");
|
||
btn.innerHTML = F7S_CHIP_REMOVE_SVG;
|
||
btn.addEventListener("click", (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
removeChatAttachmentAt(index);
|
||
});
|
||
chip.append(name, btn);
|
||
host.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function syncChatAttachmentsUI() {
|
||
const row = document.getElementById("chat-attach-row");
|
||
const n = state.chatAttachmentFiles.length;
|
||
if (row) row.classList.toggle("f7s-has-attachments", n > 0);
|
||
renderChatAttachmentChips();
|
||
}
|
||
|
||
function onChatAttachmentChange() {
|
||
const input = document.getElementById("attachment-input");
|
||
if (!input) return;
|
||
addFilesToZone("chat", input.files);
|
||
applyChatFilesToInput();
|
||
}
|
||
|
||
function addFilesToZone(zone, fileList) {
|
||
const files = Array.from(fileList || []);
|
||
if (!files.length) return;
|
||
if (zone === "create") {
|
||
const seen = new Set(state.createTicketAttachmentFiles.map(fileDedupeKey));
|
||
for (const f of files) {
|
||
const k = fileDedupeKey(f);
|
||
if (seen.has(k)) continue;
|
||
seen.add(k);
|
||
state.createTicketAttachmentFiles.push(f);
|
||
}
|
||
applyCreateTicketFilesToInput();
|
||
syncCreateTicketAttachmentsUI();
|
||
} else if (zone === "chat") {
|
||
const seen = new Set(state.chatAttachmentFiles.map(fileDedupeKey));
|
||
for (const f of files) {
|
||
const k = fileDedupeKey(f);
|
||
if (seen.has(k)) continue;
|
||
seen.add(k);
|
||
state.chatAttachmentFiles.push(f);
|
||
}
|
||
applyChatFilesToInput();
|
||
syncChatAttachmentsUI();
|
||
}
|
||
}
|
||
|
||
function dndZoneFromElement(el) {
|
||
const z = el.closest("[data-f7-dnd-zone]");
|
||
return z ? z.getAttribute("data-f7-dnd-zone") : null;
|
||
}
|
||
|
||
async function postMessageToTicket(ticketNumber, text) {
|
||
const response = await fetch(
|
||
`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/messages`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...clientIdentityHeaders(),
|
||
},
|
||
body: JSON.stringify({ text }),
|
||
}
|
||
);
|
||
if (!response.ok) {
|
||
const msg = await formatApiError(response, "Не удалось отправить сообщение");
|
||
throw new Error(msg);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
async function uploadAttachmentForMessage(ticketNumber, messageId, file) {
|
||
const form = new FormData();
|
||
form.append("file", file);
|
||
form.append("message_id", String(messageId));
|
||
const attRes = await fetch(
|
||
`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments`,
|
||
{
|
||
method: "POST",
|
||
headers: clientIdentityHeaders(),
|
||
body: form,
|
||
}
|
||
);
|
||
if (!attRes.ok) {
|
||
const msg = await formatApiError(attRes, "Вложение не принато");
|
||
throw new Error(msg);
|
||
}
|
||
return attRes;
|
||
}
|
||
|
||
root.innerHTML = `
|
||
<div class="f7support-wrap f7s-main">
|
||
<h2>F7 поддержка</h2>
|
||
<p id="error-box" class="f7support-error f7s-error"></p>
|
||
<div class="f7support-main f7s-wrapper-main f7s-home--empty" id="f7support-home">
|
||
<div class="f7s-home-top">
|
||
<div class="f7s-home-icon-wrap" aria-hidden="true">${F7S_TICKETS_ICON}</div>
|
||
<div class="f7s-home-intro">
|
||
<h3 class="f7s-section-title">Мои запросы</h3>
|
||
<p class="f7s-hint">Нажмите на запрос, чтобы открыть чат. Закройте окно чата, чтобы выбрать другой запрос.</p>
|
||
<button type="button" id="create-ticket-btn" class="f7s-create-ticket-btn">
|
||
${F7S_CREATE_BTN_PLUS}
|
||
<span class="f7s-create-ticket-btn__text">Создать</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="f7-board" id="f7-ticket-board" hidden>
|
||
<div class="f7-column f7-column--new">
|
||
<h4 class="f7-column-title">Новые</h4>
|
||
<ul id="ticket-col-new" class="f7-column-list"></ul>
|
||
</div>
|
||
<div class="f7-column f7-column--progress">
|
||
<h4 class="f7-column-title">В работе</h4>
|
||
<ul id="ticket-col-progress" class="f7-column-list"></ul>
|
||
</div>
|
||
<div class="f7-column f7-column--closed">
|
||
<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 f7s-modal f7s-modal--create" aria-hidden="true">
|
||
<div class="f7support-modal-panel f7s-modal-panel f7s-modal-panel--create">
|
||
<div class="f7support-modal-head f7s-modal-head">
|
||
<h3>Создание запроса</h3>
|
||
<button type="button" id="close-modal-btn" class="f7support-icon-btn f7s-btn-icon" aria-label="Закрыть">✕</button>
|
||
</div>
|
||
<input id="subject-input" maxlength="255" placeholder="Тема" class="f7support-input f7s-input" />
|
||
<div class="f7s-modal-compose">
|
||
<div class="f7s-textarea-attach-wrap f7support-dnd-target" data-f7-dnd-zone="create">
|
||
<textarea id="body-input" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)" class="f7support-textarea f7s-input f7s-textarea f7s-create-body f7support-dnd-target" data-f7-dnd-zone="create"></textarea>
|
||
<div class="f7s-textarea-clip">
|
||
<div class="f7s-file-trigger-wrap">
|
||
<label for="create-ticket-attachment-input" class="f7s-file-trigger" title="Добавить файл" aria-label="Добавить файл">
|
||
<span class="f7s-file-trigger__icon" aria-hidden="true">${F7S_FILE_CLIP_SVG}</span>
|
||
</label>
|
||
<input id="create-ticket-attachment-input" type="file" class="f7s-file-input-native" multiple />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="f7s-modal-attach-row f7s-mt-8" id="create-attach-row">
|
||
<div class="f7s-file-chips-slot f7support-dnd-target" data-f7-dnd-zone="create">
|
||
<div id="create-ticket-attachment-chips" class="f7s-file-chips" aria-live="polite"></div>
|
||
</div>
|
||
<button type="button" id="submit-ticket-btn" class="f7s-submit-ticket-btn f7s-send-message-btn">
|
||
${F7S_SUBMIT_BTN_ICON}
|
||
<span class="f7s-submit-ticket-btn__text">Отправить</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="chat-modal" class="f7support-modal f7s-modal f7s-modal--chat" aria-hidden="true">
|
||
<div class="f7support-modal-panel f7support-chat-panel f7s-modal-panel f7s-chat-panel">
|
||
<div class="f7support-modal-head f7s-modal-head">
|
||
<h3 id="chat-modal-title">Чат</h3>
|
||
<button type="button" id="chat-modal-close-btn" class="f7support-icon-btn f7s-btn-icon" aria-label="Закрыть чат">✕</button>
|
||
</div>
|
||
<div id="message-list" class="f7support-message-list f7s-message-list f7support-dnd-target" data-f7-dnd-zone="chat" title="Перетащите файл сюда"></div>
|
||
<div class="f7s-chat-footer">
|
||
<div class="f7s-textarea-attach-wrap f7support-dnd-target" data-f7-dnd-zone="chat">
|
||
<textarea id="message-input" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)" class="f7support-textarea f7s-input f7s-textarea f7s-textarea--message f7s-chat-textarea f7support-dnd-target" data-f7-dnd-zone="chat"></textarea>
|
||
<div class="f7s-textarea-clip">
|
||
<div class="f7s-file-trigger-wrap">
|
||
<label for="attachment-input" class="f7s-file-trigger" title="Добавить файл" aria-label="Добавить файл">
|
||
<span class="f7s-file-trigger__icon" aria-hidden="true">${F7S_FILE_CLIP_SVG}</span>
|
||
</label>
|
||
<input id="attachment-input" type="file" class="f7s-file-input-native" multiple />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="f7s-modal-attach-row f7s-mt-8" id="chat-attach-row">
|
||
<div class="f7s-file-chips-slot f7support-dnd-target" data-f7-dnd-zone="chat">
|
||
<div id="chat-attachment-chips" class="f7s-file-chips" aria-live="polite"></div>
|
||
</div>
|
||
<button type="button" id="send-message-btn" class="f7s-submit-ticket-btn f7s-send-message-btn">
|
||
${F7S_SUBMIT_BTN_ICON}
|
||
<span class="f7s-submit-ticket-btn__text">Отправить</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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");
|
||
|
||
let clientChatDndBound = false;
|
||
|
||
bindClientChatDnDOnce();
|
||
|
||
function scrollChatToBottom() {
|
||
const ml = document.getElementById("message-list");
|
||
if (!ml) return;
|
||
const run = () => {
|
||
try {
|
||
ml.scrollTop = ml.scrollHeight;
|
||
} catch (_) {}
|
||
};
|
||
run();
|
||
requestAnimationFrame(() => {
|
||
run();
|
||
requestAnimationFrame(run);
|
||
});
|
||
window.setTimeout(run, 80);
|
||
window.setTimeout(run, 320);
|
||
}
|
||
|
||
function lastMessageSignatureFromList(msgs) {
|
||
if (!Array.isArray(msgs) || msgs.length === 0) return null;
|
||
const last = msgs[msgs.length - 1];
|
||
return `${last.id ?? ""}\u0002${last.created_at ?? ""}`;
|
||
}
|
||
|
||
function syncCurrentTicketLastMsgSigFromState() {
|
||
if (!state.currentTicket) return;
|
||
const tn = String(state.currentTicket);
|
||
const sig = lastMessageSignatureFromList(state.messages);
|
||
if (sig == null) return;
|
||
state.ticketLastMsgSig[tn] = sig;
|
||
delete state.ticketUnreadByLastMsg[tn];
|
||
}
|
||
|
||
async function refreshLastMsgSigOne(tn, opts) {
|
||
const forceBaseline = opts && opts.forceBaseline === true;
|
||
const key = String(tn);
|
||
try {
|
||
const r = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(key)}/messages`, {
|
||
headers: clientIdentityHeaders(),
|
||
});
|
||
if (!r.ok) return;
|
||
const msgs = await r.json();
|
||
const sig = lastMessageSignatureFromList(msgs);
|
||
if (sig == null) return;
|
||
const prev = state.ticketLastMsgSig[key];
|
||
state.ticketLastMsgSig[key] = sig;
|
||
if (!forceBaseline && prev != null && sig !== prev) {
|
||
state.ticketUnreadByLastMsg[key] = true;
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function refreshLastMessageSignaturesStaggered() {
|
||
const cur = state.chatModalOpen && state.currentTicket != null ? String(state.currentTicket) : null;
|
||
const openTickets = state.tickets.filter((t) => {
|
||
const tn = String(t.ticket_number);
|
||
if (cur && tn === cur) return false;
|
||
if (String(t.status || "") === "Закрыт") return false;
|
||
return true;
|
||
});
|
||
if (!openTickets.length) return;
|
||
const BATCH = 6;
|
||
const n = openTickets.length;
|
||
const start = state.msgSigPollIndex % n;
|
||
const chunk = [];
|
||
for (let i = 0; i < Math.min(BATCH, n); i++) {
|
||
chunk.push(String(openTickets[(start + i) % n].ticket_number));
|
||
}
|
||
state.msgSigPollIndex = (state.msgSigPollIndex + chunk.length) % n;
|
||
await Promise.all(chunk.map((tn) => refreshLastMsgSigOne(tn, null)));
|
||
}
|
||
|
||
function ticketBoardFingerprint(t) {
|
||
const act = String(t.activity_at || t.updated_at || t.last_activity_at || t.created_at || "");
|
||
const prev = t.preview_text != null ? String(t.preview_text) : "";
|
||
const cnt =
|
||
t.messages_count ??
|
||
t.message_count ??
|
||
t.msg_count ??
|
||
t.replies_count ??
|
||
t.reply_count ??
|
||
"";
|
||
return `${act}\u0001${prev}\u0001${cnt}`;
|
||
}
|
||
|
||
/** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */
|
||
function effectiveBoardFingerprint(t) {
|
||
const base = ticketBoardFingerprint(t);
|
||
const tn = String(t.ticket_number);
|
||
if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) {
|
||
const msgs = state.messages;
|
||
if (msgs && msgs.length) {
|
||
const last = msgs[msgs.length - 1];
|
||
return `${base}\u0002${last.id ?? ""}\u0003${last.created_at ?? ""}`;
|
||
}
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function updateBoardUnreadFingerprints() {
|
||
for (const t of state.tickets) {
|
||
const tn = String(t.ticket_number);
|
||
const fp = effectiveBoardFingerprint(t);
|
||
const seenKey = tn;
|
||
if (!Object.prototype.hasOwnProperty.call(state.ticketSeenFingerprint, seenKey)) {
|
||
state.ticketSeenFingerprint[seenKey] = fp;
|
||
t._f7_board_unread = false;
|
||
continue;
|
||
}
|
||
if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) {
|
||
state.ticketSeenFingerprint[seenKey] = fp;
|
||
t._f7_board_unread = false;
|
||
continue;
|
||
}
|
||
t._f7_board_unread = fp !== state.ticketSeenFingerprint[seenKey];
|
||
}
|
||
}
|
||
|
||
function ticketHasUnread(ticket) {
|
||
const tn = String(ticket.ticket_number);
|
||
if (ticket && ticket._f7_board_unread) return true;
|
||
if (state.ticketUnreadByLastMsg[tn]) return true;
|
||
const raw =
|
||
ticket?.has_unread ??
|
||
ticket?.hasUnread ??
|
||
ticket?.unread ??
|
||
ticket?.is_unread ??
|
||
ticket?.unread_count;
|
||
if (typeof raw === "number" && raw > 0) return true;
|
||
return raw === true || raw === 1 || raw === "1" || raw === "true" || raw === "yes";
|
||
}
|
||
|
||
function scheduleTicketsBoardPolling() {
|
||
if (state.ticketsPollTimer) {
|
||
clearInterval(state.ticketsPollTimer);
|
||
state.ticketsPollTimer = null;
|
||
}
|
||
if (ticketsPollIntervalMs === 0) return;
|
||
state.ticketsPollTimer = window.setInterval(() => {
|
||
if (document.visibilityState === "hidden") return;
|
||
fetchTickets().catch(() => {});
|
||
}, ticketsPollIntervalMs);
|
||
}
|
||
|
||
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 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 zone = dndZoneFromElement(el);
|
||
if (zone && e.dataTransfer?.files?.length) {
|
||
addFilesToZone(zone, e.dataTransfer.files);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function openChatModal(ticket) {
|
||
state.chatModalOpen = true;
|
||
const subj = ticket.subject != null ? String(ticket.subject).trim() : "";
|
||
chatModalTitle.textContent = subj ? `Чат : ${subj}` : `Чат : Запрос ${ticket.ticket_number}`;
|
||
setModalVisible(chatModal, true);
|
||
}
|
||
|
||
async function closeChatModal() {
|
||
const closingTn = state.currentTicket;
|
||
if (closingTn != null) {
|
||
const row = state.tickets.find((x) => String(x.ticket_number) === String(closingTn));
|
||
if (row) state.ticketSeenFingerprint[String(closingTn)] = ticketBoardFingerprint(row);
|
||
delete state.ticketUnreadByLastMsg[String(closingTn)];
|
||
await refreshLastMsgSigOne(String(closingTn), { forceBaseline: true });
|
||
}
|
||
disconnectTicketSocket();
|
||
revokeAttachmentUrls();
|
||
state.chatModalOpen = false;
|
||
state.currentTicket = null;
|
||
state.messages = [];
|
||
messageList.innerHTML = "";
|
||
const msgInput = document.getElementById("message-input");
|
||
if (msgInput) msgInput.value = "";
|
||
clearChatAttachments();
|
||
setModalVisible(chatModal, false);
|
||
showError("");
|
||
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
|
||
await 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();
|
||
await refreshLastMessageSignaturesStaggered();
|
||
updateBoardUnreadFingerprints();
|
||
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 ? String(ticket.preview_text).trim() : "";
|
||
const preview = previewRaw
|
||
? escapeHtml(previewRaw)
|
||
: '<span class="f7-ticket-card__preview-empty">Текст последнего сообщения…</span>';
|
||
const activity = ticket.activity_at || ticket.created_at;
|
||
const time = escapeHtml(formatTicketCardTime(activity));
|
||
const hasUnread = ticketHasUnread(ticket);
|
||
const unread = hasUnread
|
||
? '<span class="f7-unread-dot" title="Новое сообщение" aria-label="Новое сообщение"></span>'
|
||
: "";
|
||
const tn = escapeHtml(ticket.ticket_number);
|
||
const subjRaw = ticket.subject != null ? String(ticket.subject).trim() : "";
|
||
const subj = escapeHtml(subjRaw || "—");
|
||
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>${subj}</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);
|
||
delete state.ticketUnreadByLastMsg[String(ticketNumber)];
|
||
try {
|
||
await fetchMessages();
|
||
await fetchTickets();
|
||
const row = state.tickets.find((x) => x.ticket_number === ticketNumber);
|
||
if (row) row.has_unread = false;
|
||
renderTickets();
|
||
reapplyActiveTicketCard(ticketNumber);
|
||
void markClientTicketRead(ticket.ticket_number);
|
||
connectTicketSocket(ticket.ticket_number);
|
||
} catch (e) {
|
||
showError(e.message || "Не удалось загрузить сообщения");
|
||
disconnectTicketSocket();
|
||
await closeChatModal();
|
||
}
|
||
}
|
||
|
||
function syncHomeLayout() {
|
||
const home = document.getElementById("f7support-home");
|
||
const board = document.getElementById("f7-ticket-board");
|
||
const iconWrap = document.querySelector(".f7s-home-icon-wrap");
|
||
if (!home) return;
|
||
const hasTickets = state.tickets.length > 0;
|
||
home.classList.toggle("f7s-home--empty", !hasTickets);
|
||
home.classList.toggle("f7s-home--has-tickets", hasTickets);
|
||
if (board) board.hidden = !hasTickets;
|
||
if (iconWrap) iconWrap.hidden = hasTickets;
|
||
}
|
||
|
||
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"));
|
||
});
|
||
});
|
||
syncHomeLayout();
|
||
}
|
||
|
||
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 = dedupeMessagesById(await response.json());
|
||
syncCurrentTicketLastMsgSigFromState();
|
||
await 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 appendAttachCard(bubble, att) {
|
||
const mime = typeof att.mime_type === "string" ? att.mime_type : "";
|
||
const filename = att.filename || "file";
|
||
const sizePart = att.size_bytes != null ? formatBytes(att.size_bytes) : "";
|
||
const displayName = formatMsgAttachDisplayName(filename);
|
||
|
||
const card = document.createElement("div");
|
||
card.className = "f7s-msg-attach-card";
|
||
card.setAttribute("role", "button");
|
||
card.tabIndex = 0;
|
||
|
||
const iconEl = document.createElement("div");
|
||
iconEl.className = "f7s-msg-attach-card__icon";
|
||
const iconImg = document.createElement("img");
|
||
iconImg.className = "f7s-msg-attach-card__icon-img";
|
||
iconImg.src = f7sAppImgUrl(attachmentIconFile(att));
|
||
iconImg.alt = "";
|
||
iconImg.width = 22;
|
||
iconImg.height = 22;
|
||
iconEl.appendChild(iconImg);
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "f7s-msg-attach-card__body";
|
||
const fn = document.createElement("div");
|
||
fn.className = "f7s-msg-attach-card__name";
|
||
fn.textContent = displayName;
|
||
if (filename.length > MSG_ATTACH_NAME_MAX_LEN) fn.title = filename;
|
||
const metaLine = document.createElement("div");
|
||
metaLine.className = "f7s-msg-attach-card__size";
|
||
metaLine.textContent = sizePart || " ";
|
||
body.append(fn, metaLine);
|
||
|
||
const openFile = async () => {
|
||
try {
|
||
const blob = await fetchClientAttachmentBlob(att.id);
|
||
openOrDownloadBlob(blob, filename, mime);
|
||
} catch {
|
||
showError("Не удалось открыть файл");
|
||
}
|
||
};
|
||
card.addEventListener("click", () => void openFile());
|
||
card.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
void openFile();
|
||
}
|
||
});
|
||
card.append(iconEl, body);
|
||
bubble.appendChild(card);
|
||
}
|
||
|
||
async function appendImageAttach(bubble, att) {
|
||
const filename = att.filename || "file";
|
||
const sizePart = att.size_bytes != null ? formatBytes(att.size_bytes) : "";
|
||
const displayName = formatMsgAttachDisplayName(filename);
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "f7s-msg-img-head";
|
||
const fn = document.createElement("div");
|
||
fn.className = "f7s-msg-img-name";
|
||
fn.textContent = displayName;
|
||
if (filename.length > MSG_ATTACH_NAME_MAX_LEN) fn.title = filename;
|
||
const sz = document.createElement("div");
|
||
sz.className = "f7s-msg-img-size";
|
||
sz.textContent = sizePart;
|
||
head.append(fn, sz);
|
||
bubble.appendChild(head);
|
||
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "f7s-msg-img-wrap";
|
||
const img = document.createElement("img");
|
||
img.className = "f7s-msg-img";
|
||
img.alt = filename;
|
||
wrap.appendChild(img);
|
||
bubble.appendChild(wrap);
|
||
|
||
try {
|
||
const blob = await fetchClientAttachmentBlob(att.id);
|
||
const u = URL.createObjectURL(blob);
|
||
state.blobUrls.push(u);
|
||
img.src = u;
|
||
img.addEventListener("load", () => scrollChatToBottom(), { once: true });
|
||
img.addEventListener("click", () => {
|
||
try {
|
||
window.open(u, "_blank", "noopener,noreferrer");
|
||
} catch (_) {}
|
||
});
|
||
} catch {
|
||
const err = document.createElement("div");
|
||
err.className = "f7s-img-err";
|
||
err.textContent = `Не удалось показать изображение: ${displayName}`;
|
||
wrap.appendChild(err);
|
||
}
|
||
}
|
||
|
||
async function renderMessages() {
|
||
revokeAttachmentUrls();
|
||
messageList.innerHTML = "";
|
||
const ticketNo = state.currentTicket;
|
||
if (!ticketNo) return;
|
||
|
||
const rows = buildRenderableMessageRows(state.messages);
|
||
for (const { m, groupPos, showSender, outgoing } of rows) {
|
||
const text = String(m.text ?? "").trim();
|
||
const attachments = messageAttachmentsFrom(m);
|
||
const showText = text && !isMessageBodyPlaceholder(text);
|
||
|
||
const article = document.createElement("article");
|
||
article.className = `f7s-msg f7s-msg--${outgoing ? "out" : "in"} f7s-msg--${groupPos}`;
|
||
if (m.id != null) article.dataset.f7MsgId = String(m.id);
|
||
|
||
if (!outgoing && showSender) {
|
||
const sender = document.createElement("div");
|
||
sender.className = "f7s-msg-sender";
|
||
const avatar = document.createElement("span");
|
||
avatar.className = "f7s-msg-avatar";
|
||
avatar.textContent = "F7";
|
||
const name = document.createElement("span");
|
||
name.className = "f7s-msg-sender-name";
|
||
name.textContent = supportSenderLabel(m);
|
||
sender.append(avatar, name);
|
||
article.appendChild(sender);
|
||
}
|
||
|
||
const bubble = document.createElement("div");
|
||
bubble.className = `f7s-msg-bubble f7s-msg-bubble--${groupPos}`;
|
||
if (attachments.length > 0) {
|
||
bubble.classList.add("f7s-msg-bubble--has-attach");
|
||
const onlyImages = attachments.every(
|
||
(a) =>
|
||
String(a.mime_type || "")
|
||
.toLowerCase()
|
||
.startsWith("image/") || isRasterImageAtt(a)
|
||
);
|
||
if (onlyImages && !showText) bubble.classList.add("f7s-msg-bubble--image");
|
||
}
|
||
|
||
if (showText) {
|
||
const p = document.createElement("p");
|
||
p.className = "f7s-msg-text";
|
||
p.textContent = text;
|
||
bubble.appendChild(p);
|
||
}
|
||
|
||
for (const att of attachments) {
|
||
const mime = typeof att.mime_type === "string" ? att.mime_type : "";
|
||
const isImg = mime.startsWith("image/") || isRasterImageAtt(att);
|
||
if (isImg) {
|
||
await appendImageAttach(bubble, att);
|
||
} else {
|
||
appendAttachCard(bubble, att);
|
||
}
|
||
}
|
||
|
||
const meta = document.createElement("div");
|
||
meta.className = "f7s-msg-meta";
|
||
const timeEl = document.createElement("time");
|
||
timeEl.className = "f7s-msg-time";
|
||
if (m.created_at) timeEl.dateTime = String(m.created_at);
|
||
timeEl.textContent = formatMsgTime(m.created_at);
|
||
meta.appendChild(timeEl);
|
||
if (outgoing) {
|
||
const read = document.createElement("span");
|
||
read.className = "f7s-msg-read";
|
||
const isRead = clientMessageIsRead(m);
|
||
read.setAttribute("aria-label", isRead ? "Прочитано" : "Доставлено");
|
||
read.innerHTML = isRead ? CHAT_MSG_READ_SVG : CHAT_MSG_UNREAD_SVG;
|
||
meta.appendChild(read);
|
||
}
|
||
bubble.appendChild(meta);
|
||
|
||
article.appendChild(bubble);
|
||
messageList.appendChild(article);
|
||
}
|
||
scrollChatToBottom();
|
||
}
|
||
|
||
|
||
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",
|
||
...clientIdentityHeaders(),
|
||
},
|
||
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 = "";
|
||
clearCreateTicketAttachments();
|
||
setModalVisible(createModal, false);
|
||
};
|
||
|
||
document.getElementById("chat-modal-close-btn").onclick = () => {
|
||
void closeChatModal();
|
||
};
|
||
|
||
document.getElementById("create-ticket-attachment-input")?.addEventListener("change", onCreateTicketAttachmentChange);
|
||
document.getElementById("attachment-input")?.addEventListener("change", onChatAttachmentChange);
|
||
|
||
const bodyInputEl = document.getElementById("body-input");
|
||
bodyInputEl?.addEventListener("paste", (e) => {
|
||
const files = e.clipboardData?.files;
|
||
if (files && files.length) {
|
||
e.preventDefault();
|
||
addFilesToZone("create", files);
|
||
}
|
||
});
|
||
|
||
const messageInputEl = document.getElementById("message-input");
|
||
messageInputEl?.addEventListener("paste", (e) => {
|
||
const files = e.clipboardData?.files;
|
||
if (files && files.length) {
|
||
e.preventDefault();
|
||
addFilesToZone("chat", files);
|
||
}
|
||
});
|
||
|
||
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 createPayload = {
|
||
server_address: serverAddress,
|
||
username,
|
||
subject,
|
||
body: TICKET_CREATE_BODY_PLACEHOLDER,
|
||
};
|
||
if (userEmail) {
|
||
createPayload.client_email = userEmail;
|
||
}
|
||
const result = await submitTicketWithRetry(createPayload);
|
||
const ticketNo = result.ticket_number;
|
||
let createdMsg;
|
||
try {
|
||
createdMsg = await postMessageToTicket(ticketNo, body);
|
||
} catch (e) {
|
||
showError(
|
||
e.message ||
|
||
`Токен ${ticketNo} создан, но текст обращения не попал в чат. Откройте тикет и напишите сообщение вручную.`
|
||
);
|
||
setModalVisible(createModal, false);
|
||
document.getElementById("subject-input").value = "";
|
||
document.getElementById("body-input").value = "";
|
||
clearCreateTicketAttachments();
|
||
await fetchTickets();
|
||
return;
|
||
}
|
||
const firstMessageId = createdMsg.id ?? createdMsg.message_id;
|
||
const createFiles = [...state.createTicketAttachmentFiles];
|
||
state.chatOutboundBusy = true;
|
||
try {
|
||
if (createFiles.length > 0) {
|
||
if (firstMessageId == null) {
|
||
showError(`Токен ${ticketNo} создан, но сервер не вернул id сообщения — вложения не отправлены.`);
|
||
} else {
|
||
try {
|
||
for (const f of createFiles) {
|
||
await uploadAttachmentForMessage(ticketNo, firstMessageId, f);
|
||
}
|
||
} catch (attErr) {
|
||
showError(
|
||
attErr.message ||
|
||
`Токен ${ticketNo} создан, текст в чате есть, но не все вложения удалось отправить.`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
state.chatOutboundBusy = false;
|
||
}
|
||
setModalVisible(createModal, false);
|
||
document.getElementById("subject-input").value = "";
|
||
document.getElementById("body-input").value = "";
|
||
clearCreateTicketAttachments();
|
||
showError(`Токен создан: ${ticketNo}`);
|
||
await fetchTickets();
|
||
void tryOpenTicket(ticketNo);
|
||
} 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 chatFiles = [...state.chatAttachmentFiles];
|
||
if (!text && !chatFiles.length) {
|
||
showError("Введите сообщение или прикрепите файл");
|
||
return;
|
||
}
|
||
if (!text && chatFiles.length) {
|
||
text = MESSAGE_BODY_PLACEHOLDER;
|
||
}
|
||
showError("");
|
||
state.chatOutboundBusy = true;
|
||
let created;
|
||
try {
|
||
created = await postMessageToTicket(state.currentTicket, text);
|
||
const messageId = created.id ?? created.message_id;
|
||
if (chatFiles.length > 0) {
|
||
if (messageId == null) {
|
||
showError("Сообщение создано, но сервер не вернул id — вложения не отправлены");
|
||
clearChatAttachments();
|
||
} else {
|
||
try {
|
||
for (const f of chatFiles) {
|
||
await uploadAttachmentForMessage(state.currentTicket, messageId, f);
|
||
}
|
||
} catch (e) {
|
||
showError(e.message || "Сообщение отправлено, но не все вложения приняты");
|
||
}
|
||
clearChatAttachments();
|
||
}
|
||
}
|
||
document.getElementById("message-input").value = "";
|
||
await fetchMessages();
|
||
} catch (e) {
|
||
showError(e.message || "Не удалось отправить сообщение");
|
||
} finally {
|
||
state.chatOutboundBusy = false;
|
||
}
|
||
};
|
||
|
||
syncCreateTicketAttachmentsUI();
|
||
syncChatAttachmentsUI();
|
||
|
||
queueMicrotask(() => {
|
||
fetchTickets()
|
||
.catch((e) => showError(e.message))
|
||
.finally(() => {
|
||
scheduleTicketsBoardPolling();
|
||
});
|
||
});
|
||
})();
|