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
+2
View File
@@ -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",
+19 -2
View File
@@ -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 };
+270
View File
@@ -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") {
+23 -1
View File
@@ -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,
};
}