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,216 @@
import { writable } from "svelte/store";
export function createAdminSupportStore({ api, onToast, at }) {
const state = writable({
tickets: [],
stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 },
filters: {
status: "active",
priority: "",
category: "",
search: "",
sort: "importance_desc",
},
loading: false,
openedTicketId: null,
openedTicket: null,
messages: [],
userSnapshot: null,
detailLoading: false,
sending: false,
composerInternalNote: false,
});
let pollTimer = null;
let active = "stats";
function setActive(section) {
active = section;
}
function pushTicketPath(ticketId) {
if (typeof window === "undefined" || window.location.protocol === "file:") return;
if (active !== "support") return;
const target = ticketId ? `/admin/support/${ticketId}` : "/admin/support";
if (window.location.pathname !== target) {
window.history.pushState(
null,
"",
`${target}${window.location.search}${window.location.hash}`
);
}
}
async function loadStats() {
const res = await api("/admin/support/stats");
if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
}
async function loadList() {
state.update((s) => ({ ...s, loading: true }));
let filters;
state.update((s) => {
filters = s.filters;
return s;
});
try {
const params = new URLSearchParams({ limit: "50", offset: "0" });
for (const [key, value] of Object.entries(filters || {})) {
if (value) params.set(key, value);
}
const res = await api(`/admin/support/tickets?${params.toString()}`);
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 }));
}
}
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 : [],
userSnapshot: s.openedTicket?.ticket_id === id ? s.userSnapshot : null,
detailLoading: true,
}));
if (!opts.skipPush) pushTicketPath(id);
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();
} else onToast(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: [],
userSnapshot: null,
}));
if (!opts.skipPush) pushTicketPath(null);
}
async function sendReply(body) {
let current;
let internal;
state.update((s) => {
current = s.openedTicketId;
internal = s.composerInternalNote;
return { ...s, sending: true };
});
if (!current) return;
try {
const res = await api(`/admin/support/tickets/${current}/messages`, {
method: "POST",
body: JSON.stringify({ body, is_internal_note: internal }),
});
if (!res?.ok) throw res;
state.update((s) => ({
...s,
openedTicket: res.ticket
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
: s.openedTicket,
messages: [...s.messages, res.message],
}));
await loadList();
await loadStats();
} catch (error) {
onToast(error?.message || at("support_send_failed", {}, "Send failed"));
} finally {
state.update((s) => ({ ...s, sending: false }));
}
}
async function patchTicket(updates) {
let current;
state.update((s) => {
current = s.openedTicketId;
return s;
});
if (!current) return;
const res = await api(`/admin/support/tickets/${current}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
: s.openedTicket,
}));
await loadList();
await loadStats();
} else onToast(res?.message || res?.error || "update_failed");
}
function closeTicket() {
patchTicket({ status: "closed" });
}
function toggleInternalNote() {
state.update((s) => ({ ...s, composerInternalNote: !s.composerInternalNote }));
}
function setFilter(key, value) {
state.update((s) => ({ ...s, filters: { ...s.filters, [key]: value } }));
}
function setStatusView(status) {
state.update((s) => ({
...s,
filters: {
...s.filters,
status: status === "closed" ? "closed" : "active",
},
}));
loadList();
}
function startStatsPolling() {
if (pollTimer || typeof window === "undefined") return;
loadStats();
pollTimer = window.setInterval(() => {
if (document.visibilityState === "visible") loadStats();
}, 30000);
}
function stopStatsPolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = null;
}
return {
subscribe: state.subscribe,
update: state.update,
setActive,
loadStats,
loadList,
openTicket,
closeTicketView,
sendReply,
patchTicket,
closeTicket,
toggleInternalNote,
setFilter,
setStatusView,
startStatsPolling,
stopStatsPolling,
};
}
+4 -28
View File
@@ -69,13 +69,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -99,13 +93,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -130,13 +118,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
@@ -160,13 +142,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(