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
+125 -8
View File
@@ -3,6 +3,7 @@
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
import { Tooltip } from "$components/ui/primitives.js";
@@ -17,6 +18,8 @@
import HomeScreen from "./webapp/screens/HomeScreen.svelte";
import InviteScreen from "./webapp/screens/InviteScreen.svelte";
import SettingsScreen from "./webapp/screens/SettingsScreen.svelte";
import SupportScreen from "./webapp/screens/SupportScreen.svelte";
import SupportTicketScreen from "./webapp/screens/SupportTicketScreen.svelte";
import {
LANGUAGE_FLAGS,
@@ -70,6 +73,7 @@
adminUserIdFromPath,
normalizeSection,
sectionFromPath,
supportTicketIdFromPath,
syncSectionPath,
} from "./lib/webapp/routes.js";
@@ -114,6 +118,8 @@
let token = MOCK ? "local-preview" : "";
let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || "";
let scrollLockApplied = false;
let adminI18nLoaded = false;
let adminI18nPromise = null;
let tg = null;
const telegramSdk = createTelegramSdk({
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
@@ -170,6 +176,7 @@
tg,
});
const devicesStore = createDevicesStore({ api, t, showToast });
const supportStore = createSupportStore({ api, t, showToast });
const accountStore = createAccountStore({
api,
publicApi,
@@ -194,6 +201,7 @@
setContext("authStore", authStore);
setContext("billingStore", billingStore);
setContext("devicesStore", devicesStore);
setContext("supportStore", supportStore);
setContext("accountStore", accountStore);
$: ({
@@ -233,6 +241,11 @@
deviceToDisconnect,
deviceDisconnectBusy,
} = $devicesStore);
$: ({
unreadCount: supportUnreadCount,
unreadLoading: supportUnreadLoading,
unreadLoaded: supportUnreadLoaded,
} = $supportStore);
$: ({
linkEmailOpen,
linkEmailBusy,
@@ -278,6 +291,7 @@
: []
: plans;
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
$: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true);
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean(
tariffMode && subscription?.active && subscription?.tariff_key
@@ -467,13 +481,28 @@
}
if (mode === "app") {
if (section === "admin" && isAdmin) {
screen = "admin";
const pathAtStart = window.location.pathname;
void ensureI18nScope("admin").finally(() => {
if (sectionFromPath(window.location.pathname) !== "admin") return;
if (window.location.pathname !== pathAtStart) return;
activeTab = "settings";
screen = "admin";
});
return;
}
const nextSection = section === "devices" && !devicesEnabled ? "home" : section;
const nextSection =
section === "devices" && !devicesEnabled
? "home"
: section === "support" && !supportEnabled
? "home"
: section;
activeTab = nextSection;
screen = nextSection;
if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled);
if (nextSection === "support") {
supportStore.loadList();
supportStore.startPolling({ includeList: true });
}
}
};
window.addEventListener("popstate", onPopState);
@@ -486,6 +515,7 @@
authStore.clearCooldownTimer();
accountStore.clearLinkEmailResendTimer();
accountStore.clearSetPasswordResendTimer();
supportStore.closePolling();
clearLanguageClickGuard();
syncBodyScrollLock(false);
};
@@ -552,6 +582,29 @@
});
}
async function ensureI18nScope(scope) {
if (MOCK || scope !== "admin" || adminI18nLoaded) return;
if (adminI18nPromise) return adminI18nPromise;
const apiBase = String(CFG.apiBase || "/api").replace(/\/+$/, "");
adminI18nPromise = fetch(`${apiBase}/i18n?scope=admin`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
})
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (!payload?.ok || !payload.i18n) return;
i18n.mergeMessages(payload.i18n);
adminI18nLoaded = true;
})
.catch((_error) => {
void _error;
})
.finally(() => {
adminI18nPromise = null;
});
return adminI18nPromise;
}
async function boot() {
await runWebappBoot({
MOCK,
@@ -640,17 +693,47 @@
: sectionFromPath(window.location.pathname);
if (section === "admin" && !payload.user?.is_admin) section = "settings";
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
if (section === "support" && payload.settings?.support_tickets_enabled === false) {
section = "home";
}
const initialAdminSection =
section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
if (section === "admin" && payload.user?.is_admin) {
await ensureI18nScope("admin");
}
const initialSupportTicketId =
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
activeTab = section === "admin" ? "settings" : section;
screen = section;
mode = "app";
syncSectionPath(
section,
true,
section === "admin" ? adminSectionFromPath(window.location.pathname) : null
);
if (payload.settings?.support_tickets_enabled !== false) {
if (typeof payload.support_unread_count !== "undefined") {
supportStore.hydrateUnread(payload.support_unread_count);
}
void supportStore.refreshUnread();
supportStore.startPolling({ includeList: false });
}
if (section === "support" && initialSupportTicketId) {
const targetPath = `/support/${initialSupportTicketId}`;
if (window.location.protocol !== "file:" && window.location.pathname !== targetPath) {
window.history.replaceState(
null,
"",
`${targetPath}${window.location.search}${window.location.hash}`
);
}
} else {
syncSectionPath(section, true, initialAdminSection);
}
if (section === "devices" && payload.settings?.my_devices_enabled) {
await devicesStore.loadDevices(true);
}
if (section === "support") {
if (initialSupportTicketId)
await supportStore.openTicket(initialSupportTicketId, { skipPush: true });
else await supportStore.loadList();
supportStore.startPolling({ includeList: true });
}
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
@@ -846,6 +929,16 @@
devicesStore.loadDevices(devicesEnabled);
}
function goSupport() {
if (!supportEnabled) return;
billingStore.closePaymentModal();
activeTab = "support";
screen = "support";
syncSectionPath("support");
supportStore.loadList();
supportStore.startPolling({ includeList: true });
}
function defaultPaymentMethod() {
return methods[0]?.id || "";
}
@@ -900,10 +993,11 @@
syncSectionPath("settings");
}
function openAdminPanel() {
async function openAdminPanel() {
if (!isAdmin) return;
clearLanguageClickGuard();
billingStore.closePaymentModal();
await ensureI18nScope("admin");
activeTab = "settings";
screen = "admin";
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
@@ -1067,12 +1161,17 @@
{brandTitle}
{brand}
{devicesEnabled}
{supportEnabled}
{supportUnreadCount}
{supportUnreadLoading}
{supportUnreadLoaded}
{hasUnlinkedIdentity}
{isAdmin}
{openAdminPanel}
{goDevices}
{goHome}
{goInvite}
{goSupport}
{goSettings}
{t}
>
@@ -1131,6 +1230,24 @@
{openDeviceTopupModal}
{t}
/>
{:else if screen === "support"}
{#if $supportStore.openedTicketId}
<SupportTicketScreen
maxBodyLength={appSettings?.support_ticket_max_body_length || 4000}
{brand}
userAvatarUrl={profileAvatarUrl}
userInitials={telegramProfileName
? telegramProfileName.slice(0, 2).toUpperCase()
: "U"}
{t}
/>
{:else}
<SupportScreen
maxSubjectLength={appSettings?.support_ticket_max_subject_length || 160}
maxBodyLength={appSettings?.support_ticket_max_body_length || 4000}
{t}
/>
{/if}
{:else if screen === "settings"}
<SettingsScreen
{currentLang}
+43 -5
View File
@@ -9,6 +9,7 @@
FileText,
Globe2,
LayoutDashboard,
LifeBuoy,
Megaphone,
Menu,
Paintbrush,
@@ -33,6 +34,7 @@
import PromosSection from "./sections/PromosSection.svelte";
import SettingsSection from "./sections/SettingsSection.svelte";
import StatsSection from "./sections/StatsSection.svelte";
import SupportSection from "./sections/SupportSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte";
import AppearanceSection from "./sections/AppearanceSection.svelte";
@@ -45,6 +47,7 @@
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js";
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
@@ -113,6 +116,7 @@
items: [
{ id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
{ id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
{ id: "support", label: at("nav_support", {}, "Поддержка"), icon: LifeBuoy },
],
},
{
@@ -159,6 +163,10 @@
title: at("section_logs_title", {}, "Логи активности"),
subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"),
},
support: {
title: at("section_support_title", {}, "Поддержка"),
subtitle: at("section_support_subtitle", {}, "Инбокс тикетов и ответы пользователям"),
},
tariffs: {
title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
@@ -209,6 +217,7 @@
const promosStore = createPromosStore({ api, onToast: flash, at });
const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at });
const supportStore = createAdminSupportStore({ api, onToast: flash, at });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
const usersStore = createUsersStore({ api, onToast: flash, at });
@@ -219,12 +228,14 @@
setContext("logsStore", logsStore);
setContext("paymentsStore", paymentsStore);
setContext("statsStore", statsStore);
setContext("adminSupportStore", supportStore);
setContext("settingsStore", settingsStore);
setContext("usersStore", usersStore);
setContext("tariffsStore", tariffsStore);
setContext("themesStore", themesStore);
$: usersStore.setActive(active);
$: supportStore.setActive(active);
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
$: syncBusy = $statsStore.syncBusy;
$: settingsSaving = $settingsStore.settingsSaving;
@@ -240,6 +251,7 @@
if (active === next) return;
active = next;
usersStore.closeUser();
supportStore.closeTicketView();
onSectionChange(next);
}
@@ -255,6 +267,12 @@
return match ? Number(match[1]) : null;
}
function readSupportTicketIdFromPath() {
if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/support\/(\d+)$/);
return match ? Number(match[1]) : null;
}
function onPopState() {
active = readSectionFromPath();
sidebarOpen = false;
@@ -266,6 +284,14 @@
} else if ($usersStore.openedUser) {
usersStore.closeUser({ skipPush: true });
}
const ticketId = readSupportTicketIdFromPath();
if (active === "support" && ticketId) {
if (!$supportStore.openedTicketId || $supportStore.openedTicketId !== ticketId) {
supportStore.openTicket(ticketId, { skipPush: true });
}
} else if (active === "support" && $supportStore.openedTicketId) {
supportStore.closeTicketView({ skipPush: true });
}
}
function exportPayments() {
@@ -288,10 +314,7 @@
}
function resolvedAvatarUrl(user) {
return (
userAvatarUrl(user) ||
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
);
return userAvatarUrl(user) || (user?.email ? gravatarCache.gravatarUrl(user.email) : "");
}
function panelStatusBadge(user) {
@@ -463,7 +486,13 @@
>
<svelte:component this={item.icon} size={16} />
<span>{item.label}</span>
<span></span>
<span>
{#if item.id === "support" && $supportStore.stats?.total_unread_admin}
<AdminBadge variant="danger">
<span class="numeric-badge-value">{$supportStore.stats.total_unread_admin}</span>
</AdminBadge>
{/if}
</span>
</button>
{/each}
</nav>
@@ -647,6 +676,15 @@
<LogsSection {at} {fmtDate} />
{/if}
{#if active === "support"}
<SupportSection
{at}
{brand}
{resolvedAvatarUrl}
initialTicketId={readSupportTicketIdFromPath()}
/>
{/if}
{#if active === "tariffs"}
<TariffsSection {at} {fmtMoney} />
{/if}
@@ -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>
@@ -0,0 +1,216 @@
import { writable } from "svelte/store";
export function createAdminSupportStore({ api, onToast, at }) {
const state = writable({
tickets: [],
stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 },
filters: {
status: "active",
priority: "",
category: "",
search: "",
sort: "importance_desc",
},
loading: false,
openedTicketId: null,
openedTicket: null,
messages: [],
userSnapshot: null,
detailLoading: false,
sending: false,
composerInternalNote: false,
});
let pollTimer = null;
let active = "stats";
function setActive(section) {
active = section;
}
function pushTicketPath(ticketId) {
if (typeof window === "undefined" || window.location.protocol === "file:") return;
if (active !== "support") return;
const target = ticketId ? `/admin/support/${ticketId}` : "/admin/support";
if (window.location.pathname !== target) {
window.history.pushState(
null,
"",
`${target}${window.location.search}${window.location.hash}`
);
}
}
async function loadStats() {
const res = await api("/admin/support/stats");
if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
}
async function loadList() {
state.update((s) => ({ ...s, loading: true }));
let filters;
state.update((s) => {
filters = s.filters;
return s;
});
try {
const params = new URLSearchParams({ limit: "50", offset: "0" });
for (const [key, value] of Object.entries(filters || {})) {
if (value) params.set(key, value);
}
const res = await api(`/admin/support/tickets?${params.toString()}`);
if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] }));
else if (res?.error) onToast(res.message || res.error);
} finally {
state.update((s) => ({ ...s, loading: false }));
}
}
async function openTicket(ticketId, opts = {}) {
const id = Number(ticketId);
if (!id) return;
state.update((s) => ({
...s,
openedTicketId: id,
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
userSnapshot: s.openedTicket?.ticket_id === id ? s.userSnapshot : null,
detailLoading: true,
}));
if (!opts.skipPush) pushTicketPath(id);
try {
const res = await api(`/admin/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
userSnapshot: res.user_snapshot || null,
}));
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
await loadStats();
} else onToast(res?.message || res?.error || "not_found");
} finally {
state.update((s) => ({ ...s, detailLoading: false }));
}
}
function closeTicketView(opts = {}) {
state.update((s) => ({
...s,
openedTicketId: null,
openedTicket: null,
messages: [],
userSnapshot: null,
}));
if (!opts.skipPush) pushTicketPath(null);
}
async function sendReply(body) {
let current;
let internal;
state.update((s) => {
current = s.openedTicketId;
internal = s.composerInternalNote;
return { ...s, sending: true };
});
if (!current) return;
try {
const res = await api(`/admin/support/tickets/${current}/messages`, {
method: "POST",
body: JSON.stringify({ body, is_internal_note: internal }),
});
if (!res?.ok) throw res;
state.update((s) => ({
...s,
openedTicket: res.ticket
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
: s.openedTicket,
messages: [...s.messages, res.message],
}));
await loadList();
await loadStats();
} catch (error) {
onToast(error?.message || at("support_send_failed", {}, "Send failed"));
} finally {
state.update((s) => ({ ...s, sending: false }));
}
}
async function patchTicket(updates) {
let current;
state.update((s) => {
current = s.openedTicketId;
return s;
});
if (!current) return;
const res = await api(`/admin/support/tickets/${current}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
: s.openedTicket,
}));
await loadList();
await loadStats();
} else onToast(res?.message || res?.error || "update_failed");
}
function closeTicket() {
patchTicket({ status: "closed" });
}
function toggleInternalNote() {
state.update((s) => ({ ...s, composerInternalNote: !s.composerInternalNote }));
}
function setFilter(key, value) {
state.update((s) => ({ ...s, filters: { ...s.filters, [key]: value } }));
}
function setStatusView(status) {
state.update((s) => ({
...s,
filters: {
...s.filters,
status: status === "closed" ? "closed" : "active",
},
}));
loadList();
}
function startStatsPolling() {
if (pollTimer || typeof window === "undefined") return;
loadStats();
pollTimer = window.setInterval(() => {
if (document.visibilityState === "visible") loadStats();
}, 30000);
}
function stopStatsPolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = null;
}
return {
subscribe: state.subscribe,
update: state.update,
setActive,
loadStats,
loadList,
openTicket,
closeTicketView,
sendReply,
patchTicket,
closeTicket,
toggleInternalNote,
setFilter,
setStatusView,
startStatsPolling,
stopStatsPolling,
};
}
+4 -28
View File
@@ -69,13 +69,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -99,13 +93,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -130,13 +118,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
@@ -160,13 +142,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
@@ -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>
+2
View File
@@ -22,6 +22,7 @@ export const APP_SECTION_PATHS = {
home: "/home",
invite: "/invite",
devices: "/devices",
support: "/support",
settings: "/settings",
admin: "/admin",
};
@@ -33,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"ads",
"broadcast",
"logs",
"support",
"tariffs",
"appearance",
"settings",
+19 -2
View File
@@ -2,7 +2,24 @@ import { LANGUAGE_LABELS } from "./constants.js";
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
import { unitPluralBucket } from "./plurals.js";
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
export function createI18n({
messages: initialMessages = {},
defaultLang = "ru",
getLang = null,
} = {}) {
const messages = {};
function mergeMessages(nextMessages = {}) {
if (!nextMessages || typeof nextMessages !== "object") return messages;
for (const [lang, bucket] of Object.entries(nextMessages)) {
if (!bucket || typeof bucket !== "object") continue;
messages[lang] = { ...(messages[lang] || {}), ...bucket };
}
return messages;
}
mergeMessages(initialMessages);
function normalizeLangCode(lang) {
const key = String(lang || "")
.trim()
@@ -45,7 +62,7 @@ export function createI18n({ messages = {}, defaultLang = "ru", getLang = null }
return t(`wa_sub_term_${unit}_${bucket}`);
}
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel, mergeMessages };
}
export { formatFraction, roundToHalf };
+270
View File
@@ -66,6 +66,150 @@ export async function mockApi(path, options = {}, context = {}) {
premium_traffic: { state: "none" },
},
];
const supportTickets = [
{
ticket_id: 42,
user_id: 100200300,
subject: "Не подключается профиль на телефоне",
category: "technical",
priority: "high",
status: "awaiting_admin",
unread_user_count: 0,
unread_admin_count: 2,
last_message_at: new Date(Date.now() - 18 * 60000).toISOString(),
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
user: adminUsers[0],
},
{
ticket_id: 43,
user_id: 100200300,
subject: "Вопрос по оплате подписки",
category: "billing",
priority: "normal",
status: "awaiting_user",
unread_user_count: 1,
unread_admin_count: 0,
last_message_at: new Date(Date.now() - 4 * 3600000).toISOString(),
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
user: adminUsers[0],
},
{
ticket_id: 41,
user_id: 100200300,
subject: "Закрытый вопрос по старому профилю",
category: "technical",
priority: "low",
status: "closed",
unread_user_count: 0,
unread_admin_count: 0,
last_message_at: new Date(Date.now() - 4 * 86400000).toISOString(),
created_at: new Date(Date.now() - 6 * 86400000).toISOString(),
closed_at: new Date(Date.now() - 4 * 86400000).toISOString(),
user: adminUsers[0],
},
];
function supportCounts(items = supportTickets) {
const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0 };
for (const item of items) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
const closed = (byStatus.closed || 0) + (byStatus.resolved || 0);
const active = items.length - closed;
return { ...byStatus, active, closed, total: items.length };
}
function filterSupportTickets(items, params) {
let out = [...items];
const status = params.get("status");
if (status === "active")
out = out.filter((item) => !["closed", "resolved"].includes(item.status));
else if (status === "closed")
out = out.filter((item) => ["closed", "resolved"].includes(item.status));
else if (status) out = out.filter((item) => item.status === status);
const priority = params.get("priority");
if (priority) out = out.filter((item) => item.priority === priority);
const category = params.get("category");
if (category) out = out.filter((item) => item.category === category);
const search = (params.get("search") || "").trim().toLowerCase();
if (search) {
out = out.filter((item) =>
[item.subject, item.user?.username, item.user?.email, String(item.ticket_id)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search))
);
}
const sort = params.get("sort") || "updated_desc";
const priorityRank = { urgent: 4, high: 3, normal: 2, low: 1 };
out.sort((a, b) => {
if (sort === "importance_desc") {
return (
(priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0) ||
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
);
}
if (sort === "updated_asc") {
return (
new Date(a.last_message_at || a.created_at) - new Date(b.last_message_at || b.created_at)
);
}
if (sort === "created_desc") return new Date(b.created_at) - new Date(a.created_at);
if (sort === "created_asc") return new Date(a.created_at) - new Date(b.created_at);
return (
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
);
});
return out;
}
const supportMessages = {
42: [
{
message_id: 1,
ticket_id: 42,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: "После обновления приложения профиль перестал подключаться. Ошибка появляется сразу после импорта ссылки.",
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
},
{
message_id: 2,
ticket_id: 42,
author_role: "admin",
author_user_id: 1,
author_name: "Мария, поддержка",
body: "Проверили подписку, она активна. Попробуйте удалить старый профиль и импортировать ссылку ещё раз.",
created_at: new Date(Date.now() - 90 * 60000).toISOString(),
},
{
message_id: 3,
ticket_id: 42,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: "Сделал так, но теперь вижу timeout. Телефон iPhone, сеть домашний Wi‑Fi.",
created_at: new Date(Date.now() - 18 * 60000).toISOString(),
},
],
43: [
{
message_id: 4,
ticket_id: 43,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: "Оплата прошла, но срок подписки не изменился.",
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
},
{
message_id: 5,
ticket_id: 43,
author_role: "admin",
author_user_id: 2,
author_name: "Иван, поддержка",
body: "Платёж нашли и применили вручную. Проверьте, пожалуйста, дату окончания подписки.",
created_at: new Date(Date.now() - 4 * 3600000).toISOString(),
},
],
};
const mockAdminDailySeries = (() => {
const days = 730;
const out = [];
@@ -331,8 +475,134 @@ export async function mockApi(path, options = {}, context = {}) {
},
],
};
if (cleanPath === "/admin/support/stats") {
return {
ok: true,
stats: { ...supportCounts(), total_unread_admin: 2 },
};
}
if (cleanPath === "/admin/support/tickets") {
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
const tickets = filterSupportTickets(supportTickets, params);
return { ok: true, tickets: clone(tickets), total: tickets.length };
}
if (cleanPath.startsWith("/admin/support/tickets/")) {
const parts = cleanPath.split("/");
const ticketId = Number(parts[4]);
const ticket = clone(
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
);
if (parts[5] === "messages") {
return {
ok: true,
ticket,
message: {
message_id: Date.now(),
ticket_id: ticket.ticket_id,
author_role: "admin",
author_user_id: 1,
author_name: "Мария, поддержка",
body: JSON.parse(options?.body || "{}")?.body || "",
is_internal_note: Boolean(JSON.parse(options?.body || "{}")?.is_internal_note),
created_at: new Date().toISOString(),
},
};
}
if (String(options.method || "GET").toUpperCase() === "PATCH") {
return { ok: true, ticket: { ...ticket, ...(JSON.parse(options?.body || "{}") || {}) } };
}
return {
ok: true,
ticket,
messages: clone([
...(supportMessages[ticket.ticket_id] || []),
{
message_id: 99,
ticket_id: ticket.ticket_id,
author_role: "admin",
author_user_id: 1,
author_name: "Мария, поддержка",
body: "Внутренняя заметка для команды: проверить последние логи панели перед ответом.",
is_internal_note: true,
created_at: new Date(Date.now() - 12 * 60000).toISOString(),
},
]),
user_snapshot: {
user_id: ticket.user_id,
name: "Анна Смирнова",
username: "anna_ops",
email: "anna@example.com",
tariff: "Standard",
panel_status: "ACTIVE",
remaining: "20 д. 4 ч.",
regular_traffic: "12 GB / 500 GB",
premium_traffic: "4 GB / 25 GB",
},
};
}
if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
if (
cleanPath === "/support/tickets" &&
String(options.method || "GET").toUpperCase() === "POST"
) {
let payload = {};
try {
payload = JSON.parse(options?.body || "{}");
} catch (_error) {
void _error;
}
return {
ok: true,
ticket: {
ticket_id: 44,
user_id: 100200300,
subject: payload.subject || "Новое обращение",
category: payload.category || "other",
priority: payload.priority || "normal",
status: "awaiting_admin",
unread_user_count: 0,
unread_admin_count: 1,
last_message_at: new Date().toISOString(),
created_at: new Date().toISOString(),
},
};
}
if (cleanPath === "/support/tickets") {
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
const tickets = filterSupportTickets(supportTickets, params);
return {
ok: true,
tickets: clone(tickets),
total: tickets.length,
counts: supportCounts(),
};
}
if (cleanPath.startsWith("/support/tickets/")) {
const parts = cleanPath.split("/");
const ticketId = Number(parts[3]);
const ticket = clone(
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
);
if (parts[4] === "read") return { ok: true };
if (parts[4] === "messages") {
return {
ok: true,
ticket,
message: {
message_id: Date.now(),
ticket_id: ticket.ticket_id,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: JSON.parse(options?.body || "{}")?.body || "",
created_at: new Date().toISOString(),
},
};
}
return { ok: true, ticket, messages: clone(supportMessages[ticket.ticket_id] || []) };
}
if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
if (path === "/me") return clone(DEV_MOCK.data);
if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
+23 -1
View File
@@ -7,6 +7,7 @@ export function normalizeSection(value) {
if (
section === "invite" ||
section === "devices" ||
section === "support" ||
section === "settings" ||
section === "admin"
) {
@@ -22,6 +23,7 @@ export function sectionFromPath(pathname) {
.replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "/") return "home";
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
if (normalizedPath === "/support" || normalizedPath.startsWith("/support/")) return "support";
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
return normalizeSection(section);
}
@@ -43,6 +45,22 @@ export function adminUserIdFromPath(pathname) {
return m ? Number(m[1]) : null;
}
export function supportTicketIdFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/support\/(\d+)$/);
return m ? Number(m[1]) : null;
}
export function adminSupportTicketIdFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/support\/(\d+)$/);
return m ? Number(m[1]) : null;
}
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section);
@@ -51,7 +69,11 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
const uid =
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
const supportTicketId =
adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null;
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
else targetPath = `/admin/${adm}`;
}
if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
@@ -0,0 +1,287 @@
import { writable } from "svelte/store";
export function createSupportStore({ api, t, showToast }) {
const state = writable({
tickets: [],
openedTicketId: null,
openedTicket: null,
messages: [],
unreadCount: 0,
unreadLoaded: false,
unreadLoading: false,
counts: { active: 0, closed: 0, awaiting_admin: 0, awaiting_user: 0, open: 0, total: 0 },
loading: false,
detailLoading: false,
sending: false,
creating: false,
statusFilter: "active",
polling: false,
});
let pollTimer = null;
let pollIncludeList = false;
let listRequestSeq = 0;
let listPromise = null;
let listPromiseKey = "";
let unreadPromise = null;
function hydrateUnread(value) {
const next = Math.max(0, Number(value || 0));
state.update((s) => ({
...s,
unreadCount: next,
unreadLoaded: true,
unreadLoading: false,
}));
}
async function loadList(options = {}) {
let filter = "all";
let hasTickets = false;
state.update((s) => {
filter = s.statusFilter;
hasTickets = Boolean(s.tickets?.length);
return s;
});
const requestKey = filter || "all";
if (!options.force && listPromise && listPromiseKey === requestKey) return listPromise;
const requestId = ++listRequestSeq;
const showLoading = !options.silent && (options.showLoading || !hasTickets);
if (showLoading) state.update((s) => ({ ...s, loading: true }));
let promise;
promise = (async () => {
try {
const params = new URLSearchParams({ limit: "50", offset: "0" });
if (filter && filter !== "all") params.set("status", filter);
const res = await api(`/support/tickets?${params.toString()}`);
if (requestId !== listRequestSeq) return res;
if (res?.ok)
state.update((s) => ({
...s,
tickets: res.tickets || [],
counts: res.counts || s.counts,
}));
else if (res?.error) showToast(res.message || res.error);
return res;
} finally {
if (requestId === listRequestSeq) {
state.update((s) => (s.loading ? { ...s, loading: false } : s));
}
if (listPromise === promise) {
listPromise = null;
listPromiseKey = "";
}
}
})();
listPromise = promise;
listPromiseKey = requestKey;
return promise;
}
async function refreshCurrentTicket(ticketId) {
const id = Number(ticketId);
if (!id) return;
try {
const res = await api(`/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
}));
await markRead(id);
}
return res;
} catch {
return null;
}
}
async function createTicket(payload) {
state.update((s) => ({ ...s, creating: true }));
try {
const res = await api("/support/tickets", {
method: "POST",
body: JSON.stringify(payload),
});
if (!res?.ok) throw res;
state.update((s) => ({ ...s, statusFilter: "active" }));
await loadList({ silent: true, force: true });
await openTicket(res.ticket.ticket_id);
return res.ticket;
} catch (error) {
showToast(error?.message || t("wa_support_create_failed"));
return null;
} finally {
state.update((s) => ({ ...s, creating: false }));
}
}
async function openTicket(ticketId, opts = {}) {
const id = Number(ticketId);
if (!id) return;
state.update((s) => ({
...s,
openedTicketId: id,
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
detailLoading: true,
}));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
const target = `/support/${id}`;
if (window.location.pathname !== target) {
window.history.pushState(
null,
"",
`${target}${window.location.search}${window.location.hash}`
);
}
}
try {
const res = await api(`/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
}));
await markRead(id);
} else {
showToast(res?.message || res?.error || "not_found");
}
} finally {
state.update((s) => ({ ...s, detailLoading: false }));
}
}
function closeTicketView(opts = {}) {
state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] }));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
if (window.location.pathname.startsWith("/support/")) {
window.history.pushState(
null,
"",
`/support${window.location.search}${window.location.hash}`
);
}
}
}
async function sendReply(body) {
let ticketId = null;
state.update((s) => {
ticketId = s.openedTicketId;
return { ...s, sending: true };
});
if (!ticketId) return;
try {
const res = await api(`/support/tickets/${ticketId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
if (!res?.ok) throw res;
state.update((s) => ({
...s,
openedTicket: res.ticket || s.openedTicket,
messages: [...s.messages, res.message],
}));
await refreshUnread();
await loadList({ silent: true, force: true });
} catch (error) {
showToast(error?.message || t("wa_support_send_failed"));
} finally {
state.update((s) => ({ ...s, sending: false }));
}
}
async function markRead(ticketId = null) {
const id =
ticketId ||
(() => {
let current = null;
state.update((s) => {
current = s.openedTicketId;
return s;
});
return current;
})();
if (!id) return;
await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
await refreshUnread();
}
async function refreshUnread() {
if (unreadPromise) return unreadPromise;
state.update((s) => ({ ...s, unreadLoading: true }));
unreadPromise = (async () => {
try {
const res = await api("/support/unread");
if (res?.ok) {
state.update((s) => ({
...s,
unreadCount: Math.max(0, Number(res.unread || 0)),
unreadLoaded: true,
}));
}
return res;
} finally {
state.update((s) => ({ ...s, unreadLoading: false }));
unreadPromise = null;
}
})();
return unreadPromise;
}
function setStatusFilter(status) {
state.update((s) => ({ ...s, statusFilter: status || "all" }));
loadList({ force: true, showLoading: true });
}
function startPolling(options = {}) {
const includeList = options.includeList !== false;
pollIncludeList = pollIncludeList || includeList;
if (pollTimer || typeof window === "undefined") return;
state.update((s) => ({ ...s, polling: true }));
const tick = async () => {
if (document.visibilityState === "visible") {
await refreshUnread();
if (!pollIncludeList) return;
let opened = null;
state.update((s) => {
opened = s.openedTicketId;
return s;
});
if (opened) await refreshCurrentTicket(opened);
else await loadList({ silent: true });
}
};
pollTimer = window.setInterval(tick, 15000);
document.addEventListener("visibilitychange", tick);
}
function closePolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = null;
pollIncludeList = false;
state.update((s) => ({ ...s, polling: false }));
}
return {
subscribe: state.subscribe,
update: state.update,
loadList,
hydrateUnread,
createTicket,
openTicket,
closeTicketView,
sendReply,
markRead,
refreshUnread,
setStatusFilter,
startPolling,
closePolling,
};
}
+2 -1
View File
@@ -6,7 +6,7 @@ import "./styles.css";
async function loadBootstrap() {
if (document.getElementById("webapp-config")) return;
try {
const response = await fetch("/api/bootstrap", {
const response = await fetch("/api/bootstrap?i18n_scope=webapp", {
credentials: "include",
headers: { Accept: "application/json" },
});
@@ -31,6 +31,7 @@ const target = document.getElementById("app");
if (target) {
loadBootstrap().finally(() => {
target.replaceChildren();
mount(App, { target });
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82 -34
View File
@@ -1466,20 +1466,6 @@ a {
position: relative;
}
.attention-dot {
position: absolute;
top: 0;
right: 0;
width: 13px;
height: 13px;
transform: translate(50%, -50%);
border-radius: 999px;
background: #ff4b4b;
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
animation: attention-pulse 1.6s ease-out infinite;
pointer-events: none;
}
.nav-attention-dot {
top: 6px;
right: 10px;
@@ -1490,20 +1476,6 @@ a {
padding-right: 6px;
}
@keyframes attention-pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
}
70% {
box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
}
}
.bottom-nav {
position: fixed !important;
left: var(--bottom-nav-left);
@@ -1514,9 +1486,11 @@ a {
bottom: max(var(--bottom-nav-offset), env(safe-area-inset-bottom));
z-index: 80;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(var(--bottom-nav-visible-items, 3), minmax(0, 1fr));
grid-auto-flow: column;
gap: 2px;
min-height: 64px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--nav-bg);
@@ -1525,7 +1499,7 @@ a {
}
.bottom-nav-devices {
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(var(--bottom-nav-visible-items, 4), minmax(0, 1fr));
}
.bottom-nav.static {
@@ -1534,6 +1508,7 @@ a {
.bottom-nav button {
display: grid;
min-width: 0;
place-items: center;
align-content: center;
gap: 4px;
@@ -1542,16 +1517,77 @@ a {
color: var(--muted);
font-size: 10px;
font-weight: 800;
overflow: hidden;
padding: 6px 2px;
}
.bottom-nav button.active {
color: var(--accent);
}
.bottom-nav .bottom-nav-label {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
}
.rail-admin-entry {
display: none !important;
}
@media (max-width: 380px) {
.bottom-nav.bottom-nav-many {
min-height: 58px;
}
.bottom-nav.bottom-nav-many button {
gap: 0;
padding: 0 2px;
}
.bottom-nav.bottom-nav-many .bottom-nav-label {
display: none;
}
.bottom-nav.bottom-nav-many .nav-attention-dot {
top: 8px;
right: calc(50% - 18px);
}
.bottom-nav.bottom-nav-many .nav-badge-floating {
top: 6px;
right: calc(50% - 24px);
}
}
@media (max-width: 360px) {
.bottom-nav {
min-height: 56px;
}
.bottom-nav button {
gap: 0;
padding: 0 2px;
}
.bottom-nav .bottom-nav-label {
display: none;
}
.bottom-nav .nav-attention-dot {
top: 8px;
right: calc(50% - 18px);
}
.bottom-nav .nav-badge-floating {
top: 6px;
right: calc(50% - 24px);
}
}
.home-layout {
display: grid;
min-height: calc(100dvh - 34px);
@@ -1975,6 +2011,10 @@ a {
font-size: 9px;
}
.preview-phone .bottom-nav .bottom-nav-label {
font-size: 9px;
}
.preview-phone .card {
padding: 12px;
}
@@ -2247,10 +2287,14 @@ a {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: none !important;
grid-auto-flow: row !important;
grid-auto-rows: auto !important;
grid-auto-columns: 1fr !important;
align-content: start !important;
gap: 2px !important;
padding: 28px 14px !important;
overflow-x: hidden !important;
overflow-y: auto !important;
border: 0 !important;
border-right: 1px solid var(--border) !important;
border-radius: 0 !important;
@@ -2271,7 +2315,7 @@ a {
align-items: center !important;
justify-items: start !important;
gap: 12px !important;
padding: 12px 14px !important;
padding: 12px 42px 12px 14px !important;
border-radius: 10px !important;
text-align: left !important;
font-size: 13px !important;
@@ -2281,6 +2325,7 @@ a {
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
width: 100%;
}
.bottom-nav button > svg {
@@ -2288,7 +2333,7 @@ a {
height: 20px;
}
.bottom-nav button > span {
.bottom-nav .bottom-nav-label {
text-align: left !important;
font-size: 13px !important;
font-weight: 600;
@@ -2305,9 +2350,11 @@ a {
color: var(--text);
}
.bottom-nav .nav-attention-dot {
top: 14px;
.bottom-nav .nav-attention-dot,
.bottom-nav .nav-badge-floating {
top: 50%;
right: 14px;
transform: translateY(-50%);
}
.bottom-nav .rail-admin-entry {
@@ -2393,6 +2440,7 @@ a {
@media (min-width: 1024px) {
.bottom-nav .rail-brand {
display: flex;
width: 100%;
align-items: center;
gap: 12px;
padding: 4px 12px 18px;
+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>