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