feat: tune visual of web app admin panel, tune limit messages and etc

This commit is contained in:
3252a8
2026-05-12 15:42:17 +03:00
parent 8af59750c6
commit 475a1953d1
34 changed files with 2777 additions and 114 deletions
+39
View File
@@ -454,6 +454,16 @@
});
}
function stripTopupQueryFromUrl() {
if (typeof window === "undefined") return;
const u = new URL(window.location.href);
if (!u.searchParams.has("topup")) return;
u.searchParams.delete("topup");
const search = u.searchParams.toString();
const qs = search ? `?${search}` : "";
window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`);
}
async function loadData() {
const payload = await api("/me");
if (!payload.ok) throw new Error(payload.error || "load_failed");
@@ -482,6 +492,35 @@
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
const topupDeep = new URLSearchParams(window.location.search).get("topup");
if (topupDeep === "regular" || topupDeep === "premium") {
const plansList = payload.plans?.length ? payload.plans : [];
const tariffCatalogLocal = buildTariffCatalog(plansList);
const sub = payload.subscription || {};
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
const hasTariffSub = Boolean(
tariffModeLocal &&
sub?.active &&
sub?.tariff_key &&
tariffCatalogLocal.some((t) => t.key === sub.tariff_key),
);
const canRegular =
hasTariffSub &&
(sub?.can_topup_regular_traffic ?? sub?.can_topup_traffic) &&
Number(sub?.traffic_limit_bytes || 0) > 0;
const canPremium =
hasTariffSub &&
(sub?.can_topup_premium_traffic ?? sub?.can_topup_traffic) &&
Number(sub?.premium_limit_bytes || 0) > 0;
if (topupDeep === "regular" && canRegular) {
billingStore.openTopupModal("regular", payload.payment_methods?.[0]?.id || "");
stripTopupQueryFromUrl();
} else if (topupDeep === "premium" && canPremium) {
billingStore.openTopupModal("premium", payload.payment_methods?.[0]?.id || "");
stripTopupQueryFromUrl();
}
}
}
function showLogin() {
@@ -219,6 +219,19 @@
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
}
function openPaymentUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) return;
const next = normalizeSection("users");
sidebarOpen = false;
if (active !== next) {
active = next;
usersStore.closeUser();
onSectionChange(next);
}
usersStore.openUser(uid);
}
function resolvedAvatarUrl(user) {
return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "");
}
@@ -470,7 +483,7 @@
{/if}
{#if active === "payments"}
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} />
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} onOpenUserCard={openPaymentUserCard} />
{/if}
{#if active === "promos"}
@@ -2,16 +2,19 @@
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");
@@ -23,9 +26,60 @@
} = $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_telegram_id", {}, "Telegram ID"),
at("payments_col_traffic_regular", {}, "Основной трафик"),
at("payments_col_traffic_premium", {}, "Премиум"),
at("amount", {}, "Сумма"),
at("provider", {}, "Провайдер"),
at("description", {}, "Описание"),
@@ -40,7 +94,11 @@
<div class="admin-table-wrap">
{#if paymentsLoading}
<AdminTableSkeleton headers={paymentHeaders} rows={8} widths={["48px", "120px", "78px", "82px", "180px", "72px", "96px"]} />
<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}
@@ -49,6 +107,9 @@
<tr>
<th>{at("id", {}, "ID")}</th>
<th>{at("user", {}, "Пользователь")}</th>
<th>{at("payments_col_telegram_id", {}, "Telegram 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>
@@ -60,10 +121,33 @@
{#each payments as p}
<tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
<td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_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_telegram_id", {}, "Telegram ID")}>
{p.telegram_id != null ? p.telegram_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", {}, "Описание")}>{p.description || "—"}</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>
@@ -81,6 +165,47 @@
nextLabel={at("next", {}, "Далее")}
prevDisabled={paymentsPage === 0}
nextDisabled={!paymentsHasMore}
onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}
onNext={() => { paymentsStore.setPage(paymentsPage + 1); }}
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>
@@ -1,7 +1,7 @@
<script>
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import { AdminBadge, AdminButton, AdminTrafficCard } from "$components/patterns/admin/index.js";
import { AdminBadge, AdminButton, AdminSectionHeader, AdminSelect, AdminTrafficCard } from "$components/patterns/admin/index.js";
import {
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
@@ -41,6 +41,8 @@
userBanConfirmOpen,
userMessageConfirmOpen,
userDetailTab,
premiumUnlimitedDraft,
premiumBonusGbDraft,
} = $usersStore);
</script>
@@ -177,7 +179,17 @@
warning={openedUserDetail.active_subscription.is_throttled}
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
/>
{#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
value={at("user_premium_unlimited_value", { used: trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) }, "∞ (использовано " + trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) + ")")}
left={at("user_premium_unlimited_hint", {}, "Безлимит (админ-оверрайд)")}
percent={0}
warning={false}
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
/>
{:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
@@ -249,6 +261,149 @@
</Label.Root>
</div>
{#if openedUserDetail?.active_subscription}
<section class="admin-user-action-sheet admin-user-action-sheet--premium-override">
<AdminSectionHeader
title={at("user_premium_override_card_title", {}, "Премиум-трафик")}
description={at("user_premium_override_card_hint", {}, "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.")}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span>
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={premiumUnlimitedDraft}
aria-label={at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}
bind:value={$usersStore.premiumBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<input type="checkbox" bind:checked={$usersStore.premiumUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton variant="primary" onclick={usersStore.savePremiumTrafficOverride} disabled={userActionBusy}>
{at("user_premium_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<span class="admin-meta-truncate">
{at("user_premium_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.premium_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_premium_override_status_bonus",
{ gb: +(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2) },
`Премиум сейчас: +${+(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
)}
</span>
{:else}
<span class="admin-muted">{at("user_premium_override_status_none", {}, "Премиум-оверрайд не задан")}</span>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--regular-override">
<AdminSectionHeader
title={at("user_regular_override_card_title", {}, "Основной трафик")}
description={at("user_regular_override_card_hint", {}, "Безлимит и постоянный бонус к лимиту основного трафика.")}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span>
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={$usersStore.regularUnlimitedDraft}
aria-label={at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}
bind:value={$usersStore.regularBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<input type="checkbox" bind:checked={$usersStore.regularUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton variant="primary" onclick={usersStore.saveRegularTrafficOverride} disabled={userActionBusy}>
{at("user_regular_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.regular_unlimited_override}
<span class="admin-meta-truncate">
{at("user_regular_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.regular_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_regular_override_status_bonus",
{ gb: +(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2) },
`Основной сейчас: +${+(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
)}
</span>
{:else}
<span class="admin-muted">{at("user_regular_override_status_none", {}, "Бонус основного трафика не задан")}</span>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
<AdminSectionHeader
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
description={at("user_traffic_grant_hint", {}, "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.")}
/>
<div class="admin-user-action-sheet-body admin-user-grant-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_kind", {}, "Тип трафика")}</span>
<AdminSelect
class="admin-grant-kind-select"
value={$usersStore.grantTrafficKindDraft}
items={[
{ value: "regular", label: at("user_traffic_grant_kind_regular", {}, "Обычный") },
{ value: "premium", label: at("user_traffic_grant_kind_premium", {}, "Премиум") },
]}
onValueChange={(v) => usersStore.updateState({ grantTrafficKindDraft: v })}
ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")}
/>
</Label.Root>
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
<div class="admin-extend-control">
<input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
aria-label={at("user_traffic_grant_gb", {}, "ГБ к выдаче")}
bind:value={$usersStore.grantTrafficGbDraft}
/>
<AdminButton variant="primary" onclick={usersStore.grantTraffic} disabled={userActionBusy}>
<Plus size={14} /> {at("user_traffic_grant_submit", {}, "Выдать")}
</AdminButton>
</div>
</Label.Root>
</div>
</section>
{/if}
<Label.Root class="admin-field-label">
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
@@ -339,3 +494,106 @@
</AdminButton>
</div>
</Dialog>
<style>
.admin-user-action-sheet {
border: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.08));
border-radius: 12px;
margin-bottom: 14px;
overflow: hidden;
background: var(--admin-surface-1, rgba(255, 255, 255, 0.02));
}
.admin-user-action-sheet :global(.admin-dashboard-section-head) {
padding: 12px 14px 10px;
margin: 0;
border-bottom: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.06));
}
.admin-user-action-sheet-body {
padding: 12px 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-user-override-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-grant-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-action-sheet-footer {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
padding: 4px 14px 12px;
}
.admin-override-card-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.admin-override-card-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
width: 100%;
}
.admin-override-card-toolbar :global(.admin-btn) {
flex: 0 0 auto;
min-height: 36px;
padding-left: 16px;
padding-right: 16px;
}
.admin-override-unlimited-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--admin-text, inherit);
cursor: pointer;
user-select: none;
min-height: 36px;
}
.admin-override-unlimited-label input[type="checkbox"] {
width: 18px;
height: 18px;
flex-shrink: 0;
}
@media (max-width: 520px) {
.admin-override-card-toolbar {
flex-direction: column;
align-items: stretch;
}
.admin-override-card-toolbar :global(.admin-btn) {
width: 100%;
}
}
.admin-override-status-lines {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
:global(.admin-user-dialog .admin-actions-tab) {
padding-bottom: 14px;
}
.admin-user-action-sheet--regular-override {
margin-top: 10px;
}
.admin-user-action-sheet--traffic-grant {
margin-top: 10px;
}
.admin-user-action-sheet :global(.admin-grant-kind-select) {
width: 100%;
max-width: 100%;
}
</style>
@@ -23,6 +23,12 @@ export function createUsersStore({ api, onToast, at }) {
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
regularUnlimitedDraft: false,
regularBonusGbDraft: "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
});
let _activeRef = "stats"; // fallback if active isn't tracked
@@ -86,10 +92,19 @@ export function createUsersStore({ api, onToast, at }) {
try {
const res = await api(`/admin/users/${userId}`);
if (res?.ok) {
const sub = res.active_subscription || null;
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
state.update(s => ({
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / (1024 ** 3)).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / (1024 ** 3)).toFixed(2) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
}));
} else {
onToast(res?.error || "load_failed");
@@ -249,6 +264,103 @@ export function createUsersStore({ api, onToast, at }) {
}
}
async function savePremiumTrafficOverride() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const bonusGbRaw = s.premiumBonusGbDraft;
const bonusGb = bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
? 0
: Number(bonusGbRaw);
if (Number.isNaN(bonusGb) || bonusGb < 0) {
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.premiumUnlimitedDraft),
bonus_gb: bonusGb,
}),
});
if (res?.ok) {
onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function saveRegularTrafficOverride() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
try {
const regGbRaw = s.regularBonusGbDraft;
const regularGb = regGbRaw === "" || regGbRaw === null || regGbRaw === undefined
? 0
: Number(regGbRaw);
if (Number.isNaN(regularGb) || regularGb < 0) {
onToast(at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика"));
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
method: "POST",
body: JSON.stringify({
unlimited: Boolean(s.regularUnlimitedDraft),
regular_bonus_gb: regularGb,
}),
});
if (res?.ok) {
onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
}
}
async function grantTraffic() {
let s;
state.update(st => { s = st; return st; });
if (!s.openedUser) return;
const gbRaw = s.grantTrafficGbDraft;
const gb = Number(gbRaw);
if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
return;
}
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
state.update(st => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
method: "POST",
body: JSON.stringify({ kind, gb }),
});
if (res?.ok) {
onToast(
kind === "premium"
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
);
state.update(st => ({ ...st, grantTrafficGbDraft: "" }));
await openUser(s.openedUser, { skipPush: true });
} 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; });
@@ -291,5 +403,8 @@ export function createUsersStore({ api, onToast, at }) {
extendUser,
resetTrialUser,
deleteUser,
savePremiumTrafficOverride,
saveRegularTrafficOverride,
grantTraffic,
};
}
@@ -267,6 +267,17 @@
flex: 0 0 auto;
}
.admin-extend-control .admin-btn.admin-btn-primary {
background: var(--accent);
color: #02110a;
border-color: color-mix(in srgb, var(--accent) 70%, #000);
font-weight: 600;
}
.admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 90%, #fff);
}
.admin-input-row .input,
.admin-input-row .admin-btn {
height: 36px;
@@ -100,7 +100,34 @@
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
</div>
</Card>
{#if Number(subscription?.premium_limit_bytes || 0) > 0}
{#if subscription?.premium_unlimited_override}
<Card class="premium-traffic-card">
<div class="traffic-top">
<span>{premiumTitle(subscription)}</span>
<strong></strong>
</div>
<div class="traffic-meta premium-traffic-meta">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown">
<summary>
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
{/if}
</div>
</Card>
{:else 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" onclick={openPremiumTopupModal} aria-label={premiumTitle(subscription)}></button>