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(
@@ -68,7 +68,7 @@
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor(
"--admin-chart-stroke",
readCssColor("--admin-text", "#e8f0ec"),
readCssColor("--admin-text", "#e8f0ec")
);
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
@@ -0,0 +1,50 @@
<script>
import { Lock, Send } from "$components/ui/icons.js";
import { Spinner, Textarea } from "$components/ui/index.js";
import { Switch } from "$components/ui/primitives.js";
import { AdminButton } from "$components/patterns/admin/index.js";
export let value = "";
export let internal = false;
export let sending = false;
export let at = (key) => key;
export let onToggleInternal = () => {};
export let onSend = () => {};
function submit() {
if (sending || !value.trim()) return;
onSend(value.trim());
}
</script>
<div class="support-admin-composer">
<Textarea
bind:value
rows={4}
placeholder={at("support_reply_placeholder", {}, "Ответ")}
ariaLabel={at("support_reply_placeholder", {}, "Ответ")}
class="support-admin-composer-textarea"
/>
<div class="support-admin-composer-row">
<div class="support-admin-note-toggle">
<Switch.Root
id="support-internal-note"
checked={internal}
onCheckedChange={onToggleInternal}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<label for="support-internal-note">
<Lock size={14} />
<span>{at("support_internal_note", {}, "Внутренняя заметка")}</span>
</label>
</div>
<AdminButton variant="primary" disabled={sending || !value.trim()} onclick={submit}>
{#if sending}<Spinner size="sm" />{:else}<Send size={14} />{/if}
{at("send", {}, "Отправить")}
</AdminButton>
</div>
</div>
@@ -0,0 +1,84 @@
<script>
import { AdminBadge } from "$components/patterns/admin/index.js";
import { MessageSquare } from "$components/ui/icons.js";
export let ticket;
export let active = false;
export let at = (key) => key;
export let onOpen = () => {};
$: user = ticket?.user || {};
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
$: userLabel = user.username ? `@${user.username}` : user.email || user.user_id || "-";
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
$: avatarInitials = computeInitials(user);
$: categoryLabel = at(`support_category_${ticket?.category}`, {}, ticket?.category || "-");
$: statusVariant =
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
$: priorityVariant =
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
function computeInitials(u) {
const source =
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
u?.username ||
u?.email ||
String(u?.user_id || "");
const clean = String(source).replace(/^@/, "").trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (clean.slice(0, 2) || "U").toUpperCase();
}
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<button
class:active
class="support-inbox-row"
type="button"
data-status={ticket?.status}
data-priority={ticket?.priority}
on:click={() => onOpen(ticket)}
>
<span class="support-inbox-row-avatar" aria-hidden="true">
{#if avatarUrl}
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
{avatarInitials}
{/if}
</span>
<span class="support-inbox-row-main">
<span class="support-inbox-row-title">
<MessageSquare size={15} />
<strong>{ticket.subject}</strong>
</span>
<small>#{ticket.ticket_id} / {userLabel} / {categoryLabel}</small>
</span>
<span class="support-row-badges">
<AdminBadge variant={statusVariant}
>{at(`support_status_${ticket.status}`, {}, ticket.status)}</AdminBadge
>
<AdminBadge variant={priorityVariant}>
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
</AdminBadge>
{#if ticket.unread_admin_count}
<b>
<span class="numeric-badge-value">{ticket.unread_admin_count}</span>
</b>
{/if}
{#if timeLabel}<small>{timeLabel}</small>{/if}
</span>
</button>
@@ -0,0 +1,79 @@
<script>
import { AdminBadge, AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { CheckCheck } from "$components/ui/icons.js";
export let ticket;
export let at = (key) => key;
export let onPatch = () => {};
export let onClose = () => {};
$: statusOptions = ["open", "awaiting_user", "awaiting_admin", "resolved", "closed"].map(
(item) => ({
value: item,
label: at(`support_status_${item}`, {}, item),
})
);
$: priorityOptions = ["low", "normal", "high", "urgent"].map((item) => ({
value: item,
label: at(`support_priority_${item}`, {}, item),
}));
$: categoryOptions = ["billing", "technical", "account", "other"].map((item) => ({
value: item,
label: at(`support_category_${item}`, {}, item),
}));
$: statusVariant =
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
$: priorityVariant =
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
function patch(key, value) {
if (!ticket || ticket[key] === value) return;
onPatch({ [key]: value });
}
</script>
{#if ticket}
<div class="support-ticket-header">
<div class="support-ticket-statusbar">
<AdminBadge variant={statusVariant}>
{at(`support_status_${ticket.status}`, {}, ticket.status)}
</AdminBadge>
<AdminBadge variant={priorityVariant}>
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
</AdminBadge>
</div>
<div class="support-ticket-actions">
<AdminButton
class="support-ticket-close"
variant="dangerSoft"
onclick={onClose}
disabled={ticket.status === "closed"}
>
<CheckCheck size={14} />
{at("support_close_ticket", {}, "Закрыть тикет")}
</AdminButton>
</div>
<div class="support-ticket-controls">
<AdminSelect
value={ticket.status}
items={statusOptions}
ariaLabel={at("support_status", {}, "Статус")}
onValueChange={(value) => patch("status", value)}
/>
<AdminSelect
value={ticket.priority}
items={priorityOptions}
ariaLabel={at("support_priority", {}, "Приоритет")}
onValueChange={(value) => patch("priority", value)}
/>
<AdminSelect
value={ticket.category}
items={categoryOptions}
ariaLabel={at("support_category", {}, "Категория")}
onValueChange={(value) => patch("category", value)}
/>
</div>
</div>
{/if}
@@ -0,0 +1,75 @@
<script>
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let ticket;
export let snapshot = {};
export let at = (key) => key;
$: user = ticket?.user || {};
$: displayName = snapshot?.name || user.username || user.email || user.user_id || "-";
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
$: avatarInitials = computeInitials(user, displayName);
$: canOpenUser = user.user_id !== undefined && user.user_id !== null && user.user_id !== "";
$: identityMeta =
[user.email, canOpenUser ? `ID ${user.user_id}` : ""].filter(Boolean).join(" / ") || "-";
$: contextItems = [
{ label: at("support_tariff", {}, "Тариф"), value: snapshot?.tariff || "-" },
{ label: at("support_status", {}, "Статус"), value: snapshot?.panel_status || "-" },
{ label: at("support_remaining", {}, "Осталось"), value: snapshot?.remaining || "-" },
];
function computeInitials(u, fallback) {
const source =
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
u?.username ||
u?.email ||
String(fallback || "");
const clean = String(source).replace(/^@/, "").trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (clean.slice(0, 2) || "U").toUpperCase();
}
</script>
<section class="support-user-context" aria-label={at("support_user_context", {}, "User")}>
<div class="support-user-context-head">
<span class="support-user-context-avatar" aria-hidden="true">
{#if avatarUrl}
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
{avatarInitials}
{/if}
</span>
<div class="support-user-context-identity">
<strong>{displayName}</strong>
<small>{identityMeta}</small>
{#if user.is_banned}
<AdminBadge variant="danger">{at("status_banned", {}, "Бан")}</AdminBadge>
{/if}
</div>
</div>
<div class="support-user-context-metrics">
{#each contextItems as item (item.label)}
<span>
<small>{item.label}</small>
<strong>{item.value}</strong>
</span>
{/each}
</div>
<div class="support-user-context-actions">
<AdminButton
class="support-user-card-btn"
variant="ghost"
size="icon"
disabled={!canOpenUser}
onclick={() => (window.location.href = `/admin/users/${user.user_id}`)}
aria-label={at("support_open_user", {}, "Карточка")}
title={at("support_open_user", {}, "Карточка")}
>
<User size={14} />
</AdminButton>
</div>
</section>
@@ -12,3 +12,7 @@ export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
export { default as AdminTable } from "./AdminTable.svelte";
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
export { default as SupportComposer } from "./SupportComposer.svelte";
export { default as SupportInboxRow } from "./SupportInboxRow.svelte";
export { default as SupportTicketHeader } from "./SupportTicketHeader.svelte";
export { default as SupportUserContextPanel } from "./SupportUserContextPanel.svelte";
@@ -0,0 +1,59 @@
<script>
import { AttentionDot, Badge } from "$components/ui/index.js";
import { MessageSquare } from "$components/ui/icons.js";
export let ticket;
export let t = (key) => key;
export let onOpen = () => {};
$: unread = Number(ticket?.unread_user_count || 0);
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<button
class="ticket-card"
type="button"
data-status={ticket?.status}
data-priority={ticket?.priority}
on:click={() => onOpen(ticket)}
>
<span class="ticket-card-main">
<span class="ticket-card-title">
<MessageSquare size={16} />
<strong>{ticket.subject}</strong>
</span>
<span class="ticket-card-meta">
<span>{t("wa_support_ticket_number", { id: ticket.ticket_id })}</span>
{#if timeLabel}<span>{timeLabel}</span>{/if}
</span>
</span>
<span class="ticket-card-side">
<span class="ticket-card-badges">
<Badge variant="outline" class={`ticket-status-badge ticket-status-badge--${ticket.status}`}>
{t(`wa_support_status_${ticket.status}`)}
</Badge>
<Badge
variant="muted"
class={`ticket-priority-badge ticket-priority-badge--${ticket.priority}`}
>
{t(`wa_support_priority_${ticket.priority}`)}
</Badge>
</span>
{#if unread}
<AttentionDot position="inline" class="ticket-card-unread-dot" />
{/if}
</span>
</button>
@@ -0,0 +1,41 @@
<script>
import { Send } from "$components/ui/icons.js";
import { Button, Spinner, Textarea } from "$components/ui/index.js";
export let value = "";
export let maxLength = 4000;
export let disabled = false;
export let sending = false;
export let placeholder = "";
export let sendLabel = "";
export let onSend = () => {};
function submit() {
if (disabled || sending || !value.trim()) return;
onSend(value.trim());
}
</script>
<div class="ticket-composer">
<Textarea
bind:value
rows={3}
maxlength={maxLength}
{disabled}
{placeholder}
ariaLabel={placeholder}
class="ticket-composer-textarea"
/>
<div class="ticket-composer-row">
<small>{value.length}/{maxLength}</small>
<Button
type="button"
class="ticket-composer-send"
disabled={disabled || sending || !value.trim()}
onclick={submit}
>
{#if sending}<Spinner size="sm" />{:else}<Send size={16} />{/if}
<span>{sendLabel}</span>
</Button>
</div>
</div>
@@ -0,0 +1,79 @@
<script>
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { LifeBuoy, Lock, MessageSquare, UserRound } from "$components/ui/icons.js";
export let role = "user";
export let body = "";
export let createdAt = "";
export let isInternalNote = false;
export let perspective = "user";
export let userAvatarUrl = "";
export let userInitials = "";
export let authorName = "";
export let supportBrand = {};
export let t = (key, _params = {}, fallback = "") => fallback || key;
$: messageRole = role || "system";
$: serviceMessage = isInternalNote || messageRole === "system";
$: outgoing =
(perspective === "admin" && (messageRole === "admin" || serviceMessage)) ||
(!serviceMessage && perspective !== "admin" && messageRole === "user");
$: roleLabel = isInternalNote
? [authorName, t("wa_support_internal_note", {}, "Внутренняя заметка")]
.filter(Boolean)
.join(" / ")
: authorName || t(`wa_support_role_${messageRole}`, {}, messageRole);
$: timeLabel = formatTime(createdAt);
$: showSupportAvatar = !isInternalNote && messageRole === "admin";
$: showUserAvatar = !isInternalNote && messageRole === "user";
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<article
class={`ticket-message-row ticket-message-row--${messageRole}`.trim()}
class:ticket-message-row--outgoing={outgoing}
class:ticket-message-row--incoming={!outgoing}
class:ticket-message-row--internal={isInternalNote}
>
<span class="ticket-message-avatar" aria-hidden="true">
{#if isInternalNote}
<Lock size={15} />
{:else if showSupportAvatar}
<BrandMark brand={supportBrand} size="sm" fallbackEmoji={true} />
{:else if showUserAvatar && userAvatarUrl}
<img src={userAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else if showUserAvatar && userInitials}
<strong>{userInitials}</strong>
{:else if messageRole === "admin"}
<LifeBuoy size={15} />
{:else if messageRole === "user"}
<UserRound size={15} />
{:else}
<MessageSquare size={15} />
{/if}
</span>
<div class="ticket-message-content">
<div class="ticket-message-meta">
<span class="ticket-message-author">{roleLabel}</span>
{#if timeLabel}
<time datetime={createdAt}>{timeLabel}</time>
{/if}
</div>
<div class="ticket-message-bubble">
<p>{body}</p>
</div>
</div>
</article>
@@ -4,3 +4,6 @@ export { default as LinearProgress } from "./LinearProgress.svelte";
export { default as LanguageSelect } from "./LanguageSelect.svelte";
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
export { default as StatusMessage } from "./StatusMessage.svelte";
export { default as TicketCard } from "./TicketCard.svelte";
export { default as TicketComposer } from "./TicketComposer.svelte";
export { default as TicketMessageBubble } from "./TicketMessageBubble.svelte";
@@ -0,0 +1,14 @@
<script>
import { cn } from "$lib/utils.js";
export let position = "absolute";
let className = "";
export { className as class };
</script>
<span
data-slot="attention-dot"
aria-hidden="true"
class={cn("attention-dot", position === "inline" && "attention-dot-inline", className)}
{...$$restProps}
></span>
+4
View File
@@ -5,6 +5,7 @@ export {
Bitcoin,
CalendarDays,
Check,
CheckCheck,
CheckCircle2,
ChevronDown,
ChevronLeft,
@@ -28,12 +29,15 @@ export {
Info,
Key,
LayoutDashboard,
LifeBuoy,
Lock,
LockKeyhole,
Mail,
Map,
Megaphone,
Menu,
MessageSquare,
MessageSquarePlus,
MousePointerClick,
Paintbrush,
Plus,
+3
View File
@@ -1,3 +1,4 @@
export { default as AttentionDot } from "./attention-dot.svelte";
export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte";
export { default as Dialog } from "./dialog.svelte";
@@ -5,6 +6,8 @@ export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte";
export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte";
export { default as ScrollArea } from "./scroll-area.svelte";
export { default as Textarea } from "./textarea.svelte";
export * as Icons from "./icons.js";
export * as Card from "./card/index.js";
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
@@ -0,0 +1,15 @@
<script>
export let maxHeight = "100%";
export let element = null;
let className = "";
export { className as class };
</script>
<div
bind:this={element}
class={`scroll-area ${className}`.trim()}
style={`max-height:${maxHeight};`}
{...$$restProps}
>
<slot />
</div>
@@ -0,0 +1,23 @@
<script>
export let value = "";
export let rows = 3;
export let disabled = false;
export let placeholder = "";
export let maxlength = undefined;
export let ariaLabel = "";
let className = "";
export { className as class };
</script>
<textarea
class={`textarea ${className}`.trim()}
bind:value
{rows}
{disabled}
{placeholder}
{maxlength}
aria-label={ariaLabel || placeholder}
on:input
on:keydown
{...$$restProps}
></textarea>
+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,
};
}