(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 supportWsBaseOverride = (root.dataset.supportWsBase || "").trim();
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, ">")
.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 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 ? '✓✓' : "";
return `
${escapeHtml(label)}
${escapeHtml(a.filename)}
${formatBytes(a.size_bytes)}
`;
}
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 messageBlockHtml(m) {
const atts = m.attachments || [];
const attHtml = atts
.map((a) => {
if (isRasterImageAtt(a)) {
return `Загрузка…
`;
}
return nonImageAttachmentHtml(m, a);
})
.join("");
return `
${escapeHtml(m.author)} (${escapeHtml(m.author_role)}): ${escapeHtml(m.text)}
${attHtml ? `
${attHtml}
` : ""}
`;
}
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 = `
`;
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 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();
}
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
? ''
: "";
const tn = escapeHtml(ticket.ticket_number);
const st = escapeHtml(ticket.status);
const stCls = clientTicketStatusClass(ticket.status);
return `
Тема#${tn}
${st}
${preview || "—"}
${unread}
`;
}
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));
});
})();