feat: add support tickets and imrpove web app loading
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user