feat: premium squads inside one tariff

This commit is contained in:
3252a8
2026-05-10 12:51:53 +03:00
parent 6f9a87cf98
commit 990eba1e1e
34 changed files with 1898 additions and 135 deletions
+45 -5
View File
@@ -253,11 +253,20 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
data = _tariffs_config_payload(config)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
tmp_path.replace(path)
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
# A docker-compose single-file bind mount can make /app/config
# unwritable while the mounted tariffs.json itself is writable.
# Fall back to updating the existing file in-place.
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
# ─── Routes ────────────────────────────────────────────────────────
@@ -1225,6 +1234,36 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
async def admin_panel_internal_squads_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
panel_service = request.app.get("panel_service")
if panel_service is None:
return _error(503, "panel_unavailable", "Panel service unavailable")
try:
squads = await panel_service.get_internal_squads()
except Exception as exc:
logger.exception("Failed to load internal squads from panel")
return _error(502, "panel_request_failed", str(exc))
if squads is None:
return _error(502, "panel_request_failed", "Unable to load internal squads")
items = []
for squad in squads:
if not isinstance(squad, dict):
continue
uuid = squad.get("uuid") or squad.get("id")
if not uuid:
continue
items.append(
{
"uuid": str(uuid),
"name": squad.get("name") or squad.get("title") or str(uuid),
"members_count": squad.get("membersCount") or squad.get("usersCount") or squad.get("members_count"),
"active_inbounds_count": squad.get("activeInboundsCount") or squad.get("active_inbounds_count"),
}
)
return _ok({"squads": items})
# ─── Router setup ──────────────────────────────────────────────────
@@ -1265,3 +1304,4 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+220 -20
View File
@@ -127,6 +127,14 @@
traffic_limit: "100 GB",
traffic_used_bytes: 19756849561,
traffic_limit_bytes: 107374182400,
premium_used: "32.0 GB",
premium_limit: "50.0 GB",
premium_used_bytes: 34359738368,
premium_limit_bytes: 53687091200,
premium_baseline_bytes: 53687091200,
premium_topup_balance_bytes: 0,
premium_is_limited: false,
premium_node_labels: ["Premium NL-1", "Premium DE-1"],
max_devices: 5,
},
devices: {
@@ -496,9 +504,16 @@
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key);
$: canChangeTariff = Boolean(hasActiveTariffSubscription && hasMultipleTariffs);
$: currentTariffName = activeTariffName(subscription, plans);
$: canOpenTopupModal = Boolean(
hasActiveTariffSubscription &&
subscription?.can_topup_traffic &&
(Number(subscription?.traffic_limit_bytes || 0) > 0 || Number(subscription?.premium_limit_bytes || 0) > 0),
);
$: canShowTopupButton = Boolean(
hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85,
canOpenTopupModal &&
(trafficPercent(subscription) >= 85 || premiumTrafficPercent(subscription) >= 85),
);
$: user = data?.user || {};
$: isAdmin = Boolean(user?.is_admin);
@@ -1845,7 +1860,7 @@
months: selectedTopupPlan.months,
traffic_gb: selectedTopupPlan.traffic_gb,
tariff_key: selectedTopupPlan.tariff_key || topupOptions?.tariff_key,
sale_mode: "topup",
sale_mode: selectedTopupPlan.sale_mode || "topup",
method: selectedMethod,
}),
});
@@ -2231,6 +2246,7 @@
}
function openTopupModal() {
if (!canOpenTopupModal) return;
topupModalOpen = true;
loadTopupOptions();
}
@@ -2313,6 +2329,11 @@
return `${formatted} GB`;
}
function formatTrafficBytes(value) {
const gb = Number(value || 0) / 1073741824;
return formatTrafficGb(gb);
}
function planKey(plan) {
return plan?.id || `${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`;
}
@@ -2421,6 +2442,33 @@
return t("wa_traffic_reset_policy");
}
function premiumTrafficPercent(sub) {
const used = Number(sub?.premium_used_bytes || 0);
const limit = Number(sub?.premium_limit_bytes || 0);
if (!limit || limit <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
}
function premiumTrafficLabel(sub) {
return t("wa_traffic_of", { used: sub?.premium_used || "0 GB", limit: sub?.premium_limit || "0 GB" });
}
function premiumTrafficLeftLabel(sub) {
const left = Math.max(0, Number(sub?.premium_limit_bytes || 0) - Number(sub?.premium_used_bytes || 0));
return formatTrafficBytes(left);
}
function premiumTopupBalanceLabel(sub) {
return formatTrafficBytes(Number(sub?.premium_topup_balance_bytes || 0));
}
function premiumServerLabels(sub) {
const labels = Array.isArray(sub?.premium_node_labels) && sub.premium_node_labels.length
? sub.premium_node_labels
: sub?.premium_squad_labels || [];
return labels.map((label) => String(label || "").trim()).filter(Boolean);
}
function planDisplayTitle(plan) {
if (plan?.tariff_key) {
return plan?.tariff_name || plan?.title || plan?.tariff_key;
@@ -2438,14 +2486,14 @@
function planSubtitle(plan) {
if (!plan?.tariff_key) return "";
if (plan?.subtitle) return plan.subtitle;
if (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.billing_model === "traffic") {
if (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.sale_mode === "premium_topup" || plan?.billing_model === "traffic") {
return formatTrafficGb(plan?.traffic_gb || plan?.months);
}
return _formatMonthsForClient(plan?.months);
}
function planUnitHint(plan) {
if (trafficMode || plan?.sale_mode === "traffic" || plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup") {
if (trafficMode || plan?.sale_mode === "traffic" || plan?.sale_mode === "traffic_package" || plan?.sale_mode === "topup" || plan?.sale_mode === "premium_topup") {
const gb = Number(plan?.traffic_gb || plan?.months || 0);
if (!gb) return "";
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
@@ -2537,6 +2585,34 @@
return t("wa_topup_warning_levels", { levels });
}
function topupModalDescription() {
if (!topupOptions) return "";
if (singleTariffMode) return "";
return topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : "";
}
function topupCarryoverNotes() {
const plans = topupOptions?.plans || [];
if (!plans.length) return [];
return [
t(
"wa_topup_carryover",
{},
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток."
),
];
}
function deviceTopupModalDescription() {
if (!deviceTopupOptions) return "";
return deviceTopupOptions?.tariff_name ? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name }) : "";
}
function tariffChangeModalDescription() {
if (!changeOptions) return "";
return changeOptions?.current ? t("wa_current_tariff", { tariff: changeOptions.current.title }) : "";
}
function _formatMonthsForClient(value) {
const months = Number(value || 0);
if (months === 1) return currentLang === "en" ? "1 month" : "1 месяц";
@@ -2913,7 +2989,10 @@
</Card>
{#if subscription.active}
<Card>
<Card class={canOpenTopupModal ? "traffic-card-clickable" : ""}>
{#if canOpenTopupModal}
<button class="card-click-target" type="button" on:click={openTopupModal} aria-label={t("wa_topup_traffic")}></button>
{/if}
<div class="traffic-top">
<span>{t("wa_home_traffic_used")}</span>
<strong>{trafficLabel(subscription)}</strong>
@@ -2926,6 +3005,44 @@
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
</div>
</Card>
{#if Number(subscription?.premium_limit_bytes || 0) > 0}
<Card class={`${canOpenTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
{#if canOpenTopupModal}
<button class="card-click-target" type="button" on:click={openTopupModal} aria-label={t("wa_topup_traffic")}></button>
{/if}
<div class="traffic-top">
<span>{t("wa_premium_traffic_title", {}, "Premium-серверы")}</span>
<strong>{premiumTrafficLabel(subscription)}</strong>
</div>
<div class="progress premium-progress">
<span style={`width: ${premiumTrafficPercent(subscription)}%`}></span>
</div>
<div class="traffic-meta">
<span>{subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")}</span>
<span class="traffic-percent">{premiumTrafficPercent(subscription)}%</span>
</div>
<div class="premium-detail-grid">
<span>
<small>{t("wa_premium_left", {}, "Осталось")}</small>
<strong>{premiumTrafficLeftLabel(subscription)}</strong>
</span>
<span>
<small>{t("wa_premium_topup_balance", {}, "Докупленный остаток")}</small>
<strong>{premiumTopupBalanceLabel(subscription)}</strong>
</span>
</div>
{#if premiumServerLabels(subscription).length}
<div class="premium-server-list">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
{/if}
</Card>
{/if}
{:else if appSettings?.trial_enabled && appSettings?.trial_available}
<Card class="trial-card">
<div class="trial-card-head">
@@ -2959,7 +3076,7 @@
{t("wa_activate_trial")}
</Button>
{/if}
{#if hasActiveTariffSubscription}
{#if canChangeTariff}
<Button class="wide" variant="secondary" onclick={openTariffChangeModal}>
<RefreshCw size={18} />
{t("wa_change_tariff")}
@@ -3159,7 +3276,6 @@
</span>
<ArrowRight size={17} />
</button>
<div class="settings-divider" aria-hidden="true"></div>
</div>
{/if}
<div class="settings-links-block">
@@ -3480,13 +3596,39 @@
<Dialog
open={changeModalOpen}
title={t("wa_change_tariff")}
description={changeOptions?.current ? t("wa_current_tariff", { tariff: changeOptions.current.title }) : t("wa_tariff_options_loading")}
description={tariffChangeModalDescription()}
closeLabel={t("wa_close")}
onclose={closeTariffChangeModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if changeOptions?.targets?.length}
{#if !changeOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="tariff-action-list">
{#each [1, 2] as _}
<div class="tariff-action-card skeleton-row">
<span>
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="skeleton-line skeleton-line-price"></span>
</div>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
<div class="option-list">
{#each [1, 2] as _}
<div class="option-row change-action-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
{:else if changeOptions?.targets?.length}
<p class="section-kicker">{t("wa_tariff_change_targets_title")}</p>
<div class="tariff-action-list">
{#each changeOptions.targets as target}
@@ -3562,7 +3704,7 @@
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
{/if}
{:else}
<Card class="empty-card">{tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_tariff_change_options")}</Card>
<Card class="empty-card">{t("wa_no_tariff_change_options")}</Card>
{/if}
</div>
</Dialog>
@@ -3594,13 +3736,39 @@
<Dialog
open={topupModalOpen}
title={t("wa_topup_traffic")}
description={topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : t("wa_tariff_options_loading")}
description={topupModalDescription()}
closeLabel={t("wa_close")}
onclose={closeTopupModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if topupOptions?.plans?.length}
{#if !topupOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="option-list">
{#each [1, 2, 3] as _}
<div class="option-row plan-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="option-row-meta">
<span class="skeleton-line skeleton-line-price"></span>
<span class="skeleton-line skeleton-line-tiny"></span>
</span>
</div>
{/each}
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
<span class="skeleton-dot"></span>
<span class="skeleton-line skeleton-line-method"></span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
{:else if topupOptions?.plans?.length}
<div class="option-list">
{#each topupOptions.plans as plan}
<button
@@ -3611,20 +3779,27 @@
>
<span class="option-row-main">
<strong>{plan.title}</strong>
<small>{plan.subtitle || topupOptions.tariff_name}</small>
{#if !singleTariffMode || plan.sale_mode === "premium_topup"}
<small>{plan.subtitle || topupOptions.tariff_name}</small>
{/if}
</span>
<span class="option-row-meta">
<em>{priceLabel(plan)}</em>
{#if planUnitHint(plan)}
<small>{planUnitHint(plan)}</small>
{/if}
{#if planKey(selectedTopupPlan) === planKey(plan)}
<CheckCircle2 size={18} />
{/if}
</span>
</button>
{/each}
</div>
{@const carryoverNotes = topupCarryoverNotes()}
{#if carryoverNotes.length}
<div class="topup-carryover-note">
{#each carryoverNotes as note}
<p>{note}</p>
{/each}
</div>
{/if}
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
@@ -3648,7 +3823,7 @@
<LockKeyhole size={17} />
</Button>
{:else}
<Card class="empty-card">{tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_topup_options")}</Card>
<Card class="empty-card">{t("wa_no_topup_options")}</Card>
{/if}
</div>
</Dialog>
@@ -3656,13 +3831,38 @@
<Dialog
open={deviceTopupModalOpen}
title={t("wa_buy_hwid_devices")}
description={deviceTopupOptions?.tariff_name ? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name }) : t("wa_tariff_options_loading")}
description={deviceTopupModalDescription()}
closeLabel={t("wa_close")}
onclose={closeDeviceTopupModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if deviceTopupOptions?.plans?.length}
{#if !deviceTopupOptions}
<div class="dialog-skeleton" aria-label={t("wa_tariff_options_loading")}>
<div class="option-list">
{#each [1, 2, 3] as _}
<div class="option-row plan-row skeleton-row">
<span class="option-row-main">
<span class="skeleton-line skeleton-line-title"></span>
<span class="skeleton-line skeleton-line-short"></span>
</span>
<span class="option-row-meta">
<span class="skeleton-line skeleton-line-price"></span>
</span>
</div>
{/each}
</div>
<div class="method-grid">
{#each [1, 2] as _}
<div class="method-card skeleton-method">
<span class="skeleton-dot"></span>
<span class="skeleton-line skeleton-line-method"></span>
</div>
{/each}
</div>
<div class="skeleton-pay-button"></div>
</div>
{:else if deviceTopupOptions?.plans?.length}
<div class="option-list">
{#each deviceTopupOptions.plans as plan}
<button
@@ -3707,7 +3907,7 @@
<LockKeyhole size={17} />
</Button>
{:else}
<Card class="empty-card">{tariffActionBusy ? t("wa_tariff_options_loading") : t("wa_no_hwid_device_options")}</Card>
<Card class="empty-card">{t("wa_no_hwid_device_options")}</Card>
{/if}
</div>
</Dialog>
+186 -11
View File
@@ -174,6 +174,10 @@
let tariffDeleteOpen = false;
let tariffDeleteTarget = null;
let tariffDraft = emptyTariffDraft();
let panelSquads = [];
let panelSquadsLoading = false;
let selectedBaseSquad = "";
let selectedPremiumSquad = "";
// Settings
let settingsSections = [];
@@ -551,10 +555,12 @@
nameEn: "",
descriptionRu: "",
descriptionEn: "",
squadUuids: "",
squadUuids: [],
premiumSquadUuids: [],
billing_model: "period",
enabled: true,
monthly_gb: 500,
premium_monthly_gb: "",
hwid_device_limit: "",
conversion_rate_rub_per_gb: "",
periodRows: [
@@ -565,6 +571,8 @@
],
topupRubRows: [],
topupStarsRows: [],
premiumTopupRubRows: [],
premiumTopupStarsRows: [],
trafficRubRows: [
{ gb: 10, price: 199 },
{ gb: 50, price: 799 },
@@ -612,15 +620,19 @@
nameEn: tariff.names?.en || "",
descriptionRu: tariff.descriptions?.ru || "",
descriptionEn: tariff.descriptions?.en || "",
squadUuids: (tariff.squad_uuids || []).join("\n"),
squadUuids: tariff.squad_uuids || [],
premiumSquadUuids: tariff.premium_squad_uuids || [],
billing_model: tariff.billing_model || "period",
enabled: tariff.enabled !== false,
monthly_gb: tariff.monthly_gb ?? "",
premium_monthly_gb: tariff.premium_monthly_gb ?? "",
hwid_device_limit: tariff.hwid_device_limit ?? "",
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
@@ -672,10 +684,8 @@
key,
names,
descriptions,
squad_uuids: tariffDraft.squadUuids
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean),
squad_uuids: normalizeUuidList(tariffDraft.squadUuids),
premium_squad_uuids: normalizeUuidList(tariffDraft.premiumSquadUuids),
billing_model: tariffDraft.billing_model,
enabled: Boolean(tariffDraft.enabled),
};
@@ -684,6 +694,14 @@
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
const hwidPackages = packageSetFromRows(tariffDraft.hwidRubRows, tariffDraft.hwidStarsRows, "count");
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
const premiumMonthlyGb = parseNumber(tariffDraft.premium_monthly_gb);
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
const premiumTopupPackages = packageSetFromRows(
tariffDraft.premiumTopupRubRows,
tariffDraft.premiumTopupStarsRows,
"gb",
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
if (tariff.billing_model === "period") {
const seenMonths = new Set();
@@ -719,6 +737,7 @@
async function loadTariffs() {
tariffsLoading = true;
try {
loadPanelSquads();
const data = await api("/admin/tariffs");
if (data?.ok) {
tariffsCatalog = cloneCatalog(data.catalog);
@@ -731,6 +750,46 @@
}
}
async function loadPanelSquads() {
if (panelSquadsLoading) return;
panelSquadsLoading = true;
try {
const data = await api("/admin/panel/internal-squads");
if (data?.ok) panelSquads = data.squads || [];
} catch (e) {
panelSquads = [];
} finally {
panelSquadsLoading = false;
}
}
function normalizeUuidList(value) {
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
return String(value || "")
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function squadLabel(uuid) {
const squad = panelSquads.find((item) => item.uuid === uuid);
return squad ? `${squad.name} · ${uuid.slice(0, 8)}…` : uuid;
}
function addSquadToDraft(field, uuid) {
if (!uuid) return;
const current = normalizeUuidList(tariffDraft[field]);
if (current.includes(uuid)) return;
tariffDraft = { ...tariffDraft, [field]: [...current, uuid] };
}
function removeSquadFromDraft(field, uuid) {
tariffDraft = {
...tariffDraft,
[field]: normalizeUuidList(tariffDraft[field]).filter((item) => item !== uuid),
};
}
async function persistTariffs(nextCatalog, successText) {
tariffsSaving = true;
try {
@@ -757,6 +816,8 @@
tariffEditingKey = "";
tariffDraft = emptyTariffDraft();
tariffEditorTab = "general";
selectedBaseSquad = "";
selectedPremiumSquad = "";
tariffEditorOpen = true;
}
@@ -764,6 +825,8 @@
tariffEditingKey = tariff.key;
tariffDraft = draftFromTariff(tariff);
tariffEditorTab = "general";
selectedBaseSquad = "";
selectedPremiumSquad = "";
tariffEditorOpen = true;
}
@@ -1794,6 +1857,7 @@
<span>{tariff.billing_model === "traffic" ? "Трафик" : "Периоды"}</span>
<span>{tariffPriceSummary(tariff)}</span>
<span>Squads: {(tariff.squad_uuids || []).length}</span>
<span>Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"}</span>
<span>Устройства: {tariff.hwid_device_limit ?? "env"}</span>
</div>
<div class="admin-tariff-actions">
@@ -1921,6 +1985,7 @@
<Tabs.Trigger value="general" class="admin-tabs-trigger">Основное</Tabs.Trigger>
<Tabs.Trigger value="pricing" class="admin-tabs-trigger">Цены</Tabs.Trigger>
<Tabs.Trigger value="topup" class="admin-tabs-trigger">Докупки</Tabs.Trigger>
<Tabs.Trigger value="premium" class="admin-tabs-trigger">Premium</Tabs.Trigger>
<Tabs.Trigger value="hwid" class="admin-tabs-trigger">Устройства</Tabs.Trigger>
</Tabs.List>
@@ -1992,11 +2057,40 @@
</Label.Root>
</div>
<Label.Root class="admin-field-label">
<span>Internal Squads UUID</span>
<small>Один UUID на строку или через запятую</small>
<textarea class="admin-textarea" rows="3" placeholder="db786ee8-816b-4760-80aa-1fc7a3669ff2" bind:value={tariffDraft.squadUuids}></textarea>
</Label.Root>
<div class="admin-field-label">
<span>Основные Internal Squads</span>
<small>{panelSquadsLoading ? "Загружаю список из панели…" : "Выберите сквады из Remnawave"}</small>
<Select.Root
type="single"
bind:value={selectedBaseSquad}
onValueChange={(value) => {
addSquadToDraft("squadUuids", value);
selectedBaseSquad = "";
}}
>
<Select.Trigger class="admin-select-trigger" aria-label="Добавить основной сквад">
<span>Добавить сквад</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each panelSquads as squad}
<Select.Item value={squad.uuid} class="admin-select-item">
<span>{squad.name}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
<button type="button" class="admin-chip" on:click={() => removeSquadFromDraft("squadUuids", uuid)}>
{squadLabel(uuid)} <X size={12} />
</button>
{/each}
</div>
</div>
<div class="admin-form-row admin-form-row-2">
<Label.Root class="admin-field-label">
@@ -2020,6 +2114,87 @@
</div>
</Tabs.Content>
<Tabs.Content value="premium" class="admin-tabs-content">
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<strong>Premium-сквад и отдельный лимит</strong>
</header>
<div class="admin-form-row admin-form-row-2">
<div class="admin-field-label">
<span>Premium Internal Squads</span>
<small>Ноды для учета трафика будут взяты из accessible nodes этих сквадов</small>
<Select.Root
type="single"
bind:value={selectedPremiumSquad}
onValueChange={(value) => {
addSquadToDraft("premiumSquadUuids", value);
selectedPremiumSquad = "";
}}
>
<Select.Trigger class="admin-select-trigger" aria-label="Добавить premium-сквад">
<span>Добавить premium-сквад</span>
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" sideOffset={6}>
{#each panelSquads as squad}
<Select.Item value={squad.uuid} class="admin-select-item">
<span>{squad.name}</span>
<Check size={14} class="admin-select-item-check" />
</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
<div class="admin-chip-list">
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
<button type="button" class="admin-chip" on:click={() => removeSquadFromDraft("premiumSquadUuids", uuid)}>
{squadLabel(uuid)} <X size={12} />
</button>
{/each}
</div>
</div>
<Label.Root class="admin-field-label">
<span>Premium лимит, GB/мес.</span>
<small>0 или пусто — нет отдельного premium-лимита</small>
<input class="input" type="number" min="0" step="0.1" placeholder="50" bind:value={tariffDraft.premium_monthly_gb} />
</Label.Root>
</div>
</section>
<section class="admin-editor-section">
<header class="admin-editor-section-head">
<strong>Докупка premium-трафика</strong>
<div class="admin-editor-section-actions">
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}><Plus size={12} /> RUB</button>
<button type="button" class="admin-btn admin-btn-sm" on:click={() => addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}><Plus size={12} /> Stars</button>
</div>
</header>
<div class="admin-package-columns">
<div class="admin-row-editor">
<span class="admin-row-editor-caption">RUB</span>
{#each tariffDraft.premiumTopupRubRows as row, index}
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" />
<input class="input" type="number" min="0" step="0.01" placeholder="Цена" bind:value={row.price} aria-label="Цена RUB" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupRubRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div>
{/each}
</div>
<div class="admin-row-editor">
<span class="admin-row-editor-caption">Stars</span>
{#each tariffDraft.premiumTopupStarsRows as row, index}
<div class="admin-row-editor-line">
<input class="input" type="number" min="0.1" step="0.1" placeholder="GB" bind:value={row.gb} aria-label="Premium GB" />
<input class="input" type="number" min="0" step="1" placeholder="Stars" bind:value={row.price} aria-label="Цена Stars" />
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" on:click={() => removeDraftRow("premiumTopupStarsRows", index)} aria-label="Удалить"><Trash2 size={13} /></button>
</div>
{/each}
</div>
</div>
</section>
</Tabs.Content>
<Tabs.Content value="pricing" class="admin-tabs-content">
{#if tariffDraft.billing_model === "period"}
<section class="admin-editor-section">
+230 -6
View File
@@ -388,6 +388,107 @@ a {
font-size: 11px;
}
.traffic-card-clickable {
cursor: pointer;
}
.card-click-target {
position: absolute;
z-index: 2;
inset: 0;
width: 100%;
border: 0;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.card-click-target:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -3px;
}
.premium-traffic-card {
display: grid;
gap: 10px;
}
.premium-traffic-card-limited {
border-color: rgba(255, 193, 7, 0.34);
}
.premium-progress span {
background: linear-gradient(90deg, #38bdf8, var(--accent));
}
.premium-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.premium-detail-grid span {
display: grid;
gap: 3px;
min-width: 0;
}
.premium-detail-grid small,
.premium-server-list small,
.topup-summary-card small {
color: var(--muted);
font-size: 11px;
line-height: 1.3;
}
.premium-detail-grid strong {
font-size: 12px;
line-height: 1.2;
}
.premium-server-list {
display: grid;
gap: 6px;
}
.premium-server-list > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.premium-server-list span {
max-width: 100%;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
color: var(--text);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.topup-summary-card {
display: grid;
gap: 10px;
padding: 12px;
}
.topup-summary-card > div:not(.premium-server-list) {
display: grid;
gap: 3px;
}
.topup-summary-card strong {
font-size: 12px;
line-height: 1.2;
}
.premium-server-list-modal {
margin-top: 0;
}
.trial-card {
padding: 13px 14px;
}
@@ -828,6 +929,102 @@ a {
color: var(--accent);
}
.dialog-skeleton {
display: grid;
gap: 12px;
}
.topup-carryover-note {
display: grid;
gap: 6px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
}
.topup-carryover-note p {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.skeleton-row,
.skeleton-method,
.skeleton-pay-button {
pointer-events: none;
}
.skeleton-line,
.skeleton-dot,
.skeleton-pay-button {
display: block;
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(
90deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.14) 42%,
rgba(255, 255, 255, 0.07) 84%
);
background-size: 220% 100%;
animation: skeleton-shimmer 1.15s ease-in-out infinite;
}
.skeleton-line-title {
width: 118px;
height: 13px;
}
.skeleton-line-short {
width: 76px;
height: 10px;
opacity: 0.75;
}
.skeleton-line-price {
width: 58px;
height: 12px;
}
.skeleton-line-tiny {
width: 34px;
height: 9px;
opacity: 0.7;
}
.skeleton-line-method {
width: 72px;
height: 12px;
}
.skeleton-dot {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.skeleton-method {
gap: 8px;
}
.skeleton-pay-button {
width: 100%;
height: 46px;
border-radius: var(--radius);
}
@keyframes skeleton-shimmer {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
.back-inline {
display: inline-flex;
width: fit-content;
@@ -3960,12 +4157,12 @@ a {
.settings-admin-block {
display: grid;
gap: 10px;
margin: 6px 0 10px;
margin: 6px 0 0;
}
.settings-row.settings-row-admin {
border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent);
background: color-mix(in srgb, #f59e0b 12%, transparent);
border-radius: var(--radius);
padding: 10px 12px;
width: 100%;
@@ -3974,12 +4171,12 @@ a {
}
.settings-row.settings-row-admin:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
background: color-mix(in srgb, #f59e0b 18%, transparent);
border-color: color-mix(in srgb, #f59e0b 58%, transparent);
}
.settings-row.settings-row-admin > svg:first-child {
color: var(--accent);
color: #fbbf24;
opacity: 1;
}
@@ -4190,6 +4387,33 @@ a {
opacity: 1;
}
.admin-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 0;
}
.admin-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
max-width: 100%;
padding: 5px 8px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface-2);
color: var(--admin-text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.admin-chip:hover {
border-color: var(--admin-border-strong);
}
/* Label primitive (bits-ui Label.Root) */
.admin-field-label {
display: flex;
+86 -17
View File
@@ -1744,7 +1744,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Stars price is not configured")
payment_units = device_count
sale_mode = f"hwid_devices@{tariff.key}"
elif tariffs_config and requested_sale_mode == "topup":
elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
return _json_error(400, "invalid_plan", "Tariff is not selected")
@@ -1760,7 +1760,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
)
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid traffic package")
packages = tariffs_config.topup_packages_for(tariff)
packages = (
tariff.premium_topup_packages
if requested_sale_mode == "premium_topup"
else tariffs_config.topup_packages_for(tariff)
)
rub_packages = {float(package.gb): float(package.price) for package in (packages.rub if packages else [])}
stars_packages = {float(package.gb): int(float(package.price)) for package in (packages.stars if packages else [])}
package_key = _resolve_numeric_option_key(rub_packages, traffic_gb)
@@ -1773,7 +1777,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
return _json_error(400, "invalid_plan", "Stars price is not configured")
payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb
traffic_gb_for_payment = float(payment_units)
sale_mode = f"topup@{tariff.key}"
sale_mode = f"{requested_sale_mode}@{tariff.key}"
elif tariffs_config:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
@@ -1980,14 +1984,40 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
tariff = config.require(sub.tariff_key)
plans = _serialize_topup_packages(settings, tariff, config.topup_packages_for(tariff), lang)
premium_plans = _serialize_topup_packages(
settings,
tariff,
tariff.premium_topup_packages,
lang,
sale_mode="premium_topup",
title_prefix="Premium ",
) if tariff.premium_squad_uuids else []
premium_limit_bytes = (
int(sub.premium_baseline_bytes or 0)
+ int(sub.premium_topup_balance_bytes or 0)
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
)
premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff)
return web.json_response(
{
"ok": True,
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes),
"premium_traffic_percent": _traffic_percent(
sub.premium_used_bytes,
premium_limit_bytes,
),
"premium_limit_bytes": premium_limit_bytes,
"premium_used_bytes": int(sub.premium_used_bytes or 0),
"premium_baseline_bytes": int(sub.premium_baseline_bytes or 0),
"premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0),
"premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0),
"premium_is_limited": bool(sub.premium_is_limited),
"premium_squad_labels": premium_access.get("squad_labels") or [],
"premium_node_labels": premium_access.get("node_labels") or [],
"warning_levels": settings.tariff_traffic_warning_levels,
"plans": plans,
"plans": plans + premium_plans,
}
)
@@ -2971,7 +3001,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"language_code": lang,
"is_admin": is_admin,
},
"subscription": _serialize_subscription(active, local_sub, lang),
"subscription": _serialize_subscription(settings, active, local_sub, lang),
"referral": {
"code": referral_code,
"bot_link": referral_link,
@@ -3052,6 +3082,7 @@ def _build_webapp_referral_link(
def _serialize_subscription(
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
@@ -3077,6 +3108,18 @@ def _serialize_subscription(
int((end_date - datetime.now(timezone.utc)).total_seconds()),
)
can_topup_traffic = False
if settings.tariffs_config and active.get("tariff_key"):
try:
tariff = settings.tariffs_config.require(str(active.get("tariff_key")))
packages = settings.tariffs_config.topup_packages_for(tariff)
can_topup_traffic = bool(
(packages and packages.has_any())
or (tariff.premium_topup_packages and tariff.premium_topup_packages.has_any())
)
except Exception:
can_topup_traffic = False
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
@@ -3097,6 +3140,17 @@ def _serialize_subscription(
"traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""),
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")),
"premium_limit": _format_bytes(active.get("premium_limit_bytes")),
"premium_used": _format_bytes(active.get("premium_used_bytes")),
"premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")),
"premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")),
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
"premium_topup_balance_bytes": _coerce_int_or_none(active.get("premium_topup_balance_bytes")),
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
"premium_is_limited": bool(active.get("premium_is_limited")),
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
"premium_node_labels": list(active.get("premium_node_labels") or []),
"can_topup_traffic": can_topup_traffic,
"period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None,
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
@@ -3242,6 +3296,9 @@ def _serialize_topup_packages(
tariff: Any,
packages: Optional[Any],
lang: str,
*,
sale_mode: str = "topup",
title_prefix: str = "",
) -> List[Dict[str, Any]]:
rub_packages = {float(package.gb): float(package.price) for package in (packages.rub if packages else [])}
stars_packages = {float(package.gb): int(float(package.price)) for package in (packages.stars if packages else [])}
@@ -3253,17 +3310,17 @@ def _serialize_topup_packages(
continue
traffic_value = float(traffic_gb)
plan: Dict[str, Any] = {
"id": f"{tariff.key}:topup:{_format_number_for_payload(traffic_value)}",
"id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": "topup",
"sale_mode": sale_mode,
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
"traffic_gb": traffic_value,
"price": float(price or 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_traffic_title(traffic_value, lang),
"subtitle": tariff.name(lang),
"title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}",
"subtitle": ("Premium-серверы" if lang == "ru" else "Premium servers") if sale_mode == "premium_topup" else tariff.name(lang),
}
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
@@ -3396,19 +3453,19 @@ def _serialize_payment_methods(
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if method == "severpay" and _service_configured(app, "severpay_service"):
if method == "severpay" and settings.SEVERPAY_ENABLED and _service_configured(app, "severpay_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "freekassa" and _service_configured(app, "freekassa_service"):
elif method == "freekassa" and settings.FREEKASSA_ENABLED and _service_configured(app, "freekassa_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "platega_sbp" and settings.PLATEGA_SBP_ENABLED and _service_configured(app, "platega_service"):
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED and _service_configured(app, "platega_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "platega_crypto" and settings.PLATEGA_CRYPTO_ENABLED and _service_configured(app, "platega_service"):
elif method == "platega_crypto" and settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED and _service_configured(app, "platega_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "yookassa" and _service_configured(app, "yookassa_service"):
elif method == "yookassa" and settings.YOOKASSA_ENABLED and _service_configured(app, "yookassa_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif method == "cryptopay" and _service_configured(app, "cryptopay_service"):
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED and _service_configured(app, "cryptopay_service"):
methods.append({"id": method, "name": labels[method]})
return methods
@@ -3429,7 +3486,7 @@ def _sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
def _sale_mode_is_traffic(sale_mode: str) -> bool:
return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup"}
return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"}
def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
@@ -3462,24 +3519,36 @@ async def _create_subscription_payment(
)
if method == "yookassa":
if not settings.YOOKASSA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_yookassa_payment(
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
)
if method == "freekassa":
if not settings.FREEKASSA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_freekassa_payment(
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
)
if method in ("platega", "platega_sbp", "platega_crypto"):
if not settings.PLATEGA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_platega_payment(
request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb
)
if method == "severpay":
if not settings.SEVERPAY_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_severpay_payment(
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
)
if method == "cryptopay":
service: CryptoPayService = request.app["cryptopay_service"]
if not service or not service.configured:
if not settings.CRYPTOPAY_ENABLED or not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
url = await service.create_invoice(
session=session,