refactor: migrate ui elements to lib

This commit is contained in:
3252a8
2026-05-12 09:05:22 +03:00
parent 6a367c1dec
commit fe976022ec
50 changed files with 1275 additions and 878 deletions
+2
View File
@@ -376,6 +376,8 @@
}, 220);
return;
}
languageClickGuard = true;
languageClickGuardArmed = false;
languageClickGuardTimer = window.setTimeout(() => {
languageClickGuard = false;
languageClickGuardTimer = null;
+3 -2
View File
@@ -17,10 +17,11 @@
Ticket,
UserRound,
Zap,
} from "lucide-svelte";
} from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { LinearProgress } from "$components/patterns/webapp/index.js";
import BackTitle from "./preview/BackTitle.svelte";
import PhoneFrame from "./preview/PhoneFrame.svelte";
import PreviewMethods from "./preview/PreviewMethods.svelte";
@@ -78,7 +79,7 @@
</Card>
<Card>
<div class="traffic-top"><span>Использовано трафика</span><strong>{sub.traffic_used} из {sub.traffic_limit}</strong></div>
<div class="progress"><span style="width: 18%"></span></div>
<LinearProgress value={18} />
<div class="traffic-percent">18%</div>
</Card>
<div class="action-stack">
@@ -18,9 +18,10 @@
Sparkles,
Tag,
UsersRound,
} from "lucide-svelte";
} from "$components/ui/icons.js";
import { onMount, setContext } from "svelte";
import { Select } from "$components/ui/primitives.js";
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
import BrandMark from "../BrandMark.svelte";
import AdsSection from "./sections/AdsSection.svelte";
@@ -140,6 +141,11 @@
}
let sidebarOpen = false;
let isCompact = false;
let adminLanguageMenuOpen = false;
let adminLanguageClickGuard = false;
let adminLanguageClickGuardArmed = false;
let adminLanguageClickGuardTimer = null;
let adminLanguageClickGuardArmTimer = null;
function flash(text) {
onToast(text);
@@ -246,6 +252,44 @@
isCompact = Boolean(event?.matches);
}
function clearAdminLanguageClickGuard() {
if (adminLanguageClickGuardTimer) {
window.clearTimeout(adminLanguageClickGuardTimer);
adminLanguageClickGuardTimer = null;
}
if (adminLanguageClickGuardArmTimer) {
window.clearTimeout(adminLanguageClickGuardArmTimer);
adminLanguageClickGuardArmTimer = null;
}
adminLanguageClickGuard = false;
adminLanguageClickGuardArmed = false;
}
function setAdminLanguageMenuOpen(open) {
adminLanguageMenuOpen = Boolean(open);
clearAdminLanguageClickGuard();
if (adminLanguageMenuOpen) {
adminLanguageClickGuard = true;
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
adminLanguageClickGuardArmed = true;
adminLanguageClickGuardArmTimer = null;
}, 220);
return;
}
adminLanguageClickGuard = true;
adminLanguageClickGuardArmed = false;
adminLanguageClickGuardTimer = window.setTimeout(() => {
adminLanguageClickGuard = false;
adminLanguageClickGuardTimer = null;
}, 260);
}
function closeAdminLanguageFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (adminLanguageClickGuardArmed) setAdminLanguageMenuOpen(false);
}
onMount(() => {
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
compactMql = window.matchMedia("(max-width: 720px)");
@@ -262,6 +306,7 @@
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
}
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
clearAdminLanguageClickGuard();
};
});
@@ -274,6 +319,16 @@
{#if sidebarOpen}
<button type="button" class="admin-sidebar-backdrop" aria-label={at("close_menu", {}, "Закрыть меню")} on:click={() => (sidebarOpen = false)}></button>
{/if}
{#if adminLanguageMenuOpen || adminLanguageClickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={adminLanguageClickGuardArmed}
type="button"
aria-label={t("wa_close", {}, at("close", {}, "Закрыть"))}
on:pointerdown={closeAdminLanguageFromGuard}
on:click={closeAdminLanguageFromGuard}
></button>
{/if}
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
<div class="admin-sidebar-brand">
@@ -282,9 +337,9 @@
<strong class="admin-brand-title">{brandTitle}</strong>
<small>{at("panel_title", {}, "Админ-панель")}</small>
</div>
<button type="button" class="admin-btn admin-btn-icon admin-btn-ghost" on:click={onClose} aria-label={at("exit", {}, "Выйти")}>
<AdminButton variant="ghost" size="icon" onclick={onClose} aria-label={at("exit", {}, "Выйти")}>
<ArrowLeft size={16} />
</button>
</AdminButton>
</div>
{#each NAV_GROUPS as group}
@@ -304,7 +359,15 @@
{#if languageOptions.length}
<div class="admin-language-switch">
<Globe2 size={16} />
<Select.Root type="single" value={currentLang} items={languageOptions} disabled={languageBusy} onValueChange={onLanguageChange}>
<Select.Root
type="single"
bind:open={adminLanguageMenuOpen}
value={currentLang}
items={languageOptions}
disabled={languageBusy}
onOpenChange={setAdminLanguageMenuOpen}
onValueChange={onLanguageChange}
>
<Select.Trigger class="admin-language-trigger" aria-label={t("wa_settings_language", {}, at("language", {}, "Язык"))}>
<span>
<strong>{t("wa_settings_language", {}, at("language", {}, "Язык"))}</strong>
@@ -351,37 +414,37 @@
</div>
<div class="admin-header-actions">
{#if active === "stats"}
<button type="button" class="admin-btn" on:click={statsStore.triggerSync} disabled={syncBusy}>
<AdminButton onclick={statsStore.triggerSync} disabled={syncBusy}>
<RefreshCw size={14} /> {syncBusy ? at("btn_syncing", {}, "Синхронизация...") : at("btn_sync", {}, "Синхронизировать")}
</button>
</AdminButton>
{/if}
{#if active === "payments"}
<button type="button" class="admin-btn" on:click={exportPayments}>
<AdminButton onclick={exportPayments}>
<Download size={14} /> CSV
</button>
</AdminButton>
{/if}
{#if active === "promos"}
<button type="button" class="admin-btn admin-btn-primary" on:click={() => promosStore.setCreateOpen(true)}>
<AdminButton variant="primary" onclick={() => promosStore.setCreateOpen(true)}>
<Plus size={14} /> {at("btn_create", {}, "Создать")}
</button>
</AdminButton>
{/if}
{#if active === "ads"}
<button type="button" class="admin-btn admin-btn-primary" on:click={() => adsStore.setCreateOpen(true)}>
<AdminButton variant="primary" onclick={() => adsStore.setCreateOpen(true)}>
<Plus size={14} /> {at("btn_campaign", {}, "Кампания")}
</button>
</AdminButton>
{/if}
{#if active === "tariffs"}
<button type="button" class="admin-btn admin-btn-primary" on:click={tariffsStore.openCreateTariff}>
<AdminButton variant="primary" onclick={tariffsStore.openCreateTariff}>
<Plus size={14} /> {at("btn_tariff", {}, "Тариф")}
</button>
</AdminButton>
{/if}
{#if active === "settings"}
{#if dirtyCount}
<span class="admin-badge admin-badge-warning">{at("settings_dirty_count", { count: dirtyCount }, "Изменений: " + dirtyCount)}</span>
<AdminBadge variant="warning">{at("settings_dirty_count", { count: dirtyCount }, "Изменений: " + dirtyCount)}</AdminBadge>
{/if}
<button type="button" class="admin-btn admin-btn-primary" on:click={() => settingsStore.saveSettings(onSettingsSaved)} disabled={!dirtyCount || settingsSaving}>
<AdminButton variant="primary" onclick={() => settingsStore.saveSettings(onSettingsSaved)} disabled={!dirtyCount || settingsSaving}>
<Save size={14} /> {settingsSaving ? at("btn_saving", {}, "Сохранение...") : at("btn_save", {}, "Сохранить")}
</button>
</AdminButton>
{/if}
</div>
</header>
@@ -395,7 +458,6 @@
<UsersSection
{at}
{fmtDateShort}
{optionLabel}
{panelStatusBadge}
{resolvedAvatarUrl}
{userDisplayName}
@@ -417,7 +479,7 @@
{/if}
{#if active === "broadcast"}
<BroadcastSection {at} {optionLabel} />
<BroadcastSection {at} />
{/if}
{#if active === "logs"}
@@ -1,8 +1,15 @@
<script>
import { Trash2 } from "lucide-svelte";
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminField,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtMoney;
@@ -16,6 +23,16 @@
adCreateOpen,
adDraft,
} = $adsStore);
$: adHeaders = [
at("id", {}, "ID"),
at("ads_col_source", {}, "Источник"),
at("ads_col_param", {}, "Параметр"),
at("ads_col_cost", {}, "Стоимость"),
at("ads_col_registrations", {}, "Регистрации"),
at("ads_col_conversions", {}, "Конверсии"),
at("ads_col_status", {}, "Статус"),
at("actions", {}, "Действия"),
];
onMount(() => {
adsStore.loadAds();
@@ -24,38 +41,11 @@
<div class="admin-table-wrap">
{#if adsLoading}
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("ads_col_source", {}, "Источник")}</th>
<th>{at("ads_col_param", {}, "Параметр")}</th>
<th>{at("ads_col_cost", {}, "Стоимость")}</th>
<th>{at("ads_col_registrations", {}, "Регистрации")}</th>
<th>{at("ads_col_conversions", {}, "Конверсии")}</th>
<th>{at("ads_col_status", {}, "Статус")}</th>
<th class="admin-cell-actions">{at("actions", {}, "Действия")}</th>
</tr>
</thead>
<tbody>
{#each Array(6) as _, i (i)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
</tr>
{/each}
</tbody>
</table>
<AdminTableSkeleton headers={adHeaders} rows={6} actionColumn widths={["44px", "96px", "110px", "70px", "54px", "54px", "72px", "92px"]} />
{:else if !ads.length}
<div class="admin-card-body"><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></div>
<AdminEmptyState tone="card"><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></AdminEmptyState>
{:else}
<table class="admin-table">
<AdminTable>
<thead>
<tr>
<th>{at("id", {}, "ID")}</th>
@@ -79,23 +69,23 @@
<td data-label={at("ads_col_conversions", {}, "Конверсии")}>{ad.stats?.conversions ?? 0}</td>
<td data-label={at("ads_col_status", {}, "Статус")}>
{#if ad.is_active}
<span class="admin-badge admin-badge-success">{at("status_active", {}, "Активна")}</span>
<AdminBadge variant="success">{at("status_active", {}, "Активна")}</AdminBadge>
{:else}
<span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключена")}</span>
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключена")}</AdminBadge>
{/if}
</td>
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => adsStore.toggleAd(ad)}>
<AdminButton size="sm" onclick={() => adsStore.toggleAd(ad)}>
{ad.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
</button>
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => adsStore.deleteAd(ad)}>
</AdminButton>
<AdminButton size="sm" variant="danger" onclick={() => adsStore.deleteAd(ad)}>
<Trash2 size={13} />
</button>
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</table>
</AdminTable>
{/if}
</div>
@@ -108,27 +98,26 @@
>
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<Label.Root class="admin-field-label">
<span>{at("ad_label_source", {}, "Источник")}</span>
<AdminField label={at("ad_label_source", {}, "Источник")}>
<input class="input" type="text" placeholder="telegram_ads" value={adDraft.source} on:input={(e) => adsStore.updateDraft({ source: e.target.value })} />
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("ad_label_param", {}, "start-параметр")}</span>
<small>{at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}</small>
</AdminField>
<AdminField
label={at("ad_label_param", {}, "start-параметр")}
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
>
<input class="input" type="text" placeholder="ads_summer25" value={adDraft.start_param} on:input={(e) => adsStore.updateDraft({ start_param: e.target.value })} />
</Label.Root>
</AdminField>
</div>
<div class="admin-dialog-form-section">
<Label.Root class="admin-field-label">
<span>{at("ad_label_cost", {}, "Стоимость, RUB")}</span>
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
<input class="input" type="number" step="0.01" min="0" value={adDraft.cost} on:input={(e) => adsStore.updateDraft({ cost: Number(e.target.value) })} />
</Label.Root>
</AdminField>
</div>
<div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => adsStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={adsStore.createAd} disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}>
<AdminButton onclick={() => adsStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="primary" onclick={adsStore.createAd} disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}>
{at("btn_create", {}, "Создать")}
</button>
</AdminButton>
</div>
</div>
</Dialog>
@@ -1,11 +1,10 @@
<script>
import { Send, ChevronDown, Check } from "lucide-svelte";
import { Send } from "$components/ui/icons.js";
import { getContext } from "svelte";
import { Label, Select } from "$components/ui/primitives.js";
import { Label } from "$components/ui/primitives.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at;
export let optionLabel;
const broadcastStore = getContext("broadcastStore");
$: ({
@@ -27,22 +26,12 @@
<div class="admin-form">
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<Select.Root type="single" value={broadcastTarget} onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}>
<Select.Trigger class="admin-select-trigger" aria-label={at("broadcast_label_audience", {}, "Аудитория")}>
<span>{optionLabel(BROADCAST_TARGET_OPTIONS, broadcastTarget)}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each BROADCAST_TARGET_OPTIONS as opt}
<Select.Item value={opt.value} class="admin-select-item">
<span>{opt.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
<AdminSelect
value={broadcastTarget}
items={BROADCAST_TARGET_OPTIONS}
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
@@ -50,9 +39,9 @@
<textarea class="admin-textarea" rows="6" value={broadcastText} on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}></textarea>
</Label.Root>
<div style="display:flex; gap:8px; align-items:center;">
<button type="button" class="admin-btn admin-btn-primary" on:click={broadcastStore.runBroadcast} disabled={broadcastBusy || !broadcastText.trim()}>
<AdminButton variant="primary" onclick={broadcastStore.runBroadcast} disabled={broadcastBusy || !broadcastText.trim()}>
<Send size={14} /> {broadcastBusy ? at("btn_sending", {}, "Отправка...") : at("btn_queue", {}, "Поставить в очередь")}
</button>
</AdminButton>
{#if broadcastResult}
<span class="admin-muted">{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed}</span>
{/if}
@@ -1,6 +1,6 @@
<script>
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import { getContext, onMount } from "svelte";
import { AdminButton, AdminEmptyState, AdminPagination, AdminTable, AdminTableSkeleton } from "$components/patterns/admin/index.js";
export let at;
export let fmtDate;
@@ -16,6 +16,13 @@
} = $logsStore);
$: logsHasMore = logs.length > 0 && logsTotal > (logsPage + 1) * 50; // 50 is LOGS_PAGE_SIZE
$: logHeaders = [
at("date", {}, "Дата"),
at("event", {}, "Событие"),
at("user_short", {}, "User"),
at("target_short", {}, "Target"),
at("content", {}, "Контент"),
];
onMount(() => {
logsStore.loadLogs();
@@ -32,8 +39,8 @@
on:input={(e) => logsStore.setFilter(e.target.value)}
on:keydown={(e) => e.key === "Enter" && (logsStore.setPage(0))}
/>
<button type="button" class="admin-btn admin-btn-primary" on:click={() => { logsStore.setPage(0); }}>{at("apply", {}, "Применить")}</button>
<button type="button" class="admin-btn admin-btn-ghost" on:click={() => { logsStore.setFilter(""); logsStore.setPage(0); }}>{at("reset", {}, "Сбросить")}</button>
<AdminButton variant="primary" onclick={() => { logsStore.setPage(0); }}>{at("apply", {}, "Применить")}</AdminButton>
<AdminButton variant="ghost" onclick={() => { logsStore.setFilter(""); logsStore.setPage(0); }}>{at("reset", {}, "Сбросить")}</AdminButton>
</div>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
@@ -43,28 +50,11 @@
<div class="admin-table-wrap">
{#if logsLoading}
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("date", {}, "Дата")}</th><th>{at("event", {}, "Событие")}</th><th>User</th><th>Target</th><th>{at("content", {}, "Контент")}</th>
</tr>
</thead>
<tbody>
{#each Array(10) as _, i (i)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
</tr>
{/each}
</tbody>
</table>
<AdminTableSkeleton headers={logHeaders} rows={10} widths={["120px", "120px", "58px", "58px", "220px"]} />
{:else if !logs.length}
<div class="admin-card-body"><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></div>
<AdminEmptyState tone="card"><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></AdminEmptyState>
{:else}
<table class="admin-table">
<AdminTable>
<thead>
<tr>
<th>{at("date", {}, "Дата")}</th>
@@ -85,18 +75,16 @@
</tr>
{/each}
</tbody>
</table>
</AdminTable>
{/if}
</div>
<div class="admin-pagination">
<span class="admin-pagination-meta">{at("page_short", {}, "Стр.")} {logsPage + 1}</span>
<div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={logsPage === 0} on:click={() => { logsStore.setPage(Math.max(0, logsPage - 1)); }}>
<ChevronLeft size={14} /> {at("back", {}, "Назад")}
</button>
<button type="button" class="admin-btn admin-btn-sm" disabled={!logsHasMore} on:click={() => { logsStore.setPage(logsPage + 1); }}>
{at("next", {}, "Далее")} <ChevronRight size={14} />
</button>
</div>
</div>
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${logsPage + 1}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={logsPage === 0}
nextDisabled={!logsHasMore}
onPrev={() => { logsStore.setPage(Math.max(0, logsPage - 1)); }}
onNext={() => { logsStore.setPage(logsPage + 1); }}
/>
@@ -1,6 +1,12 @@
<script>
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import { getContext, onMount } from "svelte";
import {
AdminBadge,
AdminEmptyState,
AdminPagination,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at = (key) => key;
export let fmtDate = (value) => value;
@@ -17,6 +23,15 @@
} = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
$: paymentHeaders = [
at("id", {}, "ID"),
at("user", {}, "Пользователь"),
at("amount", {}, "Сумма"),
at("provider", {}, "Провайдер"),
at("description", {}, "Описание"),
at("status", {}, "Статус"),
at("date", {}, "Дата"),
];
onMount(() => {
paymentsStore.loadPayments();
@@ -25,30 +40,11 @@
<div class="admin-table-wrap">
{#if paymentsLoading}
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("id", {}, "ID")}</th><th>{at("user", {}, "Пользователь")}</th><th>{at("amount", {}, "Сумма")}</th><th>{at("provider", {}, "Провайдер")}</th><th>{at("description", {}, "Описание")}</th><th>{at("status", {}, "Статус")}</th><th>{at("date", {}, "Дата")}</th>
</tr>
</thead>
<tbody>
{#each Array(8) as _, i (i)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
<td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
</tr>
{/each}
</tbody>
</table>
<AdminTableSkeleton headers={paymentHeaders} rows={8} widths={["48px", "120px", "78px", "82px", "180px", "72px", "96px"]} />
{:else if !payments.length}
<div class="admin-card-body"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></div>
<AdminEmptyState tone="card"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState>
{:else}
<table class="admin-table">
<AdminTable>
<thead>
<tr>
<th>{at("id", {}, "ID")}</th>
@@ -69,24 +65,22 @@
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{p.description || "—"}</td>
<td data-label={at("status", {}, "Статус")}>
<span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span>
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
</td>
<td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
</AdminTable>
{/if}
</div>
<div class="admin-pagination">
<span class="admin-pagination-meta">{at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal}</span>
<div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={paymentsPage === 0} on:click={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}>
<ChevronLeft size={14} /> {at("back", {}, "Назад")}
</button>
<button type="button" class="admin-btn admin-btn-sm" disabled={!paymentsHasMore} on:click={() => { paymentsStore.setPage(paymentsPage + 1); }}>
{at("next", {}, "Далее")} <ChevronRight size={14} />
</button>
</div>
</div>
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${paymentsPage + 1} · ${at("total", {}, "Всего")} ${paymentsTotal}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={paymentsPage === 0}
nextDisabled={!paymentsHasMore}
onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}
onNext={() => { paymentsStore.setPage(paymentsPage + 1); }}
/>
@@ -1,8 +1,15 @@
<script>
import { Trash2 } from "lucide-svelte";
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminField,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtDateShort;
@@ -19,6 +26,14 @@
} = $promosStore);
$: promosHasMore = promos.length < promosTotal;
$: promoHeaders = [
at("promo_col_code", {}, "Код"),
at("promo_col_bonus", {}, "Бонус"),
at("promo_col_activations", {}, "Активаций"),
at("promo_col_valid_until", {}, "Действует до"),
at("promo_col_status", {}, "Статус"),
at("actions", {}, "Действия"),
];
onMount(() => {
promosStore.loadPromos();
@@ -27,34 +42,11 @@
<div class="admin-table-wrap">
{#if promosLoading}
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("promo_col_code", {}, "Код")}</th>
<th>{at("promo_col_bonus", {}, "Бонус")}</th>
<th>{at("promo_col_activations", {}, "Активаций")}</th>
<th>{at("promo_col_valid_until", {}, "Действует до")}</th>
<th>{at("promo_col_status", {}, "Статус")}</th>
<th class="admin-cell-actions">{at("actions", {}, "Действия")}</th>
</tr>
</thead>
<tbody>
{#each Array(6) as _, i (i)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
</tr>
{/each}
</tbody>
</table>
<AdminTableSkeleton headers={promoHeaders} rows={6} actionColumn widths={["92px", "52px", "64px", "96px", "72px", "92px"]} />
{:else if !promos.length}
<div class="admin-card-body"><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></div>
<AdminEmptyState tone="card"><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></AdminEmptyState>
{:else}
<table class="admin-table">
<AdminTable>
<thead>
<tr>
<th>{at("promo_col_code", {}, "Код")}</th>
@@ -74,27 +66,27 @@
<td data-label={at("promo_col_valid_until", {}, "Действует до")}>{p.valid_until ? fmtDateShort(p.valid_until) : "∞"}</td>
<td data-label={at("promo_col_status", {}, "Статус")}>
{#if p.is_active}
<span class="admin-badge admin-badge-success">{at("status_active", {}, "Активен")}</span>
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{:else}
<span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключен")}</span>
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{/if}
</td>
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => promosStore.togglePromo(p)}>
<AdminButton size="sm" onclick={() => promosStore.togglePromo(p)}>
{p.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
</button>
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => promosStore.deletePromo(p)}>
</AdminButton>
<AdminButton size="sm" variant="danger" onclick={() => promosStore.deletePromo(p)}>
<Trash2 size={13} />
</button>
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</table>
</AdminTable>
{/if}
{#if promosHasMore}
<div style="padding: 12px; text-align: center;">
<button type="button" class="admin-btn" on:click={() => promosStore.setPage(promosPage + 1)}>{at("btn_show_more", {}, "Показать еще")}</button>
<AdminButton onclick={() => promosStore.setPage(promosPage + 1)}>{at("btn_show_more", {}, "Показать еще")}</AdminButton>
</div>
{/if}
</div>
@@ -108,32 +100,28 @@
>
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<Label.Root class="admin-field-label">
<span>{at("promo_label_code", {}, "Код")}</span>
<AdminField label={at("promo_label_code", {}, "Код")}>
<input type="text" class="input" value={promoDraft.code} on:input={(e) => promosStore.updateDraft({ code: e.target.value })} placeholder="FREE-7-DAYS" />
</Label.Root>
</AdminField>
</div>
<div class="admin-dialog-form-section">
<div class="admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("promo_label_bonus_days", {}, "Бонус (дней)")}</span>
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
<input type="number" class="input" min="1" value={promoDraft.bonus_days} on:input={(e) => promosStore.updateDraft({ bonus_days: Number(e.target.value) })} />
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("promo_label_max_activations", {}, "Макс. активаций")}</span>
</AdminField>
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
<input type="number" class="input" min="1" value={promoDraft.max_activations} on:input={(e) => promosStore.updateDraft({ max_activations: Number(e.target.value) })} />
</Label.Root>
</AdminField>
</div>
<Label.Root class="admin-field-label">
<span>{at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}</span>
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
<input type="number" class="input" min="1" value={promoDraft.valid_days} on:input={(e) => promosStore.updateDraft({ valid_days: Number(e.target.value) })} />
</Label.Root>
</AdminField>
</div>
<div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => promosStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={promosStore.createPromo} disabled={!promoDraft.code.trim()}>
<AdminButton onclick={() => promosStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="primary" onclick={promosStore.createPromo} disabled={!promoDraft.code.trim()}>
{at("btn_create", {}, "Создать")}
</button>
</AdminButton>
</div>
</div>
</Dialog>
@@ -1,6 +1,7 @@
<script>
import { ChevronRight, Eye, EyeOff, X } from "lucide-svelte";
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte";
export let at;
@@ -97,9 +98,9 @@
<div class="admin-setting-meta">
<strong>
{field.label}
<span class="admin-badge admin-badge-warning">{at("settings_badge_secret", {}, "Secret")}</span>
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
{#if isOverridden(field)}
<span class="admin-badge admin-badge-success">{at("settings_badge_override", {}, "Override")}</span>
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if}
</strong>
<code>{field.key}</code>
@@ -150,14 +151,14 @@
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<button
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
<AdminButton
size="sm"
variant="ghost"
aria-label={revealed ? at("hide", {}, "Скрыть") : at("show", {}, "Показать")}
on:click={() => toggleSecretReveal(field.key)}
onclick={() => toggleSecretReveal(field.key)}
>
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</button>
</AdminButton>
{:else}
<input
class="input"
@@ -168,29 +169,29 @@
/>
{/if}
{#if isOverridden(field) || settingsDirty[field.key]}
<button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={() => settingsStore.resetField(field)}>
<AdminButton size="sm" variant="ghost" onclick={() => settingsStore.resetField(field)}>
<X size={12} /> {at("reset", {}, "Сбросить")}
</button>
</AdminButton>
{/if}
</div>
</div>
{/snippet}
{#if settingsLoading || !settingsSections.length}
<div class="admin-empty">{settingsLoading ? at("loading", {}, "Загрузка…") : at("no_data", {}, "Нет данных")}</div>
<AdminEmptyState>{settingsLoading ? at("loading", {}, "Загрузка…") : at("no_data", {}, "Нет данных")}</AdminEmptyState>
{:else}
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<p class="admin-muted" style="margin:0;">
{at("settings_hint", {}, "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.")}
</p>
<div style="display:flex; gap:8px;">
<button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={toggleAllSections}>
<AdminButton size="sm" variant="ghost" onclick={toggleAllSections}>
{settingsAllOpen ? at("collapse_all", {}, "Свернуть всё") : at("expand_all", {}, "Развернуть всё")}
</button>
</AdminButton>
{#if Object.keys(settingsDirty).length > 0}
<button type="button" class="admin-btn admin-btn-sm admin-btn-primary" on:click={() => settingsStore.saveSettings(onSettingsSaved)} disabled={settingsSaving}>
<AdminButton size="sm" variant="primary" onclick={() => settingsStore.saveSettings(onSettingsSaved)} disabled={settingsSaving}>
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
</button>
</AdminButton>
{/if}
</div>
</div>
@@ -1,5 +1,5 @@
<script>
import { Activity, Radio, Server, TrendingDown, TrendingUp } from "lucide-svelte";
import { Activity, Radio, Server, TrendingDown, TrendingUp } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Badge from "$components/ui/badge.svelte";
@@ -7,8 +7,11 @@
import {
AdminDashboardGrid,
AdminDashboardStack,
AdminBadge,
AdminEmptyState,
AdminSectionHeader,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
@@ -39,6 +42,14 @@
$: dailySeries = Array.isArray(fin.daily_series) ? fin.daily_series : [];
$: revenueKpis = computeRevenueKpis(fin, dailySeries);
$: chartModel = buildRevenueChartModel(dailySeries, fmtDateShort);
$: recentPaymentHeaders = [
at("id", {}, ""),
at("user", {}, ""),
at("amount", {}, ""),
at("provider", {}, ""),
at("status", {}, ""),
at("date", {}, ""),
];
function parsePanelSystem(panel) {
const system = panel?.system;
@@ -161,32 +172,7 @@
</Card.Content>
</Card.Root>
<AdminSectionHeader title={at("stats_recent_payments", {}, "")} />
<div class="admin-table-wrap">
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("id", {}, "")}</th>
<th>{at("user", {}, "")}</th>
<th>{at("amount", {}, "")}</th>
<th>{at("provider", {}, "")}</th>
<th>{at("status", {}, "")}</th>
<th>{at("date", {}, "")}</th>
</tr>
</thead>
<tbody>
{#each Array(6) as _, k (k)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
</tr>
{/each}
</tbody>
</table>
</div>
<AdminTableSkeleton headers={recentPaymentHeaders} rows={6} widths={["48px", "120px", "78px", "82px", "72px", "96px"]} />
</AdminDashboardStack>
{:else if stats}
<AdminDashboardStack>
@@ -457,32 +443,9 @@
<Card.Content class="admin-cn-card-content--flush">
<div class="admin-table-wrap">
{#if statsLoading}
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("id", {}, "")}</th>
<th>{at("user", {}, "")}</th>
<th>{at("amount", {}, "")}</th>
<th>{at("provider", {}, "")}</th>
<th>{at("status", {}, "")}</th>
<th>{at("date", {}, "")}</th>
</tr>
</thead>
<tbody>
{#each Array(5) as _, r (r)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
</tr>
{/each}
</tbody>
</table>
<AdminTableSkeleton headers={recentPaymentHeaders} rows={5} widths={["48px", "120px", "78px", "82px", "72px", "96px"]} />
{:else if (stats.recent_payments || []).length}
<table class="admin-table">
<AdminTable>
<thead>
<tr>
<th>{at("id", {}, "")}</th>
@@ -501,13 +464,13 @@
<td data-label={at("amount", {}, "")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "")}>{p.provider}</td>
<td data-label={at("status", {}, "")}>
<span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span>
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
</td>
<td data-label={at("date", {}, "")}>{fmtDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
</AdminTable>
{:else}
<AdminEmptyState tone="card"><span class="admin-muted">{at("no_data", {}, "")}</span></AdminEmptyState>
{/if}
@@ -1,7 +1,8 @@
<script>
import { Tabs, Select, Switch, Label } from "$components/ui/primitives.js";
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { Check, ChevronDown, Plus, Save, Trash2, X } from "lucide-svelte";
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { getContext } from "svelte";
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js";
@@ -22,6 +23,15 @@
tariffEditorTab,
} = $tariffsStore);
$: billingModelOptions = [
{ value: "period", label: at("tariff_model_period_label", {}, "Период") },
{ value: "traffic", label: at("tariff_model_traffic_label", {}, "Трафик") },
];
$: panelSquadOptions = (panelSquads || []).map((squad) => ({
value: squad.uuid,
label: squad.name,
}));
</script>
<Dialog
@@ -52,24 +62,11 @@
<div class="admin-field-label">
<span>{at("tariff_label_model", {}, "Модель тарификации")}</span>
<small><b>{at("tariff_model_period_label", {}, "Период")}</b>{at("tariff_model_period_desc", {}, "пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)")}. <b>{at("tariff_model_traffic_label", {}, "Трафик")}</b>{at("tariff_model_traffic_desc", {}, "пользователь покупает пакеты гигабайт по фиксированной цене за GB")}</small>
<Select.Root type="single" bind:value={$tariffsStore.tariffDraft.billing_model}>
<Select.Trigger class="admin-select-trigger" aria-label={at("tariff_label_model", {}, "Модель")}>
<span>{tariffDraft.billing_model === "traffic" ? at("tariff_model_traffic_label", {}, "Трафик") : at("tariff_model_period_label", {}, "Период")}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
<Select.Item value="period" class="admin-select-item">
<span>{at("tariff_model_period_label", {}, "Период")}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
<Select.Item value="traffic" class="admin-select-item">
<span>{at("tariff_model_traffic_label", {}, "Трафик")}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
</Select.Content>
</Select.Portal>
</Select.Root>
<AdminSelect
bind:value={$tariffsStore.tariffDraft.billing_model}
items={billingModelOptions}
ariaLabel={at("tariff_label_model", {}, "Модель")}
/>
</div>
</div>
@@ -112,29 +109,16 @@
<div class="admin-field-label">
<span>{at("tariff_label_squads", {}, "Базовые Internal Squads")}</span>
<small>{panelSquadsLoading ? at("loading_squads", {}, "Загружаю список из панели…") : at("tariff_hint_squads", {}, "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько")}</small>
<Select.Root
type="single"
<AdminSelect
bind:value={$tariffsStore.selectedBaseSquad}
items={panelSquadOptions}
placeholder={at("btn_add_squad", {}, "Добавить сквад")}
ariaLabel={at("btn_add_squad", {}, "Добавить основной сквад")}
onValueChange={(value) => {
tariffsStore.addSquadToDraft("squadUuids", value);
selectedBaseSquad = "";
}}
>
<Select.Trigger class="admin-select-trigger" aria-label={at("btn_add_squad", {}, "Добавить основной сквад")}>
<span>{at("btn_add_squad", {}, "Добавить сквад")}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each panelSquads as squad}
<Select.Item value={squad.uuid} class="admin-select-item">
<span>{squad.name}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
/>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
<button type="button" class="admin-chip" on:click={() => tariffsStore.removeSquadFromDraft("squadUuids", uuid)}>
@@ -190,29 +174,16 @@
<div class="admin-field-label">
<span>{at("tariff_label_premium_squads", {}, "Premium Internal Squads")}</span>
<small>{at("tariff_hint_premium_squads", {}, "Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes")}</small>
<Select.Root
type="single"
<AdminSelect
bind:value={$tariffsStore.selectedPremiumSquad}
items={panelSquadOptions}
placeholder={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
ariaLabel={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
onValueChange={(value) => {
tariffsStore.addSquadToDraft("premiumSquadUuids", value);
selectedPremiumSquad = "";
}}
>
<Select.Trigger class="admin-select-trigger" aria-label={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}>
<span>{at("btn_add_premium_squad", {}, "Добавить premium-сквад")}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each panelSquads as squad}
<Select.Item value={squad.uuid} class="admin-select-item">
<span>{squad.name}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
/>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
<button type="button" class="admin-chip" on:click={() => tariffsStore.removeSquadFromDraft("premiumSquadUuids", uuid)}>
@@ -236,8 +207,8 @@
<small>{at("tariff_premium_topup_subtitle", {}, "Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал")}</small>
</div>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</button>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton>
</div>
</header>
<div class="admin-package-columns">
@@ -254,7 +225,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="10" bind:value={row.gb} aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")} />
<input class="input" type="number" min="0" step="0.01" placeholder="199" bind:value={row.price} aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("premiumTopupRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("premiumTopupRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -271,7 +242,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="10" bind:value={row.gb} aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")} />
<input class="input" type="number" min="0" step="1" placeholder="100" bind:value={row.price} aria-label={at("tariff_label_price_stars", {}, "Цена premium-пакета в Telegram Stars")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -287,9 +258,9 @@
<strong>{at("tariff_pricing_period_title", {}, "Периоды подписки и цены")}</strong>
<small>{at("tariff_pricing_period_subtitle", {}, "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит")}</small>
</div>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
<Plus size={13} /> {at("tariff_btn_period", {}, "Период")}
</button>
</AdminButton>
</header>
{#if !tariffDraft.periodRows.length}
<p class="admin-muted">{at("tariff_pricing_empty", {}, "Добавьте хотя бы один период — без него тариф не появится на витрине.")}</p>
@@ -306,9 +277,9 @@
<input class="input" type="number" min="1" placeholder="1" bind:value={row.months} aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")} />
<input class="input" type="number" min="0" step="0.01" placeholder="299" bind:value={row.rub} aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")} />
<input class="input" type="number" min="0" step="1" placeholder="150" bind:value={row.stars} aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("periodRows", index)} aria-label={at("btn_delete", {}, "Удалить")}>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("periodRows", index)} aria-label={at("btn_delete", {}, "Удалить")}>
<Trash2 size={13} />
</button>
</AdminButton>
</div>
{/each}
</div>
@@ -322,8 +293,8 @@
<small>{at("tariff_pricing_traffic_subtitle", {}, "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»")}</small>
</div>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</button>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton>
</div>
</header>
<div class="admin-package-columns">
@@ -340,7 +311,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="50" bind:value={row.gb} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} />
<input class="input" type="number" min="0" step="0.01" placeholder="299" bind:value={row.price} aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("trafficRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -357,7 +328,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="50" bind:value={row.gb} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} />
<input class="input" type="number" min="0" step="1" placeholder="150" bind:value={row.price} aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("trafficStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -375,8 +346,8 @@
<small>{at("tariff_topup_subtitle", {}, "Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки")}</small>
</div>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</button>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton>
</div>
</header>
<div class="admin-package-columns">
@@ -393,7 +364,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="20" bind:value={row.gb} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} />
<input class="input" type="number" min="0" step="0.01" placeholder="149" bind:value={row.price} aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("topupRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("topupRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -410,7 +381,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="20" bind:value={row.gb} aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")} />
<input class="input" type="number" min="0" step="1" placeholder="75" bind:value={row.price} aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("topupStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("topupStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -429,8 +400,8 @@
<small>{at("tariff_hwid_packages_subtitle", {}, "Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»")}</small>
</div>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</button>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton>
<AdminButton size="sm" onclick={() => tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton>
</div>
</header>
<div class="admin-package-columns">
@@ -447,7 +418,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="1" step="1" placeholder="1" bind:value={row.count} aria-label={at("tariff_label_hwid_count_full", {}, "Сколько устройств добавляет пакет")} />
<input class="input" type="number" min="0" step="0.01" placeholder="99" bind:value={row.price} aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("hwidRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("hwidRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -464,7 +435,7 @@
<div class="admin-row-editor-line">
<input class="input" type="number" min="1" step="1" placeholder="1" bind:value={row.count} aria-label={at("tariff_label_hwid_count_full", {}, "Сколько устройств добавляет пакет")} />
<input class="input" type="number" min="0" step="1" placeholder="50" bind:value={row.price} aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("hwidStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></button>
<AdminButton size="sm" variant="danger" onclick={() => tariffsStore.removeDraftRow("hwidStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton>
</div>
{/each}
</div>
@@ -474,10 +445,10 @@
</Tabs.Root>
<div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => tariffsStore.updateState({ tariffEditorOpen: false })}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={tariffsStore.saveTariffDraft} disabled={tariffsSaving || !tariffDraft.key.trim()}>
<AdminButton onclick={() => tariffsStore.updateState({ tariffEditorOpen: false })}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="primary" onclick={tariffsStore.saveTariffDraft} disabled={tariffsSaving || !tariffDraft.key.trim()}>
<Save size={14} /> {tariffsSaving ? at("btn_saving", {}, "Сохранение...") : at("btn_save_tariff", {}, "Сохранить тариф")}
</button>
</AdminButton>
</div>
</Dialog>
@@ -490,9 +461,9 @@
class="admin-dialog"
>
<div class="admin-form-row">
<button type="button" class="admin-btn" on:click={() => tariffsStore.updateState({ tariffDeleteOpen: false })}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-danger" on:click={tariffsStore.deleteTariff} disabled={tariffsSaving}>
<AdminButton onclick={() => tariffsStore.updateState({ tariffDeleteOpen: false })}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="danger" onclick={tariffsStore.deleteTariff} disabled={tariffsSaving}>
<Trash2 size={14} /> {at("btn_confirm_delete", {}, "Подтвердить удаление")}
</button>
</AdminButton>
</div>
</Dialog>
@@ -1,6 +1,7 @@
<script>
import { RefreshCw, Trash2, Plus } from "lucide-svelte";
import { RefreshCw, Trash2, Plus } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
export let at;
export let fmtMoney;
@@ -45,7 +46,7 @@
</script>
{#if tariffsLoading}
<div class="admin-empty">{at("loading", {}, "Загрузка…")}</div>
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
{:else}
<div class="admin-stat-grid">
<div class="admin-stat-card">
@@ -72,19 +73,19 @@
<small>{tariffsPath || "data/tariffs.json"}</small>
</div>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={tariffsStore.loadTariffs} disabled={tariffsLoading || tariffsSaving}>
<AdminButton size="sm" onclick={tariffsStore.loadTariffs} disabled={tariffsLoading || tariffsSaving}>
<RefreshCw size={13} /> {at("btn_refresh", {}, "Обновить")}
</button>
<button type="button" class="admin-btn admin-btn-sm admin-btn-primary" on:click={tariffsStore.openCreateTariff} disabled={tariffsLoading || tariffsSaving}>
</AdminButton>
<AdminButton size="sm" variant="primary" onclick={tariffsStore.openCreateTariff} disabled={tariffsLoading || tariffsSaving}>
<Plus size={13} /> {at("btn_create_tariff", {}, "Создать тариф")}
</button>
</AdminButton>
</div>
</header>
<div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length}
<div class="admin-empty">
<AdminEmptyState>
{at("tariffs_catalog_empty", {}, "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.")}
</div>
</AdminEmptyState>
{:else}
<div class="admin-tariff-grid">
{#each tariffsCatalog.tariffs as tariff}
@@ -94,15 +95,15 @@
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff}
<span class="admin-badge admin-badge-success">{at("status_default", {}, "Default")}</span>
<AdminBadge variant="success">{at("status_default", {}, "Default")}</AdminBadge>
{/if}
</div>
<code>{tariff.key}</code>
</div>
{#if tariff.enabled === false}
<span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключен")}</span>
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{:else}
<span class="admin-badge admin-badge-success">{at("status_active", {}, "Активен")}</span>
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{/if}
</div>
<p>{tariff.descriptions?.ru || tariff.descriptions?.en || at("no_description", {}, "Без описания")}</p>
@@ -114,29 +115,28 @@
<span>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? "env"}</span>
</div>
<div class="admin-tariff-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.openEditTariff(tariff)}>
<AdminButton size="sm" onclick={() => tariffsStore.openEditTariff(tariff)}>
{at("btn_configure", {}, "Настроить")}
</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
</AdminButton>
<AdminButton size="sm" onclick={() => tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
{tariff.enabled === false ? at("btn_enable", {}, "Включить") : at("btn_disable", {}, "Выключить")}
</button>
<button
type="button"
class="admin-btn admin-btn-sm"
on:click={() => tariffsStore.setDefaultTariff(tariff.key)}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving || tariff.enabled === false || tariff.key === tariffsCatalog.default_tariff}
>
{at("btn_set_default", {}, "По умолчанию")}
</button>
<button
type="button"
class="admin-btn admin-btn-sm admin-btn-danger"
on:click={() => tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })}
</AdminButton>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
<Trash2 size={13} />
</button>
</AdminButton>
</div>
</article>
{/each}
@@ -1,11 +1,12 @@
<script>
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { AdminBadge, AdminButton, AdminTrafficCard } from "$components/patterns/admin/index.js";
import {
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
Plus, Settings, Shield, Trash2, User, UserMinus, UserPlus, Users
} from "lucide-svelte";
} from "$components/ui/icons.js";
import { getContext } from "svelte";
export let at;
@@ -71,14 +72,14 @@
<small>{userSecondaryName(openedUser)}</small>
<div class="admin-user-summary-tags">
{#if openedUser.is_banned}
<span class="admin-badge admin-badge-danger">{at("badge_banned", {}, "Бан")}</span>
<AdminBadge variant="danger">{at("badge_banned", {}, "Бан")}</AdminBadge>
{:else}
<span class="admin-badge admin-badge-success">{at("badge_active", {}, "Активен")}</span>
<AdminBadge variant="success">{at("badge_active", {}, "Активен")}</AdminBadge>
{/if}
{#if openedUserDetail.active_subscription}
<span class="admin-badge admin-badge-success">{at("badge_subscription", {}, "Подписка")}</span>
<AdminBadge variant="success">{at("badge_subscription", {}, "Подписка")}</AdminBadge>
{:else}
<span class="admin-badge admin-badge-muted">{at("badge_no_subscription", {}, "Без подписки")}</span>
<AdminBadge variant="muted">{at("badge_no_subscription", {}, "Без подписки")}</AdminBadge>
{/if}
</div>
</div>
@@ -116,9 +117,9 @@
{openedUserDetail.subscription_url}
</a>
</div>
<button type="button" class="admin-btn admin-btn-icon" title={at("user_copy_tooltip", {}, "Скопировать")} on:click={() => usersStore.copyToClipboard(openedUserDetail.subscription_url, at("user_sub_link_copied", {}, "Ссылка на подписку скопирована"))}>
<AdminButton size="icon" variant="icon" title={at("user_copy_tooltip", {}, "Скопировать")} onclick={() => usersStore.copyToClipboard(openedUserDetail.subscription_url, at("user_sub_link_copied", {}, "Ссылка на подписку скопирована"))}>
<Copy size={14} />
</button>
</AdminButton>
</div>
{/if}
{#if openedUserDetail.referral?.bot_link}
@@ -129,9 +130,9 @@
{openedUserDetail.referral.bot_link}
</a>
</div>
<button type="button" class="admin-btn admin-btn-icon" title={at("user_copy_tooltip", {}, "Скопировать")} on:click={() => usersStore.copyToClipboard(openedUserDetail.referral.bot_link, at("user_ref_link_copied", {}, "Реф. ссылка скопирована"))}>
<AdminButton size="icon" variant="icon" title={at("user_copy_tooltip", {}, "Скопировать")} onclick={() => usersStore.copyToClipboard(openedUserDetail.referral.bot_link, at("user_ref_link_copied", {}, "Реф. ссылка скопирована"))}>
<Copy size={14} />
</button>
</AdminButton>
</div>
{/if}
{#if openedUserDetail.referral?.webapp_link}
@@ -142,9 +143,9 @@
{openedUserDetail.referral.webapp_link}
</a>
</div>
<button type="button" class="admin-btn admin-btn-icon" title={at("user_copy_tooltip", {}, "Скопировать")} on:click={() => usersStore.copyToClipboard(openedUserDetail.referral.webapp_link, at("user_ref_link_copied", {}, "Реф. ссылка скопирована"))}>
<AdminButton size="icon" variant="icon" title={at("user_copy_tooltip", {}, "Скопировать")} onclick={() => usersStore.copyToClipboard(openedUserDetail.referral.webapp_link, at("user_ref_link_copied", {}, "Реф. ссылка скопирована"))}>
<Copy size={14} />
</button>
</AdminButton>
</div>
{/if}
</div>
@@ -168,33 +169,24 @@
<li><span>{at("user_label_provider", {}, "Провайдер")}</span><strong>{openedUserDetail.active_subscription.provider || "—"}</strong></li>
</ul>
<div class="admin-traffic-summary">
<div class={`admin-traffic-card${openedUserDetail.active_subscription.is_throttled ? " admin-traffic-card-warning" : ""}`}>
<div class="admin-traffic-head">
<span>{at("user_label_main_traffic", {}, "Основной трафик")}</span>
<strong>{trafficOfLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}</strong>
</div>
<div class="admin-traffic-bar" aria-label={at("aria_label_main_traffic", {}, "Использование основного трафика")}>
<span style={`width: ${trafficPercentValue(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}%`}></span>
</div>
<div class="admin-traffic-meta">
<span>{at("user_traffic_left", { left: trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes) }, "Осталось: " + trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes))}</span>
<span>{trafficPercentValue(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}%</span>
</div>
</div>
<AdminTrafficCard
title={at("user_label_main_traffic", {}, "Основной трафик")}
value={trafficOfLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}
left={at("user_traffic_left", { left: trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes) }, "Осталось: " + trafficLeftLabel(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes))}
percent={trafficPercentValue(openedUserDetail.active_subscription.traffic_used_bytes, openedUserDetail.active_subscription.traffic_limit_bytes)}
warning={openedUserDetail.active_subscription.is_throttled}
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
/>
{#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
<div class={`admin-traffic-card admin-traffic-card-premium${openedUserDetail.active_subscription.premium_is_limited ? " admin-traffic-card-warning" : ""}`}>
<div class="admin-traffic-head">
<span>{at("user_label_premium_squads", {}, "Premium-сквады")}</span>
<strong>{trafficOfLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}</strong>
</div>
<div class="admin-traffic-bar admin-traffic-bar-premium" aria-label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}>
<span style={`width: ${trafficPercentValue(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}%`}></span>
</div>
<div class="admin-traffic-meta">
<span>{at("user_traffic_left", { left: trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes) }, "Осталось: " + trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes))}</span>
<span>{trafficPercentValue(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}%</span>
</div>
</div>
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
value={trafficOfLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}
left={at("user_traffic_left", { left: trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes) }, "Осталось: " + trafficLeftLabel(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes))}
percent={trafficPercentValue(openedUserDetail.active_subscription.premium_used_bytes, openedUserDetail.active_subscription.premium_limit_bytes)}
warning={openedUserDetail.active_subscription.premium_is_limited}
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
/>
{/if}
</div>
{:else}
@@ -212,9 +204,9 @@
<small>{at("user_history_until", { date: fmtDate(sub.end_date) }, `до ${fmtDate(sub.end_date)}`)}</small>
</div>
{#if sub.is_active}
<span class="admin-badge admin-badge-success">{at("user_history_active", {}, "Активна")}</span>
<AdminBadge variant="success">{at("user_history_active", {}, "Активна")}</AdminBadge>
{:else}
<span class="admin-badge admin-badge-muted">{sub.status_from_panel || at("user_history_status_panel", {}, "История")}</span>
<AdminBadge variant="muted">{sub.status_from_panel || at("user_history_status_panel", {}, "История")}</AdminBadge>
{/if}
</div>
{/each}
@@ -232,7 +224,7 @@
<strong>{fmtMoney(payment.amount, payment.currency)}</strong>
<small>{payment.provider} · {fmtDateShort(payment.created_at)}</small>
</div>
<span class="admin-badge admin-badge-{paymentStatusVariant(payment.status)}">{payment.status}</span>
<AdminBadge variant={paymentStatusVariant(payment.status)}>{payment.status}</AdminBadge>
</div>
{/each}
</div>
@@ -243,16 +235,16 @@
<Tabs.Content value="actions" class="admin-tabs-content admin-actions-tab">
<div class="admin-user-quick-actions">
<button type="button" class="admin-btn admin-reset-trial-btn" on:click={usersStore.resetTrialUser} disabled={userActionBusy}>
<AdminButton class="admin-reset-trial-btn" onclick={usersStore.resetTrialUser} disabled={userActionBusy}>
<RefreshCw size={14} /> {at("user_btn_reset_trial", {}, "Сбросить триал")}
</button>
</AdminButton>
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_label_extend", {}, "Продлить подписку")}</span>
<div class="admin-extend-control">
<input class="input" type="number" min="1" bind:value={$usersStore.userExtendDays} aria-label={at("user_label_extend_days", {}, "Дней")} />
<button type="button" class="admin-btn" on:click={usersStore.extendUser} disabled={userActionBusy}>
<AdminButton onclick={usersStore.extendUser} disabled={userActionBusy}>
<Plus size={14} /> {at("user_btn_extend", {}, "Продлить")}
</button>
</AdminButton>
</div>
</Label.Root>
</div>
@@ -263,12 +255,12 @@
<textarea class="admin-textarea" rows="3" placeholder={at("user_placeholder_msg", {}, "Текст сообщения")} bind:value={$usersStore.userMessageDraft}></textarea>
</Label.Root>
<div class="admin-message-actions">
<button type="button" class="admin-btn" on:click={usersStore.previewUserMessage} disabled={userActionBusy || !userMessageDraft.trim()}>
<AdminButton onclick={usersStore.previewUserMessage} disabled={userActionBusy || !userMessageDraft.trim()}>
<Eye size={14} /> {at("btn_preview_tg", {}, "Превью в Telegram")}
</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={usersStore.requestSendUserMessage} disabled={userActionBusy || !userMessageDraft.trim()}>
</AdminButton>
<AdminButton variant="primary" onclick={usersStore.requestSendUserMessage} disabled={userActionBusy || !userMessageDraft.trim()}>
<Send size={14} /> {at("btn_send_msg", {}, "Отправить сообщение")}
</button>
</AdminButton>
</div>
<section class="admin-danger-zone">
@@ -278,17 +270,17 @@
</header>
<div class="admin-action-grid">
{#if openedUser.is_banned}
<button type="button" class="admin-btn admin-btn-danger-soft" on:click={usersStore.requestBanToggle} disabled={userActionBusy}>
<AdminButton variant="dangerSoft" onclick={usersStore.requestBanToggle} disabled={userActionBusy}>
<UserPlus size={14} /> {at("btn_unban", {}, "Разбанить пользователя")}
</button>
</AdminButton>
{:else}
<button type="button" class="admin-btn admin-btn-danger" on:click={usersStore.requestBanToggle} disabled={userActionBusy}>
<AdminButton variant="danger" onclick={usersStore.requestBanToggle} disabled={userActionBusy}>
<UserMinus size={14} /> {at("btn_ban", {}, "Заблокировать")}
</button>
</AdminButton>
{/if}
<button type="button" class="admin-btn admin-btn-danger" on:click={() => usersStore.updateState({ userDeleteOpen: true })} disabled={userActionBusy}>
<AdminButton variant="danger" onclick={() => usersStore.updateState({ userDeleteOpen: true })} disabled={userActionBusy}>
<Trash2 size={14} /> {at("btn_delete_account", {}, "Удалить аккаунт")}
</button>
</AdminButton>
</div>
</section>
</Tabs.Content>
@@ -309,10 +301,10 @@
>
<div class="admin-confirm-message-preview">{userMessageDraft}</div>
<div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => usersStore.updateState({ userMessageConfirmOpen: false })}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={usersStore.sendUserMessage} disabled={userActionBusy || !userMessageDraft.trim()}>
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="primary" onclick={usersStore.sendUserMessage} disabled={userActionBusy || !userMessageDraft.trim()}>
<Send size={14} /> {at("btn_confirm_send", {}, "Подтвердить отправку")}
</button>
</AdminButton>
</div>
</Dialog>
@@ -325,10 +317,10 @@
class="admin-dialog"
>
<div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => usersStore.updateState({ userBanConfirmOpen: false })}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-danger" on:click={() => usersStore.applyBanToggle(true)} disabled={userActionBusy}>
<AdminButton onclick={() => usersStore.updateState({ userBanConfirmOpen: false })}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="danger" onclick={() => usersStore.applyBanToggle(true)} disabled={userActionBusy}>
<UserMinus size={14} /> {at("btn_ban", {}, "Заблокировать")}
</button>
</AdminButton>
</div>
</Dialog>
@@ -341,9 +333,9 @@
class="admin-dialog"
>
<div class="admin-form-row">
<button type="button" class="admin-btn" on:click={() => usersStore.updateState({ userDeleteOpen: false })}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-danger" on:click={usersStore.deleteUser} disabled={userActionBusy}>
<AdminButton onclick={() => usersStore.updateState({ userDeleteOpen: false })}>{at("btn_cancel", {}, "Отмена")}</AdminButton>
<AdminButton variant="danger" onclick={usersStore.deleteUser} disabled={userActionBusy}>
<Trash2 size={14} /> {at("btn_confirm_delete", {}, "Подтвердить удаление")}
</button>
</AdminButton>
</div>
</Dialog>
@@ -1,11 +1,10 @@
<script>
import { Check, ChevronDown, ChevronLeft, ChevronRight } from "lucide-svelte";
import { Label, Select } from "$components/ui/primitives.js";
import { Label } from "$components/ui/primitives.js";
import { AdminBadge, AdminButton, AdminEmptyState, AdminPagination, AdminSelect } from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte";
export let at = (key) => key;
export let fmtDateShort = (value) => value;
export let optionLabel = () => "";
export let panelStatusBadge = () => ({});
export let resolvedAvatarUrl = () => "";
export let userDisplayName = () => "";
@@ -70,80 +69,41 @@
on:input={(e) => usersStore.updateState({ usersQuery: e.target.value })}
on:keydown={(e) => e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())}
/>
<button type="button" class="admin-btn admin-btn-primary" on:click={() => { usersStore.updateState({ usersPage: 0 }); usersStore.loadUsers(); }}>{at("find", {}, "Найти")}</button>
<AdminButton variant="primary" onclick={() => { usersStore.updateState({ usersPage: 0 }); usersStore.loadUsers(); }}>{at("find", {}, "Найти")}</AdminButton>
</div>
<div class="admin-toolbar-controls">
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
<Select.Root
type="single"
<AdminSelect
value={usersFilter}
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => { usersStore.updateState({ usersFilter: value, usersPage: 0 }); usersStore.loadUsers(); }}
>
<Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("filter", {}, "Фильтр")}>
<span>{optionLabel(USERS_FILTER_OPTIONS, usersFilter)}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each USERS_FILTER_OPTIONS as opt}
<Select.Item value={opt.value} class="admin-select-item">
<span>{opt.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
<Select.Root
type="single"
<AdminSelect
value={usersPanelStatus}
items={USERS_PANEL_STATUS_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("panel_status", {}, "Статус панели")}
onValueChange={(value) => { usersStore.updateState({ usersPanelStatus: value, usersPage: 0 }); usersStore.loadUsers(); }}
>
<Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("panel_status", {}, "Статус панели")}>
<span>{optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each USERS_PANEL_STATUS_OPTIONS as opt}
<Select.Item value={opt.value} class="admin-select-item">
<span>{opt.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
<Select.Root
type="single"
<AdminSelect
value={usersSort}
items={USERS_SORT_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => { usersStore.updateState({ usersSort: value, usersPage: 0 }); usersStore.loadUsers(); }}
>
<Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("sort", {}, "Сортировка")}>
<span>{optionLabel(USERS_SORT_OPTIONS, usersSort)}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each USERS_SORT_OPTIONS as opt}
<Select.Item value={opt.value} class="admin-select-item">
<span>{opt.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
/>
</Label.Root>
<div class="admin-toolbar-summary">
@@ -173,7 +133,7 @@
{/each}
</ul>
{:else if !users.length}
<div class="admin-card-body"><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span></div>
<AdminEmptyState tone="card"><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span></AdminEmptyState>
{:else}
<ul class="admin-user-list">
{#each users as user}
@@ -193,7 +153,7 @@
<small>{userSecondaryName(user)}</small>
</span>
<span class="admin-user-side">
<span class="admin-badge admin-badge-{badge.variant}">{badge.label}</span>
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
<span class="admin-user-tertiary">{fmtDateShort(user.registration_date)}</span>
</span>
</button>
@@ -203,14 +163,12 @@
{/if}
</div>
<div class="admin-pagination">
<span class="admin-pagination-meta">{at("page", {}, "Страница")} {usersPage + 1}</span>
<div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={usersPage === 0} on:click={() => { usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); usersStore.loadUsers(); }}>
<ChevronLeft size={14} /> {at("back", {}, "Назад")}
</button>
<button type="button" class="admin-btn admin-btn-sm" disabled={!usersHasMore} on:click={() => { usersStore.updateState({ usersPage: usersPage + 1 }); usersStore.loadUsers(); }}>
{at("next", {}, "Далее")} <ChevronRight size={14} />
</button>
</div>
</div>
<AdminPagination
meta={`${at("page", {}, "Страница")} ${usersPage + 1}`}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={usersPage === 0}
nextDisabled={!usersHasMore}
onPrev={() => { usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); usersStore.loadUsers(); }}
onNext={() => { usersStore.updateState({ usersPage: usersPage + 1 }); usersStore.loadUsers(); }}
/>
@@ -0,0 +1,21 @@
<script>
import { cn } from "$lib/utils.js";
export let variant = "muted";
let className = "";
export { className as class };
</script>
<span
class={cn(
"admin-badge",
variant === "success" && "admin-badge-success",
variant === "danger" && "admin-badge-danger",
variant === "warning" && "admin-badge-warning",
variant === "muted" && "admin-badge-muted",
className,
)}
{...$$restProps}
>
<slot />
</span>
@@ -0,0 +1,45 @@
<script>
import { cva } from "class-variance-authority";
import { cn } from "$lib/utils.js";
export let type = "button";
export let variant = "default";
export let size = "default";
export let disabled = false;
export let onclick = undefined;
let className = "";
export { className as class };
const buttonVariants = cva("admin-btn", {
variants: {
variant: {
default: "",
primary: "admin-btn-primary",
ghost: "admin-btn-ghost",
danger: "admin-btn-danger",
dangerSoft: "admin-btn-danger-soft",
icon: "admin-btn-icon",
},
size: {
default: "",
sm: "admin-btn-sm",
icon: "admin-btn-icon",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
</script>
<button
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
onclick={onclick}
{...$$restProps}
>
<slot />
</button>
@@ -0,0 +1,16 @@
<script>
import { Label } from "$components/ui/primitives.js";
export let label = "";
export let hint = "";
</script>
<Label.Root class="admin-field-label">
{#if label}
<span>{label}</span>
{/if}
{#if hint}
<small>{hint}</small>
{/if}
<slot />
</Label.Root>
@@ -0,0 +1,24 @@
<script>
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import AdminButton from "./AdminButton.svelte";
export let meta = "";
export let prevLabel = "Back";
export let nextLabel = "Next";
export let prevDisabled = false;
export let nextDisabled = false;
export let onPrev = () => {};
export let onNext = () => {};
</script>
<div class="admin-pagination">
<span class="admin-pagination-meta">{meta}</span>
<div class="admin-pagination-buttons">
<AdminButton size="sm" disabled={prevDisabled} onclick={onPrev}>
<ChevronLeft size={14} /> {prevLabel}
</AdminButton>
<AdminButton size="sm" disabled={nextDisabled} onclick={onNext}>
{nextLabel} <ChevronRight size={14} />
</AdminButton>
</div>
</div>
@@ -0,0 +1,38 @@
<script>
import { Check, ChevronDown } from "$components/ui/icons.js";
import { Select } from "$components/ui/primitives.js";
export let value = "";
export let items = [];
export let ariaLabel = "";
export let placeholder = "";
export let disabled = false;
export let sideOffset = 6;
export let onValueChange = () => {};
let className = "";
export { className as class };
$: selected = items.find((item) => item.value === value);
function handleValueChange(next) {
value = next;
onValueChange(next);
}
</script>
<Select.Root type="single" {value} {items} {disabled} onValueChange={handleValueChange}>
<Select.Trigger class={`admin-select-trigger ${className}`.trim()} aria-label={ariaLabel || placeholder}>
<span>{selected?.label || placeholder}</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}>
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
<span>{item.label}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -0,0 +1,13 @@
<script>
import { cn } from "$lib/utils.js";
export let skeleton = false;
let className = "";
export { className as class };
</script>
<div class="admin-table-wrap">
<table class={cn("admin-table", skeleton && "admin-table-skeleton", className)} aria-hidden={skeleton ? "true" : undefined} {...$$restProps}>
<slot />
</table>
</div>
@@ -0,0 +1,38 @@
<script>
import Skeleton from "$components/ui/skeleton.svelte";
import AdminTable from "./AdminTable.svelte";
export let headers = [];
export let rows = 6;
export let actionColumn = false;
export let widths = [];
function widthFor(index) {
if (widths[index]) return widths[index];
if (actionColumn && index === headers.length - 1) return "92px";
if (index === 0) return "48px";
if (index === headers.length - 1) return "76px";
return index % 3 === 0 ? "56%" : "72%";
}
</script>
<AdminTable skeleton>
<thead>
<tr>
{#each headers as header}
<th class:admin-cell-actions={actionColumn && header === headers[headers.length - 1]}>{header}</th>
{/each}
</tr>
</thead>
<tbody>
{#each Array(rows) as _, rowIndex (rowIndex)}
<tr>
{#each headers as header, colIndex (`${rowIndex}-${colIndex}`)}
<td>
<Skeleton variant="line" width={widthFor(colIndex)} />
</td>
{/each}
</tr>
{/each}
</tbody>
</AdminTable>
@@ -0,0 +1,34 @@
<script>
import { cn } from "$lib/utils.js";
export let title = "";
export let value = "";
export let left = "";
export let percent = 0;
export let warning = false;
export let premium = false;
export let label = "";
$: clamped = Math.max(0, Math.min(100, Number(percent) || 0));
</script>
<div class={cn("admin-traffic-card", warning && "admin-traffic-card-warning", premium && "admin-traffic-card-premium")}>
<div class="admin-traffic-head">
<span>{title}</span>
<strong>{value}</strong>
</div>
<div
class={cn("admin-traffic-bar", premium && "admin-traffic-bar-premium")}
aria-label={label || title}
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(clamped)}
>
<span style={`width: ${clamped}%`}></span>
</div>
<div class="admin-traffic-meta">
<span>{left}</span>
<span>{clamped}%</span>
</div>
</div>
@@ -1,4 +1,12 @@
export { default as AdminBadge } from "./AdminBadge.svelte";
export { default as AdminButton } from "./AdminButton.svelte";
export { default as AdminDashboardGrid } from "./AdminDashboardGrid.svelte";
export { default as AdminDashboardStack } from "./AdminDashboardStack.svelte";
export { default as AdminEmptyState } from "./AdminEmptyState.svelte";
export { default as AdminField } from "./AdminField.svelte";
export { default as AdminPagination } from "./AdminPagination.svelte";
export { default as AdminSelect } from "./AdminSelect.svelte";
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";
@@ -0,0 +1,67 @@
<script>
import Skeleton from "$components/ui/skeleton.svelte";
export let label = "";
export let rows = 3;
export let actions = 0;
export let methods = 2;
export let showNote = false;
export let showPayButton = true;
export let showMeta = true;
</script>
<div class="dialog-skeleton" aria-label={label}>
{#if actions}
<div class="tariff-action-list">
{#each Array(actions) as _, index (index)}
<div class="tariff-action-card skeleton-row">
<span>
<Skeleton variant="title" />
<Skeleton variant="short" />
</span>
<Skeleton class="skeleton-line-price" />
</div>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
{/if}
<div class="option-list">
{#each Array(rows) as _, index (index)}
<div class={`option-row ${showMeta ? "plan-row" : "change-action-row"} skeleton-row`}>
<span class="option-row-main">
<Skeleton variant="title" />
<Skeleton variant="short" />
</span>
{#if showMeta}
<span class="option-row-meta">
<Skeleton class="skeleton-line-price" />
<Skeleton variant="tiny" />
</span>
{/if}
</div>
{/each}
</div>
{#if showNote}
<div class="topup-carryover-note skeleton-carryover-note">
<Skeleton variant="line" />
<Skeleton variant="short" />
</div>
{/if}
{#if methods}
<div class="method-grid">
{#each Array(methods) as _, index (index)}
<div class="method-card skeleton-method">
<Skeleton variant="dot" />
<Skeleton class="skeleton-line-method" />
</div>
{/each}
</div>
{/if}
{#if showPayButton}
<Skeleton class="skeleton-pay-button" />
{/if}
</div>
@@ -0,0 +1,11 @@
<script>
import Card from "$components/ui/card.svelte";
import { cn } from "$lib/utils.js";
let className = "";
export { className as class };
</script>
<Card class={cn("empty-card", className)} {...$$restProps}>
<slot />
</Card>
@@ -0,0 +1,71 @@
<script>
import { Check, ChevronsUpDown, Globe2 } from "$components/ui/icons.js";
import { Select } from "$components/ui/primitives.js";
export let open = false;
export let value = "ru";
export let currentOption = null;
export let userLanguage = "";
export let options = [];
export let disabled = false;
export let clickGuard = false;
export let clickGuardArmed = false;
export let closeLabel = "Close";
export let label = "Language";
export let onOpenChange = () => {};
export let onValueChange = () => {};
function closeFromGuard(event) {
event.preventDefault();
event.stopPropagation();
if (clickGuardArmed) onOpenChange(false);
}
</script>
{#if open || clickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={clickGuardArmed}
type="button"
aria-label={closeLabel}
onpointerdown={closeFromGuard}
onclick={closeFromGuard}
></button>
{/if}
<div class="settings-row settings-row-language">
<Globe2 size={21} />
<Select.Root
type="single"
bind:open
{value}
items={options}
{disabled}
onOpenChange={onOpenChange}
onValueChange={onValueChange}
>
<Select.Trigger class="language-select-trigger" aria-label={label}>
<span class="language-select-copy">
<strong>{label}</strong>
<small class="language-select-current">
<span class="emoji-flag" aria-hidden="true">{currentOption?.flag || "🏳️"}</span>
{currentOption?.label || userLanguage}
</small>
</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content class="language-select-content" side="bottom" align="end" sideOffset={6}>
<Select.Viewport class="language-select-viewport">
{#each options as option (option.value)}
<Select.Item value={option.value} label={option.label} class="language-select-item">
<span class="language-select-item-main">
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</span>
<Check size={15} class="language-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,22 @@
<script>
import { cn } from "$lib/utils.js";
export let value = 0;
export let label = "";
let className = "";
export { className as class };
$: clamped = Math.max(0, Math.min(100, Number(value) || 0));
</script>
<div
class={cn("progress", className)}
role={label ? "progressbar" : undefined}
aria-label={label || undefined}
aria-valuemin={label ? "0" : undefined}
aria-valuemax={label ? "100" : undefined}
aria-valuenow={label ? Math.round(clamped) : undefined}
{...$$restProps}
>
<span style={`width: ${clamped}%`}></span>
</div>
@@ -0,0 +1,40 @@
<script>
import { Bitcoin, CreditCard } from "$components/ui/icons.js";
export let methods = [];
export let selectedMethod = "";
export let t = (key) => key;
export let onSelect = () => {};
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp")) return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
if (id.includes("platega_crypto")) return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
if (id.includes("yookassa") || id.includes("card")) return { title: t("pay_with_yookassa_button"), icon: null };
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
if (id.includes("cryptopay") || id.includes("crypto")) return { title: t("pay_with_cryptopay_button"), icon: null };
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
return { title: t("wa_method_other_title"), icon: null };
}
</script>
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => onSelect(method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
</div>
@@ -0,0 +1,11 @@
<script>
import { cn } from "$lib/utils.js";
export let error = false;
let className = "";
export { className as class };
</script>
<p class={cn("status-line", error && "error", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,6 @@
export { default as DialogOptionsSkeleton } from "./DialogOptionsSkeleton.svelte";
export { default as EmptyCard } from "./EmptyCard.svelte";
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";
@@ -1,5 +1,5 @@
<script>
import { X } from "lucide-svelte";
import { X } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
import Button from "./button.svelte";
@@ -0,0 +1,66 @@
export {
Activity,
ArrowLeft,
ArrowRight,
Bitcoin,
CalendarDays,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Circle,
CircleX,
Coins,
Copy,
CreditCard,
Crown,
Database,
Download,
ExternalLink,
Eye,
EyeOff,
FileText,
Gift,
Globe2,
Home,
Info,
Key,
LayoutDashboard,
LockKeyhole,
Mail,
Map,
Megaphone,
Menu,
MessageSquare,
MousePointerClick,
Plus,
QrCode,
Radio,
RefreshCw,
Repeat2,
Save,
Send,
Server,
Settings,
Shield,
Sliders,
Smartphone,
Sparkles,
Tag,
Ticket,
Trash2,
TrendingDown,
TrendingUp,
TriangleAlert,
User,
UserMinus,
UserPlus,
UserRound,
Users,
UsersRound,
WalletCards,
X,
Zap,
} from "lucide-svelte";
@@ -3,5 +3,8 @@ export { default as Button } from "./button.svelte";
export { default as Dialog } from "./dialog.svelte";
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 * 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,25 @@
<script>
import { cn } from "$lib/utils.js";
export let variant = "block";
export let width = "";
export let height = "";
let className = "";
export { className as class };
</script>
<span
class={cn(
"ui-skeleton",
variant === "line" && "ui-skeleton-line",
variant === "title" && "ui-skeleton-line ui-skeleton-title",
variant === "short" && "ui-skeleton-line ui-skeleton-short",
variant === "tiny" && "ui-skeleton-line ui-skeleton-tiny",
variant === "badge" && "ui-skeleton-badge",
variant === "dot" && "ui-skeleton-dot",
className,
)}
style={`${width ? `width:${width};` : ""}${height ? `height:${height};` : ""}`}
aria-hidden="true"
{...$$restProps}
></span>
@@ -0,0 +1,16 @@
<script>
import { cn } from "$lib/utils.js";
export let size = "default";
export let label = "";
let className = "";
export { className as class };
</script>
<span
class={cn("ui-spinner", size === "sm" && "ui-spinner-sm", size === "lg" && "ui-spinner-lg", className)}
role={label ? "status" : undefined}
aria-label={label || undefined}
aria-hidden={label ? undefined : "true"}
{...$$restProps}
></span>
@@ -1,5 +1,5 @@
<script>
import { ArrowLeft } from "lucide-svelte";
import { ArrowLeft } from "$components/ui/icons.js";
export let title = "";
export let subtitle = "";
@@ -1,5 +1,5 @@
<script>
import { CreditCard, Send, WalletCards } from "lucide-svelte";
import { CreditCard, Send, WalletCards } from "$components/ui/icons.js";
export let methods = [];
@@ -1,5 +1,5 @@
<script>
import { Gift, Home, Settings } from "lucide-svelte";
import { Gift, Home, Settings } from "$components/ui/icons.js";
export let active = "home";
</script>
@@ -52,7 +52,7 @@
height: 36px;
border-radius: 8px;
border: 1px solid var(--admin-border-strong);
background: var(--admin-bg);
background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2));
color: var(--admin-text);
padding: 0 12px;
font-size: 13px;
@@ -88,7 +88,9 @@
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.014)),
var(--admin-surface-2);
color: var(--admin-text);
font-size: 13px;
font-weight: 500;
@@ -98,7 +100,10 @@
}
.admin-btn:hover:not(:disabled) {
background: var(--admin-elev);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)),
var(--admin-elev);
border-color: var(--admin-border-strong);
}
.admin-btn:disabled {
@@ -124,7 +129,7 @@
}
.admin-btn.admin-btn-ghost:hover:not(:disabled) {
background: var(--admin-surface-2);
background: rgba(255, 255, 255, 0.04);
color: var(--admin-text);
}
+76 -51
View File
@@ -6,12 +6,20 @@
.admin-screen-wrap {
--admin-sidebar-w: var(--desktop-rail-width);
--admin-header-h: 60px;
--admin-card-bg:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
--admin-card-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
background: var(--admin-bg);
background:
radial-gradient(circle at 18% 0%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 32%),
#02070b;
color: var(--admin-text);
overflow: hidden;
overscroll-behavior: contain;
@@ -29,10 +37,12 @@
height: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 12px 18px;
gap: 2px;
padding: 28px 14px;
border-right: 1px solid var(--admin-border);
background: var(--admin-surface);
background: rgba(7, 12, 17, 0.55);
backdrop-filter: blur(14px);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02);
overflow-y: auto;
}
@@ -42,17 +52,18 @@
.admin-sidebar-brand {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 32px;
grid-template-columns: 38px minmax(0, 1fr) 34px;
align-items: center;
gap: 10px;
padding: 6px 6px 14px;
margin-bottom: 6px;
gap: 12px;
padding: 4px 12px 18px;
margin-bottom: 8px;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text);
}
.admin-sidebar-brand .admin-brand-mark {
width: 36px;
height: 36px;
width: 38px;
height: 38px;
display: grid;
place-items: center;
color: var(--accent);
@@ -60,9 +71,14 @@
.admin-sidebar-brand strong {
display: block;
font-size: 13px;
font-weight: 700;
min-width: 0;
overflow: hidden;
color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-sidebar-brand small {
@@ -82,54 +98,52 @@
.admin-nav {
display: grid;
gap: 1px;
gap: 2px;
}
.admin-nav-item {
position: relative;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 8px;
border: 0;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
color: var(--admin-muted);
font-size: 13px;
font-weight: 500;
font-weight: 600;
text-align: left;
width: 100%;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.admin-nav-item > svg {
width: 20px;
height: 20px;
}
.admin-nav-item:hover {
background: var(--admin-surface-2);
background: rgba(255, 255, 255, 0.04);
color: var(--admin-text);
}
.admin-nav-item.active {
background: var(--admin-elev);
background: color-mix(in srgb, var(--accent) 16%, transparent);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--admin-text);
font-weight: 600;
}
.admin-nav-item.active::before {
content: "";
position: absolute;
width: 3px;
height: 18px;
left: -4px;
top: 50%;
transform: translateY(-50%);
border-radius: 2px;
background: var(--accent);
display: none;
}
.admin-sidebar-footer {
margin-top: auto;
padding: 10px 6px 0;
padding: 14px 0 0;
border-top: 1px solid var(--admin-border);
font-size: 11px;
color: var(--admin-dim);
@@ -156,8 +170,8 @@
justify-content: space-between;
gap: 8px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
border-radius: 10px;
background: rgba(255, 255, 255, 0.035);
color: var(--admin-text);
padding: 8px 10px;
text-align: left;
@@ -237,9 +251,10 @@
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 0 24px;
padding: 0 28px;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg);
background: color-mix(in srgb, #02070b 82%, transparent);
backdrop-filter: blur(14px);
}
.admin-header-title {
@@ -285,7 +300,7 @@
overscroll-behavior: contain;
overflow-y: auto;
overflow-x: hidden;
padding: 22px 24px 32px;
padding: 24px 28px 36px;
display: flex;
flex-direction: column;
gap: 16px;
@@ -300,8 +315,9 @@
.admin-card {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
background: var(--admin-card-bg);
border-radius: 12px;
box-shadow: var(--admin-card-shadow);
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
@@ -347,7 +363,7 @@
padding: 48px 16px;
border: 1px dashed var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
background: var(--admin-card-bg);
color: var(--admin-muted);
font-size: 13px;
}
@@ -383,8 +399,9 @@
.admin-stat-card {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
background: var(--admin-card-bg);
border-radius: 12px;
box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 6px;
@@ -443,8 +460,9 @@
.admin-revenue-panel {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
background: var(--admin-card-bg);
border-radius: 12px;
box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 14px;
@@ -572,8 +590,9 @@
.admin-dashboard-panel-card {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
background: var(--admin-card-bg);
border-radius: 12px;
box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 14px;
@@ -646,8 +665,9 @@
.admin-stat-skeleton-card {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
background: var(--admin-card-bg);
border-radius: 12px;
box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 10px;
@@ -678,7 +698,8 @@
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
background: var(--admin-card-bg);
box-shadow: var(--admin-card-shadow);
}
.admin-toolbar-card {
@@ -689,7 +710,8 @@
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
background: var(--admin-card-bg);
box-shadow: var(--admin-card-shadow);
}
.admin-toolbar-search {
@@ -776,7 +798,8 @@
.admin-table-wrap {
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-surface);
background: var(--admin-card-bg);
box-shadow: var(--admin-card-shadow);
overflow: hidden;
}
@@ -799,7 +822,7 @@
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px 16px;
background: var(--admin-surface-2);
background: color-mix(in srgb, var(--admin-surface-2) 72%, var(--admin-bg));
border-bottom: 1px solid var(--admin-border);
white-space: nowrap;
}
@@ -820,7 +843,7 @@
}
.admin-table tbody tr:hover {
background: color-mix(in srgb, var(--admin-elev) 50%, transparent);
background: rgba(255, 255, 255, 0.035);
}
.admin-table td.admin-cell-mono {
@@ -2235,8 +2258,8 @@
height: 36px;
padding: 0 12px;
border: 1px solid var(--admin-border-strong);
border-radius: 8px;
background: var(--admin-bg);
border-radius: 10px;
background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2));
color: var(--admin-text);
font: inherit;
font-size: 13px;
@@ -2273,7 +2296,9 @@
overflow-y: auto;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-surface);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
padding: 4px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
outline: none;
+111 -2
View File
@@ -25,8 +25,12 @@
min-width: 0;
border-radius: 12px;
border: 1px solid var(--admin-border);
background: var(--admin-surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: relative;
overflow: hidden;
}
@@ -180,3 +184,108 @@
background: color-mix(in srgb, var(--admin-surface-2) 70%, var(--admin-bg));
color: var(--admin-muted);
}
.ui-spinner,
.telegram-button-spinner {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid currentColor;
border-right-color: transparent;
display: inline-block;
flex: 0 0 auto;
animation: ui-spin 0.72s linear infinite;
}
.ui-spinner-sm {
width: 13px;
height: 13px;
border-width: 2px;
}
.ui-spinner-lg {
width: 24px;
height: 24px;
border-width: 3px;
}
@keyframes ui-spin {
to {
transform: rotate(360deg);
}
}
.ui-skeleton,
.admin-skeleton,
.skeleton-line,
.skeleton-dot,
.skeleton-pay-button {
display: inline-block;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.14),
rgba(255, 255, 255, 0.08)
);
background-size: 220% 100%;
animation: ui-skeleton-pulse 1.2s ease-in-out infinite;
}
.ui-skeleton {
width: 100%;
min-height: 40px;
border-radius: 10px;
}
.ui-skeleton-line,
.admin-skeleton-line,
.skeleton-line {
width: 100%;
height: 10px;
min-height: 0;
border-radius: 999px;
}
.ui-skeleton-title,
.skeleton-line-title {
height: 14px;
min-height: 0;
width: 72%;
}
.ui-skeleton-short,
.admin-skeleton-line-short,
.skeleton-line-short {
width: 56%;
}
.ui-skeleton-tiny,
.admin-skeleton-line-tiny,
.skeleton-line-tiny {
width: 34%;
}
.ui-skeleton-badge,
.admin-skeleton-badge {
width: 64px;
height: 18px;
min-height: 0;
border-radius: 999px;
}
.ui-skeleton-dot,
.skeleton-dot {
width: 18px;
height: 18px;
min-height: 0;
border-radius: 999px;
}
@keyframes ui-skeleton-pulse {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
@@ -821,22 +821,26 @@ a {
.skeleton-line-price {
width: 58px;
height: 12px;
min-height: 0;
}
.skeleton-line-tiny {
width: 34px;
height: 9px;
min-height: 0;
opacity: 0.7;
}
.skeleton-line-method {
width: 72px;
height: 12px;
min-height: 0;
}
.skeleton-dot {
width: 18px;
height: 18px;
min-height: 0;
flex: 0 0 auto;
}
@@ -2303,6 +2307,7 @@ a {
}
.bottom-nav .rail-brand strong {
color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
@@ -1,5 +1,5 @@
<script>
import { Gift, Home, Settings as SettingsIcon, Shield, Smartphone } from "lucide-svelte";
import { Gift, Home, Settings as SettingsIcon, Shield, Smartphone } from "$components/ui/icons.js";
import BrandMark from "../BrandMark.svelte";
@@ -1,11 +1,11 @@
<script>
import { ArrowLeft, CheckCircle2, CircleX, LockKeyhole, RefreshCw, TriangleAlert } from "lucide-svelte";
import { ArrowLeft, CheckCircle2, CircleX, LockKeyhole, RefreshCw, TriangleAlert } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import Dialog from "$components/ui/dialog.svelte";
import Input from "$components/ui/input.svelte";
import { EmptyCard, PaymentMethodGrid, StatusMessage } from "$components/patterns/webapp/index.js";
import {
planKey as planKeyFn,
planDisplayTitle as planDisplayTitleFn,
@@ -14,7 +14,6 @@
tariffLimitLabel as tariffLimitLabelFn,
priceLabel as priceLabelFn,
} from "../lib/webapp/tariffs.js";
import { Bitcoin, CreditCard } from "lucide-svelte";
export let createPayment = () => {};
@@ -48,19 +47,6 @@
export let tariffMode = false;
export let trafficMode = false;
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp")) return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
if (id.includes("platega_crypto")) return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
if (id.includes("yookassa") || id.includes("card")) return { title: t("pay_with_yookassa_button"), icon: null };
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
if (id.includes("cryptopay") || id.includes("crypto")) return { title: t("pay_with_cryptopay_button"), icon: null };
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
return { title: t("wa_method_other_title"), icon: null };
}
function priceLabel(plan) { return priceLabelFn(plan, selectedMethod); }
function planKey(plan) { return planKeyFn(plan); }
function planDisplayTitle(plan) { return planDisplayTitleFn(plan, { trafficMode, t }); }
@@ -140,7 +126,7 @@
<ArrowRight size={17} />
</Button>
{:else}
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
{:else}
{#if tariffMode}
@@ -175,34 +161,17 @@
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
<div class="method-grid">
{#if methods.length}
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
{:else}
<Card class="empty-card">{t("wa_payment_methods_not_configured")}</Card>
{/if}
</div>
{#if methods.length}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
{:else}
<EmptyCard>{t("wa_payment_methods_not_configured")}</EmptyCard>
{/if}
<Button class="wide bottom-action payment-submit-button" onclick={createPayment} disabled={!selectedPlan || !methods.length || payBusy}>
{t("wa_pay")} {selectedPlan ? priceLabel(selectedPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
{/if}
{#if !tariffMode}
@@ -229,28 +198,11 @@
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
<div class="method-grid">
{#if methods.length}
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
{:else}
<Card class="empty-card">{t("wa_payment_methods_not_configured")}</Card>
{/if}
</div>
{#if methods.length}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
{:else}
<EmptyCard>{t("wa_payment_methods_not_configured")}</EmptyCard>
{/if}
<Button class="wide bottom-action payment-submit-button" onclick={createPayment} disabled={!selectedPlan || !methods.length || payBusy}>
{t("wa_pay")} {selectedPlan ? priceLabel(selectedPlan) : ""}
<LockKeyhole size={17} />
@@ -350,7 +302,7 @@
</div>
{/if}
{#if linkEmailStatus}
<p class:error={linkEmailIsError} class="status-line">{linkEmailStatus}</p>
<StatusMessage error={linkEmailIsError}>{linkEmailStatus}</StatusMessage>
{/if}
</div>
</Dialog>
@@ -1,5 +1,5 @@
<script>
import { ArrowRight, CheckCircle2, LockKeyhole } from "lucide-svelte";
import { ArrowRight, CheckCircle2, LockKeyhole } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import {
@@ -10,10 +10,10 @@
} from "../lib/webapp/tariffs.js";
import { premiumTitle as premiumTitleFn, trafficPercent as trafficPercentFn } from "../lib/webapp/traffic.js";
import { formatCompactNumber } from "../lib/webapp/formatters.js";
import { Bitcoin, CreditCard } from "lucide-svelte";
import Card from "$components/ui/card.svelte";
import Dialog from "$components/ui/dialog.svelte";
import { DialogOptionsSkeleton, EmptyCard, PaymentMethodGrid } from "$components/patterns/webapp/index.js";
export let applyTariffChange = () => {};
export let changeConfirmOpen = false;
@@ -43,19 +43,6 @@
export let subscription = {};
export let trafficMode = false;
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp")) return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
if (id.includes("platega_crypto")) return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
if (id.includes("yookassa") || id.includes("card")) return { title: t("pay_with_yookassa_button"), icon: null };
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
if (id.includes("cryptopay") || id.includes("crypto")) return { title: t("pay_with_cryptopay_button"), icon: null };
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
return { title: t("wa_method_other_title"), icon: null };
}
function priceLabel(plan) { return priceLabelFn(plan, selectedMethod); }
function planKey(plan) { return planKeyFn(plan); }
function planUnitHint(plan) { return planUnitHintFn(plan, { trafficMode, selectedMethod, t }); }
@@ -152,31 +139,7 @@
>
<div class="payment-dialog-body">
{#if !changeOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="tariff-action-list">
{#each [1, 2] as _}
<div class="tariff-action-card skeleton-row">
<span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="skeleton-line skeleton-line-price"></span>
</div>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
<div class="option-list">
{#each [1, 2] as _}
<div class="option-row change-action-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} actions={2} rows={2} methods={0} showMeta={false} />
{:else if changeOptions?.targets?.length}
<p class="section-kicker">{t("wa_tariff_change_targets_title")}</p>
<div class="tariff-action-list">
@@ -226,34 +189,17 @@
{/each}
</div>
{#if selectedChangeAction?.kind === "payment"}
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
</div>
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
{/if}
<Button class="wide bottom-action payment-submit-button" onclick={openTariffChangeConfirm} disabled={tariffActionBusy || payBusy}>
{selectedChangeAction?.kind === "payment" ? t("wa_pay") : t("wa_apply")}
<ArrowRight size={17} />
</Button>
{:else}
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
{:else}
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
</div>
</Dialog>
@@ -292,35 +238,7 @@
>
<div class="payment-dialog-body">
{#if !topupOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="option-list">
{#each [1, 2, 3] as _}
<div class="option-row plan-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="option-row-meta">
<span class="skeleton-line skeleton-line-price"></span>
<span class="skeleton-line skeleton-line-tiny"></span>
</span>
</div>
{/each}
</div>
<div class="topup-carryover-note skeleton-carryover-note">
<span class="skeleton-line"></span>
<span class="skeleton-line skeleton-line-short"></span>
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
<span class="skeleton-dot"></span>
<span class="skeleton-line skeleton-line-method"></span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} showNote />
{:else if topupOptions?.plans?.length}
<div class="option-list">
{#each topupOptions.plans as plan}
@@ -353,30 +271,13 @@
{/each}
</div>
{/if}
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
</div>
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<Button class="wide bottom-action payment-submit-button" onclick={createTopupPayment} disabled={!selectedTopupPlan || !methods.length || payBusy}>
{t("wa_buy_traffic")} {selectedTopupPlan ? priceLabel(selectedTopupPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
<Card class="empty-card">{t("wa_no_topup_options")}</Card>
<EmptyCard>{t("wa_no_topup_options")}</EmptyCard>
{/if}
</div>
</Dialog>
@@ -391,30 +292,7 @@
>
<div class="payment-dialog-body">
{#if !deviceTopupOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="option-list">
{#each [1, 2, 3] as _}
<div class="option-row plan-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="option-row-meta">
<span class="skeleton-line skeleton-line-price"></span>
</span>
</div>
{/each}
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
<span class="skeleton-dot"></span>
<span class="skeleton-line skeleton-line-method"></span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} />
{:else if deviceTopupOptions?.plans?.length}
<div class="option-list">
{#each deviceTopupOptions.plans as plan}
@@ -437,30 +315,13 @@
</button>
{/each}
</div>
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
type="button"
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{/if}
<strong>{meta.title}</strong>
</span>
</button>
{/each}
</div>
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<Button class="wide bottom-action payment-submit-button" onclick={createDeviceTopupPayment} disabled={!selectedDeviceTopupPlan || !methods.length || payBusy}>
{t("wa_pay")} {selectedDeviceTopupPlan ? priceLabel(selectedDeviceTopupPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
<Card class="empty-card">{t("wa_no_hwid_device_options")}</Card>
<EmptyCard>{t("wa_no_hwid_device_options")}</EmptyCard>
{/if}
</div>
</Dialog>
@@ -1,11 +1,13 @@
<script>
import { ArrowLeft, Mail, RefreshCw, Send, TriangleAlert } from "lucide-svelte";
import { ArrowLeft, Mail, RefreshCw, Send, TriangleAlert } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import BrandMark from "../../BrandMark.svelte";
import Card from "$components/ui/card.svelte";
import Input from "$components/ui/input.svelte";
import Spinner from "$components/ui/spinner.svelte";
import { StatusMessage } from "$components/patterns/webapp/index.js";
export let screen;
export let CFG;
@@ -68,7 +70,7 @@
{t("wa_confirm")}
</Button>
{#if authStatus}
<div class:error={authIsError} class="status-line">{authStatus}</div>
<StatusMessage error={authIsError}>{authStatus}</StatusMessage>
{/if}
<button
class="link-button"
@@ -133,7 +135,7 @@
>
<span class="telegram-login-text">
{#if telegramLoginChecking}
<span class="telegram-button-spinner" aria-hidden="true"></span>
<Spinner size="sm" />
{:else}
<Send size={17} />
{/if}
@@ -142,12 +144,12 @@
</Button>
</div>
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
<div
class:error={authIsError || Boolean(telegramLoginUnavailableMessage)}
class="status-line auth-login-status"
<StatusMessage
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
class="auth-login-status"
>
{authStatus || telegramLoginUnavailableMessage}
</div>
</StatusMessage>
{/if}
</Card>
{#if userAgreementUrl || privacyPolicyUrl}
@@ -1,8 +1,9 @@
<script>
import { CircleX, Plus, RefreshCw, Smartphone } from "lucide-svelte";
import { CircleX, Plus, RefreshCw, Smartphone } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { EmptyCard, LinearProgress, StatusMessage } from "$components/patterns/webapp/index.js";
import { devicesCountLabel, devicesLimitLabel, devicesPercent } from "../../lib/webapp/devicesLabels.js";
export let devicesBusy = false;
@@ -31,9 +32,7 @@
<RefreshCw size={18} />
</Button>
</div>
<div class="progress devices-progress">
<span style={`width: ${devicesPercent(devicesData)}%`}></span>
</div>
<LinearProgress class="devices-progress" value={devicesPercent(devicesData)} label={t("wa_devices_title")} />
{#if subscription?.active && subscription?.max_devices !== 0}
<Button variant="secondary" class="wide" onclick={openDeviceTopupModal}>
<Plus size={17} />
@@ -43,17 +42,17 @@
</Card>
{#if devicesBusy && !devicesLoaded}
<Card class="empty-card">{t("wa_devices_loading")}</Card>
<EmptyCard>{t("wa_devices_loading")}</EmptyCard>
{:else if devicesStatus}
<Card class="empty-card">
<p class:error={devicesIsError} class="status-line">{devicesStatus}</p>
</Card>
<EmptyCard>
<StatusMessage error={devicesIsError}>{devicesStatus}</StatusMessage>
</EmptyCard>
{:else if !devicesData?.devices?.length}
<Card class="empty-card devices-empty-card">
<EmptyCard class="devices-empty-card">
<Smartphone size={28} />
<span>{t("wa_devices_empty")}</span>
<small>{t("wa_devices_empty_hint", { max: devicesLimitLabel(devicesData, t) })}</small>
</Card>
</EmptyCard>
{:else}
<div class="devices-list">
{#each devicesData.devices as device (device.token || device.index)}
@@ -1,9 +1,10 @@
<script>
import { CheckCircle2, ChevronsUpDown, CircleX, Database, Download, Gift, RefreshCw } from "lucide-svelte";
import { CheckCircle2, ChevronsUpDown, CircleX, Database, Download, Gift, RefreshCw } from "$components/ui/icons.js";
import BrandMark from "../../BrandMark.svelte";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { LinearProgress } from "$components/patterns/webapp/index.js";
import { formatTrafficGb } from "../../lib/webapp/formatters.js";
import {
trafficPercent as trafficPercentFn,
@@ -93,9 +94,7 @@
<span>{t("wa_home_traffic_used")}</span>
<strong>{trafficLabel(subscription)}</strong>
</div>
<div class="progress">
<span style={`width: ${trafficPercent(subscription)}%`}></span>
</div>
<LinearProgress value={trafficPercent(subscription)} label={t("wa_home_traffic_used")} />
<div class="traffic-meta">
<span>{trafficResetLabel(subscription)}</span>
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
@@ -110,9 +109,7 @@
<span>{premiumTitle(subscription)}</span>
<strong>{premiumTrafficLabel(subscription)}</strong>
</div>
<div class="progress premium-progress">
<span style={`width: ${premiumTrafficPercent(subscription)}%`}></span>
</div>
<LinearProgress class="premium-progress" value={premiumTrafficPercent(subscription)} label={premiumTitle(subscription)} />
<div class="traffic-meta premium-traffic-meta">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown">
@@ -1,10 +1,11 @@
<script>
import { Copy, Gift, Ticket, TriangleAlert } from "lucide-svelte";
import { Copy, Gift, Ticket, TriangleAlert } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import Input from "$components/ui/input.svelte";
import { StatusMessage } from "$components/patterns/webapp/index.js";
export let referral = {};
export let referralBonusDetails = [];
@@ -63,7 +64,7 @@
{/each}
</div>
{:else}
<p class="status-line">{t("wa_referral_bonus_not_configured")}</p>
<StatusMessage>{t("wa_referral_bonus_not_configured")}</StatusMessage>
{/if}
</Card>
<Card>
@@ -97,7 +98,7 @@
</Button>
</div>
{#if promoStatus && !(promoIsError && promoFieldError)}
<p class:error={promoIsError} class="status-line">{promoStatus}</p>
<StatusMessage error={promoIsError}>{promoStatus}</StatusMessage>
{/if}
</Card>
</main>
@@ -1,9 +1,9 @@
<script>
import { ArrowRight, Check, CheckCircle2, ChevronsUpDown, FileText, Globe2, Mail, Send, Shield, UserRound } from "lucide-svelte";
import { Select } from "$components/ui/primitives.js";
import { ArrowRight, CheckCircle2, FileText, Mail, Send, Shield, UserRound } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { LanguageSelect } from "$components/patterns/webapp/index.js";
export let currentLang = "ru";
export let currentLanguageOption = null;
@@ -107,53 +107,21 @@
{/if}
<div class="settings-divider" aria-hidden="true"></div>
</div>
{#if languageMenuOpen || languageClickGuard}
<button
class="language-select-guard"
class:language-select-guard--armed={languageClickGuardArmed}
type="button"
aria-label={t("wa_close")}
onpointerdown={(e) => { e.preventDefault(); e.stopPropagation(); if (languageClickGuardArmed) setLanguageMenuOpen(false); }}
onclick={(e) => { e.preventDefault(); e.stopPropagation(); if (languageClickGuardArmed) setLanguageMenuOpen(false); }}
></button>
{/if}
<div class="settings-list" class:settings-list--language-open={languageMenuOpen}>
<div class="settings-row settings-row-language">
<Globe2 size={21} />
<Select.Root
type="single"
bind:open={languageMenuOpen}
value={currentLang}
items={languageOptions}
disabled={languageBusy}
onOpenChange={setLanguageMenuOpen}
onValueChange={updateAccountLanguage}
>
<Select.Trigger class="language-select-trigger" aria-label={t("wa_settings_language")}>
<span class="language-select-copy">
<strong>{t("wa_settings_language")}</strong>
<small class="language-select-current">
<span class="emoji-flag" aria-hidden="true">{currentLanguageOption?.flag || "🏳️"}</span>
{currentLanguageOption?.label || userLanguage}
</small>
</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content class="language-select-content" side="bottom" align="end" sideOffset={6}>
<Select.Viewport class="language-select-viewport">
{#each languageOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} class="language-select-item">
<span class="language-select-item-main">
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</span>
<Check size={15} class="language-select-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
<LanguageSelect
bind:open={languageMenuOpen}
value={currentLang}
currentOption={currentLanguageOption}
{userLanguage}
options={languageOptions}
disabled={languageBusy}
clickGuard={languageClickGuard}
clickGuardArmed={languageClickGuardArmed}
closeLabel={t("wa_close")}
label={t("wa_settings_language")}
onOpenChange={setLanguageMenuOpen}
onValueChange={updateAccountLanguage}
/>
{#if supportUrl}
<button class="settings-row settings-row-support" type="button" onclick={() => openExternalLink(supportUrl)}>
<Send size={21} />