feat: add support tickets and imrpove web app loading
This commit is contained in:
@@ -22,6 +22,7 @@ export const APP_SECTION_PATHS = {
|
||||
home: "/home",
|
||||
invite: "/invite",
|
||||
devices: "/devices",
|
||||
support: "/support",
|
||||
settings: "/settings",
|
||||
admin: "/admin",
|
||||
};
|
||||
@@ -33,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"support",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"settings",
|
||||
|
||||
@@ -2,7 +2,24 @@ import { LANGUAGE_LABELS } from "./constants.js";
|
||||
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
|
||||
import { unitPluralBucket } from "./plurals.js";
|
||||
|
||||
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
|
||||
export function createI18n({
|
||||
messages: initialMessages = {},
|
||||
defaultLang = "ru",
|
||||
getLang = null,
|
||||
} = {}) {
|
||||
const messages = {};
|
||||
|
||||
function mergeMessages(nextMessages = {}) {
|
||||
if (!nextMessages || typeof nextMessages !== "object") return messages;
|
||||
for (const [lang, bucket] of Object.entries(nextMessages)) {
|
||||
if (!bucket || typeof bucket !== "object") continue;
|
||||
messages[lang] = { ...(messages[lang] || {}), ...bucket };
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
mergeMessages(initialMessages);
|
||||
|
||||
function normalizeLangCode(lang) {
|
||||
const key = String(lang || "")
|
||||
.trim()
|
||||
@@ -45,7 +62,7 @@ export function createI18n({ messages = {}, defaultLang = "ru", getLang = null }
|
||||
return t(`wa_sub_term_${unit}_${bucket}`);
|
||||
}
|
||||
|
||||
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
|
||||
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel, mergeMessages };
|
||||
}
|
||||
|
||||
export { formatFraction, roundToHalf };
|
||||
|
||||
@@ -66,6 +66,150 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
premium_traffic: { state: "none" },
|
||||
},
|
||||
];
|
||||
const supportTickets = [
|
||||
{
|
||||
ticket_id: 42,
|
||||
user_id: 100200300,
|
||||
subject: "Не подключается профиль на телефоне",
|
||||
category: "technical",
|
||||
priority: "high",
|
||||
status: "awaiting_admin",
|
||||
unread_user_count: 0,
|
||||
unread_admin_count: 2,
|
||||
last_message_at: new Date(Date.now() - 18 * 60000).toISOString(),
|
||||
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
|
||||
user: adminUsers[0],
|
||||
},
|
||||
{
|
||||
ticket_id: 43,
|
||||
user_id: 100200300,
|
||||
subject: "Вопрос по оплате подписки",
|
||||
category: "billing",
|
||||
priority: "normal",
|
||||
status: "awaiting_user",
|
||||
unread_user_count: 1,
|
||||
unread_admin_count: 0,
|
||||
last_message_at: new Date(Date.now() - 4 * 3600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||
user: adminUsers[0],
|
||||
},
|
||||
{
|
||||
ticket_id: 41,
|
||||
user_id: 100200300,
|
||||
subject: "Закрытый вопрос по старому профилю",
|
||||
category: "technical",
|
||||
priority: "low",
|
||||
status: "closed",
|
||||
unread_user_count: 0,
|
||||
unread_admin_count: 0,
|
||||
last_message_at: new Date(Date.now() - 4 * 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 6 * 86400000).toISOString(),
|
||||
closed_at: new Date(Date.now() - 4 * 86400000).toISOString(),
|
||||
user: adminUsers[0],
|
||||
},
|
||||
];
|
||||
function supportCounts(items = supportTickets) {
|
||||
const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0 };
|
||||
for (const item of items) {
|
||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||
}
|
||||
const closed = (byStatus.closed || 0) + (byStatus.resolved || 0);
|
||||
const active = items.length - closed;
|
||||
return { ...byStatus, active, closed, total: items.length };
|
||||
}
|
||||
function filterSupportTickets(items, params) {
|
||||
let out = [...items];
|
||||
const status = params.get("status");
|
||||
if (status === "active")
|
||||
out = out.filter((item) => !["closed", "resolved"].includes(item.status));
|
||||
else if (status === "closed")
|
||||
out = out.filter((item) => ["closed", "resolved"].includes(item.status));
|
||||
else if (status) out = out.filter((item) => item.status === status);
|
||||
const priority = params.get("priority");
|
||||
if (priority) out = out.filter((item) => item.priority === priority);
|
||||
const category = params.get("category");
|
||||
if (category) out = out.filter((item) => item.category === category);
|
||||
const search = (params.get("search") || "").trim().toLowerCase();
|
||||
if (search) {
|
||||
out = out.filter((item) =>
|
||||
[item.subject, item.user?.username, item.user?.email, String(item.ticket_id)]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
const sort = params.get("sort") || "updated_desc";
|
||||
const priorityRank = { urgent: 4, high: 3, normal: 2, low: 1 };
|
||||
out.sort((a, b) => {
|
||||
if (sort === "importance_desc") {
|
||||
return (
|
||||
(priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0) ||
|
||||
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
|
||||
);
|
||||
}
|
||||
if (sort === "updated_asc") {
|
||||
return (
|
||||
new Date(a.last_message_at || a.created_at) - new Date(b.last_message_at || b.created_at)
|
||||
);
|
||||
}
|
||||
if (sort === "created_desc") return new Date(b.created_at) - new Date(a.created_at);
|
||||
if (sort === "created_asc") return new Date(a.created_at) - new Date(b.created_at);
|
||||
return (
|
||||
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
|
||||
);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const supportMessages = {
|
||||
42: [
|
||||
{
|
||||
message_id: 1,
|
||||
ticket_id: 42,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: "После обновления приложения профиль перестал подключаться. Ошибка появляется сразу после импорта ссылки.",
|
||||
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
message_id: 2,
|
||||
ticket_id: 42,
|
||||
author_role: "admin",
|
||||
author_user_id: 1,
|
||||
author_name: "Мария, поддержка",
|
||||
body: "Проверили подписку, она активна. Попробуйте удалить старый профиль и импортировать ссылку ещё раз.",
|
||||
created_at: new Date(Date.now() - 90 * 60000).toISOString(),
|
||||
},
|
||||
{
|
||||
message_id: 3,
|
||||
ticket_id: 42,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: "Сделал так, но теперь вижу timeout. Телефон iPhone, сеть домашний Wi‑Fi.",
|
||||
created_at: new Date(Date.now() - 18 * 60000).toISOString(),
|
||||
},
|
||||
],
|
||||
43: [
|
||||
{
|
||||
message_id: 4,
|
||||
ticket_id: 43,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: "Оплата прошла, но срок подписки не изменился.",
|
||||
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
message_id: 5,
|
||||
ticket_id: 43,
|
||||
author_role: "admin",
|
||||
author_user_id: 2,
|
||||
author_name: "Иван, поддержка",
|
||||
body: "Платёж нашли и применили вручную. Проверьте, пожалуйста, дату окончания подписки.",
|
||||
created_at: new Date(Date.now() - 4 * 3600000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockAdminDailySeries = (() => {
|
||||
const days = 730;
|
||||
const out = [];
|
||||
@@ -331,8 +475,134 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
},
|
||||
],
|
||||
};
|
||||
if (cleanPath === "/admin/support/stats") {
|
||||
return {
|
||||
ok: true,
|
||||
stats: { ...supportCounts(), total_unread_admin: 2 },
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/admin/support/tickets") {
|
||||
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
|
||||
const tickets = filterSupportTickets(supportTickets, params);
|
||||
return { ok: true, tickets: clone(tickets), total: tickets.length };
|
||||
}
|
||||
if (cleanPath.startsWith("/admin/support/tickets/")) {
|
||||
const parts = cleanPath.split("/");
|
||||
const ticketId = Number(parts[4]);
|
||||
const ticket = clone(
|
||||
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
|
||||
);
|
||||
if (parts[5] === "messages") {
|
||||
return {
|
||||
ok: true,
|
||||
ticket,
|
||||
message: {
|
||||
message_id: Date.now(),
|
||||
ticket_id: ticket.ticket_id,
|
||||
author_role: "admin",
|
||||
author_user_id: 1,
|
||||
author_name: "Мария, поддержка",
|
||||
body: JSON.parse(options?.body || "{}")?.body || "",
|
||||
is_internal_note: Boolean(JSON.parse(options?.body || "{}")?.is_internal_note),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||
return { ok: true, ticket: { ...ticket, ...(JSON.parse(options?.body || "{}") || {}) } };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
ticket,
|
||||
messages: clone([
|
||||
...(supportMessages[ticket.ticket_id] || []),
|
||||
{
|
||||
message_id: 99,
|
||||
ticket_id: ticket.ticket_id,
|
||||
author_role: "admin",
|
||||
author_user_id: 1,
|
||||
author_name: "Мария, поддержка",
|
||||
body: "Внутренняя заметка для команды: проверить последние логи панели перед ответом.",
|
||||
is_internal_note: true,
|
||||
created_at: new Date(Date.now() - 12 * 60000).toISOString(),
|
||||
},
|
||||
]),
|
||||
user_snapshot: {
|
||||
user_id: ticket.user_id,
|
||||
name: "Анна Смирнова",
|
||||
username: "anna_ops",
|
||||
email: "anna@example.com",
|
||||
tariff: "Standard",
|
||||
panel_status: "ACTIVE",
|
||||
remaining: "20 д. 4 ч.",
|
||||
regular_traffic: "12 GB / 500 GB",
|
||||
premium_traffic: "4 GB / 25 GB",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (cleanPath.startsWith("/admin/"))
|
||||
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
|
||||
if (
|
||||
cleanPath === "/support/tickets" &&
|
||||
String(options.method || "GET").toUpperCase() === "POST"
|
||||
) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(options?.body || "{}");
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
ticket: {
|
||||
ticket_id: 44,
|
||||
user_id: 100200300,
|
||||
subject: payload.subject || "Новое обращение",
|
||||
category: payload.category || "other",
|
||||
priority: payload.priority || "normal",
|
||||
status: "awaiting_admin",
|
||||
unread_user_count: 0,
|
||||
unread_admin_count: 1,
|
||||
last_message_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/support/tickets") {
|
||||
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
|
||||
const tickets = filterSupportTickets(supportTickets, params);
|
||||
return {
|
||||
ok: true,
|
||||
tickets: clone(tickets),
|
||||
total: tickets.length,
|
||||
counts: supportCounts(),
|
||||
};
|
||||
}
|
||||
if (cleanPath.startsWith("/support/tickets/")) {
|
||||
const parts = cleanPath.split("/");
|
||||
const ticketId = Number(parts[3]);
|
||||
const ticket = clone(
|
||||
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
|
||||
);
|
||||
if (parts[4] === "read") return { ok: true };
|
||||
if (parts[4] === "messages") {
|
||||
return {
|
||||
ok: true,
|
||||
ticket,
|
||||
message: {
|
||||
message_id: Date.now(),
|
||||
ticket_id: ticket.ticket_id,
|
||||
author_role: "user",
|
||||
author_user_id: 100200300,
|
||||
author_name: "Анна Смирнова",
|
||||
body: JSON.parse(options?.body || "{}")?.body || "",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, ticket, messages: clone(supportMessages[ticket.ticket_id] || []) };
|
||||
}
|
||||
if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
|
||||
if (path === "/me") return clone(DEV_MOCK.data);
|
||||
if (path === "/auth/email/request") return { ok: true };
|
||||
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||
|
||||
@@ -7,6 +7,7 @@ export function normalizeSection(value) {
|
||||
if (
|
||||
section === "invite" ||
|
||||
section === "devices" ||
|
||||
section === "support" ||
|
||||
section === "settings" ||
|
||||
section === "admin"
|
||||
) {
|
||||
@@ -22,6 +23,7 @@ export function sectionFromPath(pathname) {
|
||||
.replace(/\/+$/, "");
|
||||
if (!normalizedPath || normalizedPath === "/") return "home";
|
||||
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
|
||||
if (normalizedPath === "/support" || normalizedPath.startsWith("/support/")) return "support";
|
||||
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
|
||||
return normalizeSection(section);
|
||||
}
|
||||
@@ -43,6 +45,22 @@ export function adminUserIdFromPath(pathname) {
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function supportTicketIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/support\/(\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function adminSupportTicketIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/support\/(\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
|
||||
if (window.location.protocol === "file:") return;
|
||||
const normalized = normalizeSection(section);
|
||||
@@ -51,7 +69,11 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
|
||||
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
|
||||
const uid =
|
||||
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
|
||||
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
|
||||
const supportTicketId =
|
||||
adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null;
|
||||
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
|
||||
else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
|
||||
else targetPath = `/admin/${adm}`;
|
||||
}
|
||||
if (window.location.pathname === targetPath) return;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
|
||||
@@ -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