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
@@ -532,7 +532,9 @@
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span>
<span
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
>
</label>
<input
bind:this={faviconFileInput}
@@ -697,8 +699,7 @@
max="300"
step="5"
value={homeLogoScale(theme)}
oninput={(event) =>
setThemeHomeLogoScale(theme, event.currentTarget.value)}
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
/>
%
</span>
@@ -139,14 +139,15 @@
function sectionTitle(id) {
const map = {
general: at("settings_section_general", {}, "Общие"),
appearance: at("settings_section_appearance", {}, "Внешний вид"),
pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
payments: at("settings_section_payments", {}, "Платёжные системы"),
trial: at("settings_section_trial", {}, "Триал"),
referral: at("settings_section_referral", {}, "Реферальная программа"),
notifications: at("settings_section_notifications", {}, "Уведомления"),
devices: at("settings_section_devices", {}, "Устройства"),
general: at("admin_settings_section_general", {}, "Общие"),
appearance: at("admin_settings_section_appearance", {}, "Внешний вид"),
pricing: at("admin_settings_section_pricing", {}, "Тарифы и цены"),
payments: at("admin_settings_section_payments", {}, "Платёжные системы"),
trial: at("admin_settings_section_trial", {}, "Триал"),
referral: at("admin_settings_section_referral", {}, "Реферальная программа"),
notifications: at("admin_settings_section_notifications", {}, "Уведомления"),
support: at("admin_settings_section_support", {}, "Поддержка"),
devices: at("admin_settings_section_devices", {}, "Устройства"),
};
return map[id] || id;
}
@@ -0,0 +1,347 @@
<script>
import { afterUpdate, getContext, onMount, tick } from "svelte";
import {
AdminButton,
AdminSelect,
SupportComposer,
SupportInboxRow,
SupportTicketHeader,
SupportUserContextPanel,
} from "$components/patterns/admin/index.js";
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
import Dialog from "$components/ui/dialog.svelte";
import { Search } from "$components/ui/icons.js";
import { ScrollArea, Skeleton } from "$components/ui/index.js";
export let at = (key) => key;
export let initialTicketId = null;
export let brand = {};
export let resolvedAvatarUrl = () => "";
const supportStore = getContext("adminSupportStore");
let reply = "";
let messagesScrollEl;
let lastMessageScrollKey = "";
$: ({
tickets,
stats,
loading,
filters,
openedTicketId,
openedTicket,
messages,
userSnapshot,
sending,
composerInternalNote,
} = $supportStore);
$: statusTabs = [
{
value: "active",
label: at("support_filter_active", {}, "Активные"),
count: stats?.active || 0,
},
{
value: "closed",
label: at("support_filter_closed", {}, "Закрытые"),
count: stats?.closed || 0,
},
];
$: priorityFilterOptions = [
{ value: "all", label: at("support_filter_all_priorities", {}, "Любой приоритет") },
{ value: "low", label: at("support_priority_low", {}, "Низкий") },
{ value: "normal", label: at("support_priority_normal", {}, "Обычный") },
{ value: "high", label: at("support_priority_high", {}, "Высокий") },
{ value: "urgent", label: at("support_priority_urgent", {}, "Срочный") },
];
$: categoryFilterOptions = [
{ value: "all", label: at("support_filter_all_categories", {}, "Все категории") },
{ value: "billing", label: at("support_category_billing", {}, "Оплата") },
{ value: "technical", label: at("support_category_technical", {}, "Техническое") },
{ value: "account", label: at("support_category_account", {}, "Аккаунт") },
{ value: "other", label: at("support_category_other", {}, "Другое") },
];
$: sortOptions = [
{ value: "importance_desc", label: at("support_sort_importance_desc", {}, "Важные сверху") },
{ value: "updated_desc", label: at("sort_updated_desc", {}, "Сначала новые") },
{ value: "updated_asc", label: at("sort_updated_asc", {}, "Сначала старые") },
{ value: "created_desc", label: at("sort_created_desc", {}, "Созданы недавно") },
{ value: "created_asc", label: at("sort_created_asc", {}, "Созданы давно") },
];
$: ticketReady = Boolean(openedTicket && openedTicket.ticket_id === openedTicketId);
$: modalTitle = ticketReady
? openedTicket.subject
: openedTicketId
? at("support_ticket_number", { id: openedTicketId }, `Тикет #${openedTicketId}`)
: at("support_ticket_dialog", {}, "Диалог поддержки");
$: modalDescription = ticketReady
? at("support_ticket_number", { id: openedTicketId }, `Тикет #${openedTicketId}`)
: at("loading", {}, "Загрузка");
$: openedTicketUser = openedTicket?.user || {};
$: openedTicketUserAvatarUrl = resolvedAvatarUrl(openedTicketUser);
$: openedTicketUserInitials = userInitials(openedTicketUser);
$: if (!openedTicketId) {
reply = "";
lastMessageScrollKey = "";
}
onMount(() => {
supportStore.loadList();
supportStore.loadStats();
supportStore.startStatsPolling();
if (initialTicketId) supportStore.openTicket(initialTicketId, { skipPush: true });
});
async function send(body) {
await supportStore.sendReply(body);
reply = "";
}
function scrollMessagesToBottom() {
if (!messagesScrollEl) return;
const scroll = () => {
messagesScrollEl.scrollTop = messagesScrollEl.scrollHeight;
};
scroll();
requestAnimationFrame(scroll);
window.setTimeout(scroll, 80);
window.setTimeout(scroll, 180);
}
function closeTicketModal() {
reply = "";
supportStore.closeTicketView();
}
function setFilter(key, value) {
supportStore.setFilter(key, value === "all" ? "" : value);
}
function setFilterAndLoad(key, value) {
setFilter(key, value);
supportStore.loadList();
}
function messageT(key, params = {}, fallback = "") {
if (key.startsWith("wa_support_")) {
return at(key.replace("wa_support_", "support_"), params, fallback || key);
}
return at(key, params, fallback || key);
}
function userInitials(user) {
const source =
[user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
user?.username ||
user?.email ||
String(user?.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 ticketUserDisplayName() {
const user = openedTicketUser || {};
const fullName = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
return (
snapshotName(userSnapshot) ||
fullName ||
user.username ||
user.email ||
String(user.user_id || "")
);
}
function snapshotName(snapshot) {
return String(snapshot?.name || "").trim();
}
function messageAuthorName(message) {
if (message?.author_name) return message.author_name;
if (message?.author_role === "user") return ticketUserDisplayName();
if (message?.author_role === "admin" && message?.author_user_id) {
return `${at("support_role_admin", {}, "Админ")} #${message.author_user_id}`;
}
return "";
}
afterUpdate(async () => {
const lastMessage = messages.at(-1);
const nextKey = `${openedTicketId || ""}:${ticketReady}:${messages.length}:${
lastMessage?.message_id || lastMessage?.created_at || ""
}`;
if (!openedTicketId || !ticketReady || !messagesScrollEl || nextKey === lastMessageScrollKey) {
return;
}
lastMessageScrollKey = nextKey;
await tick();
scrollMessagesToBottom();
});
</script>
<div class="support-admin-layout">
<div class="support-admin-summary" aria-label={at("support_summary", {}, "Сводка поддержки")}>
<span>
<strong>{stats?.open || 0}</strong>
<small>{at("support_status_open", {}, "Открыт")}</small>
</span>
<span>
<strong>{stats?.awaiting_admin || 0}</strong>
<small>{at("support_status_awaiting_admin", {}, "Ждет админа")}</small>
</span>
<span>
<strong>{stats?.total_unread_admin || 0}</strong>
<small>{at("support_unread", {}, "Непрочитано")}</small>
</span>
</div>
<section class="support-admin-list-panel">
<div class="support-admin-ticket-tabs" aria-label={at("support_status", {}, "Статус")}>
{#each statusTabs as tab (tab.value)}
<button
type="button"
class:active={filters.status === tab.value}
on:click={() => supportStore.setStatusView(tab.value)}
>
<span>{tab.label}</span>
<b>{tab.count}</b>
</button>
{/each}
</div>
<div class="support-admin-toolbar admin-toolbar-card">
<label class="support-admin-search">
<Search size={16} />
<input
class="input"
type="search"
placeholder={at("support_search", {}, "Поиск")}
value={filters.search}
on:input={(e) => supportStore.setFilter("search", e.target.value)}
on:keydown={(e) => e.key === "Enter" && supportStore.loadList()}
/>
</label>
<div class="support-admin-filter-row">
<AdminSelect
value={filters.priority || "all"}
items={priorityFilterOptions}
ariaLabel={at("support_priority", {}, "Приоритет")}
onValueChange={(value) => setFilterAndLoad("priority", value)}
/>
<AdminSelect
value={filters.category || "all"}
items={categoryFilterOptions}
ariaLabel={at("support_category", {}, "Категория")}
onValueChange={(value) => setFilterAndLoad("category", value)}
/>
<AdminSelect
value={filters.sort || "importance_desc"}
items={sortOptions}
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => setFilterAndLoad("sort", value)}
/>
<AdminButton variant="primary" onclick={() => supportStore.loadList()}>
{at("apply", {}, "Применить")}
</AdminButton>
</div>
</div>
{#if loading}
<div class="support-ticket-list-skeleton" aria-label={at("loading", {}, "Загрузка")}>
{#each Array(6) as _, index (index)}
<article class="support-ticket-row-skeleton">
<Skeleton variant="dot" width="38px" height="38px" />
<span class="support-ticket-row-skeleton-main">
<Skeleton variant="title" width="min(380px, 74%)" />
<Skeleton variant="short" width="min(280px, 58%)" />
</span>
<span class="support-ticket-row-skeleton-side">
<Skeleton variant="badge" width="92px" />
<Skeleton variant="tiny" width="64px" />
</span>
</article>
{/each}
</div>
{:else if !tickets.length}
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
{:else}
<div class="support-inbox-list">
{#each tickets as ticket}
<SupportInboxRow
{ticket}
active={openedTicketId === ticket.ticket_id}
{at}
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
/>
{/each}
</div>
{/if}
</section>
</div>
<Dialog
open={Boolean(openedTicketId)}
title={modalTitle}
description={modalDescription}
closeLabel={at("close", {}, "Закрыть")}
onclose={closeTicketModal}
class="admin-dialog support-ticket-dialog"
>
{#if !ticketReady}
<div class="support-ticket-dialog-skeleton">
<Skeleton variant="title" width="70%" />
<Skeleton variant="short" width="44%" />
<Skeleton variant="block" height="94px" />
<Skeleton variant="block" height="220px" />
<Skeleton variant="block" height="132px" />
</div>
{:else}
<div class="support-ticket-dialog-body">
<SupportTicketHeader
ticket={openedTicket}
{at}
onPatch={(updates) => supportStore.patchTicket(updates)}
onClose={() => supportStore.closeTicket()}
/>
<SupportUserContextPanel ticket={openedTicket} snapshot={userSnapshot} {at} />
<ScrollArea
bind:element={messagesScrollEl}
maxHeight="none"
class="support-admin-message-scroll scroll-area--mono"
>
<div class="support-admin-messages">
{#if messages.length}
{#each messages as message}
<TicketMessageBubble
role={message.author_role}
body={message.body}
createdAt={message.created_at}
isInternalNote={message.is_internal_note}
perspective="admin"
supportBrand={brand}
userAvatarUrl={openedTicketUserAvatarUrl}
userInitials={openedTicketUserInitials}
authorName={messageAuthorName(message)}
t={messageT}
/>
{/each}
{:else}
<div class="admin-empty-state">
{at("support_no_messages", {}, "Сообщений пока нет")}
</div>
{/if}
</div>
</ScrollArea>
<SupportComposer
bind:value={reply}
internal={composerInternalNote}
{sending}
{at}
onToggleInternal={supportStore.toggleInternalNote}
onSend={send}
/>
</div>
{/if}
</Dialog>