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
@@ -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>