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>
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import { getContext, onMount } from "svelte";
export let at = (key) => key;
export let fmtDate = (value) => value;
export let fmtMoney = (value) => value;
export let loadPayments = () => {};
export let paymentStatusVariant = () => "muted";
export let payments = [];
export let paymentsHasMore = false;
export let paymentsLoading = false;
export let paymentsPage = 0;
export let paymentsTotal = 0;
const paymentsStore = getContext("paymentsStore");
$: ({
payments,
paymentsTotal,
paymentsPage,
paymentsLoading,
} = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
onMount(() => {
paymentsStore.loadPayments();
});
</script>
<div class="admin-table-wrap">
@@ -18,7 +28,7 @@
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<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>
</thead>
<tbody>
@@ -41,7 +51,7 @@
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("amount", {}, "Сумма")}</th>
<th>{at("provider", {}, "Провайдер")}</th>
@@ -72,10 +82,10 @@
<div class="admin-pagination">
<span class="admin-pagination-meta">{at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal}</span>
<div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={paymentsPage === 0} on:click={() => { 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", {}, "Назад")}
</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} />
</button>
</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>
import { ChevronRight } from "lucide-svelte";
import { Accordion } from "bits-ui";
import { ChevronRight, Eye, EyeOff, X } from "lucide-svelte";
import { Accordion, Switch } from "bits-ui";
import { getContext, onMount } from "svelte";
export let groupSectionFields = () => [];
export let isOverridden = () => false;
export let renderField = () => {};
export let sectionTitle = (id) => id;
export let settingsAllOpen = false;
export let settingsDirty = {};
export let settingsLoading = false;
export let settingsOpenSections = [];
export let settingsOpenSubsections = {};
export let settingsSections = [];
export let toggleAllSections = () => {};
export let at;
export let onSettingsSaved;
export let isCompact = false;
const settingsStore = getContext("settingsStore");
$: ({
settingsSections,
settingsLoading,
settingsDirty,
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>
{#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}
<div class="admin-empty">{settingsLoading ? "Загрузка…" : "Нет данных"}</div>
<div class="admin-empty">{settingsLoading ? at("loading", {}, "Загрузка…") : at("no_data", {}, "Нет данных")}</div>
{:else}
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<p class="admin-muted" style="margin:0;">
Изменения в админке имеют приоритет над <code>.env</code>. Кнопка «Восстановить» возвращает значение из переменных окружения.
{at("settings_hint", {}, "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.")}
</p>
<button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={toggleAllSections}>
{settingsAllOpen ? "Свернуть всё" : "Развернуть всё"}
</button>
<div style="display:flex; gap:8px;">
<button type="button" class="admin-btn admin-btn-sm admin-btn-ghost" on:click={toggleAllSections}>
{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>
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
{#each settingsSections as section}
@@ -35,7 +203,7 @@
<Accordion.Trigger class="admin-accordion-trigger">
<span class="admin-accordion-title">{sectionTitle(section.id)}</span>
<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>
<ChevronRight size={16} class="admin-accordion-chev" />
</Accordion.Trigger>
@@ -65,7 +233,7 @@
<Accordion.Trigger class="admin-settings-subsection-trigger">
<strong>{group.label}</strong>
<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>
<ChevronRight size={14} class="admin-accordion-chev" />
</Accordion.Trigger>
@@ -1,88 +1,99 @@
<script>
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 fmtMoney = (value) => value;
export let paymentStatusVariant = () => "muted";
export let stats = null;
export let statsError = "";
export let statsLoading = false;
const statsStore = getContext("statsStore");
$: ({
stats,
statsError,
statsLoading,
} = $statsStore);
onMount(() => {
statsStore.loadStats();
});
</script>
{#if statsError}
<div class="admin-empty">Не удалось загрузить статистику: {statsError}</div>
<div class="admin-empty">{at("stats_error", { error: statsError }, "Не удалось загрузить статистику: " + statsError)}</div>
{:else if statsLoading || !stats}
<div class="admin-empty">Загрузка…</div>
<div class="admin-empty">{at("loading", {}, "Загрузка…")}</div>
{:else}
<div class="admin-stat-grid">
<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-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 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-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 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-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 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-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 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-trend">Sync: {stats.panel_sync?.status ?? "—"}</span>
<span class="admin-stat-trend">{at("stats_sync_label", {}, "Sync")}: {stats.panel_sync?.status ?? "—"}</span>
</article>
{#if stats.queue}
<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-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>
{/if}
</div>
<div class="admin-table-wrap">
<header class="admin-card-head">
<h3>Последние платежи</h3>
<small>{(stats.recent_payments || []).length} записей</small>
<h3>{at("stats_recent_payments", {}, "Последние платежи")}</h3>
<small>{at("stats_records_count", { count: (stats.recent_payments || []).length }, (stats.recent_payments || []).length + " записей")}</small>
</header>
{#if (stats.recent_payments || []).length}
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Пользователь</th>
<th>Сумма</th>
<th>Провайдер</th>
<th>Статус</th>
<th>Дата</th>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("amount", {}, "Сумма")}</th>
<th>{at("provider", {}, "Провайдер")}</th>
<th>{at("status", {}, "Статус")}</th>
<th>{at("date", {}, "Дата")}</th>
</tr>
</thead>
<tbody>
{#each stats.recent_payments as p}
<tr>
<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="Сумма">{fmtMoney(p.amount, p.currency)}</td>
<td data-label="Провайдер">{p.provider}</td>
<td data-label="Статус">
<td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_id}</td>
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
<td data-label={at("status", {}, "Статус")}>
<span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span>
</td>
<td data-label="Дата">{fmtDate(p.created_at)}</td>
<td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
{: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}
</div>
{/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>
import { RefreshCw, Trash2 } from "lucide-svelte";
import { RefreshCw, Trash2, Plus } from "lucide-svelte";
import { getContext, onMount } from "svelte";
export let disabledTariffs = 0;
export let enabledTariffs = [];
export let loadTariffs = () => {};
export let openEditTariff = () => {};
export let setDefaultTariff = () => {};
export let tariffDeleteOpen = false;
export let tariffDeleteTarget = null;
export let tariffName = () => "";
export let tariffPriceSummary = () => "";
export let tariffsCatalog = { tariffs: [] };
export let tariffsLoading = false;
export let tariffsPath = "";
export let tariffsSaving = false;
export let toggleTariffEnabled = () => {};
export let at;
export let fmtMoney;
const tariffsStore = getContext("tariffsStore");
$: ({
tariffsCatalog,
tariffsLoading,
tariffsPath,
tariffsSaving,
} = $tariffsStore);
$: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
$: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length);
function tariffName(tariff) {
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
}
function tariffPriceSummary(tariff) {
if (tariff.billing_model === "traffic") {
const rub = tariff.traffic_packages?.rub || [];
const first = rub[0];
return first ? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}` : at("tariff_traffic_packages", {}, "Пакеты трафика");
}
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
return months
.map((month) => {
const rub = tariff.prices_rub?.[String(month)];
const stars = tariff.prices_stars?.[String(month)];
if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, "RUB")}`;
if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
return `${month} ${at("months_short", {}, "мес.")}`;
})
.join(" · ");
}
onMount(() => {
tariffsStore.loadTariffs();
});
</script>
{#if tariffsLoading}
<div class="admin-empty">Загрузка…</div>
<div class="admin-empty">{at("loading", {}, "Загрузка…")}</div>
{:else}
<div class="admin-stat-grid">
<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>
<span class="admin-stat-trend">Включено: {enabledTariffs.length}</span>
<span class="admin-stat-trend">{at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}</span>
</div>
<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>
<span class="admin-stat-trend">Используется для новых подписок</span>
<span class="admin-stat-trend">{at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}</span>
</div>
<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>
<span class="admin-stat-trend">Скрыто с витрины</span>
<span class="admin-stat-trend">{at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}</span>
</div>
</div>
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>Каталог тарифов</h3>
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
<small>{tariffsPath || "config/tariffs.json"}</small>
</div>
<button type="button" class="admin-btn admin-btn-sm" on:click={loadTariffs} disabled={tariffsLoading || tariffsSaving}>
<RefreshCw size={13} /> Обновить
</button>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={tariffsStore.loadTariffs} disabled={tariffsLoading || tariffsSaving}>
<RefreshCw size={13} /> {at("btn_refresh", {}, "Обновить")}
</button>
<button type="button" class="admin-btn admin-btn-sm admin-btn-primary" on:click={tariffsStore.openCreateTariff} disabled={tariffsLoading || tariffsSaving}>
<Plus size={13} /> {at("btn_create_tariff", {}, "Создать тариф")}
</button>
</div>
</header>
<div class="admin-card-body">
{#if !tariffsCatalog.tariffs.length}
<div class="admin-empty">
Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.
{at("tariffs_catalog_empty", {}, "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.")}
</div>
{:else}
<div class="admin-tariff-grid">
@@ -62,46 +94,46 @@
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#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}
</div>
<code>{tariff.key}</code>
</div>
{#if tariff.enabled === false}
<span class="admin-badge admin-badge-muted">Выключен</span>
<span class="admin-badge admin-badge-muted">{at("status_disabled", {}, "Выключен")}</span>
{:else}
<span class="admin-badge admin-badge-success">Активен</span>
<span class="admin-badge admin-badge-success">{at("status_active", {}, "Активен")}</span>
{/if}
</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">
<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>Squads: {(tariff.squad_uuids || []).length}</span>
<span>Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"}</span>
<span>Устройства: {tariff.hwid_device_limit ?? "env"}</span>
<span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span>
<span>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"}</span>
<span>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? "env"}</span>
</div>
<div class="admin-tariff-actions">
<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 type="button" class="admin-btn admin-btn-sm" on:click={() => toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
{tariff.enabled === false ? "Включить" : "Выключить"}
<button type="button" class="admin-btn admin-btn-sm" on:click={() => tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
{tariff.enabled === false ? at("btn_enable", {}, "Включить") : at("btn_disable", {}, "Выключить")}
</button>
<button
type="button"
class="admin-btn admin-btn-sm"
on:click={() => setDefaultTariff(tariff.key)}
on:click={() => tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving || tariff.enabled === false || tariff.key === tariffsCatalog.default_tariff}
>
По умолчанию
{at("btn_set_default", {}, "По умолчанию")}
</button>
<button
type="button"
class="admin-btn admin-btn-sm admin-btn-danger"
on:click={() => { tariffDeleteTarget = tariff; tariffDeleteOpen = true; }}
on:click={() => tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })}
disabled={tariffsSaving}
aria-label="Удалить тариф"
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
<Trash2 size={13} />
</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>
import { Check, ChevronDown, ChevronLeft, ChevronRight } from "lucide-svelte";
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 fmtDateShort = (value) => value;
export let loadUsers = () => {};
export let openUser = () => {};
export let optionLabel = () => "";
export let panelStatusBadge = () => ({});
export let resolvedAvatarUrl = () => "";
export let userDisplayName = () => "";
export let userInitials = () => "";
export let userSecondaryName = () => "";
export let users = [];
export let usersFilter = "all";
export let usersHasMore = false;
export let usersLoading = false;
export let usersPage = 0;
export let usersPanelStatus = "all";
export let usersQuery = "";
export let usersSort = "registered_desc";
export let usersTotal = 0;
const usersStore = getContext("usersStore");
$: ({
users,
usersTotal,
usersPage,
usersQuery,
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>
<div class="admin-toolbar admin-toolbar-users">
@@ -33,10 +66,11 @@
type="search"
class="input"
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
bind:value={usersQuery}
on:keydown={(e) => e.key === "Enter" && ((usersPage = 0), loadUsers())}
value={usersQuery}
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 class="admin-toolbar-controls">
@@ -45,7 +79,7 @@
<Select.Root
type="single"
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", {}, "Фильтр")}>
<span>{optionLabel(USERS_FILTER_OPTIONS, usersFilter)}</span>
@@ -69,7 +103,7 @@
<Select.Root
type="single"
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", {}, "Статус панели")}>
<span>{optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)}</span>
@@ -93,7 +127,7 @@
<Select.Root
type="single"
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", {}, "Сортировка")}>
<span>{optionLabel(USERS_SORT_OPTIONS, usersSort)}</span>
@@ -146,7 +180,7 @@
{@const avatar = resolvedAvatarUrl(user)}
{@const badge = panelStatusBadge(user)}
<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">
{#if avatar}
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
@@ -172,10 +206,10 @@
<div class="admin-pagination">
<span class="admin-pagination-meta">{at("page", {}, "Страница")} {usersPage + 1}</span>
<div class="admin-pagination-buttons">
<button type="button" class="admin-btn admin-btn-sm" disabled={usersPage === 0} on:click={() => { 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", {}, "Назад")}
</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} />
</button>
</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>
{#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 />
</a>
{: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 />
</button>
{/if}
@@ -14,7 +14,7 @@
{#if open}
<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)}>
<div class="dialog-head">
<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 || ""}`;
}
function formatMonthsForClient(value, lang) {
function formatMonthsForClient(value, { t, termUnitLabel }) {
const months = Number(value || 0);
if (months === 1) return lang === "en" ? "1 month" : "1 месяц";
if (months === 12) return lang === "en" ? "1 year" : "1 год";
return lang === "en" ? `${months} months` : `${months} мес.`;
if (months === 12) return t("wa_plan_one_year");
return t("wa_sub_term_value_unit", { value: String(months), unit: termUnitLabel(months, "month") });
}
export function planDisplayTitle(plan, { trafficMode, t }) {
@@ -90,7 +89,7 @@ export function planDisplayTitle(plan, { trafficMode, t }) {
return plan?.title || "";
}
export function planSubtitle(plan, { lang }) {
export function planSubtitle(plan, { t, termUnitLabel }) {
if (!plan?.tariff_key) return "";
if (plan?.subtitle) return plan.subtitle;
if (
@@ -101,7 +100,7 @@ export function planSubtitle(plan, { lang }) {
) {
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 }) {
+3
View File
@@ -1,4 +1,7 @@
@import "./styles/base.css";
@import "./styles/controls.css";
@import "./styles/dialogs.css";
@import "./styles/webapp.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;
}
+17 -269
View File
@@ -293,6 +293,7 @@
/* Flex children must NOT shrink otherwise they collapse to fit the
container and the main never overflows, breaking scroll. */
.admin-main > * {
flex-shrink: 0;
}
@@ -451,11 +452,6 @@
grid-template-columns: minmax(0, 1fr) auto auto;
}
.admin-toolbar-search .input,
.admin-toolbar-search .admin-btn {
height: 36px;
}
.admin-toolbar-controls {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto);
@@ -463,18 +459,6 @@
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 {
display: grid;
align-items: start;
@@ -597,10 +581,6 @@
white-space: nowrap;
}
.admin-table td.admin-cell-actions .admin-btn {
margin-left: 4px;
}
.admin-table td.admin-cell-wrap {
white-space: normal;
max-width: 320px;
@@ -830,53 +810,14 @@
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 {
width: 100%;
appearance: auto;
}
.admin-textarea,
.admin-screen-wrap textarea {
.admin-screen-wrap textarea,
.admin-dialog textarea {
width: 100%;
min-height: 110px;
border-radius: 10px;
@@ -891,7 +832,8 @@
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);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
@@ -935,90 +877,6 @@
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
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 {
display: grid;
grid-template-columns: 1fr;
@@ -1353,14 +1175,6 @@
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 {
width: 36px;
height: 36px;
@@ -1408,13 +1222,6 @@
/* 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
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 {
max-height: 100%;
@@ -1426,6 +1233,7 @@
/* User-detail dialog: constrain on desktop and lay out as a two-column
sidebar (profile facts) + main content (tabs). On mobile it stacks. */
.admin-user-dialog {
width: min(100%, 1040px);
max-height: min(100%, 760px);
@@ -1532,16 +1340,6 @@
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 {
overflow: hidden;
text-overflow: ellipsis;
@@ -1758,6 +1556,7 @@
/* Section title block: stacks `<strong>` heading + `<small>` description.
Higher specificity than `.admin-editor-section-head > div` (which would
otherwise force flex-row on the wrapper). */
.admin-editor-section-head > .admin-editor-section-title {
display: flex;
flex-direction: column;
@@ -1777,6 +1576,7 @@
/* Column headers above an `.admin-row-editor` list visually distinct,
inherits the same grid template as input rows so columns line up. */
.admin-row-editor-line.admin-row-editor-header {
color: var(--admin-muted);
font-size: 11px;
@@ -1810,11 +1610,6 @@
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2111,6 +1906,7 @@
}
/* Switch (bits-ui Switch.Root + Thumb) */
.admin-switch-root {
appearance: none;
position: relative;
@@ -2158,6 +1954,7 @@
}
/* Select (bits-ui Select.Root + Trigger + Content + Item) */
.admin-select-trigger {
display: inline-flex;
align-items: center;
@@ -2270,6 +2067,7 @@
}
/* Label primitive (bits-ui Label.Root) */
.admin-field-label {
display: flex;
flex-direction: column;
@@ -2311,6 +2109,7 @@
}
/* Action rows in dialogs */
.admin-action-row {
display: flex;
align-items: center;
@@ -2353,12 +2152,6 @@
align-items: end;
}
.admin-action-grid > .admin-btn {
width: 100%;
height: 36px;
align-self: end;
}
.admin-user-quick-actions {
display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.25fr);
@@ -2392,40 +2185,6 @@
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 {
border-color: var(--admin-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
@@ -2439,26 +2198,12 @@
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.admin-message-actions .admin-btn {
height: 36px;
}
.admin-confirm-message-preview {
max-height: min(280px, 45vh);
overflow: auto;
@@ -2497,6 +2242,7 @@
}
/* Two-column variant of admin-form-row */
.admin-form-row.admin-form-row-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -2508,6 +2254,7 @@
}
/* User summary header inside the user dialog */
.admin-user-summary {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
@@ -2583,6 +2330,7 @@
}
/* Editor section header tweaks */
.admin-editor-section-actions {
display: flex;
flex-wrap: wrap;
@@ -2597,7 +2345,6 @@
letter-spacing: 0.05em;
}
.admin-danger-zone {
border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent);
border-radius: 12px;
@@ -2632,6 +2379,7 @@
/* Subsection grouping inside settings sections (per-payment-provider).
Each subsection is a nested Accordion.Item that defaults to closed
users see provider names and expand only the one they want to edit. */
.admin-subsection-accordion {
display: flex;
flex-direction: column;
@@ -2731,4 +2479,4 @@
grid-area: meta;
white-space: normal;
}
}
}
+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 {
display: inline-flex;
min-height: 44px;
min-width: 0;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 46px;
padding: 0 16px;
border: 1px solid transparent;
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-decoration: none;
font-size: 14px;
font-weight: 850;
line-height: 1.1;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
cursor: pointer;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
transition:
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);
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 {
border-color: color-mix(in srgb, var(--accent) 72%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent));
color: #031009;
box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 20%, transparent);
border-color: color-mix(in srgb, var(--accent) 72%, white);
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white));
color: #03100a;
}
.btn-secondary,
.btn-outline {
border-color: var(--border-strong);
background: rgba(255, 255, 255, 0.045);
.btn-secondary {
border-color: var(--border);
background: var(--panel-3);
color: var(--text);
}
.btn-outline {
border-color: color-mix(in srgb, var(--accent) 60%, var(--border));
color: var(--accent);
border-color: var(--border-strong);
background: transparent;
color: var(--text);
}
.btn-ghost {
border-color: transparent;
background: transparent;
color: var(--muted);
color: var(--text);
box-shadow: none;
}
.btn-telegram {
border-color: #2d9cff;
background: linear-gradient(180deg, #2f9ff4, #1786df);
color: white;
}
.btn-icon {
border-color: var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-color: color-mix(in srgb, #229ed9 78%, white);
background: #229ed9;
color: #fff;
}
.btn-icon,
.btn-square {
width: 42px;
min-width: 42px;
height: 42px;
min-height: 42px;
padding: 0;
}
.btn-sm {
min-height: 36px;
padding: 8px 10px;
font-size: 12px;
padding: 0 12px;
font-size: 13px;
}
.wide {
width: 100%;
.btn-lg {
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 {
width: 100%;
min-height: 44px;
min-width: 0;
min-height: 46px;
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(4, 8, 11, 0.48);
background: var(--panel-2);
color: var(--text);
outline: none;
padding: 11px 12px;
font-size: 13px;
font-weight: 700;
font: inherit;
font-size: 14px;
line-height: 1.5;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.input:focus {
border-color: color-mix(in srgb, var(--accent) 68%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent);
}
.input.muted {
display: flex;
align-items: center;
.input::placeholder {
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 {
position: fixed;
inset: 0;
z-index: 70;
z-index: 220;
display: grid;
place-items: center;
overflow: hidden;
align-items: center;
justify-items: center;
padding:
16px
var(--screen-gutter)
calc(var(--bottom-nav-height) + var(--bottom-nav-offset) + 16px + env(safe-area-inset-bottom));
max(14px, env(safe-area-inset-top))
max(14px, env(safe-area-inset-right))
max(14px, env(safe-area-inset-bottom))
max(14px, env(safe-area-inset-left));
overflow: hidden;
isolation: isolate;
}
.dialog-backdrop {
position: absolute;
inset: 0;
z-index: 0;
border: 0;
background: rgba(2, 7, 11, 0.54);
background: rgba(0, 0, 0, 0.56);
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 {
position: relative;
z-index: 1;
display: grid;
width: min(100%, 420px);
gap: 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--panel);
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.5);
padding: 14px;
transform-origin: center;
animation: modal-enter 0.2s ease-out both;
gap: 16px;
width: min(100%, 520px);
max-height: min(86dvh, 760px);
overflow: auto;
overscroll-behavior: contain;
padding: 18px;
border: 1px solid var(--border);
border-radius: 24px;
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
gap: 10px;
}
.dialog-head h2 {
margin: 0;
color: var(--text);
font-size: 18px;
line-height: 1.16;
font-weight: 850;
font-size: 19px;
line-height: 1.18;
}
.dialog-head p {
margin: 4px 0 0;
margin: 6px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.3;
font-size: 13px;
line-height: 1.45;
}
.dialog-card .bottom-action {
margin-top: 2px;
}
.payment-dialog-card {
@@ -64,38 +80,6 @@
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 {
display: grid;
gap: 10px;
@@ -117,22 +101,42 @@
0 0 26px color-mix(in srgb, var(--accent) 42%, transparent);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
.link-email-dialog-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: min(100%, 560px);
max-height: 100%;
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 {
opacity: 0;
transform: translateY(10px) scale(0.98);
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (min-width: 720px) {
.dialog-card {
border-radius: var(--radius-lg);
padding: 20px;
}
}
+41 -185
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 {
cursor: pointer;
}
button:disabled {
cursor: progress;
opacity: 0.58;
}
a {
color: inherit;
}
.app-shell {
min-height: 100dvh;
background: #02070b !important;
}
.phone-screen {
position: relative;
width: min(100%, 440px);
@@ -109,10 +13,7 @@ a {
margin: 0 auto;
overflow-x: hidden;
padding:
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(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));
}
.content {
@@ -366,24 +267,6 @@ a {
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 {
display: flex;
align-items: center;
@@ -407,7 +290,7 @@ a {
.card-click-target {
position: absolute;
z-index: 2;
z-index: 6;
inset: 0;
width: 100%;
border: 0;
@@ -439,7 +322,7 @@ a {
.premium-server-dropdown {
position: relative;
z-index: 3;
z-index: 7;
min-width: 0;
}
@@ -509,7 +392,7 @@ a {
gap: 6px;
}
.premium-server-list > div {
.premium-server-list>div {
display: flex;
flex-wrap: wrap;
gap: 6px;
@@ -533,7 +416,7 @@ a {
padding: 12px;
}
.topup-summary-card > div:not(.premium-server-list) {
.topup-summary-card>div:not(.premium-server-list) {
display: grid;
gap: 3px;
}
@@ -557,7 +440,7 @@ a {
gap: 11px;
}
.trial-card-head > svg {
.trial-card-head>svg {
flex: 0 0 auto;
color: var(--accent);
}
@@ -592,7 +475,7 @@ a {
gap: 11px;
}
.devices-summary-head > svg {
.devices-summary-head>svg {
color: var(--accent);
}
@@ -881,17 +764,12 @@ a {
align-items: flex-start;
}
.change-action-row > svg {
.change-action-row>svg {
flex: 0 0 auto;
margin-top: 1px;
color: var(--accent);
}
.dialog-skeleton {
display: grid;
gap: 12px;
}
.topup-carryover-note {
display: grid;
gap: 6px;
@@ -921,12 +799,10 @@ a {
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(
90deg,
linear-gradient(90deg,
rgba(255, 255, 255, 0.07) 0%,
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%;
animation: skeleton-shimmer 1.15s ease-in-out infinite;
}
@@ -978,6 +854,7 @@ a {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
@@ -1153,11 +1030,6 @@ a {
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 {
color: var(--danger);
pointer-events: none;
@@ -1205,6 +1077,7 @@ a {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
@@ -1216,6 +1089,7 @@ a {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(2px) scale(0.98);
@@ -1254,7 +1128,7 @@ a {
gap: 14px;
}
.bonus-card-head > svg {
.bonus-card-head>svg {
flex: 0 0 auto;
color: var(--accent);
}
@@ -1370,12 +1244,12 @@ a {
background: rgba(255, 255, 255, 0.03);
}
.settings-row > svg:first-child {
.settings-row>svg:first-child {
color: var(--text);
opacity: 0.9;
}
.settings-row > svg:last-child {
.settings-row>svg:last-child {
color: var(--muted);
}
@@ -1402,7 +1276,7 @@ a {
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);
}
@@ -1447,7 +1321,7 @@ a {
border-radius: 6px;
}
.language-select-trigger > svg {
.language-select-trigger>svg {
color: var(--muted);
}
@@ -1531,11 +1405,11 @@ a {
text-overflow: ellipsis;
}
.language-select-item-main > span {
.language-select-item-main>span {
display: inline-block !important;
}
.language-select-item-main > span:last-child {
.language-select-item-main>span:last-child {
margin-left: 6px;
white-space: nowrap !important;
overflow: hidden;
@@ -1591,9 +1465,11 @@ a {
0% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
}
70% {
box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
}
@@ -1924,6 +1800,7 @@ a {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -1935,6 +1812,7 @@ a {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
@@ -1946,6 +1824,7 @@ a {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -2023,19 +1902,6 @@ a {
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 {
min-height: 72px;
}
@@ -2141,6 +2007,7 @@ a {
}
@media (prefers-reduced-motion: reduce) {
.content,
.home-layout,
.language-select-content,
@@ -2166,11 +2033,6 @@ a {
Mobile ( 1023px) is intentionally untouched.
============================================================ */
:root {
--desktop-rail-width: 252px;
--desktop-page-gutter: clamp(28px, 4vw, 72px);
}
@media (min-width: 1024px) {
body {
background: #02070b;
@@ -2195,18 +2057,15 @@ a {
background: transparent;
box-shadow: none;
padding:
max(28px, env(safe-area-inset-top))
var(--desktop-page-gutter)
40px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
overflow-x: visible;
}
/* Centre and cap the width of the actual content blocks. */
.phone-screen > .app-header,
.phone-screen > main,
.phone-screen > .home-layout,
.phone-screen > nav.bottom-nav ~ * {
.phone-screen>.app-header,
.phone-screen>main,
.phone-screen>.home-layout,
.phone-screen>nav.bottom-nav~* {
max-width: 1080px;
margin-left: auto;
margin-right: auto;
@@ -2217,13 +2076,11 @@ a {
width: min(100%, 460px);
min-height: 100dvh;
padding:
max(48px, env(safe-area-inset-top))
clamp(24px, 4vw, 48px)
48px;
max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
margin: 0 auto;
}
.phone-screen.auth-screen ~ .bottom-nav,
.phone-screen.auth-screen~.bottom-nav,
.auth-screen .bottom-nav {
display: none !important;
}
@@ -2234,10 +2091,7 @@ a {
align-items: stretch;
min-height: 100dvh;
padding:
max(32px, env(safe-area-inset-top))
var(--desktop-page-gutter)
42px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
max(32px, env(safe-area-inset-top)) 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
@@ -2255,7 +2109,7 @@ a {
align-items: stretch;
}
.home-layout > .home-brand {
.home-layout>.home-brand {
grid-column: auto;
align-self: center;
justify-items: center;
@@ -2342,12 +2196,12 @@ a {
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.bottom-nav button > svg {
.bottom-nav button>svg {
width: 20px;
height: 20px;
}
.bottom-nav button > span {
.bottom-nav button>span {
text-align: left !important;
font-size: 13px !important;
font-weight: 600;
@@ -2372,11 +2226,12 @@ a {
.bottom-nav .rail-admin-entry {
display: grid !important;
}
.settings-admin-block {
display: none !important;
}
.phone-screen > main.content.with-nav {
.phone-screen>main.content.with-nav {
padding-top: 0;
}
@@ -2431,6 +2286,7 @@ a {
}
/* Brand block sits at the top of the desktop side rail; hidden on mobile. */
.rail-brand {
display: none;
}
@@ -2455,4 +2311,4 @@ a {
white-space: nowrap;
min-width: 0;
}
}
}
@@ -23,21 +23,21 @@
<BrandMark logoUrl={logoUrl} emoji={logoEmoji} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" on:click={onHome}>
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
<Home size={21} />
<span>{t("wa_nav_home")}</span>
</button>
<button class:active={activeTab === "invite"} type="button" on:click={onInvite}>
<button class:active={activeTab === "invite"} type="button" onclick={onInvite}>
<Gift size={21} />
<span>{t("wa_nav_bonuses")}</span>
</button>
{#if devicesEnabled}
<button class:active={activeTab === "devices"} type="button" on:click={onDevices}>
<button class:active={activeTab === "devices"} type="button" onclick={onDevices}>
<Smartphone size={21} />
<span>{t("wa_nav_devices")}</span>
</button>
{/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}
<span class="attention-dot nav-attention-dot" aria-hidden="true"></span>
{/if}
@@ -45,7 +45,7 @@
<span>{t("wa_nav_settings")}</span>
</button>
{#if isAdmin}
<button class="rail-admin-entry" type="button" on:click={onAdmin}>
<button class="rail-admin-entry" type="button" onclick={onAdmin}>
<Shield size={21} />
<span>{t("admin_nav_title", {}, "Админ-панель")}</span>
</button>
@@ -6,6 +6,16 @@
import Card from "../lib/components/ui/card.svelte";
import Dialog from "../lib/components/ui/dialog.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 deviceConfirmOpen = false;
@@ -21,6 +31,7 @@
export let linkEmailResendCooldown = 0;
export let linkEmailStatus = "";
export let linkEmailValue = "";
export let hasMultipleTariffs = false;
export let methods = [];
export let payBusy = false;
export let paymentModalOpen = false;
@@ -35,21 +46,58 @@
export let subscription = {};
export let tariffCatalog = [];
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 closeLinkEmailDialog = () => {};
export let closePaymentModal = () => {};
export let backToTariffList = () => {};
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 selectTariff = () => {};
export let t = (key) => key;
export let termUnitLabel = () => "";
export let verifyLinkEmailCode = () => {};
</script>
@@ -70,7 +118,7 @@
class:active={selectedTariffKey === tariff.key}
class="option-row tariff-row"
type="button"
on:click={() => selectTariff(tariff)}
onclick={() => selectTariff(tariff)}
>
<span class="option-row-main">
<strong>{tariff.title}</strong>
@@ -97,7 +145,7 @@
{:else}
{#if tariffMode}
{#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} />
{t("wa_back_to_tariffs")}
</button>
@@ -113,7 +161,7 @@
class:active={planKey(selectedPlan) === planKey(plan)}
class="period-card"
type="button"
on:click={() => (selectedPlan = plan)}
onclick={() => (selectedPlan = plan)}
>
<strong>{planSubtitle(plan) || planDisplayTitle(plan)}</strong>
<span>{priceLabel(plan)}</span>
@@ -135,7 +183,7 @@
class:active={selectedMethod === method.id}
class="method-card"
type="button"
on:click={() => (selectedMethod = method.id)}
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
@@ -164,7 +212,7 @@
class:active={planKey(selectedPlan) === planKey(plan)}
class="period-card"
type="button"
on:click={() => (selectedPlan = plan)}
onclick={() => (selectedPlan = plan)}
>
<strong>{planDisplayTitle(plan)}</strong>
{#if planSubtitle(plan)}
@@ -189,7 +237,7 @@
class:active={selectedMethod === method.id}
class="method-card"
type="button"
on:click={() => (selectedMethod = method.id)}
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
@@ -291,7 +339,7 @@
<button
class="link-button link-email-resend"
type="button"
on:click={requestLinkEmailCode}
onclick={requestLinkEmailCode}
disabled={linkEmailBusy || linkEmailResendCooldown > 0}
>
<RefreshCw size={15} />
@@ -2,12 +2,20 @@
import { ArrowRight, CheckCircle2, LockKeyhole } from "lucide-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 Dialog from "../lib/components/ui/dialog.svelte";
export let actionKey = () => "";
export let applyTariffChange = () => {};
export let changeActionTitle = () => "";
export let changeConfirmOpen = false;
export let changeModalOpen = false;
export let changeOptions = null;
@@ -17,16 +25,11 @@
export let closeTopupModal = () => {};
export let createDeviceTopupPayment = () => {};
export let createTopupPayment = () => {};
export let deviceTopupModalDescription = () => "";
export let deviceTopupModalOpen = false;
export let deviceTopupOptions = null;
export let methods = [];
export let methodMeta = () => ({});
export let openTariffChangeConfirm = () => {};
export let payBusy = false;
export let planKey = () => "";
export let planUnitHint = () => "";
export let priceLabel = () => "";
export let selectedChangeAction = null;
export let selectedChangeTarget = null;
export let selectedDeviceTopupPlan = null;
@@ -34,14 +37,110 @@
export let selectedTopupPlan = null;
export let singleTariffMode = false;
export let tariffActionBusy = false;
export let tariffChangeModalDescription = () => "";
export let tariffChangeSummary = () => [];
export let topupCarryoverNotes = () => [];
export let topupModalDescription = () => "";
export let topupModalOpen = false;
export let topupModalTitle = () => "";
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 termUnitLabel = () => "";
</script>
<Dialog
@@ -87,7 +186,7 @@
class:active={selectedChangeTarget?.tariff_key === target.tariff_key}
class="tariff-action-card"
type="button"
on:click={() => {
onclick={() => {
selectedChangeTarget = target;
selectedChangeAction = target.actions?.[0] || null;
}}
@@ -109,7 +208,7 @@
class:active={actionKey(selectedChangeAction) === actionKey(action)}
class="option-row change-action-row"
type="button"
on:click={() => (selectedChangeAction = action)}
onclick={() => (selectedChangeAction = action)}
>
<span class="option-row-main">
<strong>{changeActionTitle(action)}</strong>
@@ -135,7 +234,7 @@
class:active={selectedMethod === method.id}
class="method-card"
type="button"
on:click={() => (selectedMethod = method.id)}
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
@@ -209,6 +308,10 @@
</div>
{/each}
</div>
<div class="topup-carryover-note skeleton-carryover-note">
<span class="skeleton-line"></span>
<span class="skeleton-line skeleton-line-short"></span>
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
@@ -226,7 +329,7 @@
class:active={planKey(selectedTopupPlan) === planKey(plan)}
class="option-row plan-row"
type="button"
on:click={() => (selectedTopupPlan = plan)}
onclick={() => (selectedTopupPlan = plan)}
>
<span class="option-row-main">
<strong>{plan.title}</strong>
@@ -258,7 +361,7 @@
class:active={selectedMethod === method.id}
class="method-card"
type="button"
on:click={() => (selectedMethod = method.id)}
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
@@ -320,7 +423,7 @@
class:active={planKey(selectedDeviceTopupPlan) === planKey(plan)}
class="option-row plan-row"
type="button"
on:click={() => (selectedDeviceTopupPlan = plan)}
onclick={() => (selectedDeviceTopupPlan = plan)}
>
<span class="option-row-main">
<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="method-card"
type="button"
on:click={() => (selectedMethod = method.id)}
onclick={() => (selectedMethod = method.id)}
>
<span class="method-card-main">
{#if meta.icon}
@@ -73,7 +73,7 @@
<button
class="link-button"
type="button"
on:click={requestEmailCode}
onclick={requestEmailCode}
disabled={authBusy || authResendCooldown > 0}
>
<RefreshCw size={15} />
@@ -159,7 +159,7 @@
href={privacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
on:click|preventDefault={() => openExternalLink(privacyPolicyUrl)}
onclick={(e) => { e.preventDefault(); openExternalLink(privacyPolicyUrl); }}
>
{t("wa_auth_legal_privacy")}
</a>
@@ -172,7 +172,7 @@
href={userAgreementUrl}
target="_blank"
rel="noopener noreferrer"
on:click|preventDefault={() => openExternalLink(userAgreementUrl)}
onclick={(e) => { e.preventDefault(); openExternalLink(userAgreementUrl); }}
>
{t("wa_auth_legal_agreement")}
</a>
@@ -11,13 +11,28 @@
export let devicesStatus = "";
export let subscription = {};
export let devicesCountLabel = () => "";
export let devicesLimitLabel = () => "";
export let devicesPercent = () => 0;
export let loadDevices = () => {};
export let openDeviceDisconnectDialog = () => {};
export let openDeviceTopupModal = () => {};
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>
<main class="content with-nav">
@@ -4,6 +4,18 @@
import BrandMark from "../../BrandMark.svelte";
import Button from "../../lib/components/ui/button.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 appSettings = {};
@@ -19,23 +31,30 @@
export let subscription = {};
export let trafficMode = 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 openConnectLink = () => {};
export let openPaymentModal = () => {};
export let openRegularTopupModal = () => {};
export let openPremiumTopupModal = () => {};
export let openTariffChangeModal = () => {};
export let openTopupModal = () => {};
export let premiumServerLabels = () => [];
export let premiumTitle = () => "";
export let premiumTrafficLabel = () => "";
export let premiumTrafficPercent = () => 0;
export let primaryPayActionLabel = () => "";
export let t = (key) => key;
export let trafficLabel = () => "";
export let trafficPercent = () => 0;
export let trafficResetLabel = () => "";
export let trialTrafficLabel = () => "";
</script>
<main class="home-layout">
@@ -68,7 +87,7 @@
{#if subscription.active}
<Card class={canOpenRegularTopupModal ? "traffic-card-clickable" : ""}>
{#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}
<div class="traffic-top">
<span>{t("wa_home_traffic_used")}</span>
@@ -85,7 +104,7 @@
{#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" : ""}`}>
{#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}
<div class="traffic-top">
<span>{premiumTitle(subscription)}</span>
@@ -157,7 +176,7 @@
</Button>
{/if}
{#if canShowTopupButton}
<Button class="wide" variant="secondary" onclick={() => openTopupModal(canOpenRegularTopupModal ? "regular" : "premium")}>
<Button class="wide" variant="secondary" onclick={canOpenRegularTopupModal ? openRegularTopupModal : openPremiumTopupModal}>
<Database size={18} />
{t("wa_topup_traffic")}
</Button>
@@ -54,11 +54,11 @@
{#if isAdmin}
<div class="settings-admin-block">
<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} />
<span>
<strong>Админ-панель</strong>
<small>Управление приложением</small>
<strong>{t("wa_settings_admin_panel", {}, "Админ-панель")}</strong>
<small>{t("wa_settings_admin_panel_hint", {}, "Управление приложением")}</small>
</span>
<ArrowRight size={17} />
</button>
@@ -95,7 +95,7 @@
</span>
</div>
{: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>
<Mail size={21} />
<span>
@@ -113,8 +113,8 @@
class:language-select-guard--armed={languageClickGuardArmed}
type="button"
aria-label={t("wa_close")}
on:pointerdown|preventDefault|stopPropagation={() => languageClickGuardArmed && setLanguageMenuOpen(false)}
on:click|preventDefault|stopPropagation={() => languageClickGuardArmed && setLanguageMenuOpen(false)}
onpointerdown={(e) => { e.preventDefault(); e.stopPropagation(); if (languageClickGuardArmed) setLanguageMenuOpen(false); }}
onclick={(e) => { e.preventDefault(); e.stopPropagation(); if (languageClickGuardArmed) setLanguageMenuOpen(false); }}
></button>
{/if}
<div class="settings-list" class:settings-list--language-open={languageMenuOpen}>
@@ -155,27 +155,27 @@
</Select.Root>
</div>
{#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} />
<span><strong>{t("menu_support_button")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
{#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} />
<span><strong>{t("wa_settings_user_agreement")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
{#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} />
<span><strong>{t("wa_settings_privacy_policy")}</strong></span>
<ArrowRight size={17} />
</button>
{/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} />
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
<ArrowRight size={17} />