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