From 06cd7e6b773d252cd1eb4d3cded21a1969d13c47 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 13 May 2026 16:32:53 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0=20UI-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0,?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appinfo/info.xml | 8 +- css/f7support.css | 192 +------ js/main.js | 1338 ++++++++++++++++++++++++++++++--------------- 3 files changed, 890 insertions(+), 648 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 40927af..38ed2ab 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -4,7 +4,7 @@ f7support Support ticket client for F7cloud (F7cloud-compatible) f7support client app for creating and viewing support tickets. - 0.1.0 + 0.1.1 AGPL f7support team F7Support @@ -14,5 +14,11 @@ + + + f7support + 30 + + diff --git a/css/f7support.css b/css/f7support.css index d08734c..c002ca1 100644 --- a/css/f7support.css +++ b/css/f7support.css @@ -1,5 +1,4 @@ -/* f7support UI — вынесено из innerHTML для меньшего парсинга JS и кэширования CSS ядром NC */ - +/* Базовая изоляция; основной UI — в `; - const ticketList = document.getElementById("ticket-list"); const messageList = document.getElementById("message-list"); - const chatTitle = document.getElementById("chat-title"); + const chatModalTitle = document.getElementById("chat-modal-title"); const errorBox = document.getElementById("error-box"); - const chatErrorBox = document.getElementById("chat-error-box"); const createModal = document.getElementById("create-ticket-modal"); const chatModal = document.getElementById("chat-modal"); + bindClientChatDnDOnce(); + function showError(message) { errorBox.textContent = message || ""; } - function showChatError(message) { - chatErrorBox.textContent = message || ""; + function setModalVisible(el, visible) { + el.classList.toggle("f7support-modal-visible", visible); + el.setAttribute("aria-hidden", visible ? "false" : "true"); } - function isChatOpen() { - return chatModal.classList.contains("f7s-open"); - } - - function openChatModal() { - chatModal.classList.add("f7s-open"); - } - - function stopChatMessagesPoll() { - if (chatMessagesPollTimer !== null) { - clearInterval(chatMessagesPollTimer); - chatMessagesPollTimer = null; + 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 startChatMessagesPoll() { - stopChatMessagesPoll(); - chatMessagesPollTimer = setInterval(() => { - if (document.hidden) return; - if (!state.currentTicket || !isChatOpen()) return; - void loadMessages(false, false); - }, messagesPollMs); + function setPendingFile(file) { + if (!file) return; + state.pendingFile = file; + updatePendingFileUi(); } - function messagesFingerprint(list) { - if (!Array.isArray(list)) return ""; - return list - .map((m) => { - const attIds = (Array.isArray(m.attachments) ? m.attachments : []) - .map((a) => a.id) - .join(","); - return `${m.id}|${m.created_at ?? ""}|${(m.text || "").length}|${attIds}`; - }) - .join("\n"); + function clearPendingFile() { + state.pendingFile = null; + const inp = document.getElementById("attachment-input"); + if (inp) inp.value = ""; + updatePendingFileUi(); } - /** - * @param {boolean} force — всегда перерисовать (после отправки / первое открытие) - * @param {boolean} showErrors — показывать ошибку в UI (только при явной загрузке) - */ - async function loadMessages(force, showErrors) { - if (!state.currentTicket) return; - try { - const response = await fetch( - `${apiBase}/api/client/tickets/${encodeURIComponent(state.currentTicket)}/messages`, - { headers: clientHeaders() } - ); - if (!response.ok) { - if (showErrors) throw new Error("Не удалось загрузить сообщения"); - return; - } - const next = await response.json(); - if (!force && messagesFingerprint(next) === messagesFingerprint(state.messages)) { - return; - } - state.messages = next; - await renderMessages(); - } catch (e) { - if (showErrors) throw e; - } + 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() { - stopChatMessagesPoll(); - revokePreviewBlobs(); - chatModal.classList.remove("f7s-open"); + disconnectTicketSocket(); + revokeAttachmentUrls(); + state.chatModalOpen = false; state.currentTicket = null; state.messages = []; messageList.innerHTML = ""; const msgInput = document.getElementById("message-input"); - const fileInput = document.getElementById("attachment-input"); if (msgInput) msgInput.value = ""; - if (fileInput) fileInput.value = ""; - showChatError(""); - chatTitle.textContent = "Чат"; + clearPendingFile(); + setModalVisible(chatModal, false); + showError(""); + document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); + fetchTickets().catch(() => {}); } - function ticketsStorageKey() { - return `f7support.tickets.v1|${username}|${serverAddress}`; - } - - function readTicketsCache() { - try { - const raw = sessionStorage.getItem(ticketsStorageKey()); - if (!raw) return null; - const j = JSON.parse(raw); - return Array.isArray(j) ? j : null; - } catch { - return null; - } - } - - function writeTicketsCache(list) { - try { - sessionStorage.setItem(ticketsStorageKey(), JSON.stringify(list)); - } catch { - /* quota / private mode */ - } - } - - function applyTickets(list) { - state.tickets = list; + 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(); } - async function fetchTicketsFromNetwork() { - const response = await fetch(`${apiBase}/api/client/tickets`, { - headers: clientHeaders(), - }); - if (!response.ok) throw new Error("Не удалось получить токены"); - const list = await response.json(); - writeTicketsCache(list); - applyTickets(list); + 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 scheduleTicketsBootstrap() { - const cached = readTicketsCache(); - if (cached) { - applyTickets(cached); + 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} +
    +
    + ${time} +
    +
  • `; + } + + 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; } - const run = () => { - fetchTicketsFromNetwork().catch((e) => { - if (!cached) { - showError(e.message); - } - }); - }; - if (typeof requestIdleCallback !== "undefined") { - requestIdleCallback(run, { timeout: 2500 }); - } else { - queueMicrotask(() => setTimeout(run, 0)); + 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() { - ticketList.innerHTML = ""; - for (const ticket of state.tickets) { - const li = document.createElement("li"); - li.className = "f7s-ticket-item"; - li.textContent = `${ticket.ticket_number} — ${ticket.subject} [${ticket.status}]`; - li.onclick = () => { - if (isChatOpen()) { - return; - } - void openTicketChat(ticket.ticket_number); - }; - ticketList.appendChild(li); - } - } - - async function openTicketChat(ticketNumber) { - showError(""); - state.currentTicket = ticketNumber; - chatTitle.textContent = `Чат #${ticketNumber}`; - showChatError(""); - openChatModal(); - try { - await loadMessages(true, true); - startChatMessagesPoll(); - } catch (e) { - showChatError(e.message || "Ошибка загрузки"); - startChatMessagesPoll(); - } - } - - function attachmentDownloadUrl(ticketNumber, attachmentId) { - return `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments/${encodeURIComponent(String(attachmentId))}`; - } - - async function fetchAttachmentBlob(ticketNumber, attachmentId) { - const res = await fetch(attachmentDownloadUrl(ticketNumber, attachmentId), { - headers: clientHeaders(), + 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")); + }); }); - if (!res.ok) throw new Error("Не удалось загрузить вложение"); + } + + 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(); } - async function renderMessages() { - revokePreviewBlobs(); - messageList.innerHTML = ""; - const ticketNo = state.currentTicket; - if (!ticketNo) return; - - for (const m of state.messages) { - const row = document.createElement("div"); - row.className = "f7s-msg-row"; - - const line = document.createElement("div"); - const b = document.createElement("b"); - b.textContent = m.author ?? ""; - line.appendChild(b); - line.appendChild(document.createTextNode(` (${m.author_role ?? ""}): `)); - const textPart = document.createElement("span"); - textPart.textContent = m.text ?? ""; - line.appendChild(textPart); - row.appendChild(line); - - const attachments = Array.isArray(m.attachments) ? m.attachments : []; - for (const att of attachments) { - 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)}` : ""; - - if (mime.startsWith("image/")) { - const wrap = document.createElement("div"); - wrap.className = "f7s-img-wrap"; + 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 = "f7s-msg-img"; - img.alt = filename; - wrap.appendChild(img); - row.appendChild(wrap); - try { - const blob = await fetchAttachmentBlob(ticketNo, att.id); - const u = URL.createObjectURL(blob); - state.previewBlobUrls.push(u); - img.src = u; - } catch { - const err = document.createElement("div"); - err.className = "f7s-img-err"; - err.textContent = `Не удалось показать изображение: ${filename}`; - wrap.appendChild(err); - } - } else { - const wrap = document.createElement("div"); - wrap.className = "f7s-attach-row"; - const label = document.createElement("span"); - label.textContent = `${filename}${sizePart}`; - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = "Скачать"; - btn.onclick = async () => { - let objectUrl = null; - try { - const blob = await fetchAttachmentBlob(ticketNo, att.id); - objectUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = objectUrl; - a.download = filename; - a.rel = "noopener"; - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => { - try { - URL.revokeObjectURL(objectUrl); - } catch { - /* ignore */ - } - }, 60_000); - } catch { - showChatError("Не удалось скачать вложение"); - if (objectUrl) { - try { - URL.revokeObjectURL(objectUrl); - } catch { - /* ignore */ - } - } - } - }; - wrap.appendChild(label); - wrap.appendChild(btn); - row.appendChild(wrap); + img.className = "chat-inline-img"; + img.src = objUrl; + img.alt = ""; + img.loading = "lazy"; + frame.appendChild(img); + wrap.replaceChildren(frame); + } catch (_) { + wrap.textContent = "Не удалось показать файл"; } - } + })(); + }); - messageList.appendChild(row); - } - messageList.scrollTop = messageList.scrollHeight; + 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(); + } + }; + }); } - /** OpenAPI TicketCreate требует body minLength:1; реальный текст уходит первым сообщением в чат. */ - const TICKET_CREATE_BODY_PLACEHOLDER = "."; - - /** - * POST текстового сообщения в тикет (как в форме чата). - * @returns {Promise} тело ответа API - */ - async function postMessageToTicket(ticketNumber, text) { - const response = await fetch( - `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/messages`, - { - method: "POST", - headers: clientHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ text }), - } - ); - if (!response.ok) { - const msg = await formatApiError(response, "Не удалось отправить сообщение"); - throw new Error(msg); - } - return response.json(); - } - - /** - * POST одного файла к сообщению (message_id в query). - * @param {string|number} messageId - * @param {File} file - */ - async function uploadAttachmentForMessage(ticketNumber, messageId, file) { - const form = new FormData(); - form.append("file", file); - const q = new URLSearchParams({ message_id: String(messageId) }); - const attRes = await fetch( - `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments?${q.toString()}`, - { - method: "POST", - headers: clientHeaders(), - body: form, - } - ); - if (!attRes.ok) { - const msg = await formatApiError(attRes, "Вложение не принято"); - throw new Error(msg); - } - return attRes; + function renderMessages() { + revokeAttachmentUrls(); + messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join(""); + hydrateClientAttachments(messageList); } async function submitTicketWithRetry(payload) { @@ -478,32 +929,40 @@ throw new Error("Unknown error"); } - document.getElementById("close-chat-btn").onclick = () => { - closeChatModal(); - }; - document.getElementById("create-ticket-btn").onclick = () => { - createModal.classList.add("f7s-open"); + setModalVisible(createModal, true); showError(""); }; document.getElementById("close-modal-btn").onclick = () => { document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; - const catt = document.getElementById("create-ticket-attachment-input"); - if (catt) catt.value = ""; - createModal.classList.remove("f7s-open"); + setModalVisible(createModal, false); }; - createModal.addEventListener("click", (ev) => { - if (ev.target === createModal) { - document.getElementById("close-modal-btn").click(); - } - }); + document.getElementById("chat-modal-close-btn").onclick = () => { + closeChatModal(); + }; - chatModal.addEventListener("click", (ev) => { - if (ev.target === chatModal) { - 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]); } }); @@ -519,52 +978,13 @@ server_address: serverAddress, username, subject, - body: TICKET_CREATE_BODY_PLACEHOLDER, + body, }); - const ticketNo = result.ticket_number; - let createdMsg; - try { - createdMsg = await postMessageToTicket(ticketNo, body); - } catch (e) { - showError( - e.message || - `Токен ${ticketNo} создан, но текст обращения не попал в чат. Откройте тикет и напишите сообщение вручную.` - ); - createModal.classList.remove("f7s-open"); - document.getElementById("subject-input").value = ""; - document.getElementById("body-input").value = ""; - const cattFail = document.getElementById("create-ticket-attachment-input"); - if (cattFail) cattFail.value = ""; - await fetchTicketsFromNetwork(); - return; - } - const firstMessageId = createdMsg.id ?? createdMsg.message_id; - const createAtt = document.getElementById("create-ticket-attachment-input"); - const createFiles = createAtt && createAtt.files ? Array.from(createAtt.files) : []; - if (createFiles.length > 0) { - if (firstMessageId == null) { - showError(`Токен ${ticketNo} создан, но сервер не вернул id сообщения — вложения не отправлены.`); - createAtt.value = ""; - } else { - try { - for (const f of createFiles) { - await uploadAttachmentForMessage(ticketNo, firstMessageId, f); - } - } catch (attErr) { - showError( - attErr.message || - `Токен ${ticketNo} создан, текст в чате есть, но не все вложения удалось отправить.` - ); - } - } - createAtt.value = ""; - } - createModal.classList.remove("f7s-open"); + setModalVisible(createModal, false); + showError(`Токен создан: ${result.ticket_number}`); document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; - showError(`Токен создан: ${ticketNo}`); - await fetchTicketsFromNetwork(); - void openTicketChat(ticketNo); + await fetchTickets(); } catch (e) { showError(e.message || "Ошибка отправки"); } @@ -572,45 +992,51 @@ document.getElementById("send-message-btn").onclick = async () => { if (!state.currentTicket) { - showChatError("Сначала выберите токен"); + showError("Сначала выберите токен"); return; } - const text = document.getElementById("message-input").value.trim(); - if (!text) return; - showChatError(""); - let created; - try { - created = await postMessageToTicket(state.currentTicket, text); - } catch (e) { - showChatError(e.message || "Не удалось отправить сообщение"); + let text = document.getElementById("message-input").value.trim(); + const file = state.pendingFile; + if (!text && !file) { + showError("Введите сообщение или прикрепите файл"); return; } - const messageId = created.id ?? created.message_id; - const fileInput = document.getElementById("attachment-input"); - if (fileInput.files && fileInput.files[0]) { - if (messageId == null) { - showChatError("Сообщение создано, но сервер не вернул id — вложение не отправлено"); - fileInput.value = ""; - } else { - try { - await uploadAttachmentForMessage(state.currentTicket, messageId, fileInput.files[0]); - } catch (e) { - showChatError(e.message || "Сообщение отправлено, но вложение не принято"); - } - fileInput.value = ""; + 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 loadMessages(true, false); + await fetchMessages(); }; - document.addEventListener("visibilitychange", () => { - if (document.hidden) return; - void fetchTicketsFromNetwork().catch(() => {}); - if (isChatOpen() && state.currentTicket) { - void loadMessages(false, false); - } + queueMicrotask(() => { + fetchTickets().catch((e) => showError(e.message)); }); - - scheduleTicketsBootstrap(); })();