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
+69 -11
View File
@@ -2,10 +2,12 @@
import {
Gift,
Home,
LifeBuoy,
Settings as SettingsIcon,
Shield,
Smartphone,
} from "$components/ui/icons.js";
import { AttentionDot } from "$components/ui/index.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
@@ -13,51 +15,107 @@
export let brand = {};
export let brandTitle = "";
export let devicesEnabled = false;
export let supportEnabled = true;
export let supportUnreadCount = 0;
export let supportUnreadLoading = false;
export let supportUnreadLoaded = false;
export let hasUnlinkedIdentity = false;
export let isAdmin = false;
export let onAdmin = () => {};
export let onDevices = () => {};
export let onHome = () => {};
export let onInvite = () => {};
export let onSupport = () => {};
export let onSettings = () => {};
export let t = (key) => key;
$: visibleNavItems = 3 + (devicesEnabled ? 1 : 0) + (supportEnabled ? 1 : 0);
$: adminLabel = t("admin_nav_title", {}, "Админ-панель");
</script>
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
<nav
class:bottom-nav-devices={devicesEnabled}
class:bottom-nav-many={visibleNavItems >= 5}
class="bottom-nav"
style={`--bottom-nav-visible-items: ${visibleNavItems}`}
aria-label={t("wa_navigation")}
>
<div class="rail-brand" aria-hidden="true">
<BrandMark {brand} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
<button
class:active={activeTab === "home"}
type="button"
aria-label={t("wa_nav_home")}
title={t("wa_nav_home")}
onclick={onHome}
>
<Home size={21} />
<span>{t("wa_nav_home")}</span>
<span class="bottom-nav-label">{t("wa_nav_home")}</span>
</button>
<button class:active={activeTab === "invite"} type="button" onclick={onInvite}>
<button
class:active={activeTab === "invite"}
type="button"
aria-label={t("wa_nav_bonuses")}
title={t("wa_nav_bonuses")}
onclick={onInvite}
>
<Gift size={21} />
<span>{t("wa_nav_bonuses")}</span>
<span class="bottom-nav-label">{t("wa_nav_bonuses")}</span>
</button>
{#if devicesEnabled}
<button class:active={activeTab === "devices"} type="button" onclick={onDevices}>
<button
class:active={activeTab === "devices"}
type="button"
aria-label={t("wa_nav_devices")}
title={t("wa_nav_devices")}
onclick={onDevices}
>
<Smartphone size={21} />
<span>{t("wa_nav_devices")}</span>
<span class="bottom-nav-label">{t("wa_nav_devices")}</span>
</button>
{/if}
{#if supportEnabled}
<button
class:active={activeTab === "support"}
class="attention-wrap"
type="button"
aria-label={t("wa_nav_support")}
title={t("wa_nav_support")}
onclick={onSupport}
>
{#if supportUnreadCount || (supportUnreadLoading && !supportUnreadLoaded)}
<AttentionDot class="nav-attention-dot" />
{/if}
<LifeBuoy size={21} />
<span class="bottom-nav-label">{t("wa_nav_support")}</span>
</button>
{/if}
<button
class:active={activeTab === "settings"}
class="attention-wrap"
type="button"
aria-label={t("wa_nav_settings")}
title={t("wa_nav_settings")}
onclick={onSettings}
>
{#if hasUnlinkedIdentity}
<span class="attention-dot nav-attention-dot" aria-hidden="true"></span>
<AttentionDot class="nav-attention-dot" />
{/if}
<SettingsIcon size={21} />
<span>{t("wa_nav_settings")}</span>
<span class="bottom-nav-label">{t("wa_nav_settings")}</span>
</button>
{#if isAdmin}
<button class="rail-admin-entry" type="button" onclick={onAdmin}>
<button
class="rail-admin-entry"
type="button"
aria-label={adminLabel}
title={adminLabel}
onclick={onAdmin}
>
<Shield size={21} />
<span>{t("admin_nav_title", {}, "Админ-панель")}</span>
<span class="bottom-nav-label">{adminLabel}</span>
</button>
{/if}
</nav>
+11 -1
View File
@@ -7,18 +7,23 @@
export let brand = {};
export let brandTitle;
export let devicesEnabled;
export let supportEnabled = true;
export let supportUnreadCount = 0;
export let supportUnreadLoading = false;
export let supportUnreadLoaded = false;
export let hasUnlinkedIdentity;
export let isAdmin;
export let openAdminPanel;
export let goDevices;
export let goHome;
export let goInvite;
export let goSupport;
export let goSettings;
export let t;
</script>
<div class="phone-screen" class:home-screen={screen === "home"}>
{#if screen === "invite" || screen === "devices" || screen === "settings"}
{#if screen === "invite" || screen === "devices" || screen === "support" || screen === "settings"}
<header class="app-header accent-title">
<div class="brand-row">
<BrandMark {brand} />
@@ -34,12 +39,17 @@
{brand}
{brandTitle}
{devicesEnabled}
{supportEnabled}
{supportUnreadCount}
{supportUnreadLoading}
{supportUnreadLoaded}
{hasUnlinkedIdentity}
{isAdmin}
onAdmin={openAdminPanel}
onDevices={goDevices}
onHome={goHome}
onInvite={goInvite}
onSupport={goSupport}
onSettings={goSettings}
{t}
/>
@@ -11,6 +11,7 @@
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { AttentionDot } from "$components/ui/index.js";
import { LanguageSelect } from "$components/patterns/webapp/index.js";
export let currentLang = "ru";
@@ -95,7 +96,7 @@
onclick={linkTelegramAccount}
disabled={linkTelegramBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<AttentionDot />
<Send size={18} />
{t("wa_settings_link_telegram_action")}
</Button>
@@ -127,7 +128,7 @@
onclick={openLinkEmailDialog}
disabled={linkEmailBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<AttentionDot />
<Mail size={21} />
<span>
<strong>{t("wa_settings_link_email_action")}</strong>
@@ -0,0 +1,265 @@
<script>
import { getContext, onMount } from "svelte";
import { fade, slide } from "svelte/transition";
import { Check, ChevronsUpDown, LifeBuoy, MessageSquarePlus } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { Skeleton } from "$components/ui/index.js";
import { TicketCard } from "$components/patterns/webapp/index.js";
import { Select, Tabs } from "$components/ui/primitives.js";
export let t = (key) => key;
export let maxSubjectLength = 160;
export let maxBodyLength = 4000;
const supportStore = getContext("supportStore");
let subject = "";
let body = "";
let category = "other";
let priority = "normal";
let createOpen = false;
$: ({ tickets, loading, creating, statusFilter, counts } = $supportStore);
$: categoryOptions = [
{ value: "billing", label: t("wa_support_category_billing") },
{ value: "technical", label: t("wa_support_category_technical") },
{ value: "account", label: t("wa_support_category_account") },
{ value: "other", label: t("wa_support_category_other") },
];
$: priorityOptions = [
{ value: "normal", label: t("wa_support_priority_normal") },
{ value: "high", label: t("wa_support_priority_high") },
];
$: statusTabs = [
{
value: "active",
label: t("wa_support_filter_active", {}, "Активные"),
count: counts?.active || 0,
},
{
value: "awaiting_admin",
label: t("wa_support_status_awaiting_admin", {}, "Ждет админа"),
count: counts?.awaiting_admin || 0,
},
{
value: "awaiting_user",
label: t("wa_support_status_awaiting_user", {}, "Ждет пользователя"),
count: counts?.awaiting_user || 0,
},
{
value: "closed",
label: t("wa_support_status_closed", {}, "Закрытые"),
count: counts?.closed || 0,
},
];
$: selectedCategory =
categoryOptions.find((option) => option.value === category) || categoryOptions[0];
$: selectedPriority =
priorityOptions.find((option) => option.value === priority) || priorityOptions[0];
onMount(() => {
supportStore.loadList();
});
async function createTicket() {
const ticket = await supportStore.createTicket({ subject, body, category, priority });
if (ticket) {
subject = "";
body = "";
category = "other";
priority = "normal";
createOpen = false;
}
}
</script>
<main class="content with-nav support-screen">
<Card class="support-overview-card">
<div class="support-heading-row">
<span class="support-heading-icon" aria-hidden="true">
<LifeBuoy size={42} />
</span>
<div class="support-heading-copy">
<h1>{t("wa_support_title")}</h1>
<p>{t("wa_support_subtitle")}</p>
</div>
</div>
<button
class:active={createOpen}
class="support-new-ticket-button"
type="button"
aria-expanded={createOpen}
on:click={() => (createOpen = !createOpen)}
>
<span class="support-new-ticket-icon">
<MessageSquarePlus size={20} />
</span>
<span>
<strong>{t("wa_support_new_ticket")}</strong>
<small>{t("wa_support_contact_support")}</small>
</span>
<ChevronsUpDown size={18} />
</button>
{#if createOpen}
<div class="support-create-panel" in:slide={{ duration: 180 }} out:slide={{ duration: 140 }}>
<div class="support-create-panel-inner" in:fade={{ duration: 140 }}>
<label class="support-field">
<span>{t("wa_support_subject")}</span>
<input
class="input"
bind:value={subject}
maxlength={maxSubjectLength}
placeholder={t("wa_support_subject_placeholder")}
/>
</label>
<div class="support-create-grid">
<label class="support-field">
<span>{t("wa_support_category")}</span>
<Select.Root
type="single"
value={category}
items={categoryOptions}
onValueChange={(value) => (category = value)}
>
<Select.Trigger
class="support-select-trigger"
aria-label={t("wa_support_category")}
>
<span>{selectedCategory.label}</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content
class="support-select-content"
side="bottom"
align="start"
sideOffset={6}
>
<Select.Viewport class="support-select-viewport">
{#each categoryOptions as option (option.value)}
<Select.Item
value={option.value}
label={option.label}
class="support-select-item"
>
<span>{option.label}</span>
<Check size={15} class="support-select-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</label>
<label class="support-field">
<span>{t("wa_support_priority")}</span>
<Select.Root
type="single"
value={priority}
items={priorityOptions}
onValueChange={(value) => (priority = value)}
>
<Select.Trigger
class="support-select-trigger"
aria-label={t("wa_support_priority")}
>
<span>{selectedPriority.label}</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content
class="support-select-content"
side="bottom"
align="start"
sideOffset={6}
>
<Select.Viewport class="support-select-viewport">
{#each priorityOptions as option (option.value)}
<Select.Item
value={option.value}
label={option.label}
class="support-select-item"
>
<span>{option.label}</span>
<Check size={15} class="support-select-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</label>
</div>
<label class="support-field">
<span>{t("wa_support_message")}</span>
<textarea
class="textarea support-message-input"
bind:value={body}
maxlength={maxBodyLength}
rows="5"
placeholder={t("wa_support_message_placeholder")}
></textarea>
<small>{body.length}/{maxBodyLength}</small>
</label>
<Button
class="wide support-submit-button"
size="lg"
disabled={creating || !subject.trim() || !body.trim()}
onclick={createTicket}
>
<MessageSquarePlus size={18} />
{creating ? t("wa_support_creating") : t("wa_support_create")}
</Button>
</div>
</div>
{/if}
</Card>
<Card class="support-list-card">
<Tabs.Root
value={statusFilter}
onValueChange={(value) => supportStore.setStatusFilter(value || "all")}
class="support-status-tabs"
>
<Tabs.List class="support-status-tabs-list" aria-label={t("wa_support_filter_label")}>
{#each statusTabs as tab (tab.value)}
<Tabs.Trigger value={tab.value} class="support-status-tabs-trigger">
<span>{tab.label}</span>
<b>{tab.count}</b>
</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
{#if loading}
<div class="support-user-list-skeleton" aria-label={t("wa_loading")}>
{#each Array(5) as _, index (index)}
<article class="support-user-ticket-skeleton">
<span class="support-user-ticket-skeleton-main">
<Skeleton variant="title" width="min(420px, 76%)" />
<Skeleton variant="short" width="min(260px, 58%)" />
</span>
<span class="support-user-ticket-skeleton-side">
<Skeleton variant="badge" width="92px" />
<Skeleton variant="tiny" width="64px" />
</span>
</article>
{/each}
</div>
{:else if !tickets.length}
<div class="support-empty-state" in:fade={{ duration: 180 }}>
<MessageSquarePlus size={34} />
<strong>{t("wa_support_no_open_tickets")}</strong>
<small>{t("wa_support_empty_hint")}</small>
</div>
{:else}
<div class="ticket-list">
{#each tickets as ticket}
<TicketCard {ticket} {t} onOpen={(item) => supportStore.openTicket(item.ticket_id)} />
{/each}
</div>
{/if}
</Card>
</main>
@@ -0,0 +1,172 @@
<script>
import { afterUpdate, getContext, tick } from "svelte";
import { Badge, Button, ScrollArea, Skeleton } from "$components/ui/index.js";
import Card from "$components/ui/card.svelte";
import { ArrowLeft } from "$components/ui/icons.js";
import { TicketComposer, TicketMessageBubble } from "$components/patterns/webapp/index.js";
export let t = (key) => key;
export let maxBodyLength = 4000;
export let brand = {};
export let userAvatarUrl = "";
export let userInitials = "";
const supportStore = getContext("supportStore");
let reply = "";
let messagesScrollEl;
let lastMessageKey = "";
$: ({ openedTicket, messages, detailLoading, sending } = $supportStore);
$: closed = ["resolved", "closed"].includes(openedTicket?.status);
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 messageAuthorName(message) {
if (message?.author_name) return message.author_name;
return message?.author_role === "user" ? t("wa_support_role_user") : "";
}
afterUpdate(async () => {
const nextKey = `${openedTicket?.ticket_id || ""}:${messages.length}:${messages.at(-1)?.message_id || ""}`;
if (!messagesScrollEl || nextKey === lastMessageKey) return;
lastMessageKey = nextKey;
await tick();
scrollMessagesToBottom();
});
</script>
<main class="content with-nav support-ticket-screen">
{#if detailLoading && !openedTicket}
<Card class="support-ticket-card">
<header class="ticket-detail-header support-ticket-detail-header">
<Button
variant="ghost"
size="sm"
class="support-back-button"
onclick={() => supportStore.closeTicketView()}
>
<ArrowLeft size={16} />
<span>{t("wa_back")}</span>
</Button>
<div class="ticket-detail-title">
<small>{t("wa_loading")}</small>
<h1>{t("wa_support_title")}</h1>
</div>
</header>
</Card>
<Card class="support-conversation-card support-conversation-card--loading">
<ScrollArea
bind:element={messagesScrollEl}
maxHeight="none"
class="support-message-scroll scroll-area--mono"
>
<div class="ticket-message-list ticket-message-list--loading" aria-label={t("wa_loading")}>
{#each Array(4) as _, index (index)}
<div
class:ticket-message-skeleton-row--outgoing={index % 2 === 1}
class="ticket-message-skeleton-row"
>
<Skeleton variant="dot" width="32px" height="32px" />
<span class="ticket-message-skeleton-content">
<Skeleton variant="tiny" width={index % 2 === 1 ? "72px" : "96px"} />
<Skeleton variant="block" height={index === 1 ? "72px" : "54px"} />
</span>
</div>
{/each}
</div>
</ScrollArea>
</Card>
{:else if !openedTicket}
<Card class="support-ticket-card support-ticket-state-card">
<div class="empty-card">{t("wa_support_not_found")}</div>
</Card>
{:else}
<Card class="support-ticket-card">
<header class="ticket-detail-header support-ticket-detail-header">
<Button
variant="ghost"
size="sm"
class="support-back-button"
onclick={() => supportStore.closeTicketView()}
>
<ArrowLeft size={16} />
<span>{t("wa_back")}</span>
</Button>
<div class="ticket-detail-title">
<small>{t("wa_support_ticket_number", { id: openedTicket.ticket_id })}</small>
<h1>{openedTicket.subject}</h1>
</div>
<div class="ticket-badges">
<Badge
variant="outline"
class={`ticket-status-badge ticket-status-badge--${openedTicket.status}`}
>
{t(`wa_support_status_${openedTicket.status}`)}
</Badge>
<Badge
variant="muted"
class={`ticket-priority-badge ticket-priority-badge--${openedTicket.priority}`}
>
{t(`wa_support_priority_${openedTicket.priority}`)}
</Badge>
</div>
</header>
</Card>
<Card class="support-conversation-card">
<ScrollArea
bind:element={messagesScrollEl}
maxHeight="none"
class="support-message-scroll scroll-area--mono"
>
<div class="ticket-message-list">
{#if messages.length}
{#each messages as message}
<TicketMessageBubble
role={message.author_role}
body={message.body}
createdAt={message.created_at}
isInternalNote={message.is_internal_note}
supportBrand={brand}
{userAvatarUrl}
{userInitials}
authorName={messageAuthorName(message)}
{t}
/>
{/each}
{:else}
<div class="support-messages-empty">{t("wa_support_no_messages")}</div>
{/if}
</div>
</ScrollArea>
<TicketComposer
bind:value={reply}
maxLength={maxBodyLength}
disabled={closed}
{sending}
placeholder={closed ? t("wa_support_closed_hint") : t("wa_support_reply_placeholder")}
sendLabel={t("wa_support_send")}
onSend={send}
/>
</Card>
{/if}
</main>