fix: update support page content without page refresh

This commit is contained in:
3252a8
2026-05-25 22:03:30 +03:00
parent c68cb97964
commit 4c77d129e7
2 changed files with 249 additions and 56 deletions
+177 -23
View File
@@ -1,6 +1,11 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
export function createAdminSupportStore({ api, onToast, at }) { 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({ const state = writable({
tickets: [], tickets: [],
stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 }, 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, composerInternalNote: false,
}); });
let pollTimer = null; let statsPollTimer = null;
let ticketPollTimer = null;
let ticketPollInFlight = false;
let visibilityHandler = null;
let resumeHandler = null;
let active = "stats"; let active = "stats";
function setActive(section) { function setActive(section) {
active = 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) { function pushTicketPath(ticketId) {
if (typeof window === "undefined" || window.location.protocol === "file:") return; if (typeof window === "undefined" || window.location.protocol === "file:") return;
if (active !== "support") 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 })); if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
} }
async function loadList() { async function loadList(options = {}) {
state.update((s) => ({ ...s, loading: true })); const silent = options.silent === true;
if (!silent) state.update((s) => ({ ...s, loading: true }));
let filters; let filters;
state.update((s) => { filters = getSnapshot()?.filters;
filters = s.filters;
return s;
});
try { try {
const params = new URLSearchParams({ limit: "50", offset: "0" }); const params = new URLSearchParams({ limit: "50", offset: "0" });
for (const [key, value] of Object.entries(filters || {})) { 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 || [] })); if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] }));
else if (res?.error) onToast(res.message || res.error); else if (res?.error) onToast(res.message || res.error);
} finally { } 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 = {}) { async function openTicket(ticketId, opts = {}) {
const id = Number(ticketId); const id = Number(ticketId);
if (!id) return; if (!id) return;
@@ -81,17 +141,25 @@ export function createAdminSupportStore({ api, onToast, at }) {
try { try {
const res = await api(`/admin/support/tickets/${id}`); const res = await api(`/admin/support/tickets/${id}`);
if (res?.ok) { if (res?.ok) {
state.update((s) => ({ state.update((s) =>
...s, s.openedTicketId === id
openedTicket: res.ticket, ? {
messages: res.messages || [], ...s,
userSnapshot: res.user_snapshot || null, openedTicket: res.ticket,
})); messages: res.messages || [],
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" }); userSnapshot: res.user_snapshot || null,
await loadStats(); }
: 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"); } else onToast(res?.message || res?.error || "not_found");
} finally { } 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: [], messages: [],
userSnapshot: null, userSnapshot: null,
})); }));
clearTicketPollTimer();
if (!opts.skipPush) pushTicketPath(null); if (!opts.skipPush) pushTicketPath(null);
} }
@@ -114,7 +183,10 @@ export function createAdminSupportStore({ api, onToast, at }) {
internal = s.composerInternalNote; internal = s.composerInternalNote;
return { ...s, sending: true }; return { ...s, sending: true };
}); });
if (!current) return; if (!current) {
state.update((s) => ({ ...s, sending: false }));
return;
}
try { try {
const res = await api(`/admin/support/tickets/${current}/messages`, { const res = await api(`/admin/support/tickets/${current}/messages`, {
method: "POST", method: "POST",
@@ -183,17 +255,99 @@ export function createAdminSupportStore({ api, onToast, at }) {
loadList(); 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() { function startStatsPolling() {
if (pollTimer || typeof window === "undefined") return; if (typeof window === "undefined") return;
ensureRealtimeListeners();
if (statsPollTimer) return;
loadStats(); loadStats();
pollTimer = window.setInterval(() => { statsPollTimer = window.setInterval(() => {
if (document.visibilityState === "visible") loadStats(); if (document.visibilityState === "visible") loadStats();
}, 30000); }, STATS_POLL_MS);
} }
function stopStatsPolling() { function stopStatsPolling() {
if (pollTimer) window.clearInterval(pollTimer); if (statsPollTimer) window.clearInterval(statsPollTimer);
pollTimer = null; statsPollTimer = null;
clearTicketPollTimer();
ticketPollInFlight = false;
stopRealtimeListeners();
} }
return { return {
+72 -33
View File
@@ -1,7 +1,8 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
export function createSupportStore({ api, t, showToast }) { 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 BACKGROUND_POLL_MS = 45_000;
const IDLE_POLL_MS = 120_000; const IDLE_POLL_MS = 120_000;
const PAUSED_POLL_MS = 300_000; const PAUSED_POLL_MS = 300_000;
@@ -34,6 +35,7 @@ export function createSupportStore({ api, t, showToast }) {
let emptyUnreadPolls = 0; let emptyUnreadPolls = 0;
let lastUnreadCount = 0; let lastUnreadCount = 0;
let visibilityHandler = null; let visibilityHandler = null;
let resumeHandler = null;
let listRequestSeq = 0; let listRequestSeq = 0;
let listPromise = null; let listPromise = null;
let listPromiseKey = ""; let listPromiseKey = "";
@@ -55,6 +57,19 @@ export function createSupportStore({ api, t, showToast }) {
return BACKGROUND_POLL_MS; 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() { function clearPollTimer() {
if (!pollTimer) return; if (!pollTimer) return;
if (typeof window !== "undefined") window.clearTimeout(pollTimer); if (typeof window !== "undefined") window.clearTimeout(pollTimer);
@@ -68,12 +83,7 @@ export function createSupportStore({ api, t, showToast }) {
} }
function currentOpenedTicketId() { function currentOpenedTicketId() {
let opened = null; return getSnapshot()?.openedTicketId || null;
state.update((s) => {
opened = s.openedTicketId;
return s;
});
return opened;
} }
function hydrateUnread(value) { function hydrateUnread(value) {
@@ -138,12 +148,18 @@ export function createSupportStore({ api, t, showToast }) {
try { try {
const res = await api(`/support/tickets/${id}`); const res = await api(`/support/tickets/${id}`);
if (res?.ok) { if (res?.ok) {
state.update((s) => ({ state.update((s) =>
...s, s.openedTicketId === id
openedTicket: res.ticket, ? {
messages: res.messages || [], ...s,
})); openedTicket: res.ticket,
await markRead(id); messages: res.messages || [],
}
: s
);
if (currentOpenedTicketId() === id && Number(res.ticket?.unread_user_count || 0) > 0) {
await markRead(id, { silent: true });
}
} }
return res; return res;
} catch { } catch {
@@ -194,17 +210,22 @@ export function createSupportStore({ api, t, showToast }) {
try { try {
const res = await api(`/support/tickets/${id}`); const res = await api(`/support/tickets/${id}`);
if (res?.ok) { if (res?.ok) {
state.update((s) => ({ state.update((s) =>
...s, s.openedTicketId === id
openedTicket: res.ticket, ? {
messages: res.messages || [], ...s,
})); openedTicket: res.ticket,
await markRead(id); messages: res.messages || [],
}
: s
);
if (currentOpenedTicketId() === id) await markRead(id);
} else { } else {
showToast(res?.message || res?.error || "not_found"); showToast(res?.message || res?.error || "not_found");
} }
} finally { } 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; ticketId = s.openedTicketId;
return { ...s, sending: true }; return { ...s, sending: true };
}); });
if (!ticketId) return; if (!ticketId) {
state.update((s) => ({ ...s, sending: false }));
return;
}
try { try {
const res = await api(`/support/tickets/${ticketId}/messages`, { const res = await api(`/support/tickets/${ticketId}/messages`, {
method: "POST", 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 = const id =
ticketId || ticketId ||
(() => { (() => {
let current = null; return currentOpenedTicketId();
state.update((s) => {
current = s.openedTicketId;
return s;
});
return current;
})(); })();
if (!id) return; if (!id) return;
await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" }); await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
await refreshUnread(); await refreshUnread({ silent: options.silent === true });
} }
async function refreshUnread(options = {}) { async function refreshUnread(options = {}) {
@@ -318,7 +337,9 @@ export function createSupportStore({ api, t, showToast }) {
failed = true; failed = true;
} finally { } finally {
pollInFlight = false; 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; if (supportActive === next) return;
supportActive = next; supportActive = next;
if (supportActive) emptyUnreadPolls = 0; if (supportActive) emptyUnreadPolls = 0;
if (pollingEnabled) schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay()); if (pollingEnabled) schedulePoll(supportActive ? 0 : nextPollDelay());
} }
function startPolling(options = {}) { function startPolling(options = {}) {
@@ -348,10 +369,20 @@ export function createSupportStore({ api, t, showToast }) {
}; };
document.addEventListener("visibilitychange", visibilityHandler); 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) { if (!pollTimer && !pollInFlight) {
schedulePoll(supportActive ? ACTIVE_POLL_MS : nextPollDelay()); schedulePoll(supportActive ? 0 : nextPollDelay());
} else if (supportActive) { } else if (supportActive) {
schedulePoll(ACTIVE_POLL_MS); schedulePoll(activePollDelay());
} }
} }
@@ -361,6 +392,13 @@ export function createSupportStore({ api, t, showToast }) {
visibilityHandler = null; visibilityHandler = null;
} }
function stopResumeListeners() {
if (!resumeHandler || typeof window === "undefined") return;
window.removeEventListener("focus", resumeHandler);
window.removeEventListener("pageshow", resumeHandler);
resumeHandler = null;
}
function closePolling() { function closePolling() {
pollingEnabled = false; pollingEnabled = false;
supportActive = false; supportActive = false;
@@ -368,6 +406,7 @@ export function createSupportStore({ api, t, showToast }) {
emptyUnreadPolls = 0; emptyUnreadPolls = 0;
clearPollTimer(); clearPollTimer();
stopVisibilityListener(); stopVisibilityListener();
stopResumeListeners();
state.update((s) => (s.polling ? { ...s, polling: false } : s)); state.update((s) => (s.polling ? { ...s, polling: false } : s));
} }