feat: add support tickets and imrpove web app loading
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user