refactor: slice web app wip
This commit is contained in:
+234
-944
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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user