feat: manage trial settings on tariffs page
This commit is contained in:
@@ -266,10 +266,38 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
|
||||
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0),
|
||||
SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"),
|
||||
SettingField("TRIAL_ENABLED", "bool", "pricing", "Триал включён", subsection="trial"),
|
||||
SettingField(
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Длительность триала (дней)",
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||
"float",
|
||||
"pricing",
|
||||
"Лимит трафика триала (ГБ)",
|
||||
min=0,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_TRAFFIC_STRATEGY",
|
||||
"string",
|
||||
"pricing",
|
||||
"Стратегия сброса трафика триала",
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
"string",
|
||||
"pricing",
|
||||
"Internal Squads для триала",
|
||||
"UUID через запятую. Если пусто, используется USER_SQUAD_UUIDS.",
|
||||
subsection="trial",
|
||||
),
|
||||
# ─── Referral program ──────────────────────────────────────────
|
||||
SettingField(
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
||||
|
||||
@@ -83,7 +83,15 @@ class TrialSubscriptionMixin:
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
|
||||
traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
include_default_squads=False,
|
||||
)
|
||||
trial_squads = self.settings.parsed_trial_squad_uuids
|
||||
if trial_squads:
|
||||
panel_update_payload["activeInternalSquads"] = trial_squads
|
||||
if self.settings.parsed_user_external_squad_uuid:
|
||||
panel_update_payload["externalSquadUuid"] = (
|
||||
self.settings.parsed_user_external_squad_uuid
|
||||
)
|
||||
|
||||
panel_update_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
|
||||
|
||||
@@ -273,6 +273,13 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Comma-separated UUIDs of internal squads to assign during trial activation. "
|
||||
"Falls back to USER_SQUAD_UUIDS when empty."
|
||||
),
|
||||
)
|
||||
|
||||
CRYPT4_ENABLED: bool = Field(
|
||||
default=False, description="Enable happ crypt4 encryption for subscription URLs"
|
||||
@@ -543,6 +550,17 @@ class Settings(BaseSettings):
|
||||
return [uuid.strip() for uuid in self.USER_SQUAD_UUIDS.split(",") if uuid.strip()]
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_trial_squad_uuids(self) -> Optional[List[str]]:
|
||||
if self.TRIAL_SQUAD_UUIDS:
|
||||
trial_squads = [
|
||||
uuid.strip() for uuid in self.TRIAL_SQUAD_UUIDS.split(",") if uuid.strip()
|
||||
]
|
||||
if trial_squads:
|
||||
return trial_squads
|
||||
return self.parsed_user_squad_uuids
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||
|
||||
@@ -355,6 +355,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `TRIAL_DURATION_DAYS` | Длительность пробного периода. |
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB` | Лимит трафика пробного периода. |
|
||||
| `TRIAL_TRAFFIC_STRATEGY` | Стратегия лимита пробного периода. |
|
||||
| `TRIAL_SQUAD_UUIDS` | Internal Squads для trial через запятую. Если пусто, используется `USER_SQUAD_UUIDS`. |
|
||||
| `REFERRAL_ONE_BONUS_PER_REFEREE` | Ограничить бонусы одним успешным платежом приглашенного. |
|
||||
| `REFERRAL_WELCOME_BONUS_DAYS` | Приветственный бонус пришедшему по реферальной ссылке. |
|
||||
| `LEGACY_REFS` | Разрешить ссылки `ref_<telegram_id>`. |
|
||||
|
||||
+1
-1
@@ -286,7 +286,7 @@ Remnawave ограничивает доступ при достижении `tra
|
||||
|
||||
Автопродление через YooKassa применяется к подпискам на срок. Для режима продажи трафика без JSON-каталога автопродление пропускается. Для traffic-тарифов JSON-каталога покупка является пакетом трафика, а не периодической подпиской.
|
||||
|
||||
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB` и `TRIAL_TRAFFIC_STRATEGY`. Он не выбирает тариф из JSON-каталога.
|
||||
Пробный период использует настройки `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`, `TRIAL_TRAFFIC_STRATEGY` и `TRIAL_SQUAD_UUIDS`. Он не выбирает тариф из JSON-каталога, но его можно настроить на странице **Система → Тарифы** рядом с каталогом продаж. Если `TRIAL_SQUAD_UUIDS` пустой, для trial применяются squads из `USER_SQUAD_UUIDS`.
|
||||
|
||||
Промокоды с бонусными днями применяются к покупке period-подписки. Реферальные бонусы по периодам также относятся к подпискам на срок; в режиме продажи трафика без JSON-каталога Web App не показывает детализацию бонусов по месяцам.
|
||||
|
||||
|
||||
@@ -734,7 +734,7 @@
|
||||
{/if}
|
||||
|
||||
{#if active === "tariffs"}
|
||||
<TariffsSection {at} {fmtMoney} />
|
||||
<TariffsSection {at} {fmtMoney} {onSettingsSaved} />
|
||||
{/if}
|
||||
|
||||
{#if active === "appearance"}
|
||||
|
||||
@@ -42,11 +42,19 @@
|
||||
const PLATEGA_CRYPTO_KEYS = new Set(["PLATEGA_CRYPTO_ENABLED", "PLATEGA_CRYPTO_METHOD"]);
|
||||
const PLATEGA_LEGACY_KEYS = new Set(["PLATEGA_PAYMENT_METHOD"]);
|
||||
const LEGACY_TARIFF_TRAFFIC_KEYS = new Set(["TRAFFIC_PACKAGES", "STARS_TRAFFIC_PACKAGES"]);
|
||||
const TRIAL_TARIFF_KEYS = new Set([
|
||||
"TRIAL_ENABLED",
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||
"TRIAL_TRAFFIC_STRATEGY",
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
]);
|
||||
const SEMANTIC_FIELD_GROUP_ORDER = {
|
||||
platega_common: 1,
|
||||
platega_sbp: 2,
|
||||
platega_crypto: 3,
|
||||
platega_legacy: 4,
|
||||
trial_tariff_settings: 0,
|
||||
legacy_tariff_periods: 1,
|
||||
legacy_tariff_traffic: 2,
|
||||
};
|
||||
@@ -283,6 +291,15 @@
|
||||
|
||||
function legacyTariffSemanticGroup(field) {
|
||||
const key = String(field?.key || "");
|
||||
if (TRIAL_TARIFF_KEYS.has(key)) {
|
||||
return fieldGroupMeta(
|
||||
"trial_tariff_settings",
|
||||
"settings_group_trial_tariff_settings",
|
||||
"Trial access",
|
||||
"settings_group_trial_tariff_settings_hint",
|
||||
"Trial duration, traffic limit, and Remnawave squads are also available on the Tariffs page."
|
||||
);
|
||||
}
|
||||
if (LEGACY_TARIFF_TRAFFIC_KEYS.has(key)) {
|
||||
return fieldGroupMeta(
|
||||
"legacy_tariff_traffic",
|
||||
|
||||
@@ -1,17 +1,73 @@
|
||||
<script>
|
||||
import { RefreshCw, Trash2, Plus } from "$components/ui/icons.js";
|
||||
import { RefreshCw, Trash2, Plus, Save, TriangleAlert, X } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminSelect,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { Switch } from "$components/ui/primitives.js";
|
||||
|
||||
export let at;
|
||||
export let fmtMoney;
|
||||
export let onSettingsSaved = () => {};
|
||||
|
||||
const tariffsStore = getContext("tariffsStore");
|
||||
const settingsStore = getContext("settingsStore");
|
||||
|
||||
$: ({ tariffsCatalog, tariffsLoading, tariffsPath, tariffsSaving } = $tariffsStore);
|
||||
const TRIAL_SETTING_KEYS = [
|
||||
"TRIAL_ENABLED",
|
||||
"TRIAL_DURATION_DAYS",
|
||||
"TRIAL_TRAFFIC_LIMIT_GB",
|
||||
"TRIAL_TRAFFIC_STRATEGY",
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
];
|
||||
const LEGACY_PERIODS = [
|
||||
["1", "MONTH_1_ENABLED", "RUB_PRICE_1_MONTH", "STARS_PRICE_1_MONTH"],
|
||||
["3", "MONTH_3_ENABLED", "RUB_PRICE_3_MONTHS", "STARS_PRICE_3_MONTHS"],
|
||||
["6", "MONTH_6_ENABLED", "RUB_PRICE_6_MONTHS", "STARS_PRICE_6_MONTHS"],
|
||||
["12", "MONTH_12_ENABLED", "RUB_PRICE_12_MONTHS", "STARS_PRICE_12_MONTHS"],
|
||||
];
|
||||
const LEGACY_TARIFF_SETTING_KEYS = [
|
||||
...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
|
||||
"TRAFFIC_PACKAGES",
|
||||
"STARS_TRAFFIC_PACKAGES",
|
||||
];
|
||||
const TRAFFIC_STRATEGY_OPTIONS = [
|
||||
{ value: "NO_RESET", label: "NO_RESET" },
|
||||
{ value: "DAY", label: "DAY" },
|
||||
{ value: "WEEK", label: "WEEK" },
|
||||
{ value: "MONTH", label: "MONTH" },
|
||||
];
|
||||
|
||||
$: ({
|
||||
tariffsCatalog,
|
||||
tariffsLoading,
|
||||
tariffsPath,
|
||||
tariffsSaving,
|
||||
panelSquads,
|
||||
panelSquadsLoading,
|
||||
} = $tariffsStore);
|
||||
$: ({ settingsSections, settingsDirty, settingsSaving } = $settingsStore);
|
||||
|
||||
$: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
|
||||
$: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length);
|
||||
$: settingsFieldMap = new Map(
|
||||
(settingsSections || [])
|
||||
.flatMap((section) => section.fields || [])
|
||||
.map((field) => [field.key, field])
|
||||
);
|
||||
$: trialDirtyCount = TRIAL_SETTING_KEYS.filter((key) => Boolean(settingsDirty[key])).length;
|
||||
$: legacyDirtyCount = LEGACY_TARIFF_SETTING_KEYS.filter((key) =>
|
||||
Boolean(settingsDirty[key])
|
||||
).length;
|
||||
$: panelSquadOptions = (panelSquads || []).map((squad) => ({
|
||||
value: squad.uuid,
|
||||
label: `${squad.name || squad.uuid} · ${String(squad.uuid || "").slice(0, 8)}...`,
|
||||
}));
|
||||
|
||||
let selectedTrialSquad = "";
|
||||
|
||||
function tariffName(tariff) {
|
||||
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
|
||||
@@ -37,8 +93,65 @@
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function fieldFor(key) {
|
||||
return settingsFieldMap.get(key) || { key, value: "" };
|
||||
}
|
||||
|
||||
function valueForKey(key) {
|
||||
if (settingsDirty[key]?.deleted) return "";
|
||||
if (Object.prototype.hasOwnProperty.call(settingsDirty, key)) {
|
||||
return settingsDirty[key].value;
|
||||
}
|
||||
return fieldFor(key).value ?? "";
|
||||
}
|
||||
|
||||
function boolValue(key) {
|
||||
return Boolean(valueForKey(key));
|
||||
}
|
||||
|
||||
function setSetting(key, value) {
|
||||
settingsStore.markDirty(key, value);
|
||||
}
|
||||
|
||||
function csvList(key) {
|
||||
return String(valueForKey(key) || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function setCsvList(key, values) {
|
||||
const normalized = Array.from(
|
||||
new Set((values || []).map((item) => String(item).trim()).filter(Boolean))
|
||||
);
|
||||
settingsStore.markDirty(key, normalized.join(","));
|
||||
}
|
||||
|
||||
function addTrialSquad(uuid) {
|
||||
const next = String(uuid || "").trim();
|
||||
if (!next) return;
|
||||
setCsvList("TRIAL_SQUAD_UUIDS", [...csvList("TRIAL_SQUAD_UUIDS"), next]);
|
||||
selectedTrialSquad = "";
|
||||
}
|
||||
|
||||
function removeTrialSquad(uuid) {
|
||||
setCsvList(
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
csvList("TRIAL_SQUAD_UUIDS").filter((item) => item !== uuid)
|
||||
);
|
||||
}
|
||||
|
||||
function trialSquadLabel(uuid) {
|
||||
return tariffsStore.squadLabel(uuid);
|
||||
}
|
||||
|
||||
async function saveTariffSettings() {
|
||||
await settingsStore.saveSettings(onSettingsSaved);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
tariffsStore.loadTariffs();
|
||||
settingsStore.loadSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -69,6 +182,134 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="admin-card admin-tariff-settings-card">
|
||||
<header class="admin-card-head">
|
||||
<div>
|
||||
<h3>{at("tariffs_trial_title", {}, "Trial access")}</h3>
|
||||
<small>
|
||||
{at(
|
||||
"tariffs_trial_subtitle",
|
||||
{},
|
||||
"Configure trial duration, traffic limit, and Remnawave squads from the tariff page."
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminBadge variant={boolValue("TRIAL_ENABLED") ? "success" : "muted"}>
|
||||
{boolValue("TRIAL_ENABLED")
|
||||
? at("enabled", {}, "Enabled")
|
||||
: at("disabled", {}, "Disabled")}
|
||||
</AdminBadge>
|
||||
{#if trialDirtyCount}
|
||||
<AdminBadge variant="warning">
|
||||
{at("settings_dirty_count", { count: trialDirtyCount }, `Changes: ${trialDirtyCount}`)}
|
||||
</AdminBadge>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onclick={saveTariffSettings}
|
||||
disabled={settingsSaving}
|
||||
>
|
||||
<Save size={13} />
|
||||
{settingsSaving ? at("btn_saving", {}, "Saving...") : at("btn_save", {}, "Save")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-card-body">
|
||||
<div class="admin-form admin-tariff-settings-form">
|
||||
<div class="admin-form-row admin-form-row-3">
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_trial_enabled", {}, "Trial enabled")}</span>
|
||||
<div class="admin-setting-switch">
|
||||
<Switch.Root
|
||||
checked={boolValue("TRIAL_ENABLED")}
|
||||
onCheckedChange={(checked) => setSetting("TRIAL_ENABLED", checked)}
|
||||
class="admin-switch-root"
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<small
|
||||
>{boolValue("TRIAL_ENABLED")
|
||||
? at("enabled", {}, "Enabled")
|
||||
: at("disabled", {}, "Disabled")}</small
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_trial_days", {}, "Duration, days")}</span>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={valueForKey("TRIAL_DURATION_DAYS")}
|
||||
oninput={(event) => setSetting("TRIAL_DURATION_DAYS", event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_trial_traffic", {}, "Traffic limit, GB")}</span>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={valueForKey("TRIAL_TRAFFIC_LIMIT_GB")}
|
||||
oninput={(event) => setSetting("TRIAL_TRAFFIC_LIMIT_GB", event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_trial_strategy", {}, "Traffic reset strategy")}</span>
|
||||
<AdminSelect
|
||||
class="admin-setting-select"
|
||||
value={String(valueForKey("TRIAL_TRAFFIC_STRATEGY") || "NO_RESET")}
|
||||
items={TRAFFIC_STRATEGY_OPTIONS}
|
||||
ariaLabel={at("tariffs_trial_strategy", {}, "Traffic reset strategy")}
|
||||
onValueChange={(value) => setSetting("TRIAL_TRAFFIC_STRATEGY", value)}
|
||||
/>
|
||||
</label>
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_trial_squads", {}, "Trial Internal Squads")}</span>
|
||||
<small>
|
||||
{at(
|
||||
"tariffs_trial_squads_hint",
|
||||
{},
|
||||
"These squads are applied when trial is activated. Empty value falls back to USER_SQUAD_UUIDS."
|
||||
)}
|
||||
</small>
|
||||
<AdminSelect
|
||||
bind:value={selectedTrialSquad}
|
||||
items={panelSquadOptions}
|
||||
disabled={panelSquadsLoading || !panelSquadOptions.length}
|
||||
placeholder={panelSquadsLoading
|
||||
? at("loading", {}, "Loading...")
|
||||
: at("tariffs_trial_add_squad", {}, "Add squad from panel")}
|
||||
ariaLabel={at("tariffs_trial_add_squad", {}, "Add squad from panel")}
|
||||
onValueChange={addTrialSquad}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={valueForKey("USER_SQUAD_UUIDS") || "uuid-a,uuid-b"}
|
||||
value={valueForKey("TRIAL_SQUAD_UUIDS")}
|
||||
oninput={(event) => setSetting("TRIAL_SQUAD_UUIDS", event.currentTarget.value)}
|
||||
/>
|
||||
<div class="admin-chip-list">
|
||||
{#each csvList("TRIAL_SQUAD_UUIDS") as uuid}
|
||||
<button type="button" class="admin-chip" onclick={() => removeTrialSquad(uuid)}>
|
||||
{trialSquadLabel(uuid)}
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="admin-card">
|
||||
<header class="admin-card-head">
|
||||
<div>
|
||||
@@ -192,4 +433,116 @@
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="admin-card admin-tariff-settings-card">
|
||||
<header class="admin-card-head">
|
||||
<div>
|
||||
<h3>{at("tariffs_legacy_title", {}, "Legacy tariff compatibility")}</h3>
|
||||
<small>
|
||||
{at(
|
||||
"tariffs_legacy_subtitle",
|
||||
{},
|
||||
"Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured."
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
{#if legacyDirtyCount}
|
||||
<AdminBadge variant="warning">
|
||||
{at(
|
||||
"settings_dirty_count",
|
||||
{ count: legacyDirtyCount },
|
||||
`Changes: ${legacyDirtyCount}`
|
||||
)}
|
||||
</AdminBadge>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onclick={saveTariffSettings}
|
||||
disabled={settingsSaving}
|
||||
>
|
||||
<Save size={13} />
|
||||
{settingsSaving ? at("btn_saving", {}, "Saving...") : at("btn_save", {}, "Save")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-card-body">
|
||||
<div class="admin-settings-warning" role="status">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
<div class="admin-settings-warning-copy">
|
||||
<strong>{at("settings_legacy_tariffs_warning_title", {}, "Legacy tariffs")}</strong>
|
||||
<p>
|
||||
{at(
|
||||
"settings_legacy_tariffs_warning_body",
|
||||
{},
|
||||
"These settings are ignored when tariffs are configured in the dedicated Tariffs section."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-legacy-tariff-table">
|
||||
<div class="admin-legacy-tariff-row admin-legacy-tariff-head">
|
||||
<span>{at("tariffs_legacy_period", {}, "Period")}</span>
|
||||
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
|
||||
<span>{at("payment_rub", {}, "RUB")}</span>
|
||||
<span>{at("payment_stars", {}, "Stars")}</span>
|
||||
</div>
|
||||
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]}
|
||||
<div class="admin-legacy-tariff-row">
|
||||
<strong>{months} {at("months_short", {}, "mo")}</strong>
|
||||
<div class="admin-setting-switch">
|
||||
<Switch.Root
|
||||
checked={boolValue(enabledKey)}
|
||||
onCheckedChange={(checked) => setSetting(enabledKey, checked)}
|
||||
class="admin-switch-root"
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={valueForKey(rubKey)}
|
||||
oninput={(event) => setSetting(rubKey, event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={valueForKey(starsKey)}
|
||||
oninput={(event) => setSetting(starsKey, event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="admin-form-row admin-form-row-2 admin-legacy-traffic-row">
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_legacy_traffic_packages", {}, "Traffic packages")}</span>
|
||||
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
value={valueForKey("TRAFFIC_PACKAGES")}
|
||||
oninput={(event) => setSetting("TRAFFIC_PACKAGES", event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label class="admin-field-label admin-field-label-compact">
|
||||
<span>{at("tariffs_legacy_stars_traffic_packages", {}, "Traffic packages, Stars")}</span>
|
||||
<small>{at("tariffs_legacy_traffic_hint", {}, "Format: 10:199,50:799")}</small>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
value={valueForKey("STARS_TRAFFIC_PACKAGES")}
|
||||
oninput={(event) => setSetting("STARS_TRAFFIC_PACKAGES", event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
@@ -2814,6 +2814,67 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-tariff-settings-card {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-tariff-settings-form {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-field-label-compact {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-field-label-compact .input,
|
||||
.admin-field-label-compact .admin-select-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: var(--admin-surface-2);
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 0.7fr) minmax(92px, 0.8fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-head {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-row strong {
|
||||
color: var(--admin-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-row .input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-legacy-traffic-row {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-tariff-dialog {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
@@ -3028,6 +3089,14 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-legacy-tariff-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-tariff-actions .admin-btn {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ def test_legacy_tariff_settings_are_separated_from_payment_settings():
|
||||
assert payment_method_fields[0]["section"] == "payments"
|
||||
assert manifest["MONTH_1_ENABLED"]["section"] == "pricing"
|
||||
assert manifest["MONTH_1_ENABLED"]["section_order"] == 11
|
||||
assert manifest["TRIAL_ENABLED"]["section"] == "pricing"
|
||||
assert manifest["TRIAL_ENABLED"]["subsection"] == "trial"
|
||||
assert manifest["TRIAL_SQUAD_UUIDS"]["section"] == "pricing"
|
||||
assert manifest["TRIAL_SQUAD_UUIDS"]["subsection"] == "trial"
|
||||
|
||||
|
||||
def test_platega_settings_share_one_admin_subsection():
|
||||
|
||||
@@ -198,7 +198,8 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
|
||||
TRIAL_DURATION_DAYS=3,
|
||||
TRIAL_TRAFFIC_LIMIT_GB=5,
|
||||
TRIAL_TRAFFIC_STRATEGY="WEEK",
|
||||
USER_SQUAD_UUIDS="trial-squad",
|
||||
USER_SQUAD_UUIDS="fallback-squad",
|
||||
TRIAL_SQUAD_UUIDS="trial-squad",
|
||||
)
|
||||
service = _make_service(settings)
|
||||
service.has_had_any_subscription = AsyncMock(return_value=False)
|
||||
@@ -241,6 +242,55 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
|
||||
|
||||
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
|
||||
self.assertEqual(panel_payload["trafficLimitStrategy"], "WEEK")
|
||||
self.assertEqual(panel_payload["activeInternalSquads"], ["trial-squad"])
|
||||
|
||||
async def test_activate_trial_falls_back_to_default_user_squads(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(
|
||||
_tariffs_config_payload(),
|
||||
tmpdir,
|
||||
TRIAL_ENABLED=True,
|
||||
TRIAL_DURATION_DAYS=3,
|
||||
USER_SQUAD_UUIDS="fallback-a,fallback-b",
|
||||
TRIAL_SQUAD_UUIDS=" , ",
|
||||
)
|
||||
service = _make_service(settings)
|
||||
service.has_had_any_subscription = AsyncMock(return_value=False)
|
||||
service._get_or_create_panel_user_link_details = AsyncMock(
|
||||
return_value=("panel-user", "panel-sub", "short", True)
|
||||
)
|
||||
service.panel_service.update_user_details_on_panel = AsyncMock(
|
||||
return_value={"subscriptionUrl": "https://example.test/sub", "shortUuid": "short"}
|
||||
)
|
||||
session = AsyncMock()
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
telegram_id=42,
|
||||
email=None,
|
||||
username="trial-user",
|
||||
first_name="Trial",
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.trial.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.trial.subscription_dal.deactivate_other_active_subscriptions",
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription",
|
||||
AsyncMock(),
|
||||
),
|
||||
):
|
||||
result = await service.activate_trial_subscription(session, user_id=42)
|
||||
|
||||
self.assertTrue(result["activated"])
|
||||
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
|
||||
self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"])
|
||||
|
||||
async def test_activate_subscription_dispatches_traffic_sale_mode(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
Reference in New Issue
Block a user