refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
@@ -0,0 +1,155 @@
<script>
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminField,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtMoney;
const adsStore = getContext("adsStore");
$: ({ ads, adsLoading, 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();
});
</script>
<div class="admin-table-wrap">
{#if adsLoading}
<AdminTableSkeleton
headers={adHeaders}
rows={6}
actionColumn
widths={["44px", "96px", "110px", "70px", "54px", "54px", "72px", "92px"]}
/>
{:else if !ads.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<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 ads as ad}
<tr>
<td class="admin-cell-id" data-label={at("id", {}, "ID")}>#{ad.id}</td>
<td data-label={at("ads_col_source", {}, "Источник")}>{ad.source}</td>
<td class="admin-cell-mono" data-label={at("ads_col_param", {}, "Параметр")}
>{ad.start_param}</td
>
<td data-label={at("ads_col_cost", {}, "Стоимость")}>{fmtMoney(ad.cost)}</td>
<td data-label={at("ads_col_registrations", {}, "Регистрации")}
>{ad.stats?.registrations ?? 0}</td
>
<td data-label={at("ads_col_conversions", {}, "Конверсии")}
>{ad.stats?.conversions ?? 0}</td
>
<td data-label={at("ads_col_status", {}, "Статус")}>
{#if ad.is_active}
<AdminBadge variant="success">{at("status_active", {}, "Активна")}</AdminBadge>
{:else}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключена")}</AdminBadge>
{/if}
</td>
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
<AdminButton size="sm" onclick={() => adsStore.toggleAd(ad)}>
{ad.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
</AdminButton>
<AdminButton size="sm" variant="danger" onclick={() => adsStore.deleteAd(ad)}>
<Trash2 size={13} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</div>
<Dialog
open={adCreateOpen}
title={at("ad_create_title", {}, "Новая кампания")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => adsStore.setCreateOpen(false)}
class="admin-dialog admin-dialog-compact"
>
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<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 })}
/>
</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 })}
/>
</AdminField>
</div>
<div class="admin-dialog-form-section">
<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) })}
/>
</AdminField>
</div>
<div class="admin-dialog-actions">
<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", {}, "Создать")}
</AdminButton>
</div>
</div>
</Dialog>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
<script>
import { Send } from "$components/ui/icons.js";
import { getContext } from "svelte";
import { Label } from "$components/ui/primitives.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
export let at;
const broadcastStore = getContext("broadcastStore");
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
</script>
<div class="admin-card">
<header class="admin-card-head">
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
</header>
<div class="admin-card-body">
<div class="admin-form">
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<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>
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
<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;">
<AdminButton
variant="primary"
onclick={broadcastStore.runBroadcast}
disabled={broadcastBusy || !broadcastText.trim()}
>
<Send size={14} />
{broadcastBusy
? at("btn_sending", {}, "Отправка...")
: at("btn_queue", {}, "Поставить в очередь")}
</AdminButton>
{#if broadcastResult}
<span class="admin-muted"
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
"broadcast_stat_failed",
{},
"Неудач"
)}: {broadcastResult.failed}</span
>
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,119 @@
<script>
import { getContext, onMount } from "svelte";
import {
AdminButton,
AdminEmptyState,
AdminPagination,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtDate;
const logsStore = getContext("logsStore");
$: ({ logs, logsTotal, logsPage, logsUserFilter, logsLoading } = $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();
});
</script>
<div class="admin-toolbar admin-toolbar-card">
<div class="admin-toolbar-search admin-toolbar-search-actions">
<input
type="search"
class="input"
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
value={logsUserFilter}
on:input={(e) => logsStore.setFilter(e.target.value)}
on:keydown={(e) => e.key === "Enter" && logsStore.setPage(0)}
/>
<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>
<strong>{logsTotal}</strong>
</div>
</div>
<div class="admin-table-wrap">
{#if logsLoading}
<AdminTableSkeleton
headers={logHeaders}
rows={10}
widths={["120px", "120px", "58px", "58px", "220px"]}
/>
{:else if !logs.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("date", {}, "Дата")}</th>
<th>{at("event", {}, "Событие")}</th>
<th>{at("user_short", {}, "User")}</th>
<th>{at("target_short", {}, "Target")}</th>
<th>{at("content", {}, "Контент")}</th>
</tr>
</thead>
<tbody>
{#each logs as entry}
<tr>
<td data-label={at("date", {}, "Дата")}>{fmtDate(entry.timestamp)}</td>
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
>{entry.event_type}</td
>
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
>{entry.user_id || "—"}</td
>
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
>{entry.target_user_id || "—"}</td
>
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
>{entry.content || ""}</td
>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</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);
}}
/>
@@ -0,0 +1,216 @@
<script>
import { getContext, onMount } from "svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let at = (key) => key;
export let fmtDate = (value) => value;
export let fmtMoney = (value) => value;
export let paymentStatusVariant = () => "muted";
export let onOpenUserCard = () => {};
const paymentsStore = getContext("paymentsStore");
$: ({ payments, paymentsTotal, paymentsPage, paymentsLoading } = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
/** @param {number|null|undefined} v */
function formatTrafficGbCell(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (Number.isNaN(n)) return "—";
let s;
if (Math.abs(n - Math.round(n)) < 1e-9) {
s = String(Math.round(n));
} else {
s = String(Math.round(n * 100) / 100);
}
return `${s} GB`;
}
/** @param {number|null|undefined} v */
function formatGbAmountPlain(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (Number.isNaN(n)) return "";
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return String(Math.round(n * 100) / 100);
}
/** @param {Record<string, unknown>} p */
function paymentDescriptionDisplay(p) {
const r = p.traffic_regular_gb;
const pr = p.traffic_premium_gb;
if (r != null && pr == null) {
const gb = formatGbAmountPlain(r);
return at(
"payments_desc_traffic_package_regular",
{ gb },
`Пакет трафика ${gb} ГБ (обычный)`
);
}
if (pr != null && r == null) {
const gb = formatGbAmountPlain(pr);
return at(
"payments_desc_traffic_package_premium",
{ gb },
`Пакет трафика ${gb} ГБ (премиум)`
);
}
const raw = p.description && String(p.description).trim();
return raw || "—";
}
$: paymentHeaders = [
at("id", {}, "ID"),
at("user", {}, "Пользователь"),
at("payments_col_user_id", {}, "ID"),
at("payments_col_traffic_regular", {}, "Основной трафик"),
at("payments_col_traffic_premium", {}, "Премиум"),
at("amount", {}, "Сумма"),
at("provider", {}, "Провайдер"),
at("description", {}, "Описание"),
at("status", {}, "Статус"),
at("date", {}, "Дата"),
];
onMount(() => {
paymentsStore.loadPayments();
});
</script>
<div class="admin-table-wrap">
{#if paymentsLoading}
<AdminTableSkeleton
headers={paymentHeaders}
rows={8}
widths={["48px", "148px", "88px", "72px", "72px", "78px", "82px", "140px", "72px", "96px"]}
/>
{:else if !payments.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("payments_col_user_id", {}, "ID")}</th>
<th>{at("payments_col_traffic_regular", {}, "Основной трафик")}</th>
<th>{at("payments_col_traffic_premium", {}, "Премиум")}</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 payments as p}
<tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
<span class="admin-payments-user-cell">
<AdminButton
class="admin-payments-user-btn"
variant="ghost"
size="icon"
title={at("payments_open_user", {}, "Открыть карточку пользователя")}
aria-label={at("payments_open_user", {}, "Открыть карточку пользователя")}
onclick={() => onOpenUserCard(p.user_id)}
>
<User size={14} />
</AdminButton>
<span class="admin-payments-user-name">{p.user_label || p.user_id}</span>
</span>
</td>
<td class="admin-cell-mono" data-label={at("payments_col_user_id", {}, "ID")}>
{p.user_id != null && p.user_id !== "" ? p.user_id : "—"}
</td>
<td
class="admin-cell-traffic-gb"
data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}
>
{formatTrafficGbCell(p.traffic_regular_gb)}
</td>
<td
class="admin-cell-traffic-gb"
data-label={at("payments_col_traffic_premium", {}, "Премиум")}
>
{formatTrafficGbCell(p.traffic_premium_gb)}
</td>
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}
>{paymentDescriptionDisplay(p)}</td
>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
</td>
<td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</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);
}}
/>
<style>
.admin-payments-user-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-payments-user-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn.admin-btn) {
width: 30px;
height: 30px;
min-width: 30px;
min-height: 30px;
flex-shrink: 0;
padding: 0;
border-radius: 7px;
}
.admin-cell-user-with-action :global(.admin-payments-user-btn svg) {
width: 14px;
height: 14px;
}
.admin-cell-traffic-gb {
font-size: 12px;
white-space: nowrap;
color: var(--admin-muted);
}
</style>
@@ -0,0 +1,166 @@
<script>
import { Trash2 } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminField,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
export let at;
export let fmtDateShort;
const promosStore = getContext("promosStore");
$: ({ promos, promosTotal, promosPage, promosLoading, promoCreateOpen, promoDraft } =
$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();
});
</script>
<div class="admin-table-wrap">
{#if promosLoading}
<AdminTableSkeleton
headers={promoHeaders}
rows={6}
actionColumn
widths={["92px", "52px", "64px", "96px", "72px", "92px"]}
/>
{:else if !promos.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<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 promos as p}
<tr>
<td class="admin-cell-mono" data-label={at("promo_col_code", {}, "Код")}>{p.code}</td>
<td data-label={at("promo_col_bonus", {}, "Бонус")}
>+{p.bonus_days} {at("days_short", {}, "дн.")}</td
>
<td data-label={at("promo_col_activations", {}, "Активаций")}
>{p.current_activations}/{p.max_activations}</td
>
<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}
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{:else}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{/if}
</td>
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
<AdminButton size="sm" onclick={() => promosStore.togglePromo(p)}>
{p.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
</AdminButton>
<AdminButton size="sm" variant="danger" onclick={() => promosStore.deletePromo(p)}>
<Trash2 size={13} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
{#if promosHasMore}
<div style="padding: 12px; text-align: center;">
<AdminButton onclick={() => promosStore.setPage(promosPage + 1)}
>{at("btn_show_more", {}, "Показать еще")}</AdminButton
>
</div>
{/if}
</div>
<Dialog
open={promoCreateOpen}
title={at("promo_create_title", {}, "Создать промокод")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => promosStore.setCreateOpen(false)}
class="admin-dialog admin-dialog-compact"
>
<div class="admin-form" data-dialog-content>
<div class="admin-dialog-form-section">
<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"
/>
</AdminField>
</div>
<div class="admin-dialog-form-section">
<div class="admin-form-row-2">
<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) })}
/>
</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) })}
/>
</AdminField>
</div>
<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) })}
/>
</AdminField>
</div>
<div class="admin-dialog-actions">
<AdminButton onclick={() => promosStore.setCreateOpen(false)}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="primary"
onclick={promosStore.createPromo}
disabled={!promoDraft.code.trim()}
>
{at("btn_create", {}, "Создать")}
</AdminButton>
</div>
</div>
</Dialog>
@@ -0,0 +1,362 @@
<script>
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminSelect,
} from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte";
export let at;
export let onSettingsSaved;
export let isCompact = false;
export let currentLang = "ru";
const settingsStore = getContext("settingsStore");
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
$: settingsAllOpen =
visibleSettingsSections.length > 0 &&
settingsOpenSections.length === visibleSettingsSections.length;
onMount(() => {
settingsStore.loadSettings().then(() => {
if ($settingsStore.settingsSections.length) {
const ids = $settingsStore.settingsSections
.filter((s) => s.id !== "appearance")
.map((s) => s.id);
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
}
});
});
function toggleAllSections() {
if (settingsOpenSections.length === visibleSettingsSections.length) {
settingsOpenSections = [];
} else {
settingsOpenSections = visibleSettingsSections.map((s) => s.id);
}
}
function valueFor(field) {
if (settingsDirty[field.key]?.deleted) return "";
if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) {
return settingsDirty[field.key].value;
}
return field.value ?? "";
}
function isOverridden(field) {
return Boolean(field.overridden) && !settingsDirty[field.key]?.deleted;
}
function isSecretRevealed(key) {
return revealedSecrets.has(key);
}
function toggleSecretReveal(key) {
const next = new Set(revealedSecrets);
if (next.has(key)) next.delete(key);
else next.add(key);
revealedSecrets = next;
}
function secretPlaceholder(field) {
if (settingsDirty[field.key]?.deleted) return field.placeholder || "••••••••";
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
return field.placeholder || at("settings_secret_empty", {}, "Not set");
}
function groupSectionFields(section) {
const groups = new Map();
for (const field of section.fields || []) {
const key = field.subsection || "_root";
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(field);
}
return Array.from(groups.entries()).map(([id, fields]) => ({
id,
label: id === "_root" ? null : id,
fields,
}));
}
function sectionTitle(id) {
const map = {
general: at("settings_section_general", {}, "Общие"),
appearance: at("settings_section_appearance", {}, "Внешний вид"),
pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
payments: at("settings_section_payments", {}, "Платёжные системы"),
trial: at("settings_section_trial", {}, "Триал"),
referral: at("settings_section_referral", {}, "Реферальная программа"),
notifications: at("settings_section_notifications", {}, "Уведомления"),
devices: at("settings_section_devices", {}, "Устройства"),
};
return map[id] || id;
}
function englishFieldLabelFallback(key, originalLabel) {
if (!key) return originalLabel || "";
return String(key)
.toLowerCase()
.split("_")
.filter(Boolean)
.map((part) => {
if (part === "id") return "ID";
if (part === "url") return "URL";
if (part === "api") return "API";
if (part === "tg") return "TG";
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(" ");
}
function fieldLabelText(field) {
const isEnglish = String(currentLang || "")
.toLowerCase()
.startsWith("en");
const fallback = isEnglish ? englishFieldLabelFallback(field.key, field.label) : field.label;
return field.i18n_label_key ? at(field.i18n_label_key, {}, fallback) : fallback;
}
</script>
{#snippet renderField(field)}
{@const revealed = isSecretRevealed(field.key)}
<div class="admin-setting" class:is-overridden={isOverridden(field)}>
<div class="admin-setting-meta">
<strong>
{fieldLabelText(field)}
{#if field.secret}
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
{/if}
{#if isOverridden(field)}
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if}
</strong>
<code>{field.key}</code>
{#if field.description}
<small
>{field.i18n_description_key
? at(field.i18n_description_key, {}, field.description)
: field.description}</small
>
{/if}
</div>
<div class="admin-setting-control">
{#if field.type === "bool"}
<div class="admin-setting-switch">
<Switch.Root
checked={Boolean(valueFor(field))}
onCheckedChange={(checked) => settingsStore.markDirty(field.key, checked)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span
>{valueFor(field)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{:else if field.type === "color"}
<input
class="admin-color"
type="color"
value={valueFor(field) || "#00fe7a"}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<input
class="input"
type="text"
value={valueFor(field) || ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.choices && field.choices.length > 0}
<AdminSelect
class="admin-setting-select"
value={valueFor(field) || ""}
items={field.choices}
ariaLabel={fieldLabelText(field)}
placeholder={field.placeholder || fieldLabelText(field)}
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
/>
{:else if field.type === "int" || field.type === "float"}
<input
class="input"
type="number"
step={field.type === "float" ? "0.1" : "1"}
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.secret}
<input
class="input"
type={revealed ? "text" : "password"}
placeholder={secretPlaceholder(field)}
autocomplete="off"
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<AdminButton
size="sm"
variant="ghost"
aria-label={revealed ? at("hide", {}, "Скрыть") : at("show", {}, "Показать")}
onclick={() => toggleSecretReveal(field.key)}
>
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</AdminButton>
{:else}
<input
class="input"
type="text"
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{/if}
{#if isOverridden(field) || settingsDirty[field.key]}
<AdminButton size="sm" variant="ghost" onclick={() => settingsStore.resetField(field)}>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
{/snippet}
{#if settingsLoading || !visibleSettingsSections.length}
<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;">
<AdminButton size="sm" variant="ghost" onclick={toggleAllSections}>
{settingsAllOpen
? at("collapse_all", {}, "Свернуть всё")
: at("expand_all", {}, "Развернуть всё")}
</AdminButton>
{#if Object.keys(settingsDirty).length > 0}
<AdminButton
size="sm"
variant="primary"
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
disabled={settingsSaving}
>
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
</div>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each visibleSettingsSections as section}
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={section.id} class="admin-accordion-item admin-card">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-accordion-trigger">
<span class="admin-accordion-title">{sectionTitle(section.id)}</span>
<span class="admin-accordion-meta">
{at(
"settings_params_count",
{ count: section.fields.length },
`${section.fields.length} параметров`
)}{#if overriddenInSection}
· {at(
"settings_overridden_count",
{ count: overriddenInSection },
`${overriddenInSection} override`
)}{/if}{#if dirtyInSection}
· {at(
"settings_dirty_count",
{ count: dirtyInSection },
`${dirtyInSection} изм.`
)}{/if}
</span>
<ChevronRight size={16} class="admin-accordion-chev" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="admin-accordion-content">
{@const groups = groupSectionFields(section)}
{@const rootGroup = groups.find((g) => !g.label)}
{@const labelGroups = groups.filter((g) => g.label)}
<div class="admin-settings-fields">
{#if rootGroup}
{#each rootGroup.fields as field}
{@render renderField(field)}
{/each}
{/if}
{#if labelGroups.length}
<Accordion.Root
type="multiple"
value={settingsOpenSubsections[section.id] || []}
onValueChange={(v) =>
(settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })}
class="admin-subsection-accordion"
>
{#each labelGroups as group}
{@const subDirty = group.fields.filter((f) =>
Boolean(settingsDirty[f.key])
).length}
{@const subOverridden = group.fields.filter((f) => isOverridden(f)).length}
<Accordion.Item value={group.id} class="admin-settings-subsection">
<Accordion.Header class="admin-accordion-header">
<Accordion.Trigger class="admin-settings-subsection-trigger">
<strong>{group.label}</strong>
<span class="admin-settings-subsection-meta">
{at(
"settings_fields_count",
{ count: group.fields.length },
`${group.fields.length} полей`
)}{#if subOverridden}
· {at(
"settings_overridden_count",
{ count: subOverridden },
`${subOverridden} override`
)}{/if}{#if subDirty}
· {at(
"settings_dirty_count",
{ count: subDirty },
`${subDirty} изм.`
)}{/if}
</span>
<ChevronRight size={14} class="admin-accordion-chev" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="admin-accordion-content">
<div class="admin-settings-subsection-body">
{#each group.fields as field}
{@render renderField(field)}
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
{/if}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,964 @@
<script>
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.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";
export let at;
const tariffsStore = getContext("tariffsStore");
$: ({
tariffEditorOpen,
tariffEditingKey,
tariffDraft,
tariffsSaving,
tariffDeleteOpen,
tariffDeleteTarget,
panelSquadsLoading,
panelSquads,
} = $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
open={tariffEditorOpen}
title={tariffEditingKey
? at("tariff_edit_title", {}, "Настройка тарифа")
: at("tariff_create_title", {}, "Новый тариф")}
description={tariffEditingKey ||
at("tariff_create_subtitle", {}, "Каталог будет сохранён в JSON после подтверждения")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => tariffsStore.updateState({ tariffEditorOpen: false })}
class="admin-dialog admin-tariff-dialog"
>
<Tabs.Root bind:value={$tariffsStore.tariffEditorTab} class="admin-tabs-root">
<Tabs.List class="admin-tabs-list">
<Tabs.Trigger value="general" class="admin-tabs-trigger"
>{at("tariff_tab_general", {}, "Основное")}</Tabs.Trigger
>
<Tabs.Trigger value="pricing" class="admin-tabs-trigger"
>{at("tariff_tab_pricing", {}, "Цены")}</Tabs.Trigger
>
<Tabs.Trigger value="topup" class="admin-tabs-trigger"
>{at("tariff_tab_topup", {}, "Докупки")}</Tabs.Trigger
>
<Tabs.Trigger value="premium" class="admin-tabs-trigger"
>{at("tariff_tab_premium", {}, "Premium")}</Tabs.Trigger
>
<Tabs.Trigger value="hwid" class="admin-tabs-trigger"
>{at("tariff_tab_hwid", {}, "Устройства")}</Tabs.Trigger
>
</Tabs.List>
<Tabs.Content value="general" class="admin-tabs-content">
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_key", {}, "Ключ тарифа")}</span>
<small
>{at(
"tariff_hint_key",
{},
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
)}</small
>
<input
class="input"
type="text"
placeholder="standard"
bind:value={$tariffsStore.tariffDraft.key}
/>
</Label.Root>
<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
>
<AdminSelect
bind:value={$tariffsStore.tariffDraft.billing_model}
items={billingModelOptions}
ariaLabel={at("tariff_label_model", {}, "Модель")}
/>
</div>
</div>
<div class="admin-action-row admin-action-row-bordered">
<Switch.Root
checked={tariffDraft.enabled}
onCheckedChange={(v) => (tariffDraft.enabled = v)}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<Label.Root class="admin-action-label">
<strong
>{tariffDraft.enabled
? at("tariff_visible", {}, "Тариф виден на витрине")
: at("tariff_hidden", {}, "Тариф скрыт от пользователей")}</strong
>
<small
>{at(
"tariff_enabled_hint",
{},
"Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать"
)}</small
>
</Label.Root>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
bind:value={$tariffsStore.tariffDraft.nameRu}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
bind:value={$tariffsStore.tariffDraft.nameEn}
/>
</Label.Root>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
bind:value={$tariffsStore.tariffDraft.descriptionRu}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
bind:value={$tariffsStore.tariffDraft.descriptionEn}
/>
</Label.Root>
</div>
<div class="admin-field-label">
<span>{at("tariff_label_squads", {}, "Базовые Internal Squads")}</span>
<small
>{panelSquadsLoading
? at("loading_squads", {}, "Загружаю список из панели…")
: at(
"tariff_hint_squads",
{},
"Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"
)}</small
>
<AdminSelect
bind:value={$tariffsStore.selectedBaseSquad}
items={panelSquadOptions}
placeholder={at("btn_add_squad", {}, "Добавить сквад")}
ariaLabel={at("btn_add_squad", {}, "Добавить основной сквад")}
onValueChange={(value) => {
tariffsStore.addSquadToDraft("squadUuids", value);
tariffsStore.update((s) => ({ ...s, selectedBaseSquad: "" }));
}}
/>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
<button
type="button"
class="admin-chip"
on:click={() => tariffsStore.removeSquadFromDraft("squadUuids", uuid)}
>
{tariffsStore.squadLabel(uuid)}
<X size={12} />
</button>
{/each}
</div>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_hwid", {}, "Лимит устройств (HWID)")}</span>
<small
>{at(
"tariff_hint_hwid",
{},
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
)}</small
>
<input
class="input"
type="number"
min="0"
placeholder="5"
bind:value={$tariffsStore.tariffDraft.hwid_device_limit}
/>
</Label.Root>
{#if tariffDraft.billing_model === "period"}
<Label.Root class="admin-field-label">
<span>{at("tariff_label_traffic_limit", {}, "Месячный лимит трафика, GB")}</span>
<small
>{at(
"tariff_hint_traffic_limit",
{},
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
)}</small
>
<input
class="input"
type="number"
min="0"
step="0.1"
placeholder="100"
bind:value={$tariffsStore.tariffDraft.monthly_gb}
/>
</Label.Root>
{:else}
<Label.Root class="admin-field-label">
<span>{at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")}</span>
<small
>{at(
"tariff_hint_conversion",
{},
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
)}</small
>
<input
class="input"
type="number"
min="0"
step="0.01"
placeholder="20"
bind:value={$tariffsStore.tariffDraft.conversion_rate_rub_per_gb}
/>
</Label.Root>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="premium" class="admin-tabs-content">
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong
>{at("tariff_premium_head", {}, "Premium-доступ и отдельный счётчик трафика")}</strong
>
<small
>{at(
"tariff_premium_subhead",
{},
"Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно"
)}</small
>
</div>
</header>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("tariff_label_premium_name_ru", {}, "Название premium-раздела, RU")}</span>
<small
>{at(
"tariff_hint_premium_name_ru",
{},
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
)}</small
>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
bind:value={$tariffsStore.tariffDraft.premiumNameRu}
/>
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("tariff_label_premium_name_en", {}, "Название premium-раздела, EN")}</span>
<small
>{at(
"tariff_hint_premium_name_en",
{},
"Опционально для английского интерфейса."
)}</small
>
<input
class="input"
type="text"
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
bind:value={$tariffsStore.tariffDraft.premiumNameEn}
/>
</Label.Root>
</div>
<div class="admin-form-row admin-form-row-2">
<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
>
<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);
tariffsStore.update((s) => ({ ...s, selectedPremiumSquad: "" }));
}}
/>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
<button
type="button"
class="admin-chip"
on:click={() => tariffsStore.removeSquadFromDraft("premiumSquadUuids", uuid)}
>
{tariffsStore.squadLabel(uuid)}
<X size={12} />
</button>
{/each}
</div>
</div>
<Label.Root class="admin-field-label">
<span
>{at(
"tariff_label_premium_traffic_limit",
{},
"Месячный лимит premium-трафика, GB"
)}</span
>
<small
>{at(
"tariff_hint_premium_traffic_limit",
{},
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
)}</small
>
<input
class="input"
type="number"
min="0"
step="0.1"
placeholder="50"
bind:value={$tariffsStore.tariffDraft.premium_monthly_gb}
/>
</Label.Root>
</div>
</section>
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong>{at("tariff_premium_topup_title", {}, "Докупка premium-трафика")}</strong>
<small
>{at(
"tariff_premium_topup_subtitle",
{},
"Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<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">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
{#if tariffDraft.premiumTopupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupRubRows as row, index}
<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-пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.premiumTopupStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.premiumTopupStarsRows as row, index}
<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"
)}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
<Tabs.Content value="pricing" class="admin-tabs-content">
{#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong>{at("tariff_pricing_period_title", {}, "Периоды подписки и цены")}</strong>
<small
>{at(
"tariff_pricing_period_subtitle",
{},
"Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит"
)}</small
>
</div>
<AdminButton
size="sm"
onclick={() =>
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
>
<Plus size={13} />
{at("tariff_btn_period", {}, "Период")}
</AdminButton>
</header>
{#if !tariffDraft.periodRows.length}
<p class="admin-muted">
{at(
"tariff_pricing_empty",
{},
"Добавьте хотя бы один период — без него тариф не появится на витрине."
)}
</p>
{:else}
<div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span></span>
</div>
{#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-4">
<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")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}
>
<Trash2 size={13} />
</AdminButton>
</div>
{/each}
</div>
{/if}
</section>
{:else}
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong>{at("tariff_pricing_traffic_title", {}, "Пакеты трафика")}</strong>
<small
>{at(
"tariff_pricing_traffic_subtitle",
{},
"Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<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">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
>
{#if tariffDraft.trafficRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficRubRows as row, index}
<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", {}, "Цена пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.trafficStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.trafficStarsRows as row, index}
<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")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
{/if}
</Tabs.Content>
<Tabs.Content value="topup" class="admin-tabs-content">
{#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong
>{at("tariff_topup_title", {}, "Докупка трафика поверх месячного лимита")}</strong
>
<small
>{at(
"tariff_topup_subtitle",
{},
"Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<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">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
>
{#if tariffDraft.topupRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupRubRows as row, index}
<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", {}, "Цена пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.topupStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.topupStarsRows as row, index}
<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")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("topupStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
{:else}
<p class="admin-muted">
{at(
"tariff_topup_traffic_hint",
{},
"Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания."
)}
</p>
{/if}
</Tabs.Content>
<Tabs.Content value="hwid" class="admin-tabs-content">
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<div class="admin-editor-section-title">
<strong
>{at(
"tariff_hwid_packages_title",
{},
"Пакеты дополнительных устройств (HWID)"
)}</strong
>
<small
>{at(
"tariff_hwid_packages_subtitle",
{},
"Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<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">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
{#if tariffDraft.hwidRubRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidRubRows as row, index}
<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", {}, "Цена пакета в рублях")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidRubRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption"
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
>
{#if tariffDraft.hwidStarsRows.length}
<div class="admin-row-editor-line admin-row-editor-header">
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
<span></span>
</div>
{/if}
{#each tariffDraft.hwidStarsRows as row, index}
<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")}
/>
<AdminButton
size="sm"
variant="danger"
onclick={() => tariffsStore.removeDraftRow("hwidStarsRows", index)}
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
</Tabs.Root>
<div class="admin-dialog-actions">
<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", {}, "Сохранить тариф")}
</AdminButton>
</div>
</Dialog>
<Dialog
open={tariffDeleteOpen}
title={at("tariff_delete_title", {}, "Удалить тариф?")}
description={tariffDeleteTarget
? at(
"tariff_delete_subtitle",
{ key: tariffDeleteTarget.key },
`Тариф ${tariffDeleteTarget.key} исчезнет из каталога продаж.`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => tariffsStore.updateState({ tariffDeleteOpen: false })}
class="admin-dialog"
>
<div class="admin-form-row">
<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", {}, "Подтвердить удаление")}
</AdminButton>
</div>
</Dialog>
@@ -0,0 +1,195 @@
<script>
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;
const tariffsStore = getContext("tariffsStore");
$: ({ tariffsCatalog, tariffsLoading, tariffsPath, tariffsSaving } = $tariffsStore);
$: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
$: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length);
function tariffName(tariff) {
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
}
function tariffPriceSummary(tariff) {
if (tariff.billing_model === "traffic") {
const rub = tariff.traffic_packages?.rub || [];
const first = rub[0];
return first
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}`
: at("tariff_traffic_packages", {}, "Пакеты трафика");
}
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
return months
.map((month) => {
const rub = tariff.prices_rub?.[String(month)];
const stars = tariff.prices_stars?.[String(month)];
if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, "RUB")}`;
if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
return `${month} ${at("months_short", {}, "мес.")}`;
})
.join(" · ");
}
onMount(() => {
tariffsStore.loadTariffs();
});
</script>
{#if tariffsLoading}
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
{:else}
<div class="admin-stat-grid">
<div class="admin-stat-card">
<span class="admin-stat-label">{at("tariffs_stat_total", {}, "Всего тарифов")}</span>
<strong class="admin-stat-value">{tariffsCatalog.tariffs.length}</strong>
<span class="admin-stat-trend"
>{at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}</span
>
</div>
<div class="admin-stat-card">
<span class="admin-stat-label">{at("tariffs_stat_default", {}, "По умолчанию")}</span>
<strong class="admin-stat-value">{tariffsCatalog.default_tariff || "—"}</strong>
<span class="admin-stat-trend"
>{at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}</span
>
</div>
<div class="admin-stat-card">
<span class="admin-stat-label">{at("tariffs_stat_disabled", {}, "Отключено")}</span>
<strong class="admin-stat-value">{disabledTariffs}</strong>
<span class="admin-stat-trend"
>{at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}</span
>
</div>
</div>
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
<small>{tariffsPath || "data/tariffs.json"}</small>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={tariffsStore.loadTariffs}
disabled={tariffsLoading || tariffsSaving}
>
<RefreshCw size={13} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton
size="sm"
variant="primary"
onclick={tariffsStore.openCreateTariff}
disabled={tariffsLoading || tariffsSaving}
>
<Plus size={13} />
{at("btn_create_tariff", {}, "Создать тариф")}
</AdminButton>
</div>
</header>
<div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length}
<AdminEmptyState>
{at(
"tariffs_catalog_empty",
{},
"Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога."
)}
</AdminEmptyState>
{:else}
<div class="admin-tariff-grid">
{#each tariffsCatalog.tariffs as tariff}
<article class="admin-tariff-card" class:is-disabled={tariff.enabled === false}>
<div class="admin-tariff-top">
<div>
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff}
<AdminBadge variant="success"
>{at("status_default", {}, "Default")}</AdminBadge
>
{/if}
</div>
<code>{tariff.key}</code>
</div>
{#if tariff.enabled === false}
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
{:else}
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{/if}
</div>
<p>
{tariff.descriptions?.ru ||
tariff.descriptions?.en ||
at("no_description", {}, "Без описания")}
</p>
<div class="admin-tariff-facts">
<span
>{tariff.billing_model === "traffic"
? at("tariff_model_traffic", {}, "Трафик")
: at("tariff_model_periods", {}, "Периоды")}</span
>
<span>{tariffPriceSummary(tariff)}</span>
<span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
>
<span
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length
? `${tariff.premium_monthly_gb || 0} GB`
: "—"}</span
>
<span
>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ??
"env"}</span
>
</div>
<div class="admin-tariff-actions">
<AdminButton size="sm" onclick={() => tariffsStore.openEditTariff(tariff)}>
{at("btn_configure", {}, "Настроить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.toggleTariffEnabled(tariff)}
disabled={tariffsSaving}
>
{tariff.enabled === false
? at("btn_enable", {}, "Включить")
: at("btn_disable", {}, "Выключить")}
</AdminButton>
<AdminButton
size="sm"
onclick={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving ||
tariff.enabled === false ||
tariff.key === tariffsCatalog.default_tariff}
>
{at("btn_set_default", {}, "По умолчанию")}
</AdminButton>
<AdminButton
size="sm"
variant="danger"
onclick={() =>
tariffsStore.updateState({
tariffDeleteTarget: tariff,
tariffDeleteOpen: true,
})}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
<Trash2 size={13} />
</AdminButton>
</div>
</article>
{/each}
</div>
{/if}
</div>
</article>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,365 @@
<script>
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminSelect,
AdminTable,
AdminTableSkeleton,
} from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte";
import { trafficOfLabel } from "../../lib/admin/format.js";
export let at = (key) => key;
export let fmtDateShort = (value) => value;
export let panelStatusBadge = () => ({});
export let resolvedAvatarUrl = () => "";
export let userDisplayName = () => "";
export let userInitials = () => "";
export let userSecondaryName = () => "";
const usersStore = getContext("usersStore");
$: ({
users,
usersTotal,
usersPage,
usersQuery,
usersFilter,
usersPanelStatus,
usersPremiumTraffic,
usersSort,
usersLoading,
} = $usersStore);
const USERS_PAGE_SIZE = 25;
$: usersHasMore = users.length === USERS_PAGE_SIZE;
const USERS_FILTER_OPTIONS = [
{ value: "all", label: at("filter_all", {}, "Все") },
{ value: "active", label: at("filter_not_banned", {}, "Не забанены") },
{ value: "banned", label: at("filter_banned", {}, "Забанены") },
{ value: "tg_linked", label: at("filter_tg_linked", {}, "С Telegram") },
{ value: "no_tg", label: at("filter_no_tg", {}, "Без Telegram") },
{ value: "email_linked", label: at("filter_email_linked", {}, "С email") },
{ value: "no_email", label: at("filter_no_email", {}, "Без email") },
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
];
const USERS_SORT_OPTIONS = [
{ value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
{ value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
{ value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") },
];
const USERS_PANEL_STATUS_OPTIONS = [
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
{ value: "active", label: at("status_active", {}, "active") },
{ value: "expired", label: at("status_expired", {}, "expired") },
{ value: "limited", label: at("status_limited", {}, "limited") },
];
const USERS_PREMIUM_TRAFFIC_OPTIONS = [
{ value: "all", label: at("premium_traffic_filter_all", {}, "Все (премиум)") },
{ value: "none", label: at("premium_traffic_filter_none", {}, "Без лимита в тарифе") },
{
value: "unlimited",
label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)"),
},
{ value: "good", label: at("premium_traffic_filter_good", {}, "Премиум: норма") },
{ value: "warn", label: at("premium_traffic_filter_warn", {}, "Премиум: мало") },
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
];
/** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeVariant(pt) {
if (!pt || pt.state === "none") return "muted";
if (pt.state === "unlimited" || pt.state === "good") return "success";
if (pt.state === "warn") return "warning";
return "danger";
}
/** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeText(pt) {
if (!pt || pt.state === "none") return "";
if (pt.state === "unlimited") return trafficOfLabel(pt.used_bytes, 0);
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
}
$: userTableHeaders = [
at("user", {}, "Пользователь"),
at("premium_traffic_filter_label", {}, "Премиум трафик"),
at("status", {}, "Статус"),
at("users_col_registration", {}, "Регистрация"),
];
onMount(() => {
usersStore.loadUsers();
});
</script>
<div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search">
<input
type="search"
class="input"
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
value={usersQuery}
on:input={(e) => usersStore.updateState({ usersQuery: e.target.value })}
on:keydown={(e) =>
e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())}
/>
<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>
<AdminSelect
value={usersFilter}
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => {
usersStore.updateState({ usersFilter: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
<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();
}}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label"
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
>
<AdminSelect
value={usersPremiumTraffic}
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
onValueChange={(value) => {
usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field">
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
<AdminSelect
value={usersSort}
items={USERS_SORT_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => {
usersStore.updateState({ usersSort: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
<div class="admin-toolbar-summary">
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
<strong>{usersTotal}</strong>
</div>
</div>
</div>
<div class="admin-table-wrap admin-users-table-wrap">
{#if usersLoading}
<AdminTableSkeleton
headers={userTableHeaders}
rows={USERS_PAGE_SIZE}
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
/>
{:else if !users.length}
<AdminEmptyState tone="card"
><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span
></AdminEmptyState
>
{:else}
<AdminTable class="admin-users-table">
<thead>
<tr>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
<th>{at("status", {}, "Статус")}</th>
<th>{at("users_col_registration", {}, "Регистрация")}</th>
</tr>
</thead>
<tbody>
{#each users as user}
{@const avatar = resolvedAvatarUrl(user)}
{@const badge = panelStatusBadge(user)}
<tr
class="is-clickable"
role="button"
tabindex="0"
data-user-id={user.user_id}
on:click={() => usersStore.openUser(user)}
on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
usersStore.openUser(user);
}
}}
>
<td class="admin-users-cell-user" data-label={at("user", {}, "Пользователь")}>
<div class="admin-users-cell-user-inner">
<span class="admin-avatar admin-avatar-sm">
{#if avatar}
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span>{userInitials(user)}</span>
{/if}
</span>
<div class="admin-users-cell-user-text">
<span class="admin-users-cell-name">{userDisplayName(user)}</span>
<span class="admin-users-cell-secondary">{userSecondaryName(user)}</span>
<span class="admin-users-cell-id">#{user.user_id}</span>
</div>
</div>
</td>
<td
class="admin-users-cell-premium"
data-label={at("premium_traffic_filter_label", {}, "Премиум трафик")}
>
{#if user.premium_traffic && user.premium_traffic.state !== "none"}
<AdminBadge
variant={premiumTrafficBadgeVariant(user.premium_traffic)}
class="admin-user-premium-badge"
>
{premiumTrafficBadgeText(user.premium_traffic)}
</AdminBadge>
{:else}
<span class="admin-user-premium-placeholder"
>{at("premium_traffic_na", {}, "—")}</span
>
{/if}
</td>
<td data-label={at("status", {}, "Статус")}>
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
</td>
<td
class="admin-users-cell-date admin-cell-mono"
data-label={at("users_col_registration", {}, "Регистрация")}
>
{fmtDateShort(user.registration_date)}
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</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();
}}
/>
<style>
.admin-users-cell-user-inner {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.admin-users-cell-user-text {
display: grid;
gap: 2px;
min-width: 0;
text-align: left;
}
.admin-users-cell-name {
font-weight: 650;
font-size: 13px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-cell-secondary {
font-size: 11px;
color: var(--admin-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-cell-id {
font-family: var(--font-mono);
font-size: 11px;
color: var(--admin-muted);
}
.admin-users-cell-premium {
white-space: nowrap;
}
.admin-users-cell-premium :global(.admin-user-premium-badge) {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.admin-user-premium-placeholder {
color: var(--admin-dim);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.admin-users-cell-date {
white-space: nowrap;
font-size: 12px;
color: var(--admin-muted);
}
.admin-users-table-wrap :global(.admin-users-table tbody tr.is-clickable:focus-visible) {
outline: 2px solid var(--admin-ring);
outline-offset: -2px;
}
</style>