refactor: slice web app wip

This commit is contained in:
3252a8
2026-05-11 21:41:31 +03:00
parent 7d8bbbc904
commit ed6b214b29
48 changed files with 5367 additions and 4106 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
<script>
import { Trash2, Check } from "lucide-svelte";
import { getContext, onMount } from "svelte";
import Dialog from "../../lib/components/ui/dialog.svelte";
import { Label } from "bits-ui";
export let at;
export let fmtMoney;
const adsStore = getContext("adsStore");
$: ({
ads,
adsTotals,
adsLoading,
adCreateOpen,
adDraft,
} = $adsStore);
onMount(() => {
adsStore.loadAds();
});
</script>
<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>
{:else if !ads.length}
<div class="admin-card-body"><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></div>
{:else}
<table class="admin-table">
<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}
<span class="admin-badge admin-badge-success">{at("status_active", {}, "Активна")}</span>
{:else}
<span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключена")}</span>
{/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)}>
{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)}>
<Trash2 size={13} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<Dialog open={adCreateOpen} title={at("ad_create_title", {}, "Новая кампания")} closeLabel={at("close", {}, "Закрыть")} onclose={() => adsStore.setCreateOpen(false)} class="admin-dialog">
<div class="admin-form">
<Label.Root class="admin-field-label">
<span>{at("ad_label_source", {}, "Источник")}</span>
<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>
<input class="input" type="text" placeholder="ads_summer25" value={adDraft.start_param} on:input={(e) => adsStore.updateDraft({ start_param: e.target.value })} />
</Label.Root>
<Label.Root class="admin-field-label">
<span>{at("ad_label_cost", {}, "Стоимость, RUB")}</span>
<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>
<button type="button" class="admin-btn admin-btn-primary" on:click={adsStore.createAd} disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}>
<Check size={14} /> {at("btn_create", {}, "Создать")}
</button>
</div>
</Dialog>
@@ -0,0 +1,62 @@
<script>
import { Send, ChevronDown, Check } from "lucide-svelte";
import { getContext } from "svelte";
import { Label, Select } from "bits-ui";
export let at;
export let optionLabel;
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>
<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>
</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;">
<button type="button" class="admin-btn admin-btn-primary" on:click={broadcastStore.runBroadcast} disabled={broadcastBusy || !broadcastText.trim()}>
<Send size={14} /> {broadcastBusy ? at("btn_sending", {}, "Отправка...") : at("btn_queue", {}, "Поставить в очередь")}
</button>
{#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,102 @@
<script>
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import { getContext, onMount } from "svelte";
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
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))}
/>
<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>
</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}
<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>
{:else if !logs.length}
<div class="admin-card-body"><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></div>
{:else}
<table class="admin-table">
<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>
</table>
{/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>
@@ -1,16 +1,26 @@
<script> <script>
import { ChevronLeft, ChevronRight } from "lucide-svelte"; import { ChevronLeft, ChevronRight } from "lucide-svelte";
import { getContext, onMount } from "svelte";
export let at = (key) => key; export let at = (key) => key;
export let fmtDate = (value) => value; export let fmtDate = (value) => value;
export let fmtMoney = (value) => value; export let fmtMoney = (value) => value;
export let loadPayments = () => {};
export let paymentStatusVariant = () => "muted"; export let paymentStatusVariant = () => "muted";
export let payments = [];
export let paymentsHasMore = false; const paymentsStore = getContext("paymentsStore");
export let paymentsLoading = false;
export let paymentsPage = 0; $: ({
export let paymentsTotal = 0; payments,
paymentsTotal,
paymentsPage,
paymentsLoading,
} = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
onMount(() => {
paymentsStore.loadPayments();
});
</script> </script>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
@@ -18,7 +28,7 @@
<table class="admin-table admin-table-skeleton" aria-hidden="true"> <table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead> <thead>
<tr> <tr>
<th>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> <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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -41,7 +51,7 @@
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th> <th>{at("user", {}, "Пользователь")}</th>
<th>{at("amount", {}, "Сумма")}</th> <th>{at("amount", {}, "Сумма")}</th>
<th>{at("provider", {}, "Провайдер")}</th> <th>{at("provider", {}, "Провайдер")}</th>
@@ -72,10 +82,10 @@
<div class="admin-pagination"> <div class="admin-pagination">
<span class="admin-pagination-meta">{at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal}</span> <span class="admin-pagination-meta">{at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal}</span>
<div class="admin-pagination-buttons"> <div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={paymentsPage === 0} on:click={() => { paymentsPage = Math.max(0, paymentsPage - 1); loadPayments(); }}> <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", {}, "Назад")} <ChevronLeft size={14} /> {at("back", {}, "Назад")}
</button> </button>
<button type="button" class="admin-btn admin-btn-sm" disabled={!paymentsHasMore} on:click={() => { paymentsPage += 1; loadPayments(); }}> <button type="button" class="admin-btn admin-btn-sm" disabled={!paymentsHasMore} on:click={() => { paymentsStore.setPage(paymentsPage + 1); }}>
{at("next", {}, "Далее")} <ChevronRight size={14} /> {at("next", {}, "Далее")} <ChevronRight size={14} />
</button> </button>
</div> </div>
@@ -0,0 +1,140 @@
<script>
import { Trash2 } from "lucide-svelte";
import { getContext, onMount } from "svelte";
import Dialog from "../../lib/components/ui/dialog.svelte";
import { Label } from "bits-ui";
export let at;
export let fmtDateShort;
const promosStore = getContext("promosStore");
$: ({
promos,
promosTotal,
promosPage,
promosLoading,
promoCreateOpen,
promoDraft,
} = $promosStore);
$: promosHasMore = promos.length < promosTotal;
onMount(() => {
promosStore.loadPromos();
});
</script>
<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>
{:else if !promos.length}
<div class="admin-card-body"><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></div>
{:else}
<table class="admin-table">
<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}
<span class="admin-badge admin-badge-success">{at("status_active", {}, "Активен")}</span>
{:else}
<span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключен")}</span>
{/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)}>
{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)}>
<Trash2 size={13} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/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>
</div>
{/if}
</div>
<Dialog
open={promoCreateOpen}
title={at("promo_create_title", {}, "Создать промокод")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => promosStore.setCreateOpen(false)}
class="admin-dialog"
>
<div class="admin-modal" data-dialog-content>
<div class="admin-modal-head">
<h3>{at("promo_create_title", {}, "Создать промокод")}</h3>
</div>
<div class="admin-modal-body admin-form">
<Label.Root class="admin-field-label">
<span>{at("promo_label_code", {}, "Код")}</span>
<input type="text" class="input" value={promoDraft.code} on:input={(e) => promosStore.updateDraft({ code: e.target.value })} placeholder="FREE-7-DAYS" />
</Label.Root>
<div class="admin-form-row-2">
<Label.Root class="admin-field-label">
<span>{at("promo_label_bonus_days", {}, "Бонус (дней)")}</span>
<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>
<input type="number" class="input" min="1" value={promoDraft.max_activations} on:input={(e) => promosStore.updateDraft({ max_activations: Number(e.target.value) })} />
</Label.Root>
</div>
<Label.Root class="admin-field-label">
<span>{at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}</span>
<input type="number" class="input" min="1" value={promoDraft.valid_days} on:input={(e) => promosStore.updateDraft({ valid_days: Number(e.target.value) })} />
</Label.Root>
</div>
<div class="admin-modal-footer">
<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()}>
{at("btn_create", {}, "Создать")}
</button>
</div>
</div>
</Dialog>
@@ -1,30 +1,198 @@
<script> <script>
import { ChevronRight } from "lucide-svelte"; import { ChevronRight, Eye, EyeOff, X } from "lucide-svelte";
import { Accordion } from "bits-ui"; import { Accordion, Switch } from "bits-ui";
import { getContext, onMount } from "svelte";
export let groupSectionFields = () => []; export let at;
export let isOverridden = () => false; export let onSettingsSaved;
export let renderField = () => {}; export let isCompact = false;
export let sectionTitle = (id) => id;
export let settingsAllOpen = false; const settingsStore = getContext("settingsStore");
export let settingsDirty = {};
export let settingsLoading = false; $: ({
export let settingsOpenSections = []; settingsSections,
export let settingsOpenSubsections = {}; settingsLoading,
export let settingsSections = []; settingsDirty,
export let toggleAllSections = () => {}; settingsSaving,
} = $settingsStore);
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
$: settingsAllOpen = settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length;
onMount(() => {
settingsStore.loadSettings().then(() => {
if ($settingsStore.settingsSections.length) {
const ids = $settingsStore.settingsSections.map((s) => s.id);
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
}
});
});
function toggleAllSections() {
if (settingsOpenSections.length === settingsSections.length) {
settingsOpenSections = [];
} else {
settingsOpenSections = settingsSections.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 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;
}
</script> </script>
{#snippet renderField(field)}
{@const revealed = isSecretRevealed(field.key)}
<div class="admin-setting" class:is-overridden={isOverridden(field)}>
<div class="admin-setting-meta">
<strong>
{field.label}
<span class="admin-badge admin-badge-warning">{at("settings_badge_secret", {}, "Secret")}</span>
{#if isOverridden(field)}
<span class="admin-badge admin-badge-success">{at("settings_badge_override", {}, "Override")}</span>
{/if}
</strong>
<code>{field.key}</code>
{#if field.description}
<small>{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>{Boolean(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.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={field.placeholder || "••••••••"}
autocomplete="off"
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"
aria-label={revealed ? at("hide", {}, "Скрыть") : at("show", {}, "Показать")}
on:click={() => toggleSecretReveal(field.key)}
>
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
</button>
{: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]}
<button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={() => settingsStore.resetField(field)}>
<X size={12} /> {at("reset", {}, "Сбросить")}
</button>
{/if}
</div>
</div>
{/snippet}
{#if settingsLoading || !settingsSections.length} {#if settingsLoading || !settingsSections.length}
<div class="admin-empty">{settingsLoading ? "Загрузка…" : "Нет данных"}</div> <div class="admin-empty">{settingsLoading ? at("loading", {}, "Загрузка…") : at("no_data", {}, "Нет данных")}</div>
{:else} {:else}
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;"> <div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<p class="admin-muted" style="margin:0;"> <p class="admin-muted" style="margin:0;">
Изменения в админке имеют приоритет над <code>.env</code>. Кнопка «Восстановить» возвращает значение из переменных окружения. {at("settings_hint", {}, "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.")}
</p> </p>
<button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={toggleAllSections}> <div style="display:flex; gap:8px;">
{settingsAllOpen ? "Свернуть всё" : "Развернуть всё"} <button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={toggleAllSections}>
</button> {settingsAllOpen ? at("collapse_all", {}, "Свернуть всё") : at("expand_all", {}, "Развернуть всё")}
</button>
{#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}>
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
</button>
{/if}
</div>
</div> </div>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion"> <Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each settingsSections as section} {#each settingsSections as section}
@@ -35,7 +203,7 @@
<Accordion.Trigger class="admin-accordion-trigger"> <Accordion.Trigger class="admin-accordion-trigger">
<span class="admin-accordion-title">{sectionTitle(section.id)}</span> <span class="admin-accordion-title">{sectionTitle(section.id)}</span>
<span class="admin-accordion-meta"> <span class="admin-accordion-meta">
{section.fields.length} параметров{#if overriddenInSection} · {overriddenInSection} override{/if}{#if dirtyInSection} · {dirtyInSection} изм.{/if} {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> </span>
<ChevronRight size={16} class="admin-accordion-chev" /> <ChevronRight size={16} class="admin-accordion-chev" />
</Accordion.Trigger> </Accordion.Trigger>
@@ -65,7 +233,7 @@
<Accordion.Trigger class="admin-settings-subsection-trigger"> <Accordion.Trigger class="admin-settings-subsection-trigger">
<strong>{group.label}</strong> <strong>{group.label}</strong>
<span class="admin-settings-subsection-meta"> <span class="admin-settings-subsection-meta">
{group.fields.length} полей{#if subOverridden} · {subOverridden} override{/if}{#if subDirty} · {subDirty} изм.{/if} {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> </span>
<ChevronRight size={14} class="admin-accordion-chev" /> <ChevronRight size={14} class="admin-accordion-chev" />
</Accordion.Trigger> </Accordion.Trigger>
@@ -1,88 +1,99 @@
<script> <script>
import { BarChart3, Coins, Database, Send, Shield, UsersRound } from "lucide-svelte"; import { BarChart3, Coins, Database, Send, Shield, UsersRound } from "lucide-svelte";
import { getContext, onMount } from "svelte";
export let at;
export let fmtDate = (value) => value; export let fmtDate = (value) => value;
export let fmtMoney = (value) => value; export let fmtMoney = (value) => value;
export let paymentStatusVariant = () => "muted"; export let paymentStatusVariant = () => "muted";
export let stats = null;
export let statsError = ""; const statsStore = getContext("statsStore");
export let statsLoading = false;
$: ({
stats,
statsError,
statsLoading,
} = $statsStore);
onMount(() => {
statsStore.loadStats();
});
</script> </script>
{#if statsError} {#if statsError}
<div class="admin-empty">Не удалось загрузить статистику: {statsError}</div> <div class="admin-empty">{at("stats_error", { error: statsError }, "Не удалось загрузить статистику: " + statsError)}</div>
{:else if statsLoading || !stats} {:else if statsLoading || !stats}
<div class="admin-empty">Загрузка…</div> <div class="admin-empty">{at("loading", {}, "Загрузка…")}</div>
{:else} {:else}
<div class="admin-stat-grid"> <div class="admin-stat-grid">
<article class="admin-stat-card"> <article class="admin-stat-card">
<span class="admin-stat-label"><UsersRound size={14} /> Пользователи</span> <span class="admin-stat-label"><UsersRound size={14} /> {at("stats_label_users", {}, "Пользователи")}</span>
<span class="admin-stat-value">{stats.users?.total_users ?? 0}</span> <span class="admin-stat-value">{stats.users?.total_users ?? 0}</span>
<span class="admin-stat-trend">В бане: {stats.users?.banned_users ?? 0}</span> <span class="admin-stat-trend">{at("stats_trend_banned", { count: stats.users?.banned_users ?? 0 }, "В бане: " + (stats.users?.banned_users ?? 0))}</span>
</article> </article>
<article class="admin-stat-card"> <article class="admin-stat-card">
<span class="admin-stat-label"><Shield size={14} /> Платные подписки</span> <span class="admin-stat-label"><Shield size={14} /> {at("stats_label_paid_subs", {}, "Платные подписки")}</span>
<span class="admin-stat-value">{stats.users?.paid_subscriptions ?? 0}</span> <span class="admin-stat-value">{stats.users?.paid_subscriptions ?? 0}</span>
<span class="admin-stat-trend">Триалы: {stats.users?.trial_users ?? 0}</span> <span class="admin-stat-trend">{at("stats_trend_trials", { count: stats.users?.trial_users ?? 0 }, "Триалы: " + (stats.users?.trial_users ?? 0))}</span>
</article> </article>
<article class="admin-stat-card"> <article class="admin-stat-card">
<span class="admin-stat-label"><Coins size={14} /> Доход за день</span> <span class="admin-stat-label"><Coins size={14} /> {at("stats_label_today_rev", {}, "Доход за день")}</span>
<span class="admin-stat-value">{fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)}</span> <span class="admin-stat-value">{fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)}</span>
<span class="admin-stat-trend">{stats.financial?.today_payments_count ?? 0} платежей</span> <span class="admin-stat-trend">{at("stats_trend_payments", { count: stats.financial?.today_payments_count ?? 0 }, (stats.financial?.today_payments_count ?? 0) + " платежей")}</span>
</article> </article>
<article class="admin-stat-card"> <article class="admin-stat-card">
<span class="admin-stat-label"><BarChart3 size={14} /> За неделю</span> <span class="admin-stat-label"><BarChart3 size={14} /> {at("stats_label_week", {}, "За неделю")}</span>
<span class="admin-stat-value">{fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)}</span> <span class="admin-stat-value">{fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)}</span>
<span class="admin-stat-trend">Месяц: {fmtMoney(stats.financial?.month_revenue, stats.currency_symbol)}</span> <span class="admin-stat-trend">{at("stats_trend_month", { value: fmtMoney(stats.financial?.month_revenue, stats.currency_symbol) }, "Месяц: " + fmtMoney(stats.financial?.month_revenue, stats.currency_symbol))}</span>
</article> </article>
<article class="admin-stat-card"> <article class="admin-stat-card">
<span class="admin-stat-label"><Database size={14} /> Всё время</span> <span class="admin-stat-label"><Database size={14} /> {at("stats_label_all_time", {}, "Всё время")}</span>
<span class="admin-stat-value">{fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)}</span> <span class="admin-stat-value">{fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)}</span>
<span class="admin-stat-trend">Sync: {stats.panel_sync?.status ?? "—"}</span> <span class="admin-stat-trend">{at("stats_sync_label", {}, "Sync")}: {stats.panel_sync?.status ?? "—"}</span>
</article> </article>
{#if stats.queue} {#if stats.queue}
<article class="admin-stat-card"> <article class="admin-stat-card">
<span class="admin-stat-label"><Send size={14} /> Очередь</span> <span class="admin-stat-label"><Send size={14} /> {at("stats_label_queue", {}, "Очередь")}</span>
<span class="admin-stat-value">{stats.queue.user_queue_size ?? 0}</span> <span class="admin-stat-value">{stats.queue.user_queue_size ?? 0}</span>
<span class="admin-stat-trend">Группы: {stats.queue.group_queue_size ?? 0}</span> <span class="admin-stat-trend">{at("stats_trend_groups", { count: stats.queue.group_queue_size ?? 0 }, "Группы: " + (stats.queue.group_queue_size ?? 0))}</span>
</article> </article>
{/if} {/if}
</div> </div>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<header class="admin-card-head"> <header class="admin-card-head">
<h3>Последние платежи</h3> <h3>{at("stats_recent_payments", {}, "Последние платежи")}</h3>
<small>{(stats.recent_payments || []).length} записей</small> <small>{at("stats_records_count", { count: (stats.recent_payments || []).length }, (stats.recent_payments || []).length + " записей")}</small>
</header> </header>
{#if (stats.recent_payments || []).length} {#if (stats.recent_payments || []).length}
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>{at("id", {}, "ID")}</th>
<th>Пользователь</th> <th>{at("user", {}, "Пользователь")}</th>
<th>Сумма</th> <th>{at("amount", {}, "Сумма")}</th>
<th>Провайдер</th> <th>{at("provider", {}, "Провайдер")}</th>
<th>Статус</th> <th>{at("status", {}, "Статус")}</th>
<th>Дата</th> <th>{at("date", {}, "Дата")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each stats.recent_payments as p} {#each stats.recent_payments as p}
<tr> <tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td> <td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
<td data-label="Пользователь">{p.user_label || p.user_id}</td> <td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_id}</td>
<td data-label="Сумма">{fmtMoney(p.amount, p.currency)}</td> <td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label="Провайдер">{p.provider}</td> <td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td data-label="Статус"> <td data-label={at("status", {}, "Статус")}>
<span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span> <span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span>
</td> </td>
<td data-label="Дата">{fmtDate(p.created_at)}</td> <td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
{:else} {:else}
<div class="admin-card-body"><span class="admin-muted">Нет данных</span></div> <div class="admin-card-body"><span class="admin-muted">{at("no_data", {}, "Нет данных")}</span></div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -0,0 +1,498 @@
<script>
import { Tabs, Select, Switch, Label } from "bits-ui";
import Dialog from "../../lib/components/ui/dialog.svelte";
import { Check, ChevronDown, Plus, Save, Trash2, X } from "lucide-svelte";
import { getContext } from "svelte";
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js";
export let at;
const tariffsStore = getContext("tariffsStore");
$: ({
tariffEditorOpen,
tariffEditingKey,
tariffDraft,
tariffsSaving,
tariffDeleteOpen,
tariffDeleteTarget,
selectedBaseSquad,
selectedPremiumSquad,
panelSquadsLoading,
panelSquads,
tariffEditorTab,
} = $tariffsStore);
</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>
<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>
</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>
<Select.Root
type="single"
bind:value={$tariffsStore.selectedBaseSquad}
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)}>
{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>
<Select.Root
type="single"
bind:value={$tariffsStore.selectedPremiumSquad}
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)}>
{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">
<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>
</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-пакета в рублях")} />
<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>
</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")} />
<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>
</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>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
<Plus size={13} /> {at("tariff_btn_period", {}, "Период")}
</button>
</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")} />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => tariffsStore.removeDraftRow("periodRows", index)} aria-label={at("btn_delete", {}, "Удалить")}>
<Trash2 size={13} />
</button>
</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">
<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>
</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", {}, "Цена пакета в рублях")} />
<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>
</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")} />
<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>
</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">
<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>
</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", {}, "Цена пакета в рублях")} />
<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>
</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")} />
<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>
</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">
<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>
</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", {}, "Цена пакета в рублях")} />
<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>
</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")} />
<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>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
</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()}>
<Save size={14} /> {tariffsSaving ? at("btn_saving", {}, "Сохранение...") : at("btn_save_tariff", {}, "Сохранить тариф")}
</button>
</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">
<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}>
<Trash2 size={14} /> {at("btn_confirm_delete", {}, "Подтвердить удаление")}
</button>
</div>
</Dialog>
@@ -1,57 +1,89 @@
<script> <script>
import { RefreshCw, Trash2 } from "lucide-svelte"; import { RefreshCw, Trash2, Plus } from "lucide-svelte";
import { getContext, onMount } from "svelte";
export let disabledTariffs = 0; export let at;
export let enabledTariffs = []; export let fmtMoney;
export let loadTariffs = () => {};
export let openEditTariff = () => {}; const tariffsStore = getContext("tariffsStore");
export let setDefaultTariff = () => {};
export let tariffDeleteOpen = false; $: ({
export let tariffDeleteTarget = null; tariffsCatalog,
export let tariffName = () => ""; tariffsLoading,
export let tariffPriceSummary = () => ""; tariffsPath,
export let tariffsCatalog = { tariffs: [] }; tariffsSaving,
export let tariffsLoading = false; } = $tariffsStore);
export let tariffsPath = "";
export let tariffsSaving = false; $: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
export let toggleTariffEnabled = () => {}; $: 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> </script>
{#if tariffsLoading} {#if tariffsLoading}
<div class="admin-empty">Загрузка…</div> <div class="admin-empty">{at("loading", {}, "Загрузка…")}</div>
{:else} {:else}
<div class="admin-stat-grid"> <div class="admin-stat-grid">
<div class="admin-stat-card"> <div class="admin-stat-card">
<span class="admin-stat-label">Всего тарифов</span> <span class="admin-stat-label">{at("tariffs_stat_total", {}, "Всего тарифов")}</span>
<strong class="admin-stat-value">{tariffsCatalog.tariffs.length}</strong> <strong class="admin-stat-value">{tariffsCatalog.tariffs.length}</strong>
<span class="admin-stat-trend">Включено: {enabledTariffs.length}</span> <span class="admin-stat-trend">{at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}</span>
</div> </div>
<div class="admin-stat-card"> <div class="admin-stat-card">
<span class="admin-stat-label">По умолчанию</span> <span class="admin-stat-label">{at("tariffs_stat_default", {}, "По умолчанию")}</span>
<strong class="admin-stat-value">{tariffsCatalog.default_tariff || "—"}</strong> <strong class="admin-stat-value">{tariffsCatalog.default_tariff || "—"}</strong>
<span class="admin-stat-trend">Используется для новых подписок</span> <span class="admin-stat-trend">{at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}</span>
</div> </div>
<div class="admin-stat-card"> <div class="admin-stat-card">
<span class="admin-stat-label">Отключено</span> <span class="admin-stat-label">{at("tariffs_stat_disabled", {}, "Отключено")}</span>
<strong class="admin-stat-value">{disabledTariffs}</strong> <strong class="admin-stat-value">{disabledTariffs}</strong>
<span class="admin-stat-trend">Скрыто с витрины</span> <span class="admin-stat-trend">{at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}</span>
</div> </div>
</div> </div>
<article class="admin-card"> <article class="admin-card">
<header class="admin-card-head"> <header class="admin-card-head">
<div> <div>
<h3>Каталог тарифов</h3> <h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
<small>{tariffsPath || "config/tariffs.json"}</small> <small>{tariffsPath || "config/tariffs.json"}</small>
</div> </div>
<button type="button" class="admin-btn admin-btn-sm" on:click={loadTariffs} disabled={tariffsLoading || tariffsSaving}> <div class="admin-editor-section-actions">
<RefreshCw size={13} /> Обновить <button type="button" class="admin-btn admin-btn-sm" on:click={tariffsStore.loadTariffs} disabled={tariffsLoading || tariffsSaving}>
</button> <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}>
<Plus size={13} /> {at("btn_create_tariff", {}, "Создать тариф")}
</button>
</div>
</header> </header>
<div class="admin-card-body"> <div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length} {#if !tariffsCatalog.tariffs.length}
<div class="admin-empty"> <div class="admin-empty">
Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога. {at("tariffs_catalog_empty", {}, "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.")}
</div> </div>
{:else} {:else}
<div class="admin-tariff-grid"> <div class="admin-tariff-grid">
@@ -62,46 +94,46 @@
<div class="admin-tariff-title"> <div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong> <strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff} {#if tariff.key === tariffsCatalog.default_tariff}
<span class="admin-badge admin-badge-success">Default</span> <span class="admin-badge admin-badge-success">{at("status_default", {}, "Default")}</span>
{/if} {/if}
</div> </div>
<code>{tariff.key}</code> <code>{tariff.key}</code>
</div> </div>
{#if tariff.enabled === false} {#if tariff.enabled === false}
<span class="admin-badge admin-badge-muted">Выключен</span> <span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключен")}</span>
{:else} {:else}
<span class="admin-badge admin-badge-success">Активен</span> <span class="admin-badge admin-badge-success">{at("status_active", {}, "Активен")}</span>
{/if} {/if}
</div> </div>
<p>{tariff.descriptions?.ru || tariff.descriptions?.en || "Без описания"}</p> <p>{tariff.descriptions?.ru || tariff.descriptions?.en || at("no_description", {}, "Без описания")}</p>
<div class="admin-tariff-facts"> <div class="admin-tariff-facts">
<span>{tariff.billing_model === "traffic" ? "Трафик" : "Периоды"}</span> <span>{tariff.billing_model === "traffic" ? at("tariff_model_traffic", {}, "Трафик") : at("tariff_model_periods", {}, "Периоды")}</span>
<span>{tariffPriceSummary(tariff)}</span> <span>{tariffPriceSummary(tariff)}</span>
<span>Squads: {(tariff.squad_uuids || []).length}</span> <span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span>
<span>Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"}</span> <span>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"}</span>
<span>Устройства: {tariff.hwid_device_limit ?? "env"}</span> <span>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? "env"}</span>
</div> </div>
<div class="admin-tariff-actions"> <div class="admin-tariff-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => openEditTariff(tariff)}> <button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.openEditTariff(tariff)}>
Настроить {at("btn_configure", {}, "Настроить")}
</button> </button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => toggleTariffEnabled(tariff)} disabled={tariffsSaving}> <button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
{tariff.enabled === false ? "Включить" : "Выключить"} {tariff.enabled === false ? at("btn_enable", {}, "Включить") : at("btn_disable", {}, "Выключить")}
</button> </button>
<button <button
type="button" type="button"
class="admin-btn admin-btn-sm" class="admin-btn admin-btn-sm"
on:click={() => setDefaultTariff(tariff.key)} on:click={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving || tariff.enabled === false || tariff.key === tariffsCatalog.default_tariff} disabled={tariffsSaving || tariff.enabled === false || tariff.key === tariffsCatalog.default_tariff}
> >
По умолчанию {at("btn_set_default", {}, "По умолчанию")}
</button> </button>
<button <button
type="button" type="button"
class="admin-btn admin-btn-sm admin-btn-danger" class="admin-btn admin-btn-sm admin-btn-danger"
on:click={() => { tariffDeleteTarget = tariff; tariffDeleteOpen = true; }} on:click={() => tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })}
disabled={tariffsSaving} disabled={tariffsSaving}
aria-label="Удалить тариф" aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
> >
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
@@ -0,0 +1,349 @@
<script>
import { Label, Select, Separator, Tabs } from "bits-ui";
import Dialog from "../../lib/components/ui/dialog.svelte";
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";
import { getContext } from "svelte";
export let at;
export let fmtDate;
export let fmtMoney;
export let resolvedAvatarUrl;
export let userDisplayName;
export let userSecondaryName;
export let paymentStatusVariant;
export let trafficPercentValue;
export let trafficLeftLabel;
export let trafficOfLabel;
export let userInitials = () => "";
export let fmtDateShort = (v) => v;
function pretty(val) {
if (val === true) return at("yes", {}, "Да");
if (val === false) return at("no", {}, "Нет");
return String(val ?? "—");
}
const usersStore = getContext("usersStore");
$: ({
openedUser,
openedUserDetail,
userDetailLoading,
userMessageDraft,
userExtendDays,
userActionBusy,
userDeleteOpen,
userBanConfirmOpen,
userMessageConfirmOpen,
userDetailTab,
} = $usersStore);
</script>
<Dialog
open={Boolean(openedUser)}
title={openedUser ? at("user_detail_title", { id: openedUser.user_id }, `Пользователь #${openedUser.user_id}`) : ""}
description={openedUser?.username ? "@" + openedUser.username : ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUser}
class="admin-dialog admin-user-dialog"
>
{#if openedUser}
{#if userDetailLoading || !openedUserDetail}
<p class="admin-muted">{at("loading", {}, "Загрузка…")}</p>
{:else}
<div class="admin-user-dialog-body">
<aside class="admin-user-aside">
<div class="admin-user-summary">
<span class="admin-avatar admin-avatar-lg">
{#if resolvedAvatarUrl(openedUser)}
<img src={resolvedAvatarUrl(openedUser)} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span>{userInitials(openedUser)}</span>
{/if}
</span>
<div class="admin-user-summary-meta">
<strong>{userDisplayName(openedUser)}</strong>
<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>
{:else}
<span class="admin-badge admin-badge-success">{at("badge_active", {}, "Активен")}</span>
{/if}
{#if openedUserDetail.active_subscription}
<span class="admin-badge admin-badge-success">{at("badge_subscription", {}, "Подписка")}</span>
{:else}
<span class="admin-badge admin-badge-muted">{at("badge_no_subscription", {}, "Без подписки")}</span>
{/if}
</div>
</div>
</div>
<div class="admin-user-stats">
<div class="admin-user-stat">
<span>{at("user_label_paid", {}, "Заплачено")}</span>
<strong>{fmtMoney(openedUserDetail.total_paid)}</strong>
</div>
<div class="admin-user-stat">
<span>{at("user_label_logs", {}, "Логов")}</span>
<strong>{openedUserDetail.log_count}</strong>
</div>
</div>
<div class="admin-subsection-title">{at("user_section_profile", {}, "Профиль")}</div>
<ul class="admin-meta-list">
<li><span>ID</span><strong>{openedUser.user_id}</strong></li>
<li><span>Telegram ID</span><strong>{openedUser.telegram_id || "—"}</strong></li>
<li><span>Username</span><strong>{openedUser.username ? "@" + openedUser.username : "—"}</strong></li>
<li><span>Email</span><strong class="admin-meta-truncate">{openedUser.email || "—"}</strong></li>
<li><span>{at("user_label_registration", {}, "Регистрация")}</span><strong>{fmtDate(openedUser.registration_date)}</strong></li>
<li><span>{at("user_label_ref_code", {}, "Реф. код")}</span><strong>{openedUserDetail.referral?.code || openedUserDetail.user?.referral_code || "—"}</strong></li>
</ul>
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
<div class="admin-subsection-title">{at("user_section_links", {}, "Ссылки")}</div>
<div class="admin-link-list">
{#if openedUserDetail.subscription_url}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label">{at("status_subscription", {}, "Подписка")}</span>
<a class="admin-link-row-url" href={openedUserDetail.subscription_url} target="_blank" rel="noopener">
{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", {}, "Ссылка на подписку скопирована"))}>
<Copy size={14} />
</button>
</div>
{/if}
{#if openedUserDetail.referral?.bot_link}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label">{at("user_label_ref_bot", {}, "Реф. ссылка (бот)")}</span>
<a class="admin-link-row-url" href={openedUserDetail.referral.bot_link} target="_blank" rel="noopener">
{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", {}, "Реф. ссылка скопирована"))}>
<Copy size={14} />
</button>
</div>
{/if}
{#if openedUserDetail.referral?.webapp_link}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label">{at("user_label_ref_web", {}, "Реф. ссылка (веб)")}</span>
<a class="admin-link-row-url" href={openedUserDetail.referral.webapp_link} target="_blank" rel="noopener">
{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", {}, "Реф. ссылка скопирована"))}>
<Copy size={14} />
</button>
</div>
{/if}
</div>
{/if}
</aside>
<main class="admin-user-main">
<Tabs.Root bind:value={$usersStore.userDetailTab} class="admin-tabs-root admin-user-tabs-root">
<Tabs.List class="admin-tabs-list">
<Tabs.Trigger value="subscription" class="admin-tabs-trigger">{at("user_tab_subscription", {}, "Подписка")}</Tabs.Trigger>
<Tabs.Trigger value="activity" class="admin-tabs-trigger">{at("user_tab_activity", {}, "Активность")}</Tabs.Trigger>
<Tabs.Trigger value="actions" class="admin-tabs-trigger">{at("user_tab_actions", {}, "Действия")}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="subscription" class="admin-tabs-content">
{#if openedUserDetail.active_subscription}
<ul class="admin-meta-list">
<li><span>{at("user_label_active_until", {}, "Активна до")}</span><strong>{fmtDate(openedUserDetail.active_subscription.end_date)}</strong></li>
<li><span>{at("user_label_tariff", {}, "Тариф")}</span><strong>{openedUserDetail.active_subscription.tariff_key || "—"}</strong></li>
<li><span>{at("user_label_auto_renew", {}, "Авто-продление")}</span><strong>{pretty(openedUserDetail.active_subscription.auto_renew_enabled)}</strong></li>
<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>
{#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>
{/if}
</div>
{:else}
<p class="admin-muted">{at("user_no_active_subscription", {}, "Активной подписки нет")}</p>
{/if}
{#if (openedUserDetail.subscriptions || []).length}
<Separator.Root class="admin-separator" />
<div class="admin-subsection-title">{at("user_history_title", { count: openedUserDetail.subscriptions.length }, `История подписок · ${openedUserDetail.subscriptions.length}`)}</div>
<div class="admin-mini-list">
{#each openedUserDetail.subscriptions.slice(0, 8) as sub}
<div class="admin-mini-list-row">
<div>
<strong>{sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа")}</strong>
<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>
{:else}
<span class="admin-badge admin-badge-muted">{sub.status_from_panel || at("user_history_status_panel", {}, "История")}</span>
{/if}
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="activity" class="admin-tabs-content">
<div class="admin-subsection-title">{at("user_recent_payments_title", { count: (openedUserDetail.recent_payments || []).length }, `Последние платежи · ${(openedUserDetail.recent_payments || []).length}`)}</div>
{#if (openedUserDetail.recent_payments || []).length}
<div class="admin-mini-list">
{#each openedUserDetail.recent_payments.slice(0, 8) as payment}
<div class="admin-mini-list-row">
<div>
<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>
</div>
{/each}
</div>
{:else}
<p class="admin-muted">{at("user_no_payments", {}, "Платежей нет")}</p>
{/if}
</Tabs.Content>
<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}>
<RefreshCw size={14} /> {at("user_btn_reset_trial", {}, "Сбросить триал")}
</button>
<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}>
<Plus size={14} /> {at("user_btn_extend", {}, "Продлить")}
</button>
</div>
</Label.Root>
</div>
<Label.Root class="admin-field-label">
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
<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()}>
<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()}>
<Send size={14} /> {at("btn_send_msg", {}, "Отправить сообщение")}
</button>
</div>
<section class="admin-danger-zone">
<header class="admin-danger-zone-head">
<strong>{at("user_danger_zone_title", {}, "Опасные действия")}</strong>
<small>{at("user_danger_zone_subtitle", {}, "Эти действия требуют подтверждения и (для удаления) необратимы")}</small>
</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}>
<UserPlus size={14} /> {at("btn_unban", {}, "Разбанить пользователя")}
</button>
{:else}
<button type="button" class="admin-btn admin-btn-danger" on:click={usersStore.requestBanToggle} disabled={userActionBusy}>
<UserMinus size={14} /> {at("btn_ban", {}, "Заблокировать")}
</button>
{/if}
<button type="button" class="admin-btn admin-btn-danger" on:click={() => usersStore.updateState({ userDeleteOpen: true })} disabled={userActionBusy}>
<Trash2 size={14} /> {at("btn_delete_account", {}, "Удалить аккаунт")}
</button>
</div>
</section>
</Tabs.Content>
</Tabs.Root>
</main>
</div>
{/if}
{/if}
</Dialog>
<Dialog
open={userMessageConfirmOpen}
title={at("user_msg_confirm_title", {}, "Отправить сообщение пользователю?")}
description={openedUser ? at("user_msg_confirm_recipient", { name: userDisplayName(openedUser) }, `Получатель: ${userDisplayName(openedUser)}`) : ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
class="admin-dialog"
>
<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()}>
<Send size={14} /> {at("btn_confirm_send", {}, "Подтвердить отправку")}
</button>
</div>
</Dialog>
<Dialog
open={userBanConfirmOpen}
title={at("user_ban_confirm_title", {}, "Заблокировать пользователя?")}
description={openedUser ? at("user_ban_confirm_subtitle", { name: userDisplayName(openedUser) }, `${userDisplayName(openedUser)} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.`) : ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userBanConfirmOpen: false })}
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}>
<UserMinus size={14} /> {at("btn_ban", {}, "Заблокировать")}
</button>
</div>
</Dialog>
<Dialog
open={userDeleteOpen}
title={at("user_delete_confirm_title", {}, "Удалить пользователя?")}
description={at("user_delete_confirm_subtitle", {}, "Действие необратимо. Удалятся все платежи, подписки и логи.")}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userDeleteOpen: false })}
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}>
<Trash2 size={14} /> {at("btn_confirm_delete", {}, "Подтвердить удаление")}
</button>
</div>
</Dialog>
@@ -1,30 +1,63 @@
<script> <script>
import { Check, ChevronDown, ChevronLeft, ChevronRight } from "lucide-svelte"; import { Check, ChevronDown, ChevronLeft, ChevronRight } from "lucide-svelte";
import { Label, Select } from "bits-ui"; import { Label, Select } from "bits-ui";
import { getContext, onMount } from "svelte";
export let USERS_FILTER_OPTIONS = [];
export let USERS_PAGE_SIZE = 25;
export let USERS_PANEL_STATUS_OPTIONS = [];
export let USERS_SORT_OPTIONS = [];
export let at = (key) => key; export let at = (key) => key;
export let fmtDateShort = (value) => value; export let fmtDateShort = (value) => value;
export let loadUsers = () => {};
export let openUser = () => {};
export let optionLabel = () => ""; export let optionLabel = () => "";
export let panelStatusBadge = () => ({}); export let panelStatusBadge = () => ({});
export let resolvedAvatarUrl = () => ""; export let resolvedAvatarUrl = () => "";
export let userDisplayName = () => ""; export let userDisplayName = () => "";
export let userInitials = () => ""; export let userInitials = () => "";
export let userSecondaryName = () => ""; export let userSecondaryName = () => "";
export let users = [];
export let usersFilter = "all"; const usersStore = getContext("usersStore");
export let usersHasMore = false;
export let usersLoading = false; $: ({
export let usersPage = 0; users,
export let usersPanelStatus = "all"; usersTotal,
export let usersQuery = ""; usersPage,
export let usersSort = "registered_desc"; usersQuery,
export let usersTotal = 0; usersFilter,
usersPanelStatus,
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 ↓") },
];
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") },
];
onMount(() => {
usersStore.loadUsers();
});
</script> </script>
<div class="admin-toolbar admin-toolbar-users"> <div class="admin-toolbar admin-toolbar-users">
@@ -33,10 +66,11 @@
type="search" type="search"
class="input" class="input"
placeholder={at("users_search_placeholder", {}, "ID, @username или email")} placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
bind:value={usersQuery} value={usersQuery}
on:keydown={(e) => e.key === "Enter" && ((usersPage = 0), loadUsers())} 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={() => { usersPage = 0; loadUsers(); }}>{at("find", {}, "Найти")}</button> <button type="button" class="admin-btn admin-btn-primary" on:click={() => { usersStore.updateState({ usersPage: 0 }); usersStore.loadUsers(); }}>{at("find", {}, "Найти")}</button>
</div> </div>
<div class="admin-toolbar-controls"> <div class="admin-toolbar-controls">
@@ -45,7 +79,7 @@
<Select.Root <Select.Root
type="single" type="single"
value={usersFilter} value={usersFilter}
onValueChange={(value) => { usersFilter = value; usersPage = 0; loadUsers(); }} onValueChange={(value) => { usersStore.updateState({ usersFilter: value, usersPage: 0 }); usersStore.loadUsers(); }}
> >
<Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("filter", {}, "Фильтр")}> <Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("filter", {}, "Фильтр")}>
<span>{optionLabel(USERS_FILTER_OPTIONS, usersFilter)}</span> <span>{optionLabel(USERS_FILTER_OPTIONS, usersFilter)}</span>
@@ -69,7 +103,7 @@
<Select.Root <Select.Root
type="single" type="single"
value={usersPanelStatus} value={usersPanelStatus}
onValueChange={(value) => { usersPanelStatus = value; usersPage = 0; loadUsers(); }} onValueChange={(value) => { usersStore.updateState({ usersPanelStatus: value, usersPage: 0 }); usersStore.loadUsers(); }}
> >
<Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("panel_status", {}, "Статус панели")}> <Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("panel_status", {}, "Статус панели")}>
<span>{optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)}</span> <span>{optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)}</span>
@@ -93,7 +127,7 @@
<Select.Root <Select.Root
type="single" type="single"
value={usersSort} value={usersSort}
onValueChange={(value) => { usersSort = value; usersPage = 0; loadUsers(); }} onValueChange={(value) => { usersStore.updateState({ usersSort: value, usersPage: 0 }); usersStore.loadUsers(); }}
> >
<Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("sort", {}, "Сортировка")}> <Select.Trigger class="admin-select-trigger admin-toolbar-select" aria-label={at("sort", {}, "Сортировка")}>
<span>{optionLabel(USERS_SORT_OPTIONS, usersSort)}</span> <span>{optionLabel(USERS_SORT_OPTIONS, usersSort)}</span>
@@ -146,7 +180,7 @@
{@const avatar = resolvedAvatarUrl(user)} {@const avatar = resolvedAvatarUrl(user)}
{@const badge = panelStatusBadge(user)} {@const badge = panelStatusBadge(user)}
<li> <li>
<button type="button" class="admin-user-row" on:click={() => openUser(user)}> <button type="button" class="admin-user-row" on:click={() => usersStore.openUser(user)}>
<span class="admin-avatar admin-avatar-sm"> <span class="admin-avatar admin-avatar-sm">
{#if avatar} {#if avatar}
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" /> <img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
@@ -172,10 +206,10 @@
<div class="admin-pagination"> <div class="admin-pagination">
<span class="admin-pagination-meta">{at("page", {}, "Страница")} {usersPage + 1}</span> <span class="admin-pagination-meta">{at("page", {}, "Страница")} {usersPage + 1}</span>
<div class="admin-pagination-buttons"> <div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={usersPage === 0} on:click={() => { usersPage = Math.max(0, usersPage - 1); loadUsers(); }}> <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", {}, "Назад")} <ChevronLeft size={14} /> {at("back", {}, "Назад")}
</button> </button>
<button type="button" class="admin-btn admin-btn-sm" disabled={!usersHasMore} on:click={() => { usersPage += 1; loadUsers(); }}> <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} /> {at("next", {}, "Далее")} <ChevronRight size={14} />
</button> </button>
</div> </div>
@@ -0,0 +1,101 @@
import { writable } from "svelte/store";
export function createAdsStore({ api, onToast, at }) {
const state = writable({
ads: [],
adsTotals: null,
adsLoading: false,
adCreateOpen: false,
adDraft: { source: "", start_param: "", cost: 0 },
});
async function loadAds() {
state.update((s) => ({ ...s, adsLoading: true }));
try {
const data = await api("/admin/ads");
if (data?.ok) {
state.update((s) => ({
...s,
ads: data.campaigns || [],
adsTotals: data.totals || {},
}));
}
} finally {
state.update((s) => ({ ...s, adsLoading: false }));
}
}
async function createAd() {
let draft = null;
state.update((s) => {
draft = s.adDraft;
return s;
});
if (!draft.source.trim() || !draft.start_param.trim()) return;
const res = await api("/admin/ads", {
method: "POST",
body: JSON.stringify(draft),
});
if (res?.ok) {
onToast(at("ad_created", {}, "Кампания создана"));
state.update((s) => ({
...s,
adCreateOpen: false,
adDraft: { source: "", start_param: "", cost: 0 },
}));
await loadAds();
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
async function toggleAd(ad) {
const res = await api(`/admin/ads/${ad.id}/toggle`, {
method: "POST",
body: JSON.stringify({ is_active: !ad.is_active }),
});
if (res?.ok) {
state.update((s) => ({
...s,
ads: s.ads.map((c) => (c.id === ad.id ? { ...c, is_active: !ad.is_active } : c)),
}));
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
async function deleteAd(ad) {
const res = await api(`/admin/ads/${ad.id}`, { method: "DELETE" });
if (res?.ok) {
state.update((s) => ({
...s,
ads: s.ads.filter((c) => c.id !== ad.id),
}));
onToast(at("ad_deleted", {}, "Кампания удалена"));
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
}
function setCreateOpen(open) {
state.update((s) => ({ ...s, adCreateOpen: open }));
}
function updateDraft(fields) {
state.update((s) => ({ ...s, adDraft: { ...s.adDraft, ...fields } }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadAds,
createAd,
toggleAd,
deleteAd,
setCreateOpen,
updateDraft,
};
}
@@ -0,0 +1,60 @@
import { writable } from "svelte/store";
export function createBroadcastStore({ api, onToast, at }) {
const state = writable({
broadcastTarget: "all",
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
});
const BROADCAST_TARGET_OPTIONS = [
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
];
async function runBroadcast() {
let text = "";
let target = "";
state.update((s) => {
text = s.broadcastText;
target = s.broadcastTarget;
s.broadcastBusy = true;
s.broadcastResult = null;
return s;
});
try {
const res = await api("/admin/broadcast", {
method: "POST",
body: JSON.stringify({ target, text }),
});
if (res?.ok) {
state.update((s) => ({
...s,
broadcastText: "",
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
}));
onToast(at("broadcast_started", {}, "Рассылка запущена"));
} else {
onToast(res?.error || at("broadcast_failed", {}, "Ошибка рассылки"));
}
} finally {
state.update((s) => ({ ...s, broadcastBusy: false }));
}
}
function updateField(fields) {
state.update((s) => ({ ...s, ...fields }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
runBroadcast,
updateField,
BROADCAST_TARGET_OPTIONS,
};
}
@@ -0,0 +1,59 @@
import { writable } from "svelte/store";
export function createLogsStore({ api, at }) {
const state = writable({
logs: [],
logsTotal: 0,
logsPage: 0,
logsUserFilter: "",
logsLoading: false,
});
const LOGS_PAGE_SIZE = 50;
async function loadLogs() {
state.update((s) => ({ ...s, logsLoading: true }));
let currentPage = 0;
let filter = "";
state.update((s) => {
currentPage = s.logsPage;
filter = s.logsUserFilter;
return s;
});
try {
let q = `/admin/logs?page=${currentPage}&page_size=${LOGS_PAGE_SIZE}`;
if (filter.trim()) {
q += `&user_id=${encodeURIComponent(filter.trim())}`;
}
const data = await api(q);
if (data?.ok) {
state.update((s) => ({
...s,
logs: data.logs || [],
logsTotal: data.total || 0,
}));
}
} finally {
state.update((s) => ({ ...s, logsLoading: false }));
}
}
function setPage(page) {
state.update((s) => ({ ...s, logsPage: page }));
loadLogs();
}
function setFilter(filter) {
state.update((s) => ({ ...s, logsUserFilter: filter }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadLogs,
setPage,
setFilter,
};
}
@@ -0,0 +1,47 @@
import { writable } from "svelte/store";
export function createPaymentsStore({ api, at }) {
const state = writable({
payments: [],
paymentsTotal: 0,
paymentsPage: 0,
paymentsLoading: false,
});
const PAYMENTS_PAGE_SIZE = 25;
async function loadPayments() {
state.update((s) => ({ ...s, paymentsLoading: true }));
let currentPage = 0;
state.update((s) => {
currentPage = s.paymentsPage;
return s;
});
try {
const data = await api(`/admin/payments?page=${currentPage}&page_size=${PAYMENTS_PAGE_SIZE}`);
if (data?.ok) {
state.update((s) => ({
...s,
payments: data.payments || [],
paymentsTotal: data.total || 0,
}));
}
} finally {
state.update((s) => ({ ...s, paymentsLoading: false }));
}
}
function setPage(page) {
state.update((s) => ({ ...s, paymentsPage: page }));
loadPayments();
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadPayments,
setPage,
};
}
@@ -0,0 +1,112 @@
import { writable } from "svelte/store";
export function createPromosStore({ api, onToast }) {
const state = writable({
promos: [],
promosTotal: 0,
promosPage: 0,
promosLoading: false,
promoCreateOpen: false,
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
});
const PROMOS_PAGE_SIZE = 25;
async function loadPromos() {
state.update((s) => ({ ...s, promosLoading: true }));
let currentPage = 0;
state.update((s) => {
currentPage = s.promosPage;
return s;
});
try {
const data = await api(`/admin/promos?page=${currentPage}&page_size=${PROMOS_PAGE_SIZE}`);
if (data?.ok) {
state.update((s) => ({ ...s, promos: data.promos || [], promosTotal: data.total || 0 }));
}
} finally {
state.update((s) => ({ ...s, promosLoading: false }));
}
}
async function createPromo() {
let draft = null;
state.update((s) => {
draft = s.promoDraft;
return s;
});
if (!draft.code.trim()) return;
const res = await api("/admin/promos", {
method: "POST",
body: JSON.stringify(draft),
});
if (res?.ok) {
onToast("Промокод создан");
state.update((s) => ({
...s,
promoCreateOpen: false,
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 }
}));
await loadPromos();
} else {
onToast(res?.error || "Ошибка");
}
}
async function togglePromo(promo) {
const res = await api(`/admin/promos/${promo.id}`, {
method: "PATCH",
body: JSON.stringify({ is_active: !promo.is_active }),
});
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p))
}));
} else {
onToast(res?.error || "Ошибка");
}
}
async function deletePromo(promo) {
const res = await api(`/admin/promos/${promo.id}`, { method: "DELETE" });
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.filter((p) => p.id !== promo.id)
}));
onToast("Промокод удалён");
} else {
onToast(res?.error || "Ошибка");
}
}
function setPage(page) {
state.update((s) => ({ ...s, promosPage: page }));
loadPromos();
}
function setCreateOpen(open) {
state.update((s) => ({ ...s, promoCreateOpen: open }));
}
function updateDraft(fields) {
state.update((s) => ({ ...s, promoDraft: { ...s.promoDraft, ...fields } }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadPromos,
createPromo,
togglePromo,
deletePromo,
setPage,
setCreateOpen,
updateDraft,
PROMOS_PAGE_SIZE,
};
}
@@ -0,0 +1,92 @@
import { writable } from "svelte/store";
export function createSettingsStore({ api, onToast, at }) {
const state = writable({
settingsSections: [],
settingsLoading: false,
settingsDirty: {},
settingsSaving: false,
});
async function loadSettings(isCompact = false) {
state.update((s) => ({ ...s, settingsLoading: true, settingsDirty: {} }));
try {
const data = await api("/admin/settings");
if (data?.ok) {
state.update((s) => ({
...s,
settingsSections: data.sections || [],
}));
}
} finally {
state.update((s) => ({ ...s, settingsLoading: false }));
}
}
function markDirty(key, value, deleted = false) {
state.update((s) => ({
...s,
settingsDirty: { ...s.settingsDirty, [key]: { value, deleted } },
}));
}
function clearDirty(key) {
state.update((s) => {
const next = { ...s.settingsDirty };
delete next[key];
return { ...s, settingsDirty: next };
});
}
async function saveSettings(onSettingsSaved) {
let dirty = {};
state.update(s => { dirty = s.settingsDirty; return s; });
if (!Object.keys(dirty).length) return;
state.update(s => ({ ...s, settingsSaving: true }));
try {
const updates = {};
const deletes = [];
for (const [key, change] of Object.entries(dirty)) {
if (change.deleted) deletes.push(key);
else updates[key] = change.value;
}
const res = await api("/admin/settings", {
method: "PATCH",
body: JSON.stringify({ updates, deletes }),
});
if (res?.ok) {
onToast(at("settings_saved", {}, "Настройки сохранены"));
state.update(s => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings();
} else if (res?.errors) {
const summary = Object.entries(res.errors).map(([k, v]) => `${k}: ${v}`).join("; ");
onToast(`Ошибки: ${summary}`);
} else {
onToast(res?.error || "Ошибка");
}
} finally {
state.update(s => ({ ...s, settingsSaving: false }));
}
}
function resetField(field) {
if (field.overridden) {
markDirty(field.key, "", true);
} else {
clearDirty(field.key);
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadSettings,
markDirty,
clearDirty,
resetField,
saveSettings,
};
}
@@ -0,0 +1,53 @@
import { writable } from "svelte/store";
export function createStatsStore({ api, onToast, at }) {
const state = writable({
stats: null,
statsLoading: false,
statsError: "",
syncBusy: false,
});
async function loadStats() {
state.update((s) => ({ ...s, statsLoading: true, statsError: "" }));
try {
const data = await api("/admin/stats");
if (!data?.ok) {
state.update((s) => ({ ...s, statsError: data?.error || "load_failed" }));
} else {
state.update((s) => ({ ...s, stats: data }));
}
} catch (e) {
state.update((s) => ({ ...s, statsError: e?.message || String(e) }));
} finally {
state.update((s) => ({ ...s, statsLoading: false }));
}
}
async function triggerSync() {
let busy = false;
state.update(s => { busy = s.syncBusy; return s; });
if (busy) return;
state.update((s) => ({ ...s, syncBusy: true }));
try {
const res = await api("/admin/sync", { method: "POST" });
if (res?.ok) {
onToast(at("sync_started", {}, "Синхронизация запущена"));
await loadStats();
} else {
onToast(res?.error || at("sync_error", {}, "Ошибка синхронизации"));
}
} finally {
state.update((s) => ({ ...s, syncBusy: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadStats,
triggerSync,
};
}
@@ -0,0 +1,255 @@
import { writable } from "svelte/store";
import {
emptyTariffDraft,
cloneCatalog,
draftFromTariff,
tariffFromDraft as tariffFromDraftFn,
normalizeUuidList,
} from "../tariffDraft.js";
export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at }) {
const state = writable({
tariffsCatalog: {
default_tariff: "",
topup_packages_default: { rub: [], stars: [] },
tariffs: [],
},
tariffsPath: "",
tariffsLoading: false,
tariffsSaving: false,
tariffEditorOpen: false,
tariffEditingKey: "",
tariffDeleteOpen: false,
tariffDeleteTarget: null,
tariffDraft: emptyTariffDraft(),
panelSquads: [],
panelSquadsLoading: false,
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorTab: "general",
});
const tariffFromDraft = (draft) => tariffFromDraftFn(draft);
async function loadTariffs() {
state.update((s) => ({ ...s, tariffsLoading: true }));
try {
loadPanelSquads();
const data = await api("/admin/tariffs");
if (data?.ok) {
state.update((s) => ({
...s,
tariffsCatalog: cloneCatalog(data.catalog),
tariffsPath: data.path || "",
}));
} else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
}
} finally {
state.update((s) => ({ ...s, tariffsLoading: false }));
}
}
async function loadPanelSquads() {
let loading = false;
state.update(s => { loading = s.panelSquadsLoading; return s; });
if (loading) return;
state.update((s) => ({ ...s, panelSquadsLoading: true }));
try {
const data = await api("/admin/panel/internal-squads");
if (data?.ok) state.update(s => ({ ...s, panelSquads: data.squads || [] }));
} catch (e) {
state.update(s => ({ ...s, panelSquads: [] }));
} finally {
state.update((s) => ({ ...s, panelSquadsLoading: false }));
}
}
function squadLabel(uuid) {
let squads = [];
state.update(s => { squads = s.panelSquads; return s; });
const squad = squads.find((item) => item.uuid === uuid);
return squad ? `${squad.name} · ${uuid.slice(0, 8)}` : uuid;
}
function addSquadToDraft(field, uuid) {
if (!uuid) return;
state.update(s => {
const current = normalizeUuidList(s.tariffDraft[field]);
if (current.includes(uuid)) return s;
return { ...s, tariffDraft: { ...s.tariffDraft, [field]: [...current, uuid] } };
});
}
function removeSquadFromDraft(field, uuid) {
state.update(s => {
return {
...s,
tariffDraft: {
...s.tariffDraft,
[field]: normalizeUuidList(s.tariffDraft[field]).filter((item) => item !== uuid),
}
};
});
}
async function persistTariffs(nextCatalog, successText) {
state.update((s) => ({ ...s, tariffsSaving: true }));
let currentPath = "";
state.update(s => { currentPath = s.tariffsPath; return s; });
try {
const res = await api("/admin/tariffs", {
method: "PUT",
body: JSON.stringify({ catalog: nextCatalog }),
});
if (res?.ok) {
state.update(s => ({
...s,
tariffsCatalog: cloneCatalog(res.catalog),
tariffsPath: res.path || currentPath,
tariffEditorOpen: false,
tariffDeleteOpen: false,
tariffDeleteTarget: null,
}));
if (onTariffsSaved) await onTariffsSaved(res.catalog);
flash(successText || at("tariffs_saved", {}, "Тарифы сохранены"));
} else {
flash(res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов"));
}
} finally {
state.update((s) => ({ ...s, tariffsSaving: false }));
}
}
function openCreateTariff() {
state.update(s => ({
...s,
tariffEditingKey: "",
tariffDraft: emptyTariffDraft(),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorOpen: true,
}));
}
function openEditTariff(tariff) {
state.update(s => ({
...s,
tariffEditingKey: tariff.key,
tariffDraft: draftFromTariff(tariff),
tariffEditorTab: "general",
selectedBaseSquad: "",
selectedPremiumSquad: "",
tariffEditorOpen: true,
}));
}
async function saveTariffDraft() {
let s;
state.update(st => { s = st; return st; });
const tariff = tariffFromDraft(s.tariffDraft);
if (!tariff.key) {
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
return;
}
const existing = (s.tariffsCatalog.tariffs || []).find((item) => item.key === tariff.key && item.key !== s.tariffEditingKey);
if (existing) {
flash(at("tariff_error_key_exists", {}, "Тариф с таким ключом уже есть"));
return;
}
const current = s.tariffsCatalog.tariffs || [];
const tariffs = s.tariffEditingKey
? current.map((item) => (item.key === s.tariffEditingKey ? tariff : item))
: [...current, tariff];
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_min_enabled", {}, "Должен быть хотя бы один включённый тариф"));
return;
}
const currentDefault = s.tariffsCatalog.default_tariff === s.tariffEditingKey ? tariff.key : s.tariffsCatalog.default_tariff;
const defaultTariff = enabledKeys.includes(currentDefault)
? currentDefault
: enabledKeys[0];
await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs }, at("tariff_saved", {}, "Тариф сохранён"));
}
async function toggleTariffEnabled(tariff) {
let s;
state.update(st => { s = st; return st; });
const tariffs = (s.tariffsCatalog.tariffs || []).map((item) =>
item.key === tariff.key ? { ...item, enabled: item.enabled === false } : item,
);
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_min_enabled", {}, "Должен остаться хотя бы один включённый тариф"));
return;
}
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff) ? s.tariffsCatalog.default_tariff : enabledKeys[0];
await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs }, at("tariff_status_updated", {}, "Статус тарифа обновлён"));
}
async function setDefaultTariff(key) {
let s;
state.update(st => { s = st; return st; });
if (!key || key === s.tariffsCatalog.default_tariff) return;
await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: key }, at("tariff_default_updated", {}, "Тариф по умолчанию обновлён"));
}
async function deleteTariff() {
let s;
state.update(st => { s = st; return st; });
if (!s.tariffDeleteTarget) return;
const tariffs = (s.tariffsCatalog.tariffs || []).filter((item) => item.key !== s.tariffDeleteTarget.key);
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
if (!enabledKeys.length) {
flash(at("tariff_error_delete_last_enabled", {}, "Нельзя удалить последний включённый тариф"));
return;
}
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff) ? s.tariffsCatalog.default_tariff : enabledKeys[0];
await persistTariffs({ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs }, at("tariff_deleted", {}, "Тариф удалён"));
}
function addDraftRow(field, row) {
state.update(s => ({
...s,
tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] }
}));
}
function removeDraftRow(field, index) {
state.update(s => ({
...s,
tariffDraft: {
...s.tariffDraft,
[field]: (s.tariffDraft[field] || []).filter((_, idx) => idx !== index),
}
}));
}
function updateState(updates) {
state.update(s => ({ ...s, ...updates }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
updateState,
loadTariffs,
loadPanelSquads,
squadLabel,
addSquadToDraft,
removeSquadFromDraft,
openCreateTariff,
openEditTariff,
saveTariffDraft,
toggleTariffEnabled,
setDefaultTariff,
deleteTariff,
addDraftRow,
removeDraftRow,
};
}
@@ -0,0 +1,295 @@
import { writable } from "svelte/store";
export function createUsersStore({ api, onToast, at }) {
const USERS_PAGE_SIZE = 25;
const state = writable({
users: [],
usersTotal: 0,
usersPage: 0,
usersQuery: "",
usersFilter: "all",
usersPanelStatus: "all",
usersSort: "registered_desc",
usersLoading: false,
openedUser: null,
openedUserDetail: null,
userDetailLoading: false,
userMessageDraft: "",
userExtendDays: 30,
userActionBusy: false,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userDetailTab: "profile",
});
let _activeRef = "stats"; // fallback if active isn't tracked
function setActive(active) {
_activeRef = active;
}
function _pushUserPath(userId) {
if (typeof window === "undefined") return;
if (window.location.protocol === "file:") return;
if (_activeRef !== "users") return;
const target = userId ? `/admin/users/${userId}` : `/admin/users`;
if (window.location.pathname === target) return;
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
}
async function loadUsers() {
state.update((s) => ({ ...s, usersLoading: true }));
let s;
state.update((st) => { s = st; return st; });
try {
const params = new URLSearchParams({
page: String(s.usersPage),
page_size: String(USERS_PAGE_SIZE),
});
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
if (s.usersPanelStatus && s.usersPanelStatus !== "all") params.set("panel_status", s.usersPanelStatus);
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
state.update(st => ({
...st,
users: data.users || [],
usersTotal: data.total || (data.users || []).length,
}));
}
} finally {
state.update((st) => ({ ...st, usersLoading: false }));
}
}
async function openUser(userOrId, opts = {}) {
const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
if (!userId) return;
state.update(s => ({
...s,
openedUser: typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
openedUserDetail: null,
userMessageDraft: "",
userMessageConfirmOpen: false,
userExtendDays: 30,
userDetailLoading: true,
userDetailTab: "subscription",
}));
if (!opts.skipPush) _pushUserPath(userId);
try {
const res = await api(`/admin/users/${userId}`);
if (res?.ok) {
state.update(s => ({
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser
}));
} else {
onToast(res?.error || "load_failed");
state.update(s => ({ ...s, openedUser: null }));
if (!opts.skipPush) _pushUserPath(null);
}
} finally {
state.update(s => ({ ...s, userDetailLoading: false }));
}
}
function closeUser(opts = {}) {
let wasOpen = false;
state.update(s => {
wasOpen = Boolean(s.openedUser);
return {
...s,
openedUser: null,
openedUserDetail: null,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
};
});
if (wasOpen && !opts.skipPush) _pushUserPath(null);
}
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
if (!text) return;
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(
() => onToast(successMessage),
() => onToast(text)
);
} else {
onToast(text);
}
}
function requestBanToggle() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
if (s.openedUser.is_banned) {
applyBanToggle(false);
} else {
state.update(st => ({ ...st, userBanConfirmOpen: true }));
}
}
async function applyBanToggle(banned) {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/ban`, {
method: "POST",
body: JSON.stringify({ banned }),
});
if (res?.ok) {
state.update(st => {
const updatedUser = { ...st.openedUser, is_banned: banned };
return {
...st,
openedUser: updatedUser,
users: st.users.map((u) => (u.user_id === updatedUser.user_id ? updatedUser : u)),
userBanConfirmOpen: false,
};
});
onToast(banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован"));
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function sendUserMessage() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message`, {
method: "POST",
body: JSON.stringify({ text: s.userMessageDraft }),
});
if (res?.ok) {
onToast(at("message_sent", {}, "Отправлено"));
state.update(st => ({
...st,
userMessageDraft: "",
userMessageConfirmOpen: false,
}));
} else onToast(res?.error || at("message_send_failed", {}, "Ошибка отправки"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
function requestSendUserMessage() {
state.update(s => {
if (!s.openedUser || !s.userMessageDraft.trim()) return s;
return { ...s, userMessageConfirmOpen: true };
});
}
async function previewUserMessage() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message/preview`, {
method: "POST",
body: JSON.stringify({ text: s.userMessageDraft }),
});
if (res?.ok) onToast(at("message_preview_sent", {}, "Превью отправлено в Telegram"));
else onToast(res?.error || at("message_preview_failed", {}, "Ошибка отправки превью"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function extendUser() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
const days = Number(s.userExtendDays);
if (!days || days <= 0) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
method: "POST",
body: JSON.stringify({ days }),
});
if (res?.ok) {
onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`));
await openUser(s.openedUser, { skipPush: true });
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function resetTrialUser() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function deleteUser() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}`, { method: "DELETE" });
if (res?.ok) {
onToast(at("user_deleted", {}, "Удален"));
state.update(st => ({
...st,
users: st.users.filter((u) => u.user_id !== st.openedUser.user_id)
}));
closeUser();
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
function updateState(updates) {
state.update(s => ({ ...s, ...updates }));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
updateState,
setActive,
loadUsers,
openUser,
closeUser,
copyToClipboard,
requestBanToggle,
applyBanToggle,
sendUserMessage,
requestSendUserMessage,
previewUserMessage,
extendUser,
resetTrialUser,
deleteUser,
};
}
@@ -28,11 +28,11 @@
</script> </script>
{#if href} {#if href}
<a class={cn(variants[variant], sizes[size], className)} {href} on:click={onclick} {...$$restProps}> <a class={cn(variants[variant], sizes[size], className)} {href} onclick={onclick} {...$$restProps}>
<slot /> <slot />
</a> </a>
{:else} {:else}
<button class={cn(variants[variant], sizes[size], className)} {type} {disabled} on:click={onclick} {...$$restProps}> <button class={cn(variants[variant], sizes[size], className)} {type} {disabled} onclick={onclick} {...$$restProps}>
<slot /> <slot />
</button> </button>
{/if} {/if}
@@ -14,7 +14,7 @@
{#if open} {#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}> <div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} on:click={onclose}></button> <button class="dialog-backdrop" type="button" aria-label={closeLabel} onclick={onclose}></button>
<section class={cn("dialog-card", className)}> <section class={cn("dialog-card", className)}>
<div class="dialog-head"> <div class="dialog-head">
<div> <div>
@@ -0,0 +1,215 @@
import { writable, get } from "svelte/store";
import { emailError, buildTelegramOAuthStartUrl } from "../authHelpers.js";
export function createAccountStore({ api, publicApi, setToken, loadData, t, showToast, clearToken, markManualLogout, showLogin, telegramSdk, getTg, telegramOAuthClientId, currentLang, normalizeLangCode, updateLocalData }) {
const state = writable({
linkEmailOpen: false,
linkEmailBusy: false,
linkTelegramBusy: false,
linkEmailValue: "",
linkEmailPending: "",
linkEmailCode: "",
linkEmailStatus: "",
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailResendCooldown: 0,
languageBusy: false,
});
let linkEmailResendTimer = null;
function setLinkEmailStatus(message, isError = false) {
state.update(s => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
}
function clearCooldownTimer() {
if (linkEmailResendTimer) {
window.clearInterval(linkEmailResendTimer);
linkEmailResendTimer = null;
}
}
function startCooldownTimer(seconds = 60) {
clearCooldownTimer();
state.update(s => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
linkEmailResendTimer = window.setInterval(() => {
const s = get(state);
if (s.linkEmailResendCooldown <= 1) {
state.update(s => ({ ...s, linkEmailResendCooldown: 0 }));
clearCooldownTimer();
return;
}
state.update(s => ({ ...s, linkEmailResendCooldown: s.linkEmailResendCooldown - 1 }));
}, 1000);
}
function openLinkEmailDialog(email) {
state.update(s => ({
...s,
linkEmailOpen: true,
linkEmailBusy: false,
linkEmailCode: "",
linkEmailPending: "",
linkEmailStatus: "",
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailValue: email || "",
linkEmailResendCooldown: 0,
}));
clearCooldownTimer();
}
function closeLinkEmailDialog() {
state.update(s => ({
...s,
linkEmailOpen: false,
linkEmailBusy: false,
linkEmailCode: "",
linkEmailPending: "",
linkEmailStatus: "",
linkEmailIsError: false,
linkEmailFieldError: "",
linkEmailResendCooldown: 0,
}));
clearCooldownTimer();
}
async function requestLinkEmailCode() {
const s = get(state);
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
const normalized = String(s.linkEmailValue || "").trim().toLowerCase();
if (!normalized || !normalized.includes("@")) {
state.update(s => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
return;
}
state.update(s => ({ ...s, linkEmailFieldError: "", linkEmailBusy: true }));
setLinkEmailStatus(t("wa_auth_sending_code"));
try {
const response = await api("/account/email/request", {
method: "POST",
body: JSON.stringify({ email: normalized }),
});
if (!response?.ok) throw response;
state.update(s => ({ ...s, linkEmailPending: normalized, linkEmailCode: "" }));
setLinkEmailStatus("");
startCooldownTimer(60);
} catch (error) {
setLinkEmailStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
} finally {
state.update(s => ({ ...s, linkEmailBusy: false }));
}
}
async function verifyLinkEmailCode() {
const s = get(state);
const code = String(s.linkEmailCode || "").replace(/\\D/g, "").slice(0, 6);
if (!s.linkEmailPending) {
setLinkEmailStatus(t("wa_auth_send_code_failed"), true);
return;
}
if (code.length !== 6) {
setLinkEmailStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update(s => ({ ...s, linkEmailBusy: true }));
setLinkEmailStatus(t("wa_auth_checking_code"));
try {
const response = await api("/account/email/verify", {
method: "POST",
body: JSON.stringify({ email: s.linkEmailPending, code }),
});
if (!response?.ok) throw response;
if (response?.token) setToken(response.token, response.csrf_token);
await loadData();
closeLinkEmailDialog();
showToast(t("wa_settings_linked"));
} catch (error) {
setLinkEmailStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
} finally {
state.update(s => ({ ...s, linkEmailBusy: false }));
}
}
async function linkTelegramAccountWithPayload(payload) {
state.update(s => ({ ...s, linkTelegramBusy: true }));
try {
const response = await api("/account/telegram/link", {
method: "POST",
body: JSON.stringify(payload),
});
if (!response?.ok) throw response;
if (response?.token) setToken(response.token, response.csrf_token);
await loadData();
showToast(t("wa_settings_linked"));
} catch (error) {
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
} finally {
state.update(s => ({ ...s, linkTelegramBusy: false }));
}
}
async function linkTelegramAccount(getTelegramMiniAppInitData) {
const s = get(state);
if (s.linkTelegramBusy) return;
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
if (isTelegramMiniAppAttempt) {
await telegramSdk.ensureForAction();
}
const initData = getTelegramMiniAppInitData();
if (initData) {
await linkTelegramAccountWithPayload({ init_data: initData });
return;
}
if (!telegramOAuthClientId) {
showToast(t("wa_auth_telegram_not_configured"));
return;
}
state.update(s => ({ ...s, linkTelegramBusy: true }));
window.location.assign(buildTelegramOAuthStartUrl("link", getTg()));
}
async function updateAccountLanguage(nextValue) {
const s = get(state);
const normalize = typeof normalizeLangCode === "function" ? normalizeLangCode : (v) => v;
const language = normalize(nextValue);
if (!language || s.languageBusy || language === currentLang()) return;
state.update(s => ({ ...s, languageBusy: true }));
try {
const response = await api("/account/language", {
method: "POST",
body: JSON.stringify({ language }),
});
if (!response?.ok) throw response;
if (typeof updateLocalData === "function") {
updateLocalData(normalize(response.language || language));
}
await loadData();
} catch {
showToast(t("wa_settings_language_update_failed"));
} finally {
state.update(s => ({ ...s, languageBusy: false }));
}
}
async function logout() {
markManualLogout();
clearToken();
try {
await publicApi("/auth/logout", { keepalive: true });
} catch {}
showLogin();
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
openLinkEmailDialog,
closeLinkEmailDialog,
requestLinkEmailCode,
verifyLinkEmailCode,
linkTelegramAccount,
updateAccountLanguage,
logout,
};
}
@@ -0,0 +1,274 @@
import { writable, get } from "svelte/store";
import {
readReferralParam,
readTelegramAuthStatus,
readMagicLoginToken,
readTelegramLoginWidgetAuthData,
clearAuthQuery,
buildTelegramOAuthStartUrl,
emailError,
} from "../authHelpers.js";
export function createAuthStore({
publicApi,
setToken,
loadData,
telegramSdk,
getTg,
t,
currentLang,
clearManualLogoutFlag
}) {
const state = writable({
authStatus: "",
authIsError: false,
authBusy: false,
telegramLoginBusy: false,
telegramLoginAttemptId: 0,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
authResendCooldown: 0,
email: "",
pendingEmail: "",
emailCode: "",
});
let authResendTimer = null;
let telegramLoginWatchdogTimer = null;
function setAuthStatus(message, isError = false) {
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
}
function clearCooldownTimer() {
if (authResendTimer) {
window.clearInterval(authResendTimer);
authResendTimer = null;
}
}
function startCooldownTimer(seconds = 60) {
clearCooldownTimer();
state.update((s) => ({ ...s, authResendCooldown: Math.max(0, Number(seconds || 60)) }));
authResendTimer = window.setInterval(() => {
const { authResendCooldown } = get(state);
if (authResendCooldown <= 1) {
state.update((s) => ({ ...s, authResendCooldown: 0 }));
clearCooldownTimer();
return;
}
state.update((s) => ({ ...s, authResendCooldown: authResendCooldown - 1 }));
}, 1000);
}
function startTelegramLoginWatchdog() {
const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 6000;
stopTelegramLoginWatchdog();
state.update((s) => ({ ...s, telegramLoginAttemptId: s.telegramLoginAttemptId + 1 }));
const { telegramLoginAttemptId } = get(state);
telegramLoginWatchdogTimer = window.setTimeout(() => {
if (get(state).telegramLoginAttemptId !== telegramLoginAttemptId) return;
telegramLoginWatchdogTimer = null;
state.update((s) => ({ ...s, telegramLoginBusy: false, authBusy: false }));
setAuthStatus(t("wa_auth_telegram_timeout"), true);
}, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS);
return telegramLoginAttemptId;
}
function stopTelegramLoginWatchdog(attemptId = null) {
if (attemptId !== null && attemptId !== get(state).telegramLoginAttemptId) return;
if (telegramLoginWatchdogTimer) {
window.clearTimeout(telegramLoginWatchdogTimer);
telegramLoginWatchdogTimer = null;
}
}
function isActiveTelegramLoginAttempt(attemptId) {
const s = get(state);
return attemptId === s.telegramLoginAttemptId && s.telegramLoginBusy;
}
async function finalizeMagicLogin(loginToken) {
const s = get(state);
if (s.authBusy) return false;
state.update(s => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_login"));
try {
const payload = { token: loginToken };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/magic", payload);
if (response.ok && response.token) {
setToken(response.token, response.csrf_token);
clearAuthQuery();
await loadData();
return true;
}
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
} catch {
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
} finally {
state.update(s => ({ ...s, authBusy: false }));
}
return false;
}
async function finalizeTelegramAuth(authData, source = "auth_data", options = {}) {
const s = get(state);
if (s.authBusy) return false;
state.update(s => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_telegram"));
try {
const payload =
source === "init_data"
? { init_data: authData }
: source === "id_token"
? { id_token: authData.id_token, nonce: authData.nonce }
: { auth_data: authData };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/token", payload, { signal: options.signal });
if (response.ok && response.token) {
setToken(response.token, response.csrf_token);
clearAuthQuery();
setAuthStatus("");
await loadData();
return true;
}
setAuthStatus(response.error === "banned" ? t("wa_auth_access_denied") : t("wa_auth_telegram_not_confirmed"), true);
} catch (error) {
setAuthStatus(
error?.name === "AbortError" ? t("wa_auth_telegram_timeout") : t("wa_auth_telegram_unavailable"),
true,
);
} finally {
state.update(s => ({ ...s, authBusy: false }));
}
return false;
}
async function requestEmailCode(changeScreen) {
const s = get(state);
if (s.authResendCooldown > 0 && s.pendingEmail) return;
const normalized = s.email.trim().toLowerCase();
if (!normalized || !normalized.includes("@")) {
state.update(s => ({ ...s, loginEmailFieldError: t("wa_auth_invalid_email"), loginEmailTooltipOpen: true }));
return;
}
state.update(s => ({ ...s, loginEmailFieldError: "", loginEmailTooltipOpen: false, authBusy: true }));
setAuthStatus(t("wa_auth_sending_code"));
try {
const payload = { email: normalized, language: currentLang() };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/request", payload);
if (!response.ok) throw response;
state.update(s => ({ ...s, pendingEmail: normalized, emailCode: "" }));
changeScreen("code");
setAuthStatus("");
startCooldownTimer(60);
} catch (error) {
setAuthStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
} finally {
state.update(s => ({ ...s, authBusy: false }));
}
}
async function verifyEmailCode() {
const s = get(state);
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
if (code.length !== 6) {
setAuthStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update(s => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_code"));
try {
const payload = { email: s.pendingEmail, code };
const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/verify", payload);
if (!response.ok || !response.token) throw response;
setToken(response.token, response.csrf_token);
await loadData();
setAuthStatus("");
} catch (error) {
setAuthStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
} finally {
state.update(s => ({ ...s, authBusy: false }));
}
}
async function openTelegramLogin(telegramOAuthClientId, getTelegramMiniAppInitData) {
const s = get(state);
if (s.authBusy || s.telegramLoginBusy) return;
setAuthStatus("");
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
if (!isTelegramMiniAppAttempt && telegramOAuthClientId) {
state.update(s => ({ ...s, telegramLoginBusy: true }));
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
window.setTimeout(() => {
state.update(s => ({ ...s, telegramLoginBusy: false }));
}, 1500);
return;
}
state.update(s => ({ ...s, telegramLoginBusy: true }));
const attemptId = startTelegramLoginWatchdog();
const loginTimeout = telegramSdk.createMiniAppAuthTimeout();
try {
await Promise.race([
(async () => {
await telegramSdk.ensureForAction();
if (!isActiveTelegramLoginAttempt(attemptId)) return;
const initData = getTelegramMiniAppInitData();
if (initData) {
await finalizeTelegramAuth(initData, "init_data", { signal: loginTimeout.signal });
return;
}
if (!telegramOAuthClientId) {
setAuthStatus(t("wa_auth_telegram_not_configured"), true);
return;
}
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
})(),
loginTimeout.promise,
]);
} catch (error) {
if (!isActiveTelegramLoginAttempt(attemptId)) return;
if (error?.name === "AbortError") {
setAuthStatus(t("wa_auth_telegram_timeout"), true);
} else {
setAuthStatus(t("wa_auth_telegram_unavailable"), true);
}
} finally {
loginTimeout.clear();
if (loginTimeout.timedOut) {
setAuthStatus(t("wa_auth_telegram_timeout"), true);
state.update(s => ({ ...s, authBusy: false }));
}
if (isActiveTelegramLoginAttempt(attemptId)) {
stopTelegramLoginWatchdog(attemptId);
state.update(s => ({ ...s, telegramLoginBusy: false }));
}
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
finalizeMagicLogin,
finalizeTelegramAuth,
requestEmailCode,
verifyEmailCode,
openTelegramLogin,
clearCooldownTimer,
setAuthStatus
};
}
@@ -0,0 +1,360 @@
import { writable, get } from "svelte/store";
export function createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg }) {
const state = writable({
paymentModalOpen: false,
paymentStep: "tariff",
selectedTariffKey: "",
selectedPlan: null,
selectedMethod: "",
topupModalOpen: false,
topupKind: "regular",
deviceTopupModalOpen: false,
changeModalOpen: false,
topupOptions: null,
deviceTopupOptions: null,
changeOptions: null,
selectedTopupPlan: null,
selectedDeviceTopupPlan: null,
selectedChangeTarget: null,
selectedChangeAction: null,
changeConfirmOpen: false,
tariffActionBusy: false,
payBusy: false,
});
let topupOptionsRequestId = 0;
function openPaymentModal(tariffMode, singleTariffMode, tariffCatalog, subscription, plans, defaultMethod = "") {
state.update((s) => {
let step = s.paymentStep;
let plan = s.selectedPlan;
let tariffKey = s.selectedTariffKey;
if (tariffMode) {
if (singleTariffMode && tariffCatalog[0]?.key) {
tariffKey = tariffCatalog[0].key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
step = "checkout";
} else if (subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key)) {
tariffKey = subscription.tariff_key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
step = "checkout";
} else {
step = "tariff";
tariffKey = "";
plan = null;
}
} else {
step = "checkout";
}
return {
...s,
paymentModalOpen: true,
paymentStep: step,
selectedTariffKey: tariffKey,
selectedPlan: plan,
selectedMethod: s.selectedMethod || defaultMethod,
};
});
}
function closePaymentModal() {
state.update((s) => ({ ...s, paymentModalOpen: false }));
}
function selectTariff(tariff, plans = []) {
const key = String(tariff?.key || "").trim();
if (!key) return;
state.update((s) => ({
...s,
selectedTariffKey: key,
selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null,
}));
}
function continueWithSelectedTariff(selectedTariffPlans = []) {
state.update((s) => {
if (!s.selectedTariffKey) return s;
return {
...s,
selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null,
paymentStep: "checkout",
};
});
}
function backToTariffList(subscription, tariffCatalog = []) {
if (subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key)) {
return;
}
state.update((s) => ({ ...s, paymentStep: "tariff" }));
}
function openTopupModal(kind = "regular", defaultMethod = "") {
const normalizedKind = kind === "premium" ? "premium" : "regular";
state.update((s) => ({
...s,
topupKind: normalizedKind,
topupModalOpen: true,
topupOptions: s.topupOptions?.topup_kind === normalizedKind ? s.topupOptions : null,
selectedTopupPlan: s.topupOptions?.topup_kind === normalizedKind ? s.selectedTopupPlan : null,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadTopupOptions(normalizedKind);
}
function closeTopupModal() {
state.update((s) => ({ ...s, topupModalOpen: false }));
}
function openDeviceTopupModal(defaultMethod = "") {
state.update((s) => ({
...s,
deviceTopupModalOpen: true,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadDeviceTopupOptions();
}
function closeDeviceTopupModal() {
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
}
function openTariffChangeModal(defaultMethod = "") {
state.update((s) => ({
...s,
changeModalOpen: true,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadTariffChangeOptions();
}
function closeTariffChangeModal() {
state.update((s) => ({ ...s, changeModalOpen: false }));
}
function openTariffChangeConfirm() {
const s = get(state);
if (!s.selectedChangeTarget || !s.selectedChangeAction) return;
state.update((s) => ({ ...s, changeConfirmOpen: true }));
}
function closeTariffChangeConfirm() {
state.update((s) => ({ ...s, changeConfirmOpen: false }));
}
function openTelegramInvoice(url) {
if (!url) return;
if (tg?.openInvoice) {
tg.openInvoice(url, (status) => {
if (status === "paid") {
showToast(t("wa_payment_success", {}, "Payment successful"));
loadData();
} else if (status === "failed") {
showToast(t("wa_payment_create_failed"));
}
});
return;
}
openExternalLink(url);
}
async function createPayment() {
const s = get(state);
if (!s.selectedPlan || !s.selectedMethod || s.payBusy) return;
state.update(s => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(billing.planPaymentBody(s.selectedPlan, s.selectedMethod));
if (!response.ok) throw response;
showToast(t("wa_payment_created"));
if (response.action === "open_invoice") {
if (!response.payment_url) throw response;
openTelegramInvoice(response.payment_url);
} else if (response.action === "invoice_sent") {
state.update(s => ({ ...s, paymentModalOpen: false }));
return;
} else {
if (!response.payment_url) throw response;
openExternalLink(response.payment_url);
}
state.update(s => ({ ...s, paymentModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
}
}
async function loadTopupOptions(kind) {
const s = get(state);
if (s.topupOptions?.topup_kind === kind) return;
const requestId = ++topupOptionsRequestId;
state.update(s => ({ ...s, tariffActionBusy: true, topupOptions: null, selectedTopupPlan: null }));
try {
const response = await billing.fetchTopupOptions(kind);
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
if (!response?.ok) throw response;
state.update(s => ({ ...s, topupOptions: response, selectedTopupPlan: response.plans?.[0] || null }));
} catch (error) {
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
showToast(error?.message || t("wa_tariff_options_failed"));
state.update(s => ({ ...s, topupModalOpen: false }));
} finally {
if (requestId === topupOptionsRequestId) {
state.update(s => ({ ...s, tariffActionBusy: false }));
}
}
}
async function createTopupPayment() {
const s = get(state);
if (!s.selectedTopupPlan || !s.selectedMethod || s.payBusy) return;
state.update(s => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.topupPaymentBody(s.selectedTopupPlan, s.selectedMethod, s.topupOptions?.tariff_key),
);
if (!response.ok || !response.payment_url) throw response;
showToast(t("wa_payment_created"));
openExternalLink(response.payment_url);
state.update(s => ({ ...s, topupModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
}
}
async function loadTariffChangeOptions() {
const s = get(state);
if (s.changeOptions || s.tariffActionBusy) return;
state.update(s => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.fetchTariffChangeOptions();
if (!response?.ok) throw response;
state.update(s => ({
...s,
changeOptions: response,
selectedChangeTarget: response.targets?.[0] || null,
selectedChangeAction: response.targets?.[0]?.actions?.[0] || null
}));
} catch (error) {
showToast(error?.message || t("wa_tariff_options_failed"));
state.update(s => ({ ...s, changeModalOpen: false }));
} finally {
state.update(s => ({ ...s, tariffActionBusy: false }));
}
}
async function applyTariffChange() {
const s = get(state);
if (!s.selectedChangeTarget || !s.selectedChangeAction || s.tariffActionBusy) return;
if (s.selectedChangeAction.kind === "payment") {
await createTariffChangePayment();
return;
}
state.update(s => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.postTariffChange({
tariff_key: s.selectedChangeTarget.tariff_key,
mode: s.selectedChangeAction.mode,
});
if (!response?.ok) throw response;
showToast(t("wa_tariff_change_applied"));
state.update(s => ({ ...s, changeConfirmOpen: false, changeModalOpen: false, changeOptions: null }));
await loadData();
} catch (error) {
showToast(error?.message || t("wa_tariff_change_failed"));
} finally {
state.update(s => ({ ...s, tariffActionBusy: false }));
}
}
async function createTariffChangePayment() {
const s = get(state);
if (!s.selectedChangeTarget || !s.selectedChangeAction || !s.selectedMethod || s.payBusy) return;
state.update(s => ({ ...s, payBusy: true }));
try {
const body = billing.changePaymentBody(s.selectedChangeAction, s.selectedChangeTarget, s.selectedMethod);
const response =
s.selectedChangeAction.mode === "buy_package" || s.selectedChangeAction.mode === "buy_period"
? await billing.postPayment(body)
: await billing.postTariffChangePayment(body);
if (!response.ok || !response.payment_url) throw response;
showToast(t("wa_payment_created"));
openExternalLink(response.payment_url);
state.update(s => ({ ...s, changeConfirmOpen: false, changeModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
}
}
async function loadDeviceTopupOptions() {
const s = get(state);
if (s.deviceTopupOptions || s.tariffActionBusy) return;
state.update(s => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.fetchDeviceTopupOptions();
if (!response?.ok) throw response;
state.update(s => ({
...s,
deviceTopupOptions: response,
selectedDeviceTopupPlan: response.plans?.[0] || null
}));
} catch (error) {
showToast(error?.message || t("wa_device_topup_options_failed"));
state.update(s => ({ ...s, deviceTopupModalOpen: false }));
} finally {
state.update(s => ({ ...s, tariffActionBusy: false }));
}
}
async function createDeviceTopupPayment() {
const s = get(state);
if (!s.selectedDeviceTopupPlan || !s.selectedMethod || s.payBusy) return;
state.update(s => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.deviceTopupPaymentBody(s.selectedDeviceTopupPlan, s.selectedMethod, s.deviceTopupOptions?.tariff_key),
);
if (!response.ok || !response.payment_url) throw response;
showToast(t("wa_payment_created"));
openExternalLink(response.payment_url);
state.update(s => ({ ...s, deviceTopupModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
}
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
openPaymentModal,
closePaymentModal,
selectTariff,
continueWithSelectedTariff,
backToTariffList,
createPayment,
openTopupModal,
closeTopupModal,
loadTopupOptions,
createTopupPayment,
openTariffChangeModal,
closeTariffChangeModal,
openTariffChangeConfirm,
closeTariffChangeConfirm,
loadTariffChangeOptions,
applyTariffChange,
createTariffChangePayment,
openDeviceTopupModal,
closeDeviceTopupModal,
loadDeviceTopupOptions,
createDeviceTopupPayment
};
}
@@ -0,0 +1,100 @@
import { writable, get } from "svelte/store";
export function createDevicesStore({ api, t, showToast }) {
const state = writable({
devicesData: null,
devicesLoaded: false,
devicesBusy: false,
devicesStatus: "",
devicesIsError: false,
deviceConfirmOpen: false,
deviceToDisconnect: null,
deviceDisconnectBusy: false,
});
async function loadDevices(devicesEnabled, force = false) {
const s = get(state);
if (!devicesEnabled || s.devicesBusy || (s.devicesLoaded && !force)) return;
state.update(s => ({ ...s, devicesBusy: true, devicesStatus: "", devicesIsError: false }));
try {
const response = await api("/devices");
if (!response?.ok) throw response;
state.update(s => ({ ...s, devicesData: response, devicesLoaded: true }));
} catch (error) {
state.update(s => ({
...s,
devicesStatus: error?.message || t("wa_devices_load_failed"),
devicesIsError: true,
devicesLoaded: true
}));
} finally {
state.update(s => ({ ...s, devicesBusy: false }));
}
}
function openDeviceDisconnectDialog(device) {
state.update(s => ({ ...s, deviceToDisconnect: device, deviceConfirmOpen: true }));
}
function closeDeviceDisconnectDialog() {
const s = get(state);
if (s.deviceDisconnectBusy) return;
state.update(s => ({ ...s, deviceConfirmOpen: false, deviceToDisconnect: null }));
}
async function disconnectDevice(devicesEnabled) {
const s = get(state);
const token = String(s.deviceToDisconnect?.token || "").trim();
if (!token || s.deviceDisconnectBusy) return;
state.update(s => ({ ...s, deviceDisconnectBusy: true }));
try {
const response = await api("/devices/disconnect", {
method: "POST",
body: JSON.stringify({ token }),
});
if (!response?.ok) throw response;
showToast(t("wa_device_disconnected"));
state.update(s => ({ ...s, deviceConfirmOpen: false, deviceToDisconnect: null, devicesLoaded: false }));
await loadDevices(devicesEnabled, true);
} catch (error) {
showToast(error?.message || t("wa_device_disconnect_failed"));
} finally {
state.update(s => ({ ...s, deviceDisconnectBusy: false }));
}
}
function devicesLimitLabel() {
const s = get(state);
const value = s.devicesData?.max_devices;
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
function devicesCountLabel() {
const s = get(state);
const current = Number(s.devicesData?.current_devices ?? s.devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel() });
}
function devicesPercent() {
const s = get(state);
const current = Number(s.devicesData?.current_devices ?? s.devicesData?.devices?.length ?? 0);
const max = Number(s.devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
loadDevices,
openDeviceDisconnectDialog,
closeDeviceDisconnectDialog,
disconnectDevice,
devicesLimitLabel,
devicesCountLabel,
devicesPercent,
};
}
@@ -71,11 +71,10 @@ export function actionKey(action) {
return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`; return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`;
} }
function formatMonthsForClient(value, lang) { function formatMonthsForClient(value, { t, termUnitLabel }) {
const months = Number(value || 0); const months = Number(value || 0);
if (months === 1) return lang === "en" ? "1 month" : "1 месяц"; if (months === 12) return t("wa_plan_one_year");
if (months === 12) return lang === "en" ? "1 year" : "1 год"; return t("wa_sub_term_value_unit", { value: String(months), unit: termUnitLabel(months, "month") });
return lang === "en" ? `${months} months` : `${months} мес.`;
} }
export function planDisplayTitle(plan, { trafficMode, t }) { export function planDisplayTitle(plan, { trafficMode, t }) {
@@ -90,7 +89,7 @@ export function planDisplayTitle(plan, { trafficMode, t }) {
return plan?.title || ""; return plan?.title || "";
} }
export function planSubtitle(plan, { lang }) { export function planSubtitle(plan, { t, termUnitLabel }) {
if (!plan?.tariff_key) return ""; if (!plan?.tariff_key) return "";
if (plan?.subtitle) return plan.subtitle; if (plan?.subtitle) return plan.subtitle;
if ( if (
@@ -101,7 +100,7 @@ export function planSubtitle(plan, { lang }) {
) { ) {
return formatTrafficGb(plan?.traffic_gb || plan?.months); return formatTrafficGb(plan?.traffic_gb || plan?.months);
} }
return formatMonthsForClient(plan?.months, lang); return formatMonthsForClient(plan?.months, { t, termUnitLabel });
} }
export function planUnitHint(plan, { trafficMode, selectedMethod, t }) { export function planUnitHint(plan, { trafficMode, selectedMethod, t }) {
+3
View File
@@ -1,4 +1,7 @@
@import "./styles/base.css";
@import "./styles/controls.css"; @import "./styles/controls.css";
@import "./styles/dialogs.css"; @import "./styles/dialogs.css";
@import "./styles/webapp.css"; @import "./styles/webapp.css";
@import "./styles/admin.css"; @import "./styles/admin.css";
@import "./styles/admin-controls.css";
@import "./styles/admin-dialogs.css";
@@ -0,0 +1,277 @@
.admin-toolbar-search .input,
.admin-toolbar-search .admin-btn {
height: 36px;
}
.admin-toolbar > .input,
.admin-toolbar input[type="text"],
.admin-toolbar input[type="number"],
.admin-toolbar input[type="search"] {
flex: 1 1 200px;
min-width: 180px;
}
.admin-toolbar > .admin-btn {
height: 36px;
}
.admin-table td.admin-cell-actions .admin-btn {
margin-left: 4px;
}
.admin-screen-wrap input,
.admin-screen-wrap textarea,
.admin-screen-wrap select,
.admin-screen-wrap .input,
.admin-dialog input,
.admin-dialog textarea,
.admin-dialog select,
.admin-dialog .input {
min-width: 0;
max-width: 100%;
}
.admin-screen-wrap .input,
.admin-screen-wrap input[type="text"],
.admin-screen-wrap input[type="number"],
.admin-screen-wrap input[type="email"],
.admin-screen-wrap input[type="search"],
.admin-screen-wrap input[type="password"],
.admin-screen-wrap input[type="url"],
.admin-screen-wrap select,
.admin-dialog .input,
.admin-dialog input[type="text"],
.admin-dialog input[type="number"],
.admin-dialog input[type="email"],
.admin-dialog input[type="search"],
.admin-dialog input[type="password"],
.admin-dialog input[type="url"],
.admin-dialog select {
height: 36px;
border-radius: 8px;
border: 1px solid var(--admin-border-strong);
background: var(--admin-bg);
color: var(--admin-text);
padding: 0 12px;
font-size: 13px;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.admin-screen-wrap .input::placeholder,
.admin-screen-wrap input::placeholder,
.admin-screen-wrap textarea::placeholder,
.admin-dialog .input::placeholder,
.admin-dialog input::placeholder,
.admin-dialog textarea::placeholder {
color: var(--admin-dim);
}
.admin-screen-wrap .input:focus,
.admin-screen-wrap input:focus,
.admin-screen-wrap select:focus,
.admin-dialog .input:focus,
.admin-dialog input:focus,
.admin-dialog select:focus {
border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
color: var(--admin-text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.admin-btn:hover:not(:disabled) {
background: var(--admin-elev);
}
.admin-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-btn.admin-btn-primary {
background: var(--accent);
color: #02110a;
border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600;
}
.admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 90%, #fff);
}
.admin-btn.admin-btn-ghost {
background: transparent;
border-color: transparent;
color: var(--admin-muted);
}
.admin-btn.admin-btn-ghost:hover:not(:disabled) {
background: var(--admin-surface-2);
color: var(--admin-text);
}
.admin-btn.admin-btn-danger {
background: color-mix(in srgb, #ff5757 80%, #000);
color: #fff;
border-color: color-mix(in srgb, #ff5757 60%, #000);
font-weight: 600;
}
.admin-btn.admin-btn-danger:hover:not(:disabled) {
background: #ff5757;
border-color: #ff5757;
}
.admin-btn.admin-btn-danger-soft {
color: #ffb4b4;
background: var(--admin-surface-2);
border-color: color-mix(in srgb, #ff6b6b 30%, transparent);
}
.admin-btn.admin-btn-danger-soft:hover:not(:disabled) {
background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface));
border-color: color-mix(in srgb, #ff6b6b 50%, transparent);
}
.admin-btn.admin-btn-icon {
width: 34px;
padding: 0;
}
.admin-btn.admin-btn-sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
border-radius: 6px;
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
color: var(--admin-muted);
white-space: nowrap;
}
.admin-badge.admin-badge-success {
border-color: color-mix(in srgb, var(--accent) 36%, transparent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
}
.admin-badge.admin-badge-danger {
border-color: color-mix(in srgb, #ff6b6b 32%, transparent);
color: #ffb4b4;
background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface));
}
.admin-badge.admin-badge-warning {
border-color: color-mix(in srgb, #ffd166 32%, transparent);
color: #ffd166;
background: color-mix(in srgb, #ffd166 12%, var(--admin-surface));
}
.admin-badge.admin-badge-muted {
color: var(--admin-muted);
}
.admin-setting-control .input,
.admin-setting-control input[type="text"],
.admin-setting-control input[type="number"] {
flex: 1 1 160px;
min-width: 0;
width: 100%;
}
.admin-btn.admin-btn-icon {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.admin-row-editor-line .admin-btn {
width: 32px;
padding: 0;
}
.admin-action-grid > .admin-btn {
width: 100%;
height: 36px;
align-self: end;
}
.admin-extend-control .input {
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
line-height: 34px;
box-shadow: none;
}
.admin-extend-control .input:focus {
box-shadow: none;
}
.admin-extend-control .admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
padding: 0 12px;
border-width: 0 0 0 1px;
border-radius: 0;
border-color: var(--admin-border-strong);
background: var(--admin-surface-2);
line-height: 1;
white-space: nowrap;
}
.admin-extend-control .admin-btn svg {
flex: 0 0 auto;
}
.admin-input-row .input,
.admin-input-row .admin-btn {
height: 36px;
}
.admin-actions-tab > .admin-btn {
width: 100%;
height: 36px;
}
.admin-message-actions .admin-btn {
height: 36px;
}
@@ -0,0 +1,8 @@
.dialog:has(.admin-dialog) {
padding: max(12px, env(safe-area-inset-top))
max(12px, env(safe-area-inset-right))
max(12px, env(safe-area-inset-bottom))
max(12px, env(safe-area-inset-left));
z-index: 90;
}
+16 -268
View File
@@ -293,6 +293,7 @@
/* Flex children must NOT shrink otherwise they collapse to fit the /* Flex children must NOT shrink otherwise they collapse to fit the
container and the main never overflows, breaking scroll. */ container and the main never overflows, breaking scroll. */
.admin-main > * { .admin-main > * {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -451,11 +452,6 @@
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
} }
.admin-toolbar-search .input,
.admin-toolbar-search .admin-btn {
height: 36px;
}
.admin-toolbar-controls { .admin-toolbar-controls {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto); grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto);
@@ -463,18 +459,6 @@
gap: 8px; gap: 8px;
} }
.admin-toolbar > .input,
.admin-toolbar input[type="text"],
.admin-toolbar input[type="number"],
.admin-toolbar input[type="search"] {
flex: 1 1 200px;
min-width: 180px;
}
.admin-toolbar > .admin-btn {
height: 36px;
}
.admin-toolbar-field { .admin-toolbar-field {
display: grid; display: grid;
align-items: start; align-items: start;
@@ -597,10 +581,6 @@
white-space: nowrap; white-space: nowrap;
} }
.admin-table td.admin-cell-actions .admin-btn {
margin-left: 4px;
}
.admin-table td.admin-cell-wrap { .admin-table td.admin-cell-wrap {
white-space: normal; white-space: normal;
max-width: 320px; max-width: 320px;
@@ -830,53 +810,14 @@
gap: 6px; gap: 6px;
} }
.admin-screen-wrap input,
.admin-screen-wrap textarea,
.admin-screen-wrap select,
.admin-screen-wrap .input {
min-width: 0;
max-width: 100%;
}
.admin-screen-wrap .input,
.admin-screen-wrap input[type="text"],
.admin-screen-wrap input[type="number"],
.admin-screen-wrap input[type="email"],
.admin-screen-wrap input[type="search"],
.admin-screen-wrap input[type="password"],
.admin-screen-wrap input[type="url"],
.admin-screen-wrap select {
height: 36px;
border-radius: 8px;
border: 1px solid var(--admin-border-strong);
background: var(--admin-bg);
color: var(--admin-text);
padding: 0 12px;
font-size: 13px;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.admin-screen-wrap .input::placeholder,
.admin-screen-wrap input::placeholder,
.admin-screen-wrap textarea::placeholder {
color: var(--admin-dim);
}
.admin-screen-wrap .input:focus,
.admin-screen-wrap input:focus,
.admin-screen-wrap select:focus {
border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.admin-select { .admin-select {
width: 100%; width: 100%;
appearance: auto; appearance: auto;
} }
.admin-textarea, .admin-textarea,
.admin-screen-wrap textarea { .admin-screen-wrap textarea,
.admin-dialog textarea {
width: 100%; width: 100%;
min-height: 110px; min-height: 110px;
border-radius: 10px; border-radius: 10px;
@@ -891,7 +832,8 @@
transition: border-color 0.12s ease, box-shadow 0.12s ease; transition: border-color 0.12s ease, box-shadow 0.12s ease;
} }
.admin-textarea:focus { .admin-textarea:focus,
.admin-dialog textarea:focus {
border-color: var(--admin-ring); border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
} }
@@ -935,90 +877,6 @@
display: flex; display: flex;
} }
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
color: var(--admin-text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.admin-btn:hover:not(:disabled) {
background: var(--admin-elev);
}
.admin-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-btn.admin-btn-primary {
background: var(--accent);
color: #02110a;
border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600;
}
.admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 90%, #fff);
}
.admin-btn.admin-btn-ghost {
background: transparent;
border-color: transparent;
color: var(--admin-muted);
}
.admin-btn.admin-btn-ghost:hover:not(:disabled) {
background: var(--admin-surface-2);
color: var(--admin-text);
}
.admin-btn.admin-btn-danger {
background: color-mix(in srgb, #ff5757 80%, #000);
color: #fff;
border-color: color-mix(in srgb, #ff5757 60%, #000);
font-weight: 600;
}
.admin-btn.admin-btn-danger:hover:not(:disabled) {
background: #ff5757;
border-color: #ff5757;
}
.admin-btn.admin-btn-danger-soft {
color: #ffb4b4;
background: var(--admin-surface-2);
border-color: color-mix(in srgb, #ff6b6b 30%, transparent);
}
.admin-btn.admin-btn-danger-soft:hover:not(:disabled) {
background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface));
border-color: color-mix(in srgb, #ff6b6b 50%, transparent);
}
.admin-btn.admin-btn-icon {
width: 34px;
padding: 0;
}
.admin-btn.admin-btn-sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
border-radius: 6px;
}
/* ============================================================ /* ============================================================
Admin skeletons (users list, payments / logs / promos / ads Admin skeletons (users list, payments / logs / promos / ads
tables). Rendered while data is loading to soften the empty tables). Rendered while data is loading to soften the empty
@@ -1109,42 +967,6 @@
} }
} }
.admin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
color: var(--admin-muted);
white-space: nowrap;
}
.admin-badge.admin-badge-success {
border-color: color-mix(in srgb, var(--accent) 36%, transparent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
}
.admin-badge.admin-badge-danger {
border-color: color-mix(in srgb, #ff6b6b 32%, transparent);
color: #ffb4b4;
background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface));
}
.admin-badge.admin-badge-warning {
border-color: color-mix(in srgb, #ffd166 32%, transparent);
color: #ffd166;
background: color-mix(in srgb, #ffd166 12%, var(--admin-surface));
}
.admin-badge.admin-badge-muted {
color: var(--admin-muted);
}
.admin-detail-grid { .admin-detail-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1353,14 +1175,6 @@
min-width: 0; min-width: 0;
} }
.admin-setting-control .input,
.admin-setting-control input[type="text"],
.admin-setting-control input[type="number"] {
flex: 1 1 160px;
min-width: 0;
width: 100%;
}
.admin-setting-control .admin-color { .admin-setting-control .admin-color {
width: 36px; width: 36px;
height: 36px; height: 36px;
@@ -1408,13 +1222,6 @@
/* Admin dialogs don't share the user-facing bottom-nav, so reset the .dialog /* Admin dialogs don't share the user-facing bottom-nav, so reset the .dialog
wrapper's padding (which reserves space for the mobile nav) and let the wrapper's padding (which reserves space for the mobile nav) and let the
card use the full viewport for its scroll area. */ card use the full viewport for its scroll area. */
.dialog:has(.admin-dialog) {
padding: max(12px, env(safe-area-inset-top))
max(12px, env(safe-area-inset-right))
max(12px, env(safe-area-inset-bottom))
max(12px, env(safe-area-inset-left));
z-index: 90;
}
.admin-dialog { .admin-dialog {
max-height: 100%; max-height: 100%;
@@ -1426,6 +1233,7 @@
/* User-detail dialog: constrain on desktop and lay out as a two-column /* User-detail dialog: constrain on desktop and lay out as a two-column
sidebar (profile facts) + main content (tabs). On mobile it stacks. */ sidebar (profile facts) + main content (tabs). On mobile it stacks. */
.admin-user-dialog { .admin-user-dialog {
width: min(100%, 1040px); width: min(100%, 1040px);
max-height: min(100%, 760px); max-height: min(100%, 760px);
@@ -1532,16 +1340,6 @@
text-decoration: underline; text-decoration: underline;
} }
.admin-btn.admin-btn-icon {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.admin-meta-truncate { .admin-meta-truncate {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1758,6 +1556,7 @@
/* Section title block: stacks `<strong>` heading + `<small>` description. /* Section title block: stacks `<strong>` heading + `<small>` description.
Higher specificity than `.admin-editor-section-head > div` (which would Higher specificity than `.admin-editor-section-head > div` (which would
otherwise force flex-row on the wrapper). */ otherwise force flex-row on the wrapper). */
.admin-editor-section-head > .admin-editor-section-title { .admin-editor-section-head > .admin-editor-section-title {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1777,6 +1576,7 @@
/* Column headers above an `.admin-row-editor` list visually distinct, /* Column headers above an `.admin-row-editor` list visually distinct,
inherits the same grid template as input rows so columns line up. */ inherits the same grid template as input rows so columns line up. */
.admin-row-editor-line.admin-row-editor-header { .admin-row-editor-line.admin-row-editor-header {
color: var(--admin-muted); color: var(--admin-muted);
font-size: 11px; font-size: 11px;
@@ -1810,11 +1610,6 @@
grid-template-columns: minmax(80px, 0.8fr) minmax(100px, 1fr) minmax(100px, 1fr) 32px; grid-template-columns: minmax(80px, 0.8fr) minmax(100px, 1fr) minmax(100px, 1fr) 32px;
} }
.admin-row-editor-line .admin-btn {
width: 32px;
padding: 0;
}
.admin-package-columns { .admin-package-columns {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2111,6 +1906,7 @@
} }
/* Switch (bits-ui Switch.Root + Thumb) */ /* Switch (bits-ui Switch.Root + Thumb) */
.admin-switch-root { .admin-switch-root {
appearance: none; appearance: none;
position: relative; position: relative;
@@ -2158,6 +1954,7 @@
} }
/* Select (bits-ui Select.Root + Trigger + Content + Item) */ /* Select (bits-ui Select.Root + Trigger + Content + Item) */
.admin-select-trigger { .admin-select-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2270,6 +2067,7 @@
} }
/* Label primitive (bits-ui Label.Root) */ /* Label primitive (bits-ui Label.Root) */
.admin-field-label { .admin-field-label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2311,6 +2109,7 @@
} }
/* Action rows in dialogs */ /* Action rows in dialogs */
.admin-action-row { .admin-action-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2353,12 +2152,6 @@
align-items: end; align-items: end;
} }
.admin-action-grid > .admin-btn {
width: 100%;
height: 36px;
align-self: end;
}
.admin-user-quick-actions { .admin-user-quick-actions {
display: grid; display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.25fr); grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.25fr);
@@ -2392,40 +2185,6 @@
overflow: hidden; overflow: hidden;
} }
.admin-extend-control .input {
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
line-height: 34px;
box-shadow: none;
}
.admin-extend-control .input:focus {
box-shadow: none;
}
.admin-extend-control .admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
padding: 0 12px;
border-width: 0 0 0 1px;
border-radius: 0;
border-color: var(--admin-border-strong);
background: var(--admin-surface-2);
line-height: 1;
white-space: nowrap;
}
.admin-extend-control .admin-btn svg {
flex: 0 0 auto;
}
.admin-extend-control:focus-within { .admin-extend-control:focus-within {
border-color: var(--admin-ring); border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
@@ -2439,26 +2198,12 @@
min-width: 0; min-width: 0;
} }
.admin-input-row .input,
.admin-input-row .admin-btn {
height: 36px;
}
.admin-actions-tab > .admin-btn {
width: 100%;
height: 36px;
}
.admin-message-actions { .admin-message-actions {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px; gap: 8px;
} }
.admin-message-actions .admin-btn {
height: 36px;
}
.admin-confirm-message-preview { .admin-confirm-message-preview {
max-height: min(280px, 45vh); max-height: min(280px, 45vh);
overflow: auto; overflow: auto;
@@ -2497,6 +2242,7 @@
} }
/* Two-column variant of admin-form-row */ /* Two-column variant of admin-form-row */
.admin-form-row.admin-form-row-2 { .admin-form-row.admin-form-row-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -2508,6 +2254,7 @@
} }
/* User summary header inside the user dialog */ /* User summary header inside the user dialog */
.admin-user-summary { .admin-user-summary {
display: grid; display: grid;
grid-template-columns: 56px minmax(0, 1fr); grid-template-columns: 56px minmax(0, 1fr);
@@ -2583,6 +2330,7 @@
} }
/* Editor section header tweaks */ /* Editor section header tweaks */
.admin-editor-section-actions { .admin-editor-section-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2597,7 +2345,6 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.admin-danger-zone { .admin-danger-zone {
border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent);
border-radius: 12px; border-radius: 12px;
@@ -2632,6 +2379,7 @@
/* Subsection grouping inside settings sections (per-payment-provider). /* Subsection grouping inside settings sections (per-payment-provider).
Each subsection is a nested Accordion.Item that defaults to closed Each subsection is a nested Accordion.Item that defaults to closed
users see provider names and expand only the one they want to edit. */ users see provider names and expand only the one they want to edit. */
.admin-subsection-accordion { .admin-subsection-accordion {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+95
View File
@@ -0,0 +1,95 @@
@font-face {
font-family: "Twemoji Country Flags";
src: url("https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2")
format("woff2");
font-display: swap;
unicode-range:
U+1F1E6-1F1FF,
U+1F3F4,
U+E0062-E0063,
U+E0065,
U+E0067,
U+E006C,
U+E006E,
U+E0073-E0074,
U+E0077,
U+E007F;
}
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
color-scheme: dark;
--accent: #00fe7a;
--bg: #03070b;
--panel: #111820;
--panel-2: #0b1118;
--panel-3: #17212b;
--border: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.2);
--text: #f2f7f4;
--muted: #a9b4b0;
--dim: #68736f;
--danger: #ff6b6b;
--blue: #2d9cff;
--radius: 8px;
/* Admin design tokens kept on :root so portal-rendered admin
surfaces (dialogs, bits-ui Select.Portal content) inherit them. */
--admin-bg: var(--bg);
--admin-surface: var(--panel);
--admin-surface-2: var(--panel-2);
--admin-elev: var(--panel-3);
--admin-border: var(--border);
--admin-border-strong: var(--border-strong);
--admin-text: var(--text);
--admin-muted: var(--muted);
--admin-dim: var(--dim);
--admin-ring: color-mix(in srgb, var(--accent) 50%, transparent);
--screen-gutter: 18px;
--safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right));
--nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline));
--bottom-nav-height: 64px;
--bottom-nav-offset: 10px;
--bottom-nav-left: calc(max(0px, (100% - 440px) / 2) + var(--nav-inline-gutter));
--bottom-nav-width: min(
calc(100% - (var(--nav-inline-gutter) * 2)),
calc(440px - (var(--nav-inline-gutter) * 2))
);
font-family: var(--font-sans);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
margin: 0;
overflow-x: clip;
}
body {
background: #02070b;
color: var(--text);
-webkit-font-smoothing: antialiased;
letter-spacing: 0;
}
button,
input {
font: inherit;
letter-spacing: 0;
}
.app-shell {
min-height: 100dvh;
background: #02070b !important;
}
:root {
--desktop-rail-width: 252px;
--desktop-page-gutter: clamp(28px, 4vw, 72px);
}
+117 -45
View File
@@ -1,101 +1,173 @@
/* Shared control primitives: buttons, inputs. */
button:disabled {
cursor: progress;
opacity: 0.58;
}
.btn { .btn {
display: inline-flex; display: inline-flex;
min-height: 44px;
min-width: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
min-height: 46px;
padding: 0 16px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
padding: 10px 14px; background: var(--panel-2);
color: var(--text);
font: inherit;
font-size: 14px;
font-weight: 800;
line-height: 1.1;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
font-size: 14px; cursor: pointer;
font-weight: 850; box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
line-height: 1.1; transition:
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease; transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease,
color 0.16s ease;
} }
.btn:hover { .btn:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.btn:focus-visible {
outline: none;
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent),
0 14px 32px rgba(0, 0, 0, 0.22);
}
.btn.wide,
.wide {
width: 100%;
} }
.btn-primary { .btn-primary {
border-color: color-mix(in srgb, var(--accent) 72%, transparent); border-color: color-mix(in srgb, var(--accent) 72%, white);
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white));
color: #031009; color: #03100a;
box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 20%, transparent);
} }
.btn-secondary, .btn-secondary {
.btn-outline { border-color: var(--border);
border-color: var(--border-strong); background: var(--panel-3);
background: rgba(255, 255, 255, 0.045);
color: var(--text); color: var(--text);
} }
.btn-outline { .btn-outline {
border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); border-color: var(--border-strong);
color: var(--accent); background: transparent;
color: var(--text);
} }
.btn-ghost { .btn-ghost {
border-color: transparent;
background: transparent; background: transparent;
color: var(--muted); color: var(--text);
box-shadow: none;
} }
.btn-telegram { .btn-telegram {
border-color: #2d9cff; border-color: color-mix(in srgb, #229ed9 78%, white);
background: linear-gradient(180deg, #2f9ff4, #1786df); background: #229ed9;
color: white; color: #fff;
}
.btn-icon {
border-color: var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
} }
.btn-icon,
.btn-square { .btn-square {
width: 42px; width: 42px;
min-width: 42px; min-width: 42px;
height: 42px; height: 42px;
min-height: 42px;
padding: 0; padding: 0;
} }
.btn-sm { .btn-sm {
min-height: 36px; min-height: 36px;
padding: 8px 10px; padding: 0 12px;
font-size: 12px; font-size: 13px;
} }
.wide { .btn-lg {
width: 100%; min-height: 52px;
padding: 0 18px;
font-size: 16px;
}
.progress {
position: relative;
height: 8px;
margin-top: 12px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.progress span {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 72%, white));
box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 40%, transparent);
} }
.input { .input {
width: 100%; width: 100%;
min-height: 44px; min-height: 46px;
min-width: 0; padding: 8px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(4, 8, 11, 0.48); background: var(--panel-2);
color: var(--text); color: var(--text);
outline: none; font: inherit;
padding: 11px 12px; font-size: 14px;
font-size: 13px; line-height: 1.5;
font-weight: 700; transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
} }
.input:focus { .input:focus {
border-color: color-mix(in srgb, var(--accent) 68%, var(--border)); outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent);
} }
.input.muted { .input::placeholder {
display: flex;
align-items: center;
color: var(--dim); color: var(--dim);
} }
.input:disabled {
opacity: 0.52;
cursor: not-allowed;
}
.input.input-error {
border-color: var(--danger);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--danger) 70%, transparent);
}
.preview-phone .btn {
min-height: 38px;
font-size: 12px;
padding: 8px 10px;
}
.preview-phone .btn-square {
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
}
+71 -67
View File
@@ -1,62 +1,78 @@
/* Modal/dialog primitives — bottom-sheet style cards used across the webapp. */
.dialog-skeleton {
display: grid;
gap: 12px;
}
.dialog { .dialog {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 70; z-index: 220;
display: grid; display: grid;
place-items: center; align-items: center;
overflow: hidden; justify-items: center;
padding: padding:
16px max(14px, env(safe-area-inset-top))
var(--screen-gutter) max(14px, env(safe-area-inset-right))
calc(var(--bottom-nav-height) + var(--bottom-nav-offset) + 16px + env(safe-area-inset-bottom)); max(14px, env(safe-area-inset-bottom))
max(14px, env(safe-area-inset-left));
overflow: hidden;
isolation: isolate;
} }
.dialog-backdrop { .dialog-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0;
border: 0; border: 0;
background: rgba(2, 7, 11, 0.54); background: rgba(0, 0, 0, 0.56);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
animation: fade-in 0.18s ease-out both; cursor: pointer;
animation: dialog-fade-in 0.18s ease-out both;
} }
.dialog-card { .dialog-card {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: grid; display: grid;
width: min(100%, 420px); gap: 16px;
gap: 12px; width: min(100%, 520px);
border: 1px solid var(--border-strong); max-height: min(86dvh, 760px);
border-radius: var(--radius); overflow: auto;
background: var(--panel); overscroll-behavior: contain;
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.5); padding: 18px;
padding: 14px; border: 1px solid var(--border);
transform-origin: center; border-radius: 24px;
animation: modal-enter 0.2s ease-out both; background: color-mix(in srgb, var(--panel) 94%, #07111a);
color: var(--text);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
animation: dialog-slide-up 0.2s ease-out both;
} }
.dialog-head { .dialog-head {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 42px; grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start; align-items: start;
gap: 10px;
} }
.dialog-head h2 { .dialog-head h2 {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-size: 18px; font-size: 19px;
line-height: 1.16; line-height: 1.18;
font-weight: 850;
} }
.dialog-head p { .dialog-head p {
margin: 4px 0 0; margin: 6px 0 0;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 13px;
line-height: 1.3; line-height: 1.45;
}
.dialog-card .bottom-action {
margin-top: 2px;
} }
.payment-dialog-card { .payment-dialog-card {
@@ -64,38 +80,6 @@
overflow-y: auto; overflow-y: auto;
} }
.link-email-dialog-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: min(100%, 560px);
max-height: 100%;
overflow: hidden;
}
.link-email-dialog-card .dialog-head {
position: relative;
z-index: 2;
}
.link-email-dialog-card .payment-dialog-body {
min-height: 0;
}
.link-email-code-layout {
display: grid;
min-height: 0;
height: 100%;
grid-template-rows: minmax(0, 1fr) auto;
}
.link-email-code-center {
align-self: center;
}
.link-email-resend {
align-self: end;
}
.payment-dialog-body { .payment-dialog-body {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -117,22 +101,42 @@
0 0 26px color-mix(in srgb, var(--accent) 42%, transparent); 0 0 26px color-mix(in srgb, var(--accent) 42%, transparent);
} }
@keyframes fade-in { .link-email-dialog-card {
from { display: grid;
opacity: 0; grid-template-rows: auto minmax(0, 1fr);
} height: min(100%, 560px);
to { max-height: 100%;
opacity: 1; overflow: hidden;
}
} }
@keyframes modal-enter { .link-email-dialog-card .dialog-head {
position: relative;
z-index: 2;
}
.link-email-dialog-card .payment-dialog-body {
min-height: 0;
}
@keyframes dialog-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-slide-up {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px) scale(0.98); transform: translateY(18px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
@media (min-width: 720px) {
.dialog-card {
border-radius: var(--radius-lg);
padding: 20px;
}
}
+40 -184
View File
@@ -1,107 +1,11 @@
@font-face {
font-family: "Twemoji Country Flags";
src: url("https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2")
format("woff2");
font-display: swap;
unicode-range:
U+1F1E6-1F1FF,
U+1F3F4,
U+E0062-E0063,
U+E0065,
U+E0067,
U+E006C,
U+E006E,
U+E0073-E0074,
U+E0077,
U+E007F;
}
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
color-scheme: dark;
--accent: #00fe7a;
--bg: #03070b;
--panel: #111820;
--panel-2: #0b1118;
--panel-3: #17212b;
--border: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.2);
--text: #f2f7f4;
--muted: #a9b4b0;
--dim: #68736f;
--danger: #ff6b6b;
--blue: #2d9cff;
--radius: 8px;
/* Admin design tokens kept on :root so portal-rendered admin
surfaces (dialogs, bits-ui Select.Portal content) inherit them. */
--admin-bg: var(--bg);
--admin-surface: var(--panel);
--admin-surface-2: var(--panel-2);
--admin-elev: var(--panel-3);
--admin-border: var(--border);
--admin-border-strong: var(--border-strong);
--admin-text: var(--text);
--admin-muted: var(--muted);
--admin-dim: var(--dim);
--admin-ring: color-mix(in srgb, var(--accent) 50%, transparent);
--screen-gutter: 18px;
--safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right));
--nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline));
--bottom-nav-height: 64px;
--bottom-nav-offset: 10px;
--bottom-nav-left: calc(max(0px, (100% - 440px) / 2) + var(--nav-inline-gutter));
--bottom-nav-width: min(
calc(100% - (var(--nav-inline-gutter) * 2)),
calc(440px - (var(--nav-inline-gutter) * 2))
);
font-family: var(--font-sans);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
margin: 0;
overflow-x: clip;
}
body {
background: #02070b;
color: var(--text);
-webkit-font-smoothing: antialiased;
letter-spacing: 0;
}
button,
input {
font: inherit;
letter-spacing: 0;
}
button { button {
cursor: pointer; cursor: pointer;
} }
button:disabled {
cursor: progress;
opacity: 0.58;
}
a { a {
color: inherit; color: inherit;
} }
.app-shell {
min-height: 100dvh;
background: #02070b !important;
}
.phone-screen { .phone-screen {
position: relative; position: relative;
width: min(100%, 440px); width: min(100%, 440px);
@@ -109,10 +13,7 @@ a {
margin: 0 auto; margin: 0 auto;
overflow-x: hidden; overflow-x: hidden;
padding: padding:
max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline));
max(var(--screen-gutter), var(--safe-inline))
max(18px, env(safe-area-inset-bottom))
max(var(--screen-gutter), var(--safe-inline));
} }
.content { .content {
@@ -366,24 +267,6 @@ a {
gap: 8px; gap: 8px;
} }
.progress {
position: relative;
height: 8px;
margin-top: 12px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.progress span {
position: absolute;
inset: 0 auto 0 0;
width: 0;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 72%, white));
box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 40%, transparent);
}
.traffic-meta { .traffic-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -407,7 +290,7 @@ a {
.card-click-target { .card-click-target {
position: absolute; position: absolute;
z-index: 2; z-index: 6;
inset: 0; inset: 0;
width: 100%; width: 100%;
border: 0; border: 0;
@@ -439,7 +322,7 @@ a {
.premium-server-dropdown { .premium-server-dropdown {
position: relative; position: relative;
z-index: 3; z-index: 7;
min-width: 0; min-width: 0;
} }
@@ -509,7 +392,7 @@ a {
gap: 6px; gap: 6px;
} }
.premium-server-list > div { .premium-server-list>div {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
@@ -533,7 +416,7 @@ a {
padding: 12px; padding: 12px;
} }
.topup-summary-card > div:not(.premium-server-list) { .topup-summary-card>div:not(.premium-server-list) {
display: grid; display: grid;
gap: 3px; gap: 3px;
} }
@@ -557,7 +440,7 @@ a {
gap: 11px; gap: 11px;
} }
.trial-card-head > svg { .trial-card-head>svg {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--accent); color: var(--accent);
} }
@@ -592,7 +475,7 @@ a {
gap: 11px; gap: 11px;
} }
.devices-summary-head > svg { .devices-summary-head>svg {
color: var(--accent); color: var(--accent);
} }
@@ -881,17 +764,12 @@ a {
align-items: flex-start; align-items: flex-start;
} }
.change-action-row > svg { .change-action-row>svg {
flex: 0 0 auto; flex: 0 0 auto;
margin-top: 1px; margin-top: 1px;
color: var(--accent); color: var(--accent);
} }
.dialog-skeleton {
display: grid;
gap: 12px;
}
.topup-carryover-note { .topup-carryover-note {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -921,12 +799,10 @@ a {
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: background:
linear-gradient( linear-gradient(90deg,
90deg,
rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.14) 42%, rgba(255, 255, 255, 0.14) 42%,
rgba(255, 255, 255, 0.07) 84% rgba(255, 255, 255, 0.07) 84%);
);
background-size: 220% 100%; background-size: 220% 100%;
animation: skeleton-shimmer 1.15s ease-in-out infinite; animation: skeleton-shimmer 1.15s ease-in-out infinite;
} }
@@ -978,6 +854,7 @@ a {
0% { 0% {
background-position: 120% 0; background-position: 120% 0;
} }
100% { 100% {
background-position: -120% 0; background-position: -120% 0;
} }
@@ -1153,11 +1030,6 @@ a {
min-width: 0; min-width: 0;
} }
.input.input-error {
border-color: var(--danger);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--danger) 70%, transparent);
}
.field-error-icon { .field-error-icon {
color: var(--danger); color: var(--danger);
pointer-events: none; pointer-events: none;
@@ -1205,6 +1077,7 @@ a {
opacity: 0; opacity: 0;
transform: translateY(4px) scale(0.98); transform: translateY(4px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
@@ -1216,6 +1089,7 @@ a {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
to { to {
opacity: 0; opacity: 0;
transform: translateY(2px) scale(0.98); transform: translateY(2px) scale(0.98);
@@ -1254,7 +1128,7 @@ a {
gap: 14px; gap: 14px;
} }
.bonus-card-head > svg { .bonus-card-head>svg {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--accent); color: var(--accent);
} }
@@ -1370,12 +1244,12 @@ a {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.settings-row > svg:first-child { .settings-row>svg:first-child {
color: var(--text); color: var(--text);
opacity: 0.9; opacity: 0.9;
} }
.settings-row > svg:last-child { .settings-row>svg:last-child {
color: var(--muted); color: var(--muted);
} }
@@ -1402,7 +1276,7 @@ a {
background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03)); background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03));
} }
.settings-row-linked > svg:first-child { .settings-row-linked>svg:first-child {
color: var(--accent); color: var(--accent);
} }
@@ -1447,7 +1321,7 @@ a {
border-radius: 6px; border-radius: 6px;
} }
.language-select-trigger > svg { .language-select-trigger>svg {
color: var(--muted); color: var(--muted);
} }
@@ -1531,11 +1405,11 @@ a {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.language-select-item-main > span { .language-select-item-main>span {
display: inline-block !important; display: inline-block !important;
} }
.language-select-item-main > span:last-child { .language-select-item-main>span:last-child {
margin-left: 6px; margin-left: 6px;
white-space: nowrap !important; white-space: nowrap !important;
overflow: hidden; overflow: hidden;
@@ -1591,9 +1465,11 @@ a {
0% { 0% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75); box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
} }
70% { 70% {
box-shadow: 0 0 0 8px rgba(255, 75, 75, 0); box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
} }
100% { 100% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0); box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
} }
@@ -1924,6 +1800,7 @@ a {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -1935,6 +1812,7 @@ a {
opacity: 0; opacity: 0;
transform: translateY(-6px) scale(0.97); transform: translateY(-6px) scale(0.97);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
@@ -1946,6 +1824,7 @@ a {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -2023,19 +1902,6 @@ a {
padding: 14px; padding: 14px;
} }
.preview-phone .btn {
min-height: 38px;
font-size: 12px;
padding: 8px 10px;
}
.preview-phone .btn-square {
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
}
.preview-phone .period-card { .preview-phone .period-card {
min-height: 72px; min-height: 72px;
} }
@@ -2141,6 +2007,7 @@ a {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.content, .content,
.home-layout, .home-layout,
.language-select-content, .language-select-content,
@@ -2166,11 +2033,6 @@ a {
Mobile ( 1023px) is intentionally untouched. Mobile ( 1023px) is intentionally untouched.
============================================================ */ ============================================================ */
:root {
--desktop-rail-width: 252px;
--desktop-page-gutter: clamp(28px, 4vw, 72px);
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
body { body {
background: #02070b; background: #02070b;
@@ -2195,18 +2057,15 @@ a {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
padding: padding:
max(28px, env(safe-area-inset-top)) max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
var(--desktop-page-gutter)
40px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
overflow-x: visible; overflow-x: visible;
} }
/* Centre and cap the width of the actual content blocks. */ /* Centre and cap the width of the actual content blocks. */
.phone-screen > .app-header, .phone-screen>.app-header,
.phone-screen > main, .phone-screen>main,
.phone-screen > .home-layout, .phone-screen>.home-layout,
.phone-screen > nav.bottom-nav ~ * { .phone-screen>nav.bottom-nav~* {
max-width: 1080px; max-width: 1080px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@@ -2217,13 +2076,11 @@ a {
width: min(100%, 460px); width: min(100%, 460px);
min-height: 100dvh; min-height: 100dvh;
padding: padding:
max(48px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
clamp(24px, 4vw, 48px)
48px;
margin: 0 auto; margin: 0 auto;
} }
.phone-screen.auth-screen ~ .bottom-nav, .phone-screen.auth-screen~.bottom-nav,
.auth-screen .bottom-nav { .auth-screen .bottom-nav {
display: none !important; display: none !important;
} }
@@ -2234,10 +2091,7 @@ a {
align-items: stretch; align-items: stretch;
min-height: 100dvh; min-height: 100dvh;
padding: padding:
max(32px, env(safe-area-inset-top)) max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
var(--desktop-page-gutter)
42px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
} }
/* Home: keep the phone-style vertical flow, but center it in the /* Home: keep the phone-style vertical flow, but center it in the
@@ -2255,7 +2109,7 @@ a {
align-items: stretch; align-items: stretch;
} }
.home-layout > .home-brand { .home-layout>.home-brand {
grid-column: auto; grid-column: auto;
align-self: center; align-self: center;
justify-items: center; justify-items: center;
@@ -2342,12 +2196,12 @@ a {
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
} }
.bottom-nav button > svg { .bottom-nav button>svg {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
.bottom-nav button > span { .bottom-nav button>span {
text-align: left !important; text-align: left !important;
font-size: 13px !important; font-size: 13px !important;
font-weight: 600; font-weight: 600;
@@ -2372,11 +2226,12 @@ a {
.bottom-nav .rail-admin-entry { .bottom-nav .rail-admin-entry {
display: grid !important; display: grid !important;
} }
.settings-admin-block { .settings-admin-block {
display: none !important; display: none !important;
} }
.phone-screen > main.content.with-nav { .phone-screen>main.content.with-nav {
padding-top: 0; padding-top: 0;
} }
@@ -2431,6 +2286,7 @@ a {
} }
/* Brand block sits at the top of the desktop side rail; hidden on mobile. */ /* Brand block sits at the top of the desktop side rail; hidden on mobile. */
.rail-brand { .rail-brand {
display: none; display: none;
} }
@@ -23,21 +23,21 @@
<BrandMark logoUrl={logoUrl} emoji={logoEmoji} /> <BrandMark logoUrl={logoUrl} emoji={logoEmoji} />
<strong>{brandTitle}</strong> <strong>{brandTitle}</strong>
</div> </div>
<button class:active={activeTab === "home"} type="button" on:click={onHome}> <button class:active={activeTab === "home"} type="button" onclick={onHome}>
<Home size={21} /> <Home size={21} />
<span>{t("wa_nav_home")}</span> <span>{t("wa_nav_home")}</span>
</button> </button>
<button class:active={activeTab === "invite"} type="button" on:click={onInvite}> <button class:active={activeTab === "invite"} type="button" onclick={onInvite}>
<Gift size={21} /> <Gift size={21} />
<span>{t("wa_nav_bonuses")}</span> <span>{t("wa_nav_bonuses")}</span>
</button> </button>
{#if devicesEnabled} {#if devicesEnabled}
<button class:active={activeTab === "devices"} type="button" on:click={onDevices}> <button class:active={activeTab === "devices"} type="button" onclick={onDevices}>
<Smartphone size={21} /> <Smartphone size={21} />
<span>{t("wa_nav_devices")}</span> <span>{t("wa_nav_devices")}</span>
</button> </button>
{/if} {/if}
<button class:active={activeTab === "settings"} class="attention-wrap" type="button" on:click={onSettings}> <button class:active={activeTab === "settings"} class="attention-wrap" type="button" onclick={onSettings}>
{#if hasUnlinkedIdentity} {#if hasUnlinkedIdentity}
<span class="attention-dot nav-attention-dot" aria-hidden="true"></span> <span class="attention-dot nav-attention-dot" aria-hidden="true"></span>
{/if} {/if}
@@ -45,7 +45,7 @@
<span>{t("wa_nav_settings")}</span> <span>{t("wa_nav_settings")}</span>
</button> </button>
{#if isAdmin} {#if isAdmin}
<button class="rail-admin-entry" type="button" on:click={onAdmin}> <button class="rail-admin-entry" type="button" onclick={onAdmin}>
<Shield size={21} /> <Shield size={21} />
<span>{t("admin_nav_title", {}, "Админ-панель")}</span> <span>{t("admin_nav_title", {}, "Админ-панель")}</span>
</button> </button>
@@ -6,6 +6,16 @@
import Card from "../lib/components/ui/card.svelte"; import Card from "../lib/components/ui/card.svelte";
import Dialog from "../lib/components/ui/dialog.svelte"; import Dialog from "../lib/components/ui/dialog.svelte";
import Input from "../lib/components/ui/input.svelte"; import Input from "../lib/components/ui/input.svelte";
import {
planKey as planKeyFn,
planDisplayTitle as planDisplayTitleFn,
planSubtitle as planSubtitleFn,
planUnitHint as planUnitHintFn,
tariffLimitLabel as tariffLimitLabelFn,
priceLabel as priceLabelFn,
} from "../lib/webapp/tariffs.js";
import { Bitcoin, CreditCard } from "lucide-svelte";
export let createPayment = () => {}; export let createPayment = () => {};
export let deviceConfirmOpen = false; export let deviceConfirmOpen = false;
@@ -21,6 +31,7 @@
export let linkEmailResendCooldown = 0; export let linkEmailResendCooldown = 0;
export let linkEmailStatus = ""; export let linkEmailStatus = "";
export let linkEmailValue = ""; export let linkEmailValue = "";
export let hasMultipleTariffs = false;
export let methods = []; export let methods = [];
export let payBusy = false; export let payBusy = false;
export let paymentModalOpen = false; export let paymentModalOpen = false;
@@ -35,21 +46,58 @@
export let subscription = {}; export let subscription = {};
export let tariffCatalog = []; export let tariffCatalog = [];
export let tariffMode = false; 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 }); }
function planSubtitle(plan) { return planSubtitleFn(plan, { t, termUnitLabel }); }
function planUnitHint(plan) { return planUnitHintFn(plan, { trafficMode, selectedMethod, t }); }
function tariffLimitLabel(tariff) { return tariffLimitLabelFn(tariff, { t }); }
function paymentTitle() {
if (singleTariffMode) {
return selectedTariff?.billing_model === "traffic" ? t("wa_traffic_packages_title") : t("wa_subscription_title");
}
if (tariffMode) return t("wa_tariffs_title");
return trafficMode ? t("wa_traffic_packages_title") : t("wa_subscription_title");
}
function paymentDescription() {
if (tariffMode) {
if (singleTariffMode) {
return selectedTariff?.billing_model === "traffic" ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
}
return paymentStep === "checkout" && selectedTariff
? t("wa_tariff_choose_period_payment", { tariff: selectedTariff.title })
: t("wa_tariffs_choose");
}
return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
}
export let closeDeviceDisconnectDialog = () => {}; export let closeDeviceDisconnectDialog = () => {};
export let closeLinkEmailDialog = () => {}; export let closeLinkEmailDialog = () => {};
export let closePaymentModal = () => {}; export let closePaymentModal = () => {};
export let backToTariffList = () => {};
export let continueWithSelectedTariff = () => {}; export let continueWithSelectedTariff = () => {};
export let methodMeta = () => ({});
export let paymentDescription = () => "";
export let paymentTitle = () => "";
export let planKey = () => "";
export let planSubtitle = () => "";
export let planUnitHint = () => "";
export let priceLabel = () => "";
export let requestLinkEmailCode = () => {}; export let requestLinkEmailCode = () => {};
export let selectTariff = () => {}; export let selectTariff = () => {};
export let t = (key) => key; export let t = (key) => key;
export let termUnitLabel = () => "";
export let verifyLinkEmailCode = () => {}; export let verifyLinkEmailCode = () => {};
</script> </script>
@@ -70,7 +118,7 @@
class:active={selectedTariffKey === tariff.key} class:active={selectedTariffKey === tariff.key}
class="option-row tariff-row" class="option-row tariff-row"
type="button" type="button"
on:click={() => selectTariff(tariff)} onclick={() => selectTariff(tariff)}
> >
<span class="option-row-main"> <span class="option-row-main">
<strong>{tariff.title}</strong> <strong>{tariff.title}</strong>
@@ -97,7 +145,7 @@
{:else} {:else}
{#if tariffMode} {#if tariffMode}
{#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))} {#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))}
<button class="back-inline" type="button" on:click={backToTariffList}> <button class="back-inline" type="button" onclick={backToTariffList}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
{t("wa_back_to_tariffs")} {t("wa_back_to_tariffs")}
</button> </button>
@@ -113,7 +161,7 @@
class:active={planKey(selectedPlan) === planKey(plan)} class:active={planKey(selectedPlan) === planKey(plan)}
class="period-card" class="period-card"
type="button" type="button"
on:click={() => (selectedPlan = plan)} onclick={() => (selectedPlan = plan)}
> >
<strong>{planSubtitle(plan) || planDisplayTitle(plan)}</strong> <strong>{planSubtitle(plan) || planDisplayTitle(plan)}</strong>
<span>{priceLabel(plan)}</span> <span>{priceLabel(plan)}</span>
@@ -135,7 +183,7 @@
class:active={selectedMethod === method.id} class:active={selectedMethod === method.id}
class="method-card" class="method-card"
type="button" type="button"
on:click={() => (selectedMethod = method.id)} onclick={() => (selectedMethod = method.id)}
> >
<span class="method-card-main"> <span class="method-card-main">
{#if meta.icon} {#if meta.icon}
@@ -164,7 +212,7 @@
class:active={planKey(selectedPlan) === planKey(plan)} class:active={planKey(selectedPlan) === planKey(plan)}
class="period-card" class="period-card"
type="button" type="button"
on:click={() => (selectedPlan = plan)} onclick={() => (selectedPlan = plan)}
> >
<strong>{planDisplayTitle(plan)}</strong> <strong>{planDisplayTitle(plan)}</strong>
{#if planSubtitle(plan)} {#if planSubtitle(plan)}
@@ -189,7 +237,7 @@
class:active={selectedMethod === method.id} class:active={selectedMethod === method.id}
class="method-card" class="method-card"
type="button" type="button"
on:click={() => (selectedMethod = method.id)} onclick={() => (selectedMethod = method.id)}
> >
<span class="method-card-main"> <span class="method-card-main">
{#if meta.icon} {#if meta.icon}
@@ -291,7 +339,7 @@
<button <button
class="link-button link-email-resend" class="link-button link-email-resend"
type="button" type="button"
on:click={requestLinkEmailCode} onclick={requestLinkEmailCode}
disabled={linkEmailBusy || linkEmailResendCooldown > 0} disabled={linkEmailBusy || linkEmailResendCooldown > 0}
> >
<RefreshCw size={15} /> <RefreshCw size={15} />
@@ -2,12 +2,20 @@
import { ArrowRight, CheckCircle2, LockKeyhole } from "lucide-svelte"; import { ArrowRight, CheckCircle2, LockKeyhole } from "lucide-svelte";
import Button from "../lib/components/ui/button.svelte"; import Button from "../lib/components/ui/button.svelte";
import {
planKey as planKeyFn,
planUnitHint as planUnitHintFn,
priceLabel as priceLabelFn,
actionKey as actionKeyFn,
} 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 "../lib/components/ui/card.svelte"; import Card from "../lib/components/ui/card.svelte";
import Dialog from "../lib/components/ui/dialog.svelte"; import Dialog from "../lib/components/ui/dialog.svelte";
export let actionKey = () => "";
export let applyTariffChange = () => {}; export let applyTariffChange = () => {};
export let changeActionTitle = () => "";
export let changeConfirmOpen = false; export let changeConfirmOpen = false;
export let changeModalOpen = false; export let changeModalOpen = false;
export let changeOptions = null; export let changeOptions = null;
@@ -17,16 +25,11 @@
export let closeTopupModal = () => {}; export let closeTopupModal = () => {};
export let createDeviceTopupPayment = () => {}; export let createDeviceTopupPayment = () => {};
export let createTopupPayment = () => {}; export let createTopupPayment = () => {};
export let deviceTopupModalDescription = () => "";
export let deviceTopupModalOpen = false; export let deviceTopupModalOpen = false;
export let deviceTopupOptions = null; export let deviceTopupOptions = null;
export let methods = []; export let methods = [];
export let methodMeta = () => ({});
export let openTariffChangeConfirm = () => {}; export let openTariffChangeConfirm = () => {};
export let payBusy = false; export let payBusy = false;
export let planKey = () => "";
export let planUnitHint = () => "";
export let priceLabel = () => "";
export let selectedChangeAction = null; export let selectedChangeAction = null;
export let selectedChangeTarget = null; export let selectedChangeTarget = null;
export let selectedDeviceTopupPlan = null; export let selectedDeviceTopupPlan = null;
@@ -34,14 +37,110 @@
export let selectedTopupPlan = null; export let selectedTopupPlan = null;
export let singleTariffMode = false; export let singleTariffMode = false;
export let tariffActionBusy = false; export let tariffActionBusy = false;
export let tariffChangeModalDescription = () => "";
export let tariffChangeSummary = () => [];
export let topupCarryoverNotes = () => [];
export let topupModalDescription = () => "";
export let topupModalOpen = false; export let topupModalOpen = false;
export let topupModalTitle = () => "";
export let topupOptions = null; export let topupOptions = null;
export let topupKind = "regular";
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 }); }
function actionKey(action) { return actionKeyFn(action); }
function changeActionTitle(action) {
const mode = String(action?.mode || "");
if (mode === "recalc_days") {
return t("wa_tariff_change_recalc_days", { days: Number(action?.days_after || 0) });
}
if (mode === "convert_days_to_gb") {
return t("wa_tariff_change_convert_gb", { gb: formatCompactNumber(action?.converted_gb || 0) });
}
if (mode === "paid_diff") {
return t("wa_tariff_change_pay_diff", { price: priceLabel(action) });
}
if (mode === "buy_package") {
return t("wa_tariff_change_buy_package", { gb: formatCompactNumber(action?.traffic_gb || 0), price: priceLabel(action) });
}
if (mode === "buy_period") {
return `${action?.title || ""} · ${priceLabel(action)}`;
}
return action?.title || mode;
}
function tariffChangeSummary() {
if (!selectedChangeTarget || !selectedChangeAction) return [];
const rows = [
t("wa_tariff_change_confirm_target", { tariff: selectedChangeTarget.title }),
t("wa_tariff_change_confirm_action", { action: changeActionTitle(selectedChangeAction) }),
];
const mode = String(selectedChangeAction.mode || "");
if (mode === "recalc_days") {
rows.push(t("wa_tariff_change_confirm_recalc", { days: Number(selectedChangeAction.days_after || 0) }));
} else if (mode === "convert_days_to_gb") {
rows.push(t("wa_tariff_change_confirm_convert", { gb: formatCompactNumber(selectedChangeAction.converted_gb || 0) }));
} else if (selectedChangeAction.kind === "payment") {
rows.push(t("wa_tariff_change_confirm_payment", { price: priceLabel(selectedChangeAction) }));
}
return rows;
}
function topupCarryoverNotes() {
const plans = topupOptions?.plans || [];
if (!plans.length) return [];
return [
t(
"wa_topup_carryover",
{},
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток."
),
];
}
function deviceTopupModalDescription() {
if (!deviceTopupOptions) return "";
return deviceTopupOptions?.tariff_name ? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name }) : "";
}
function tariffChangeModalDescription() {
if (!changeOptions) return "";
return changeOptions?.current ? t("wa_current_tariff", { tariff: changeOptions.current.title }) : "";
}
function isPremiumTopupContext() {
if (selectedTopupPlan?.sale_mode === "premium_topup") return true;
if (topupOptions?.topup_kind) return topupOptions.topup_kind === "premium";
return topupKind === "premium";
}
function topupModalDescription() {
if (!topupOptions) return "";
if (isPremiumTopupContext()) return topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : "";
if (singleTariffMode) return "";
return topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : "";
}
function topupModalTitle() {
if (isPremiumTopupContext()) return premiumTitleFn({ ...subscription, ...(topupOptions || {}) }, t);
return t("wa_topup_traffic");
}
export let t = (key) => key; export let t = (key) => key;
export let termUnitLabel = () => "";
</script> </script>
<Dialog <Dialog
@@ -87,7 +186,7 @@
class:active={selectedChangeTarget?.tariff_key === target.tariff_key} class:active={selectedChangeTarget?.tariff_key === target.tariff_key}
class="tariff-action-card" class="tariff-action-card"
type="button" type="button"
on:click={() => { onclick={() => {
selectedChangeTarget = target; selectedChangeTarget = target;
selectedChangeAction = target.actions?.[0] || null; selectedChangeAction = target.actions?.[0] || null;
}} }}
@@ -109,7 +208,7 @@
class:active={actionKey(selectedChangeAction) === actionKey(action)} class:active={actionKey(selectedChangeAction) === actionKey(action)}
class="option-row change-action-row" class="option-row change-action-row"
type="button" type="button"
on:click={() => (selectedChangeAction = action)} onclick={() => (selectedChangeAction = action)}
> >
<span class="option-row-main"> <span class="option-row-main">
<strong>{changeActionTitle(action)}</strong> <strong>{changeActionTitle(action)}</strong>
@@ -135,7 +234,7 @@
class:active={selectedMethod === method.id} class:active={selectedMethod === method.id}
class="method-card" class="method-card"
type="button" type="button"
on:click={() => (selectedMethod = method.id)} onclick={() => (selectedMethod = method.id)}
> >
<span class="method-card-main"> <span class="method-card-main">
{#if meta.icon} {#if meta.icon}
@@ -209,6 +308,10 @@
</div> </div>
{/each} {/each}
</div> </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"> <div class="method-grid">
{#each [1, 2] as _} {#each [1, 2] as _}
<div class="method-card skeleton-method"> <div class="method-card skeleton-method">
@@ -226,7 +329,7 @@
class:active={planKey(selectedTopupPlan) === planKey(plan)} class:active={planKey(selectedTopupPlan) === planKey(plan)}
class="option-row plan-row" class="option-row plan-row"
type="button" type="button"
on:click={() => (selectedTopupPlan = plan)} onclick={() => (selectedTopupPlan = plan)}
> >
<span class="option-row-main"> <span class="option-row-main">
<strong>{plan.title}</strong> <strong>{plan.title}</strong>
@@ -258,7 +361,7 @@
class:active={selectedMethod === method.id} class:active={selectedMethod === method.id}
class="method-card" class="method-card"
type="button" type="button"
on:click={() => (selectedMethod = method.id)} onclick={() => (selectedMethod = method.id)}
> >
<span class="method-card-main"> <span class="method-card-main">
{#if meta.icon} {#if meta.icon}
@@ -320,7 +423,7 @@
class:active={planKey(selectedDeviceTopupPlan) === planKey(plan)} class:active={planKey(selectedDeviceTopupPlan) === planKey(plan)}
class="option-row plan-row" class="option-row plan-row"
type="button" type="button"
on:click={() => (selectedDeviceTopupPlan = plan)} onclick={() => (selectedDeviceTopupPlan = plan)}
> >
<span class="option-row-main"> <span class="option-row-main">
<strong>{t("wa_hwid_devices_package", { count: Number(plan.device_count || plan.months || 0) })}</strong> <strong>{t("wa_hwid_devices_package", { count: Number(plan.device_count || plan.months || 0) })}</strong>
@@ -342,7 +445,7 @@
class:active={selectedMethod === method.id} class:active={selectedMethod === method.id}
class="method-card" class="method-card"
type="button" type="button"
on:click={() => (selectedMethod = method.id)} onclick={() => (selectedMethod = method.id)}
> >
<span class="method-card-main"> <span class="method-card-main">
{#if meta.icon} {#if meta.icon}
@@ -73,7 +73,7 @@
<button <button
class="link-button" class="link-button"
type="button" type="button"
on:click={requestEmailCode} onclick={requestEmailCode}
disabled={authBusy || authResendCooldown > 0} disabled={authBusy || authResendCooldown > 0}
> >
<RefreshCw size={15} /> <RefreshCw size={15} />
@@ -159,7 +159,7 @@
href={privacyPolicyUrl} href={privacyPolicyUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
on:click|preventDefault={() => openExternalLink(privacyPolicyUrl)} onclick={(e) => { e.preventDefault(); openExternalLink(privacyPolicyUrl); }}
> >
{t("wa_auth_legal_privacy")} {t("wa_auth_legal_privacy")}
</a> </a>
@@ -172,7 +172,7 @@
href={userAgreementUrl} href={userAgreementUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
on:click|preventDefault={() => openExternalLink(userAgreementUrl)} onclick={(e) => { e.preventDefault(); openExternalLink(userAgreementUrl); }}
> >
{t("wa_auth_legal_agreement")} {t("wa_auth_legal_agreement")}
</a> </a>
@@ -11,13 +11,28 @@
export let devicesStatus = ""; export let devicesStatus = "";
export let subscription = {}; export let subscription = {};
export let devicesCountLabel = () => "";
export let devicesLimitLabel = () => "";
export let devicesPercent = () => 0;
export let loadDevices = () => {}; export let loadDevices = () => {};
export let openDeviceDisconnectDialog = () => {}; export let openDeviceDisconnectDialog = () => {};
export let openDeviceTopupModal = () => {}; export let openDeviceTopupModal = () => {};
export let t = (key) => key; export let t = (key) => key;
function devicesLimitLabel(value = devicesData?.max_devices) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
function devicesCountLabel() {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel() });
}
function devicesPercent() {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
const max = Number(devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
</script> </script>
<main class="content with-nav"> <main class="content with-nav">
@@ -4,6 +4,18 @@
import BrandMark from "../../BrandMark.svelte"; import BrandMark from "../../BrandMark.svelte";
import Button from "../../lib/components/ui/button.svelte"; import Button from "../../lib/components/ui/button.svelte";
import Card from "../../lib/components/ui/card.svelte"; import Card from "../../lib/components/ui/card.svelte";
import { formatTrafficGb } from "../../lib/webapp/formatters.js";
import {
trafficPercent as trafficPercentFn,
trafficLabel as trafficLabelFn,
trafficResetLabel as trafficResetLabelFn,
premiumTrafficPercent as premiumTrafficPercentFn,
premiumTrafficLabel as premiumTrafficLabelFn,
premiumTitle as premiumTitleFn,
premiumServerLabels as premiumServerLabelsFn,
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
} from "../../lib/webapp/traffic.js";
export let CFG = {}; export let CFG = {};
export let appSettings = {}; export let appSettings = {};
@@ -19,23 +31,30 @@
export let subscription = {}; export let subscription = {};
export let trafficMode = false; export let trafficMode = false;
export let trialBusy = false; export let trialBusy = false;
export let termUnitLabel = () => ""; // We need this passed from App or context. Actually, App.svelte doesn't pass it yet. We'll pass it.
function trafficPercent(sub) { return trafficPercentFn(sub); }
function trafficLabel(sub) { return trafficLabelFn(sub, t); }
function trafficResetLabel(sub) { return trafficResetLabelFn(sub, t); }
function premiumTrafficPercent(sub) { return premiumTrafficPercentFn(sub); }
function premiumTrafficLabel(sub) { return premiumTrafficLabelFn(sub, t); }
function premiumTitle(sub = subscription) { return premiumTitleFn(sub, t); }
function premiumServerLabels(sub) { return premiumServerLabelsFn(sub); }
function activeSubscriptionTermLabel(sub) { return activeSubscriptionTermLabelFn(sub, { t, termUnitLabel }); }
function trialTrafficLabel() {
const limit = Number(appSettings?.trial_traffic_limit_gb || 0);
return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic");
}
export let activeSubscriptionTermLabel = () => "";
export let activateTrial = () => {}; export let activateTrial = () => {};
export let openConnectLink = () => {}; export let openConnectLink = () => {};
export let openPaymentModal = () => {}; export let openPaymentModal = () => {};
export let openRegularTopupModal = () => {};
export let openPremiumTopupModal = () => {};
export let openTariffChangeModal = () => {}; export let openTariffChangeModal = () => {};
export let openTopupModal = () => {};
export let premiumServerLabels = () => [];
export let premiumTitle = () => "";
export let premiumTrafficLabel = () => "";
export let premiumTrafficPercent = () => 0;
export let primaryPayActionLabel = () => ""; export let primaryPayActionLabel = () => "";
export let t = (key) => key; export let t = (key) => key;
export let trafficLabel = () => "";
export let trafficPercent = () => 0;
export let trafficResetLabel = () => "";
export let trialTrafficLabel = () => "";
</script> </script>
<main class="home-layout"> <main class="home-layout">
@@ -68,7 +87,7 @@
{#if subscription.active} {#if subscription.active}
<Card class={canOpenRegularTopupModal ? "traffic-card-clickable" : ""}> <Card class={canOpenRegularTopupModal ? "traffic-card-clickable" : ""}>
{#if canOpenRegularTopupModal} {#if canOpenRegularTopupModal}
<button class="card-click-target" type="button" on:click={() => openTopupModal("regular")} aria-label={t("wa_topup_traffic")}></button> <button class="card-click-target" type="button" onclick={openRegularTopupModal} aria-label={t("wa_topup_traffic")}></button>
{/if} {/if}
<div class="traffic-top"> <div class="traffic-top">
<span>{t("wa_home_traffic_used")}</span> <span>{t("wa_home_traffic_used")}</span>
@@ -85,7 +104,7 @@
{#if Number(subscription?.premium_limit_bytes || 0) > 0} {#if Number(subscription?.premium_limit_bytes || 0) > 0}
<Card class={`${canOpenPremiumTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}> <Card class={`${canOpenPremiumTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
{#if canOpenPremiumTopupModal} {#if canOpenPremiumTopupModal}
<button class="card-click-target" type="button" on:click={() => openTopupModal("premium")} aria-label={premiumTitle(subscription)}></button> <button class="card-click-target" type="button" onclick={openPremiumTopupModal} aria-label={premiumTitle(subscription)}></button>
{/if} {/if}
<div class="traffic-top"> <div class="traffic-top">
<span>{premiumTitle(subscription)}</span> <span>{premiumTitle(subscription)}</span>
@@ -157,7 +176,7 @@
</Button> </Button>
{/if} {/if}
{#if canShowTopupButton} {#if canShowTopupButton}
<Button class="wide" variant="secondary" onclick={() => openTopupModal(canOpenRegularTopupModal ? "regular" : "premium")}> <Button class="wide" variant="secondary" onclick={canOpenRegularTopupModal ? openRegularTopupModal : openPremiumTopupModal}>
<Database size={18} /> <Database size={18} />
{t("wa_topup_traffic")} {t("wa_topup_traffic")}
</Button> </Button>
@@ -54,11 +54,11 @@
{#if isAdmin} {#if isAdmin}
<div class="settings-admin-block"> <div class="settings-admin-block">
<div class="settings-divider" aria-hidden="true"></div> <div class="settings-divider" aria-hidden="true"></div>
<button class="settings-row settings-row-admin" type="button" on:click={openAdminPanel}> <button class="settings-row settings-row-admin" type="button" onclick={openAdminPanel}>
<Shield size={21} /> <Shield size={21} />
<span> <span>
<strong>Админ-панель</strong> <strong>{t("wa_settings_admin_panel", {}, "Админ-панель")}</strong>
<small>Управление приложением</small> <small>{t("wa_settings_admin_panel_hint", {}, "Управление приложением")}</small>
</span> </span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
@@ -95,7 +95,7 @@
</span> </span>
</div> </div>
{:else} {:else}
<button class="settings-row attention-wrap" type="button" on:click={openLinkEmailDialog} disabled={linkEmailBusy}> <button class="settings-row attention-wrap" type="button" onclick={openLinkEmailDialog} disabled={linkEmailBusy}>
<span class="attention-dot" aria-hidden="true"></span> <span class="attention-dot" aria-hidden="true"></span>
<Mail size={21} /> <Mail size={21} />
<span> <span>
@@ -113,8 +113,8 @@
class:language-select-guard--armed={languageClickGuardArmed} class:language-select-guard--armed={languageClickGuardArmed}
type="button" type="button"
aria-label={t("wa_close")} aria-label={t("wa_close")}
on:pointerdown|preventDefault|stopPropagation={() => languageClickGuardArmed && setLanguageMenuOpen(false)} onpointerdown={(e) => { e.preventDefault(); e.stopPropagation(); if (languageClickGuardArmed) setLanguageMenuOpen(false); }}
on:click|preventDefault|stopPropagation={() => languageClickGuardArmed && setLanguageMenuOpen(false)} onclick={(e) => { e.preventDefault(); e.stopPropagation(); if (languageClickGuardArmed) setLanguageMenuOpen(false); }}
></button> ></button>
{/if} {/if}
<div class="settings-list" class:settings-list--language-open={languageMenuOpen}> <div class="settings-list" class:settings-list--language-open={languageMenuOpen}>
@@ -155,27 +155,27 @@
</Select.Root> </Select.Root>
</div> </div>
{#if supportUrl} {#if supportUrl}
<button class="settings-row settings-row-support" type="button" on:click={() => openExternalLink(supportUrl)}> <button class="settings-row settings-row-support" type="button" onclick={() => openExternalLink(supportUrl)}>
<Send size={21} /> <Send size={21} />
<span><strong>{t("menu_support_button")}</strong></span> <span><strong>{t("menu_support_button")}</strong></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
{#if userAgreementUrl} {#if userAgreementUrl}
<button class="settings-row settings-row-policy" type="button" on:click={() => openExternalLink(userAgreementUrl)}> <button class="settings-row settings-row-policy" type="button" onclick={() => openExternalLink(userAgreementUrl)}>
<FileText size={21} /> <FileText size={21} />
<span><strong>{t("wa_settings_user_agreement")}</strong></span> <span><strong>{t("wa_settings_user_agreement")}</strong></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
{#if privacyPolicyUrl} {#if privacyPolicyUrl}
<button class="settings-row settings-row-policy" type="button" on:click={() => openExternalLink(privacyPolicyUrl)}> <button class="settings-row settings-row-policy" type="button" onclick={() => openExternalLink(privacyPolicyUrl)}>
<Shield size={21} /> <Shield size={21} />
<span><strong>{t("wa_settings_privacy_policy")}</strong></span> <span><strong>{t("wa_settings_privacy_policy")}</strong></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
<button class="settings-row settings-row-logout" type="button" on:click={logout}> <button class="settings-row settings-row-logout" type="button" onclick={logout}>
<UserRound size={21} /> <UserRound size={21} />
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span> <span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
<ArrowRight size={17} /> <ArrowRight size={17} />
+206 -1
View File
@@ -889,5 +889,210 @@
"admin_broadcast_target_active": "With subscription", "admin_broadcast_target_active": "With subscription",
"admin_broadcast_target_inactive": "No subscription", "admin_broadcast_target_inactive": "No subscription",
"admin_expired_at": "Expired {date}", "admin_expired_at": "Expired {date}",
"admin_expired_badge": "Expired {date}" "admin_expired_badge": "Expired {date}",
"admin_stats_error": "Failed to load statistics: {error}",
"admin_stats_label_users": "Users",
"admin_stats_trend_banned": "Banned: {count}",
"admin_stats_label_paid_subs": "Paid Subscriptions",
"admin_stats_trend_trials": "Trials: {count}",
"admin_stats_label_today_rev": "Today's Revenue",
"admin_stats_trend_payments": "{count} payments",
"admin_stats_label_week": "This Week",
"admin_stats_trend_month": "Month: {value}",
"admin_stats_label_all_time": "All Time",
"admin_stats_sync_label": "Sync",
"admin_stats_label_queue": "Queue",
"admin_stats_trend_groups": "Groups: {count}",
"admin_stats_recent_payments": "Recent Payments",
"admin_stats_records_count": "{count} records",
"admin_id": "ID",
"admin_status_default": "Default",
"admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret",
"admin_settings_badge_override": "Override",
"admin_status_limited": "Limited",
"admin_status_expired": "Expired",
"admin_status_disabled": "Disabled",
"admin_status_bot_only": "Bot Only",
"admin_user_detail_title": "User #{id}",
"admin_close": "Close",
"admin_loading": "Loading…",
"admin_badge_banned": "Banned",
"admin_badge_active": "Active",
"admin_badge_subscription": "Subscription",
"admin_badge_no_subscription": "No subscription",
"admin_user_label_paid": "Total Paid",
"admin_user_label_logs": "Logs",
"admin_user_section_profile": "Profile",
"admin_user_label_registration": "Registration",
"admin_user_label_ref_code": "Referral Code",
"admin_user_section_links": "Links",
"admin_user_copy_tooltip": "Copy",
"admin_user_sub_link_copied": "Subscription link copied",
"admin_user_ref_link_copied": "Referral link copied",
"admin_user_tab_subscription": "Subscription",
"admin_user_tab_activity": "Activity",
"admin_user_tab_actions": "Actions",
"admin_user_label_active_until": "Active until",
"admin_user_label_tariff": "Tariff",
"admin_user_label_auto_renew": "Auto-renew",
"admin_user_label_provider": "Provider",
"admin_user_label_main_traffic": "Main Traffic",
"admin_user_traffic_left": "Left: {left}",
"admin_user_label_premium_squads": "Premium Squads",
"admin_user_no_active_subscription": "No active subscription",
"admin_user_history_title": "Subscription History · {count}",
"admin_user_history_no_tariff": "No tariff",
"admin_user_history_until": "until {date}",
"admin_user_history_active": "Active",
"admin_user_history_status_panel": "History",
"admin_user_recent_payments_title": "Recent Payments · {count}",
"admin_user_no_payments": "No payments",
"admin_user_btn_reset_trial": "Reset Trial",
"admin_user_label_extend": "Extend Subscription",
"admin_user_label_extend_days": "Days",
"admin_user_btn_extend": "Extend",
"admin_user_label_telegram_msg": "Telegram Message",
"admin_user_hint_telegram_msg": "Telegram HTML formatting supported",
"admin_user_placeholder_msg": "Message text",
"admin_user_btn_preview_tg": "Preview in Telegram",
"admin_user_btn_send_msg": "Send Message",
"admin_user_danger_zone_title": "Danger Zone",
"admin_user_danger_zone_subtitle": "These actions require confirmation and (for deletion) are irreversible",
"admin_user_btn_unban": "Unban user",
"admin_user_btn_ban": "Ban user",
"admin_user_btn_delete_account": "Delete account",
"admin_user_msg_confirm_title": "Send message to user?",
"admin_user_msg_confirm_recipient": "Recipient: {name}",
"admin_user_btn_cancel": "Cancel",
"admin_user_btn_confirm_send": "Confirm Send",
"admin_user_ban_confirm_title": "Ban user?",
"admin_user_ban_confirm_subtitle": "{name} will no longer be able to interact with the bot. This can be undone later.",
"admin_user_delete_confirm_title": "Delete user?",
"admin_user_delete_confirm_subtitle": "This action is irreversible. All payments, subscriptions, and logs will be deleted.",
"admin_user_btn_confirm_delete": "Confirm Delete",
"admin_btn_add_premium_squad": "Add premium squad",
"admin_tariff_col_volume_gb": "Volume, GB",
"admin_tariff_col_price_rub": "Price, ₽",
"admin_tariff_col_price_stars": "Price, ⭐",
"admin_tariff_col_price_stars_full": "Price, ⭐ Stars",
"admin_tariff_col_period_months": "Period, mo.",
"admin_tariff_col_hwid_count": "+ devices",
"admin_tariff_label_hwid_count_full": "How many devices does this package add",
"admin_tariff_label_price_rub": "Price in Rubles",
"admin_tariff_label_price_stars": "Price in Telegram Stars",
"admin_status_active": "Active",
"admin_aria_label_main_traffic": "Main traffic usage",
"admin_aria_label_premium_traffic": "Premium traffic usage",
"admin_btn_delete": "Delete",
"admin_promos_empty": "No promo codes found",
"admin_btn_create": "Create",
"admin_ads_empty": "No campaigns found",
"admin_btn_campaign": "Campaign",
"admin_btn_tariff": "Tariff",
"admin_btn_sync": "Synchronize",
"admin_btn_syncing": "Synchronizing...",
"admin_btn_save": "Save",
"admin_btn_saving": "Saving...",
"admin_btn_enable": "On",
"admin_btn_disable": "Off",
"admin_btn_show_more": "Show more",
"admin_settings_dirty_count": "Changes: {count}",
"admin_nav_overview": "Overview",
"admin_nav_dashboard": "Dashboard",
"admin_nav_operations": "Operations",
"admin_nav_users": "Users",
"admin_nav_payments": "Payments",
"admin_nav_promos": "Promos",
"admin_nav_ads": "Ads",
"admin_nav_communication": "Communication",
"admin_nav_broadcast": "Broadcast",
"admin_nav_logs": "Logs",
"admin_nav_system": "System",
"admin_nav_tariffs": "Tariffs",
"admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Shop and panel overview",
"admin_section_users_title": "Users",
"admin_section_users_subtitle": "Search, bans, and account actions",
"admin_section_payments_title": "Payments",
"admin_section_payments_subtitle": "Transaction history and export",
"admin_section_promos_title": "Promos",
"admin_section_promos_subtitle": "Create and manage promo codes",
"admin_section_ads_title": "Ad Campaigns",
"admin_section_ads_subtitle": "UTM sources and attribution",
"admin_section_broadcast_title": "Broadcast",
"admin_section_broadcast_subtitle": "Mass messaging in Telegram",
"admin_section_logs_title": "Activity Logs",
"admin_section_logs_subtitle": "User events and admin actions",
"admin_section_tariffs_title": "Tariffs",
"admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits",
"admin_section_settings_title": "App Settings",
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
"admin_panel_title": "Admin Panel",
"admin_promo_col_code": "Code",
"admin_promo_col_bonus": "Bonus",
"admin_promo_col_activations": "Activations",
"admin_promo_col_valid_until": "Valid until",
"admin_promo_col_status": "Status",
"admin_promo_create_title": "Create promo code",
"admin_promo_label_code": "Code",
"admin_promo_label_bonus_days": "Bonus (days)",
"admin_promo_label_max_activations": "Max activations",
"admin_promo_label_valid_days": "Validity (days)",
"admin_ad_create_title": "New campaign",
"admin_ad_label_source": "Source",
"admin_ad_label_param": "start parameter",
"admin_ad_hint_param": "Unique identifier for the referral link",
"admin_ad_label_cost": "Cost, RUB",
"admin_ads_col_source": "Source",
"admin_ads_col_param": "Parameter",
"admin_ads_col_cost": "Cost",
"admin_ads_col_registrations": "Registrations",
"admin_ads_col_conversions": "Conversions",
"admin_ads_col_status": "Status",
"admin_no_data": "No data",
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
"admin_collapse_all": "Collapse all",
"admin_expand_all": "Expand all",
"admin_settings_params_count": "{count} parameters",
"admin_settings_overridden_count": "{count} override",
"admin_settings_fields_count": "{count} fields",
"admin_show": "Show",
"admin_hide": "Hide",
"admin_tariffs_stat_total": "Total tariffs",
"admin_tariffs_stat_enabled": "Enabled",
"admin_tariffs_stat_default": "Default",
"admin_tariffs_stat_default_hint": "Used for new subscriptions",
"admin_tariffs_stat_disabled": "Disabled",
"admin_tariffs_stat_disabled_hint": "Hidden from showcase",
"admin_tariffs_catalog_empty": "The catalog is empty. Add your first tariff; a catalog JSON file will be created after saving.",
"admin_no_description": "No description",
"admin_tariff_model_traffic": "Traffic",
"admin_tariff_model_periods": "Periods",
"admin_tariff_devices": "Devices",
"admin_btn_configure": "Configure",
"admin_btn_set_default": "Set Default",
"admin_btn_delete_tariff": "Delete Tariff",
"admin_months_short": "mo.",
"admin_at": "for",
"admin_tariff_traffic_packages": "Traffic packages",
"admin_btn_refresh": "Refresh",
"admin_btn_create_tariff": "Create Tariff",
"admin_enabled": "Enabled",
"admin_disabled": "Disabled",
"admin_broadcast_title": "Broadcast",
"admin_broadcast_subtitle": "Delivery via message queue",
"admin_broadcast_label_audience": "Audience",
"admin_broadcast_label_text": "Message Text",
"admin_broadcast_hint_text": "Telegram HTML formatting supported",
"admin_btn_queue": "Queue Message",
"admin_btn_sending": "Sending...",
"admin_broadcast_stat_queued": "Queued",
"admin_broadcast_stat_failed": "Failed",
"admin_broadcast_started": "Broadcast started",
"admin_broadcast_failed": "Broadcast failed",
"admin_user_short": "User",
"admin_target_short": "Target"
} }
+206 -1
View File
@@ -889,5 +889,210 @@
"admin_broadcast_target_active": "С подпиской", "admin_broadcast_target_active": "С подпиской",
"admin_broadcast_target_inactive": "Без подписки", "admin_broadcast_target_inactive": "Без подписки",
"admin_expired_at": "Истекла {date}", "admin_expired_at": "Истекла {date}",
"admin_expired_badge": "Expired {date}" "admin_expired_badge": "Expired {date}",
"admin_stats_error": "Не удалось загрузить статистику: {error}",
"admin_stats_label_users": "Пользователи",
"admin_stats_trend_banned": "В бане: {count}",
"admin_stats_label_paid_subs": "Платные подписки",
"admin_stats_trend_trials": "Триалы: {count}",
"admin_stats_label_today_rev": "Доход за день",
"admin_stats_trend_payments": "{count} платежей",
"admin_stats_label_week": "За неделю",
"admin_stats_trend_month": "Месяц: {value}",
"admin_stats_label_all_time": "Всё время",
"admin_stats_sync_label": "Sync",
"admin_stats_label_queue": "Очередь",
"admin_stats_trend_groups": "Группы: {count}",
"admin_stats_recent_payments": "Последние платежи",
"admin_stats_records_count": "{count} записей",
"admin_id": "ID",
"admin_status_default": "По умолчанию",
"admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret",
"admin_settings_badge_override": "Override",
"admin_status_limited": "Ограничен",
"admin_status_expired": "Истёк",
"admin_status_disabled": "Выключен",
"admin_status_bot_only": "Только бот",
"admin_user_detail_title": "Пользователь #{id}",
"admin_close": "Закрыть",
"admin_loading": "Загрузка…",
"admin_badge_banned": "Бан",
"admin_badge_active": "Активен",
"admin_badge_subscription": "Подписка",
"admin_badge_no_subscription": "Без подписки",
"admin_user_label_paid": "Заплачено",
"admin_user_label_logs": "Логов",
"admin_user_section_profile": "Профиль",
"admin_user_label_registration": "Регистрация",
"admin_user_label_ref_code": "Реф. код",
"admin_user_section_links": "Ссылки",
"admin_user_copy_tooltip": "Скопировать",
"admin_user_sub_link_copied": "Ссылка на подписку скопирована",
"admin_user_ref_link_copied": "Реф. ссылка скопирована",
"admin_user_tab_subscription": "Подписка",
"admin_user_tab_activity": "Активность",
"admin_user_tab_actions": "Действия",
"admin_user_label_active_until": "Активна до",
"admin_user_label_tariff": "Тариф",
"admin_user_label_auto_renew": "Авто-продление",
"admin_user_label_provider": "Провайдер",
"admin_user_label_main_traffic": "Основной трафик",
"admin_user_traffic_left": "Осталось: {left}",
"admin_user_label_premium_squads": "Premium-сквады",
"admin_user_no_active_subscription": "Активной подписки нет",
"admin_user_history_title": "История подписок · {count}",
"admin_user_history_no_tariff": "Без тарифа",
"admin_user_history_until": "до {date}",
"admin_user_history_active": "Активна",
"admin_user_history_status_panel": "История",
"admin_user_recent_payments_title": "Последние платежи · {count}",
"admin_user_no_payments": "Платежей нет",
"admin_user_btn_reset_trial": "Сбросить триал",
"admin_user_label_extend": "Продлить подписку",
"admin_user_label_extend_days": "Дней",
"admin_user_btn_extend": "Продлить",
"admin_user_label_telegram_msg": "Сообщение в Telegram",
"admin_user_hint_telegram_msg": "Поддерживается HTML-разметка Telegram",
"admin_user_placeholder_msg": "Текст сообщения",
"admin_user_btn_preview_tg": "Превью в Telegram",
"admin_user_btn_send_msg": "Отправить сообщение",
"admin_user_danger_zone_title": "Опасные действия",
"admin_user_danger_zone_subtitle": "Эти действия требуют подтверждения и (для удаления) необратимы",
"admin_user_btn_unban": "Разбанить пользователя",
"admin_user_btn_ban": "Заблокировать",
"admin_user_btn_delete_account": "Удалить аккаунт",
"admin_user_msg_confirm_title": "Отправить сообщение пользователю?",
"admin_user_msg_confirm_recipient": "Получатель: {name}",
"admin_user_btn_cancel": "Отмена",
"admin_user_btn_confirm_send": "Подтвердить отправку",
"admin_user_ban_confirm_title": "Заблокировать пользователя?",
"admin_user_ban_confirm_subtitle": "{name} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.",
"admin_user_delete_confirm_title": "Удалить пользователя?",
"admin_user_delete_confirm_subtitle": "Действие необратимо. Удалятся все платежи, подписки и логи.",
"admin_user_btn_confirm_delete": "Подтвердить удаление",
"admin_btn_add_premium_squad": "Добавить premium-сквад",
"admin_tariff_col_volume_gb": "Объём, GB",
"admin_tariff_col_price_rub": "Цена, ₽",
"admin_tariff_col_price_stars": "Цена, ⭐",
"admin_tariff_col_price_stars_full": "Цена, ⭐ Stars",
"admin_tariff_col_period_months": "Срок, мес.",
"admin_tariff_col_hwid_count": "+ устройств",
"admin_tariff_label_hwid_count_full": "Сколько устройств добавляет пакет",
"admin_tariff_label_price_rub": "Цена в рублях",
"admin_tariff_label_price_stars": "Цена в Telegram Stars",
"admin_status_active": "Активен",
"admin_aria_label_main_traffic": "Использование основного трафика",
"admin_aria_label_premium_traffic": "Использование premium-трафика",
"admin_btn_delete": "Удалить",
"admin_promos_empty": "Промокодов нет",
"admin_btn_create": "Создать",
"admin_ads_empty": "Кампаний нет",
"admin_btn_campaign": "Кампания",
"admin_btn_tariff": "Тариф",
"admin_btn_sync": "Синхронизировать",
"admin_btn_syncing": "Синхронизация...",
"admin_btn_save": "Сохранить",
"admin_btn_saving": "Сохранение...",
"admin_btn_enable": "Вкл",
"admin_btn_disable": "Выкл",
"admin_btn_show_more": "Показать еще",
"admin_settings_dirty_count": "Изменений: {count}",
"admin_nav_overview": "Обзор",
"admin_nav_dashboard": "Дашборд",
"admin_nav_operations": "Управление",
"admin_nav_users": "Пользователи",
"admin_nav_payments": "Платежи",
"admin_nav_promos": "Промокоды",
"admin_nav_ads": "Реклама",
"admin_nav_communication": "Коммуникации",
"admin_nav_broadcast": "Рассылка",
"admin_nav_logs": "Логи",
"admin_nav_system": "Система",
"admin_nav_tariffs": "Тарифы",
"admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Сводка по магазину и панели",
"admin_section_users_title": "Пользователи",
"admin_section_users_subtitle": "Поиск, баны и действия над аккаунтами",
"admin_section_payments_title": "Платежи",
"admin_section_payments_subtitle": "История транзакций и экспорт",
"admin_section_promos_title": "Промокоды",
"admin_section_promos_subtitle": "Создание и управление кодами",
"admin_section_ads_title": "Рекламные кампании",
"admin_section_ads_subtitle": "UTM-источники и атрибуция",
"admin_section_broadcast_title": "Рассылка",
"admin_section_broadcast_subtitle": "Массовая отправка сообщений в Telegram",
"admin_section_logs_title": "Логи активности",
"admin_section_logs_subtitle": "События пользователей и админ-действия",
"admin_section_tariffs_title": "Тарифы",
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
"admin_section_settings_title": "Настройки приложения",
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
"admin_panel_title": "Админ-панель",
"admin_promo_col_code": "Код",
"admin_promo_col_bonus": "Бонус",
"admin_promo_col_activations": "Активаций",
"admin_promo_col_valid_until": "Действует до",
"admin_promo_col_status": "Статус",
"admin_promo_create_title": "Создать промокод",
"admin_promo_label_code": "Код",
"admin_promo_label_bonus_days": "Бонус (дней)",
"admin_promo_label_max_activations": "Макс. активаций",
"admin_promo_label_valid_days": "Срок действия (дней)",
"admin_ad_create_title": "Новая кампания",
"admin_ad_label_source": "Источник",
"admin_ad_label_param": "start-параметр",
"admin_ad_hint_param": "Unique identifier for the referral link",
"admin_ad_label_cost": "Стоимость, RUB",
"admin_ads_col_source": "Источник",
"admin_ads_col_param": "Параметр",
"admin_ads_col_cost": "Стоимость",
"admin_ads_col_registrations": "Регистрации",
"admin_ads_col_conversions": "Конверсии",
"admin_ads_col_status": "Статус",
"admin_no_data": "Нет данных",
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
"admin_collapse_all": "Свернуть всё",
"admin_expand_all": "Развернуть всё",
"admin_settings_params_count": "{count} параметров",
"admin_settings_overridden_count": "{count} override",
"admin_settings_fields_count": "{count} полей",
"admin_show": "Показать",
"admin_hide": "Скрыть",
"admin_tariffs_stat_total": "Всего тарифов",
"admin_tariffs_stat_enabled": "Включено",
"admin_tariffs_stat_default": "По умолчанию",
"admin_tariffs_stat_default_hint": "Используется для новых подписок",
"admin_tariffs_stat_disabled": "Отключено",
"admin_tariffs_stat_disabled_hint": "Скрыто с витрины",
"admin_tariffs_catalog_empty": "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.",
"admin_no_description": "Без описания",
"admin_tariff_model_traffic": "Трафик",
"admin_tariff_model_periods": "Периоды",
"admin_tariff_devices": "Устройства",
"admin_btn_configure": "Настроить",
"admin_btn_set_default": "По умолчанию",
"admin_btn_delete_tariff": "Удалить тариф",
"admin_months_short": "мес.",
"admin_at": "за",
"admin_tariff_traffic_packages": "Пакеты трафика",
"admin_btn_refresh": "Обновить",
"admin_btn_create_tariff": "Создать тариф",
"admin_enabled": "Включено",
"admin_disabled": "Выключено",
"admin_broadcast_title": "Рассылка",
"admin_broadcast_subtitle": "Доставка через очередь сообщений",
"admin_broadcast_label_audience": "Аудитория",
"admin_broadcast_label_text": "Текст сообщения",
"admin_broadcast_hint_text": "Поддерживается HTML-разметка Telegram",
"admin_btn_queue": "Поставить в очередь",
"admin_btn_sending": "Отправка...",
"admin_broadcast_stat_queued": "В очереди",
"admin_broadcast_stat_failed": "Неудач",
"admin_broadcast_started": "Рассылка запущена",
"admin_broadcast_failed": "Ошибка рассылки",
"admin_user_short": "Пользователь",
"admin_target_short": "Цель"
} }
-159
View File
@@ -1,159 +0,0 @@
import re
html_path = "bot/app/web/templates/subscription_webapp.html"
with open(html_path, 'r', encoding='utf-8') as f:
html = f.read()
replacements = {
r'class="flex items-start justify-between gap-3"': 'class="panel-head"',
r'class="text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]"': 'class="section-title"',
r'class="mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]"': 'class="section-caption"',
r'class="min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]"': 'class="metric-label"',
r'class="min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]"': 'class="metric-value"',
r'class="m-0 text-\[32px\] font-extrabold leading-\[1\.08\] text-\[var\(--text-primary\)\]"': 'class="main-value"',
r'class="mt-1\.5 mb-0 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="main-caption"',
r'class="login-text login-status hidden m-0 min-h-5 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="status-text hidden"',
r'class="login-text login-status hidden"': 'class="status-text hidden"',
r'class="actions grid min-w-0 grid-cols-\[minmax\(0,1fr\)_48px\] gap-2"': 'class="actions-row"',
r'class="hidden flex flex-wrap justify-center gap-x-4 gap-y-1\.5"': 'class="legal-links hidden"',
r'class="flex min-h-12 items-center justify-between gap-3"': 'class="app-header"',
r'class="flex min-w-0 items-center gap-2\.5"': 'class="app-header-title"',
r'class="flex min-w-0 items-center gap-2"': 'class="app-header-actions"',
r'class="flex min-w-0 items-center justify-between gap-2\.5"': 'class="panel-head-sm"',
r'class="flex min-w-0 items-center justify-center gap-3\.5"': 'class="login-head"',
}
for pattern, repl in replacements.items():
html = re.sub(pattern, repl, html)
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html)
js_path = "bot/app/web/templates/subscription_webapp.js"
with open(js_path, 'r', encoding='utf-8') as f:
js = f.read()
js_replacements = {
r"panelHead: 'flex items-start justify-between gap-3',": "panelHead: 'panel-head',",
r"flowCaption: 'mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]',": "flowCaption: 'section-caption',",
r"sectionTitle: 'text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]',": "sectionTitle: 'section-title',",
r"metricLabel: 'min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]',": "metricLabel: 'metric-label',",
r"metricValue: 'min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]',": "metricValue: 'metric-value',",
r"referralLinkRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,1fr\)_48px\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "referralLinkRow: 'referral-link-row',",
r"referralLinkValue: 'mt-1 font-\[family-name:var\(--font-mono\)\] text-xs font-bold leading-\[1\.35\] text-\[var\(--text-primary\)\]',": "referralLinkValue: 'referral-link-value',",
r"bonusRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,0\.8fr\)_minmax\(0,1\.2fr\)\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "bonusRow: 'bonus-row',",
r"empty: 'rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]',": "empty: 'empty-state',",
r"planCard: 'flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-left text-\[var\(--text-primary\)\] transition-\[transform,border-color,background,box-shadow\] hover:-translate-y-0\.5 hover:border-\[color-mix\(in_srgb,var\(--accent\)_42%,var\(--border\)\)\] hover:bg-\[var\(--bg-card-hover\)\]',": "planCard: 'plan-card',",
r"planCardActive: 'border-\[var\(--accent\)\] bg-\[color-mix\(in_srgb,var\(--accent\)_8%,transparent\)\] ring-1 ring-\[color-mix\(in_srgb,var\(--accent\)_58%,transparent\)\] shadow-\[0_12px_30px_rgba\(0,0,0,0\.2\)\]',": "planCardActive: 'plan-card-active',",
r"planName: 'block text-\[15px\] font-extrabold leading-tight',": "planName: 'plan-name',",
r"planMeta: 'mt-1 block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold leading-\[1\.3\] text-\[var\(--text-muted\)\]',": "planMeta: 'plan-meta',",
r"planPrice: 'block max-w-\[48%\] flex-none text-right font-\[family-name:var\(--font-mono\)\] text-\[15px\] font-extrabold leading-tight text-\[var\(--accent\)\]',": "planPrice: 'plan-price',",
r"notice: 'rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] p-\[13px\] text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]',": "notice: 'notice',",
r"stepNum: 'block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-extrabold leading-\[1\.1\] text-current',": "stepNum: 'step-num',",
r"stepName: 'mt-1 block text-xs font-extrabold leading-\[1\.15\] text-current'": "stepName: 'step-name'"
}
for pattern, repl in js_replacements.items():
js = re.sub(pattern, repl, js)
with open(js_path, 'w', encoding='utf-8') as f:
f.write(js)
# CSS additions
css_path = "bot/app/web/templates/subscription_webapp.tailwind.css"
with open(css_path, 'r', encoding='utf-8') as f:
css = f.read()
new_components = """
.panel-head {
@apply flex items-start justify-between gap-3;
}
.section-title {
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
}
.section-caption {
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
}
.metric-label {
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
}
.metric-value {
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
}
.referral-link-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.referral-link-value {
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
}
.bonus-row {
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
}
.empty-state {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.plan-card {
@apply flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-left text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
transition-duration: 200ms;
}
.plan-card-active {
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
}
.plan-name {
@apply block text-[15px] font-extrabold leading-tight;
}
.plan-meta {
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
}
.plan-price {
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
}
.notice {
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
}
.step-num {
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
}
.step-name {
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
}
.main-value {
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
}
.main-caption {
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.status-text {
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
}
.legal-links {
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5;
}
.actions-row {
@apply grid min-w-0 grid-cols-[minmax(0,1fr)_48px] gap-2;
}
.app-header {
@apply flex min-h-12 items-center justify-between gap-3;
}
.app-header-title {
@apply flex min-w-0 items-center gap-2.5;
}
.app-header-actions {
@apply flex min-w-0 items-center gap-2;
}
.panel-head-sm {
@apply flex min-w-0 items-center justify-between gap-2.5;
}
.login-head {
@apply flex min-w-0 items-center justify-center gap-3.5;
}
"""
css_split = css.split('.btn {')
new_css = css_split[0] + new_components + '\n .btn {' + css_split[1]
with open(css_path, 'w', encoding='utf-8') as f:
f.write(new_css)
print("Done")
-8
View File
@@ -1,8 +0,0 @@
import re
with open('bot/app/web/templates/subscription_webapp.html', 'r', encoding='utf-8') as f:
html = f.read()
long_classes = set(re.findall(r'class="([^"]{40,})"', html))
for cls in long_classes:
print(f"--- LONG CLASS ({len(cls)} chars) ---\n{cls}\n")