fix: update support page content without page refresh
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createAdminSupportStore({ api, onToast, at }) {
|
||||
const OPEN_TICKET_POLL_MS = 3_000;
|
||||
const STATS_POLL_MS = 30_000;
|
||||
const HIDDEN_POLL_MS = 300_000;
|
||||
const ERROR_POLL_MS = 90_000;
|
||||
|
||||
const state = writable({
|
||||
tickets: [],
|
||||
stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 },
|
||||
@@ -21,13 +26,35 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
composerInternalNote: false,
|
||||
});
|
||||
|
||||
let pollTimer = null;
|
||||
let statsPollTimer = null;
|
||||
let ticketPollTimer = null;
|
||||
let ticketPollInFlight = false;
|
||||
let visibilityHandler = null;
|
||||
let resumeHandler = null;
|
||||
let active = "stats";
|
||||
|
||||
function setActive(section) {
|
||||
active = section;
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
let snapshot;
|
||||
const unsubscribe = state.subscribe((s) => {
|
||||
snapshot = s;
|
||||
});
|
||||
unsubscribe();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function currentOpenedTicketId() {
|
||||
return getSnapshot()?.openedTicketId || null;
|
||||
}
|
||||
|
||||
function lastMessageId(messages) {
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
return Number(list.at(-1)?.message_id || 0);
|
||||
}
|
||||
|
||||
function pushTicketPath(ticketId) {
|
||||
if (typeof window === "undefined" || window.location.protocol === "file:") return;
|
||||
if (active !== "support") return;
|
||||
@@ -46,13 +73,11 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
state.update((s) => ({ ...s, loading: true }));
|
||||
async function loadList(options = {}) {
|
||||
const silent = options.silent === true;
|
||||
if (!silent) state.update((s) => ({ ...s, loading: true }));
|
||||
let filters;
|
||||
state.update((s) => {
|
||||
filters = s.filters;
|
||||
return s;
|
||||
});
|
||||
filters = getSnapshot()?.filters;
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "50", offset: "0" });
|
||||
for (const [key, value] of Object.entries(filters || {})) {
|
||||
@@ -62,10 +87,45 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] }));
|
||||
else if (res?.error) onToast(res.message || res.error);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, loading: false }));
|
||||
if (!silent) state.update((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCurrentTicket(ticketId) {
|
||||
const id = Number(ticketId);
|
||||
if (!id) return null;
|
||||
const res = await api(`/admin/support/tickets/${id}`);
|
||||
if (!res?.ok) return res;
|
||||
|
||||
let shouldRefreshList = false;
|
||||
let shouldMarkRead = false;
|
||||
state.update((s) => {
|
||||
if (s.openedTicketId !== id) return s;
|
||||
const nextMessages = res.messages || [];
|
||||
shouldRefreshList =
|
||||
lastMessageId(nextMessages) !== lastMessageId(s.messages) ||
|
||||
res.ticket?.status !== s.openedTicket?.status ||
|
||||
Number(res.ticket?.unread_admin_count || 0) !==
|
||||
Number(s.openedTicket?.unread_admin_count || 0);
|
||||
shouldMarkRead = Number(res.ticket?.unread_admin_count || 0) > 0;
|
||||
return {
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: nextMessages,
|
||||
userSnapshot: res.user_snapshot || null,
|
||||
};
|
||||
});
|
||||
|
||||
if (currentOpenedTicketId() !== id) return res;
|
||||
if (shouldMarkRead) {
|
||||
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
|
||||
await loadStats();
|
||||
shouldRefreshList = true;
|
||||
}
|
||||
if (shouldRefreshList) await loadList({ silent: true });
|
||||
return res;
|
||||
}
|
||||
|
||||
async function openTicket(ticketId, opts = {}) {
|
||||
const id = Number(ticketId);
|
||||
if (!id) return;
|
||||
@@ -81,17 +141,25 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
try {
|
||||
const res = await api(`/admin/support/tickets/${id}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
userSnapshot: res.user_snapshot || null,
|
||||
}));
|
||||
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
|
||||
await loadStats();
|
||||
state.update((s) =>
|
||||
s.openedTicketId === id
|
||||
? {
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
userSnapshot: res.user_snapshot || null,
|
||||
}
|
||||
: s
|
||||
);
|
||||
if (currentOpenedTicketId() === id) {
|
||||
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
|
||||
await loadStats();
|
||||
await loadList({ silent: true });
|
||||
scheduleTicketPoll(OPEN_TICKET_POLL_MS);
|
||||
}
|
||||
} else onToast(res?.message || res?.error || "not_found");
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, detailLoading: false }));
|
||||
state.update((s) => (s.openedTicketId === id ? { ...s, detailLoading: false } : s));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +171,7 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
messages: [],
|
||||
userSnapshot: null,
|
||||
}));
|
||||
clearTicketPollTimer();
|
||||
if (!opts.skipPush) pushTicketPath(null);
|
||||
}
|
||||
|
||||
@@ -114,7 +183,10 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
internal = s.composerInternalNote;
|
||||
return { ...s, sending: true };
|
||||
});
|
||||
if (!current) return;
|
||||
if (!current) {
|
||||
state.update((s) => ({ ...s, sending: false }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api(`/admin/support/tickets/${current}/messages`, {
|
||||
method: "POST",
|
||||
@@ -183,17 +255,99 @@ export function createAdminSupportStore({ api, onToast, at }) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
function clearTicketPollTimer() {
|
||||
if (!ticketPollTimer || typeof window === "undefined") return;
|
||||
window.clearTimeout(ticketPollTimer);
|
||||
ticketPollTimer = null;
|
||||
}
|
||||
|
||||
function scheduleTicketPoll(delayMs = OPEN_TICKET_POLL_MS) {
|
||||
if (typeof window === "undefined") return;
|
||||
clearTicketPollTimer();
|
||||
if (!currentOpenedTicketId()) return;
|
||||
ticketPollTimer = window.setTimeout(runTicketPoll, Math.max(0, Number(delayMs) || 0));
|
||||
}
|
||||
|
||||
async function runTicketPoll() {
|
||||
ticketPollTimer = null;
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||
scheduleTicketPoll(HIDDEN_POLL_MS);
|
||||
return;
|
||||
}
|
||||
const ticketId = currentOpenedTicketId();
|
||||
if (!ticketId) return;
|
||||
if (ticketPollInFlight) {
|
||||
scheduleTicketPoll(OPEN_TICKET_POLL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
ticketPollInFlight = true;
|
||||
let failed = false;
|
||||
try {
|
||||
const res = await refreshCurrentTicket(ticketId);
|
||||
if (res?.error) failed = true;
|
||||
} catch (_error) {
|
||||
failed = true;
|
||||
} finally {
|
||||
ticketPollInFlight = false;
|
||||
if (currentOpenedTicketId()) {
|
||||
scheduleTicketPoll(failed ? ERROR_POLL_MS : OPEN_TICKET_POLL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRealtimeListeners() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!visibilityHandler && typeof document !== "undefined") {
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
loadStats();
|
||||
scheduleTicketPoll(0);
|
||||
} else {
|
||||
scheduleTicketPoll(HIDDEN_POLL_MS);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
}
|
||||
if (!resumeHandler) {
|
||||
resumeHandler = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
loadStats();
|
||||
scheduleTicketPoll(0);
|
||||
};
|
||||
window.addEventListener("focus", resumeHandler);
|
||||
window.addEventListener("pageshow", resumeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function stopRealtimeListeners() {
|
||||
if (visibilityHandler && typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||
visibilityHandler = null;
|
||||
}
|
||||
if (resumeHandler && typeof window !== "undefined") {
|
||||
window.removeEventListener("focus", resumeHandler);
|
||||
window.removeEventListener("pageshow", resumeHandler);
|
||||
resumeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startStatsPolling() {
|
||||
if (pollTimer || typeof window === "undefined") return;
|
||||
if (typeof window === "undefined") return;
|
||||
ensureRealtimeListeners();
|
||||
if (statsPollTimer) return;
|
||||
loadStats();
|
||||
pollTimer = window.setInterval(() => {
|
||||
statsPollTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") loadStats();
|
||||
}, 30000);
|
||||
}, STATS_POLL_MS);
|
||||
}
|
||||
|
||||
function stopStatsPolling() {
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
if (statsPollTimer) window.clearInterval(statsPollTimer);
|
||||
statsPollTimer = null;
|
||||
clearTicketPollTimer();
|
||||
ticketPollInFlight = false;
|
||||
stopRealtimeListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createSupportStore({ api, t, showToast }) {
|
||||
const ACTIVE_POLL_MS = 15_000;
|
||||
const OPEN_TICKET_POLL_MS = 3_000;
|
||||
const ACTIVE_POLL_MS = 8_000;
|
||||
const BACKGROUND_POLL_MS = 45_000;
|
||||
const IDLE_POLL_MS = 120_000;
|
||||
const PAUSED_POLL_MS = 300_000;
|
||||
@@ -34,6 +35,7 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
let emptyUnreadPolls = 0;
|
||||
let lastUnreadCount = 0;
|
||||
let visibilityHandler = null;
|
||||
let resumeHandler = null;
|
||||
let listRequestSeq = 0;
|
||||
let listPromise = null;
|
||||
let listPromiseKey = "";
|
||||
@@ -55,6 +57,19 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
return BACKGROUND_POLL_MS;
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
let snapshot;
|
||||
const unsubscribe = state.subscribe((s) => {
|
||||
snapshot = s;
|
||||
});
|
||||
unsubscribe();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function activePollDelay() {
|
||||
return currentOpenedTicketId() ? OPEN_TICKET_POLL_MS : ACTIVE_POLL_MS;
|
||||
}
|
||||
|
||||
function clearPollTimer() {
|
||||
if (!pollTimer) return;
|
||||
if (typeof window !== "undefined") window.clearTimeout(pollTimer);
|
||||
@@ -68,12 +83,7 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
}
|
||||
|
||||
function currentOpenedTicketId() {
|
||||
let opened = null;
|
||||
state.update((s) => {
|
||||
opened = s.openedTicketId;
|
||||
return s;
|
||||
});
|
||||
return opened;
|
||||
return getSnapshot()?.openedTicketId || null;
|
||||
}
|
||||
|
||||
function hydrateUnread(value) {
|
||||
@@ -138,12 +148,18 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
try {
|
||||
const res = await api(`/support/tickets/${id}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
}));
|
||||
await markRead(id);
|
||||
state.update((s) =>
|
||||
s.openedTicketId === id
|
||||
? {
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
}
|
||||
: s
|
||||
);
|
||||
if (currentOpenedTicketId() === id && Number(res.ticket?.unread_user_count || 0) > 0) {
|
||||
await markRead(id, { silent: true });
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} catch {
|
||||
@@ -194,17 +210,22 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
try {
|
||||
const res = await api(`/support/tickets/${id}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
}));
|
||||
await markRead(id);
|
||||
state.update((s) =>
|
||||
s.openedTicketId === id
|
||||
? {
|
||||
...s,
|
||||
openedTicket: res.ticket,
|
||||
messages: res.messages || [],
|
||||
}
|
||||
: s
|
||||
);
|
||||
if (currentOpenedTicketId() === id) await markRead(id);
|
||||
} else {
|
||||
showToast(res?.message || res?.error || "not_found");
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, detailLoading: false }));
|
||||
state.update((s) => (s.openedTicketId === id ? { ...s, detailLoading: false } : s));
|
||||
if (pollingEnabled) schedulePoll(activePollDelay());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +248,10 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
ticketId = s.openedTicketId;
|
||||
return { ...s, sending: true };
|
||||
});
|
||||
if (!ticketId) return;
|
||||
if (!ticketId) {
|
||||
state.update((s) => ({ ...s, sending: false }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api(`/support/tickets/${ticketId}/messages`, {
|
||||
method: "POST",
|
||||
@@ -248,20 +272,15 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(ticketId = null) {
|
||||
async function markRead(ticketId = null, options = {}) {
|
||||
const id =
|
||||
ticketId ||
|
||||
(() => {
|
||||
let current = null;
|
||||
state.update((s) => {
|
||||
current = s.openedTicketId;
|
||||
return s;
|
||||
});
|
||||
return current;
|
||||
return currentOpenedTicketId();
|
||||
})();
|
||||
if (!id) return;
|
||||
await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
|
||||
await refreshUnread();
|
||||
await refreshUnread({ silent: options.silent === true });
|
||||
}
|
||||
|
||||
async function refreshUnread(options = {}) {
|
||||
@@ -318,7 +337,9 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
failed = true;
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
if (pollingEnabled) schedulePoll(failed ? ERROR_POLL_MS : nextPollDelay());
|
||||
if (pollingEnabled) {
|
||||
schedulePoll(failed ? ERROR_POLL_MS : supportActive ? activePollDelay() : nextPollDelay());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +348,7 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
if (supportActive === next) return;
|
||||
supportActive = next;
|
||||
if (supportActive) emptyUnreadPolls = 0;
|
||||
if (pollingEnabled) schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay());
|
||||
if (pollingEnabled) schedulePoll(supportActive ? 0 : nextPollDelay());
|
||||
}
|
||||
|
||||
function startPolling(options = {}) {
|
||||
@@ -348,10 +369,20 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
}
|
||||
if (!resumeHandler) {
|
||||
resumeHandler = () => {
|
||||
if (!pollingEnabled) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
emptyUnreadPolls = 0;
|
||||
schedulePoll(0);
|
||||
};
|
||||
window.addEventListener("focus", resumeHandler);
|
||||
window.addEventListener("pageshow", resumeHandler);
|
||||
}
|
||||
if (!pollTimer && !pollInFlight) {
|
||||
schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay());
|
||||
schedulePoll(supportActive ? 0 : nextPollDelay());
|
||||
} else if (supportActive) {
|
||||
schedulePoll(ACTIVE_POLL_MS);
|
||||
schedulePoll(activePollDelay());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +392,13 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
visibilityHandler = null;
|
||||
}
|
||||
|
||||
function stopResumeListeners() {
|
||||
if (!resumeHandler || typeof window === "undefined") return;
|
||||
window.removeEventListener("focus", resumeHandler);
|
||||
window.removeEventListener("pageshow", resumeHandler);
|
||||
resumeHandler = null;
|
||||
}
|
||||
|
||||
function closePolling() {
|
||||
pollingEnabled = false;
|
||||
supportActive = false;
|
||||
@@ -368,6 +406,7 @@ export function createSupportStore({ api, t, showToast }) {
|
||||
emptyUnreadPolls = 0;
|
||||
clearPollTimer();
|
||||
stopVisibilityListener();
|
||||
stopResumeListeners();
|
||||
state.update((s) => (s.polling ? { ...s, polling: false } : s));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user