feat: add support tickets and imrpove web app loading

This commit is contained in:
3252a8
2026-05-20 16:38:24 +03:00
parent 3b846f0d44
commit d8a1da1f13
78 changed files with 8990 additions and 168 deletions
@@ -0,0 +1,287 @@
import { writable } from "svelte/store";
export function createSupportStore({ api, t, showToast }) {
const state = writable({
tickets: [],
openedTicketId: null,
openedTicket: null,
messages: [],
unreadCount: 0,
unreadLoaded: false,
unreadLoading: false,
counts: { active: 0, closed: 0, awaiting_admin: 0, awaiting_user: 0, open: 0, total: 0 },
loading: false,
detailLoading: false,
sending: false,
creating: false,
statusFilter: "active",
polling: false,
});
let pollTimer = null;
let pollIncludeList = false;
let listRequestSeq = 0;
let listPromise = null;
let listPromiseKey = "";
let unreadPromise = null;
function hydrateUnread(value) {
const next = Math.max(0, Number(value || 0));
state.update((s) => ({
...s,
unreadCount: next,
unreadLoaded: true,
unreadLoading: false,
}));
}
async function loadList(options = {}) {
let filter = "all";
let hasTickets = false;
state.update((s) => {
filter = s.statusFilter;
hasTickets = Boolean(s.tickets?.length);
return s;
});
const requestKey = filter || "all";
if (!options.force && listPromise && listPromiseKey === requestKey) return listPromise;
const requestId = ++listRequestSeq;
const showLoading = !options.silent && (options.showLoading || !hasTickets);
if (showLoading) state.update((s) => ({ ...s, loading: true }));
let promise;
promise = (async () => {
try {
const params = new URLSearchParams({ limit: "50", offset: "0" });
if (filter && filter !== "all") params.set("status", filter);
const res = await api(`/support/tickets?${params.toString()}`);
if (requestId !== listRequestSeq) return res;
if (res?.ok)
state.update((s) => ({
...s,
tickets: res.tickets || [],
counts: res.counts || s.counts,
}));
else if (res?.error) showToast(res.message || res.error);
return res;
} finally {
if (requestId === listRequestSeq) {
state.update((s) => (s.loading ? { ...s, loading: false } : s));
}
if (listPromise === promise) {
listPromise = null;
listPromiseKey = "";
}
}
})();
listPromise = promise;
listPromiseKey = requestKey;
return promise;
}
async function refreshCurrentTicket(ticketId) {
const id = Number(ticketId);
if (!id) return;
try {
const res = await api(`/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
}));
await markRead(id);
}
return res;
} catch {
return null;
}
}
async function createTicket(payload) {
state.update((s) => ({ ...s, creating: true }));
try {
const res = await api("/support/tickets", {
method: "POST",
body: JSON.stringify(payload),
});
if (!res?.ok) throw res;
state.update((s) => ({ ...s, statusFilter: "active" }));
await loadList({ silent: true, force: true });
await openTicket(res.ticket.ticket_id);
return res.ticket;
} catch (error) {
showToast(error?.message || t("wa_support_create_failed"));
return null;
} finally {
state.update((s) => ({ ...s, creating: false }));
}
}
async function openTicket(ticketId, opts = {}) {
const id = Number(ticketId);
if (!id) return;
state.update((s) => ({
...s,
openedTicketId: id,
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
detailLoading: true,
}));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
const target = `/support/${id}`;
if (window.location.pathname !== target) {
window.history.pushState(
null,
"",
`${target}${window.location.search}${window.location.hash}`
);
}
}
try {
const res = await api(`/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
}));
await markRead(id);
} else {
showToast(res?.message || res?.error || "not_found");
}
} finally {
state.update((s) => ({ ...s, detailLoading: false }));
}
}
function closeTicketView(opts = {}) {
state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] }));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
if (window.location.pathname.startsWith("/support/")) {
window.history.pushState(
null,
"",
`/support${window.location.search}${window.location.hash}`
);
}
}
}
async function sendReply(body) {
let ticketId = null;
state.update((s) => {
ticketId = s.openedTicketId;
return { ...s, sending: true };
});
if (!ticketId) return;
try {
const res = await api(`/support/tickets/${ticketId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
if (!res?.ok) throw res;
state.update((s) => ({
...s,
openedTicket: res.ticket || s.openedTicket,
messages: [...s.messages, res.message],
}));
await refreshUnread();
await loadList({ silent: true, force: true });
} catch (error) {
showToast(error?.message || t("wa_support_send_failed"));
} finally {
state.update((s) => ({ ...s, sending: false }));
}
}
async function markRead(ticketId = null) {
const id =
ticketId ||
(() => {
let current = null;
state.update((s) => {
current = s.openedTicketId;
return s;
});
return current;
})();
if (!id) return;
await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
await refreshUnread();
}
async function refreshUnread() {
if (unreadPromise) return unreadPromise;
state.update((s) => ({ ...s, unreadLoading: true }));
unreadPromise = (async () => {
try {
const res = await api("/support/unread");
if (res?.ok) {
state.update((s) => ({
...s,
unreadCount: Math.max(0, Number(res.unread || 0)),
unreadLoaded: true,
}));
}
return res;
} finally {
state.update((s) => ({ ...s, unreadLoading: false }));
unreadPromise = null;
}
})();
return unreadPromise;
}
function setStatusFilter(status) {
state.update((s) => ({ ...s, statusFilter: status || "all" }));
loadList({ force: true, showLoading: true });
}
function startPolling(options = {}) {
const includeList = options.includeList !== false;
pollIncludeList = pollIncludeList || includeList;
if (pollTimer || typeof window === "undefined") return;
state.update((s) => ({ ...s, polling: true }));
const tick = async () => {
if (document.visibilityState === "visible") {
await refreshUnread();
if (!pollIncludeList) return;
let opened = null;
state.update((s) => {
opened = s.openedTicketId;
return s;
});
if (opened) await refreshCurrentTicket(opened);
else await loadList({ silent: true });
}
};
pollTimer = window.setInterval(tick, 15000);
document.addEventListener("visibilitychange", tick);
}
function closePolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = null;
pollIncludeList = false;
state.update((s) => ({ ...s, polling: false }));
}
return {
subscribe: state.subscribe,
update: state.update,
loadList,
hydrateUnread,
createTicket,
openTicket,
closeTicketView,
sendReply,
markRead,
refreshUnread,
setStatusFilter,
startPolling,
closePolling,
};
}