feat: improve admin trial settings controls

This commit is contained in:
3252a8
2026-05-24 19:19:39 +03:00
parent 3c4ff66150
commit 00b54e1f15
5 changed files with 409 additions and 89 deletions
+342 -89
View File
@@ -31,6 +31,10 @@
"TRIAL_TRAFFIC_STRATEGY",
"TRIAL_SQUAD_UUIDS",
];
const TRIAL_SWITCH_KEYS = ["TRIAL_ENABLED"];
const TRIAL_GENERAL_KEYS = ["TRIAL_DURATION_DAYS", "TRIAL_TRAFFIC_LIMIT_GB"];
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
const TRIAL_SQUAD_KEYS = ["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"],
@@ -76,6 +80,7 @@
}));
let selectedTrialSquad = "";
let trialSquadSelectKey = 0;
let tariffSettingsOpen = [];
function tariffName(tariff) {
@@ -122,6 +127,18 @@
settingsStore.markDirty(key, value);
}
function isSettingDirty(key) {
return Boolean(settingsDirty[key]);
}
function dirtyCount(keys) {
return (keys || []).filter((key) => isSettingDirty(key)).length;
}
function resetSetting(key) {
settingsStore.clearDirty(key);
}
function csvList(key) {
return String(valueForKey(key) || "")
.split(",")
@@ -139,10 +156,19 @@
function addTrialSquad(uuid) {
const next = String(uuid || "").trim();
if (!next) return;
setCsvList("TRIAL_SQUAD_UUIDS", [...csvList("TRIAL_SQUAD_UUIDS"), next]);
const current = csvList("TRIAL_SQUAD_UUIDS");
if (!current.includes(next)) {
setCsvList("TRIAL_SQUAD_UUIDS", [...current, next]);
}
selectedTrialSquad = "";
}
function handleTrialSquadSelect(uuid) {
addTrialSquad(uuid);
selectedTrialSquad = "";
trialSquadSelectKey += 1;
}
function removeTrialSquad(uuid) {
setCsvList(
"TRIAL_SQUAD_UUIDS",
@@ -225,96 +251,323 @@
{/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 class="admin-card-body admin-trial-settings-body">
<div class="admin-settings-field-groups admin-trial-settings-groups">
<section class="admin-settings-field-group" class:is-dirty={dirtyCount(TRIAL_SWITCH_KEYS)}>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_trial_group_switch", {}, "Доступ")}</strong>
<small>
{at(
"tariffs_trial_group_switch_hint",
{},
"Включает или выключает выдачу пробного периода пользователям."
)}
</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}
{#if dirtyCount(TRIAL_SWITCH_KEYS)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(TRIAL_SWITCH_KEYS) },
`Изменений: ${dirtyCount(TRIAL_SWITCH_KEYS)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_ENABLED")}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_enabled", {}, "Триал включён")}
{#if isSettingDirty("TRIAL_ENABLED")}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_ENABLED</code>
</div>
<div class="admin-setting-control">
<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>
<span
>{boolValue("TRIAL_ENABLED")
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{#if isSettingDirty("TRIAL_ENABLED")}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_ENABLED")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</label>
</div>
</div>
</section>
<section class="admin-settings-field-group" class:is-dirty={dirtyCount(TRIAL_GENERAL_KEYS)}>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_trial_group_general", {}, "Общие настройки")}</strong>
<small>
{at(
"tariffs_trial_group_general_hint",
{},
"Длительность пробного доступа и объём трафика, который получает пользователь."
)}
</small>
</div>
{#if dirtyCount(TRIAL_GENERAL_KEYS)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(TRIAL_GENERAL_KEYS) },
`Изменений: ${dirtyCount(TRIAL_GENERAL_KEYS)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_DURATION_DAYS")}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_days", {}, "Длительность, дней")}
{#if isSettingDirty("TRIAL_DURATION_DAYS")}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_DURATION_DAYS</code>
</div>
<div class="admin-setting-control">
<input
class="input"
type="number"
min="0"
step="1"
value={valueForKey("TRIAL_DURATION_DAYS")}
oninput={(event) => setSetting("TRIAL_DURATION_DAYS", event.currentTarget.value)}
/>
{#if isSettingDirty("TRIAL_DURATION_DAYS")}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_DURATION_DAYS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_TRAFFIC_LIMIT_GB")}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_traffic", {}, "Лимит трафика, GB")}
{#if isSettingDirty("TRIAL_TRAFFIC_LIMIT_GB")}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_TRAFFIC_LIMIT_GB</code>
</div>
<div class="admin-setting-control">
<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)}
/>
{#if isSettingDirty("TRIAL_TRAFFIC_LIMIT_GB")}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_TRAFFIC_LIMIT_GB")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
<section class="admin-settings-field-group" class:is-dirty={dirtyCount(TRIAL_RESET_KEYS)}>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_trial_group_reset", {}, "Сброс трафика")}</strong>
<small>
{at(
"tariffs_trial_group_reset_hint",
{},
"Стратегия, по которой Remnawave обновляет лимит трафика для пробного периода."
)}
</small>
</div>
{#if dirtyCount(TRIAL_RESET_KEYS)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(TRIAL_RESET_KEYS) },
`Изменений: ${dirtyCount(TRIAL_RESET_KEYS)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_TRAFFIC_STRATEGY")}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_strategy", {}, "Стратегия сброса трафика")}
{#if isSettingDirty("TRIAL_TRAFFIC_STRATEGY")}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_TRAFFIC_STRATEGY</code>
</div>
<div class="admin-setting-control">
<AdminSelect
class="admin-setting-select"
value={String(valueForKey("TRIAL_TRAFFIC_STRATEGY") || "NO_RESET")}
items={TRAFFIC_STRATEGY_OPTIONS}
ariaLabel={at("tariffs_trial_strategy", {}, "Стратегия сброса трафика")}
onValueChange={(value) => setSetting("TRIAL_TRAFFIC_STRATEGY", value)}
/>
{#if isSettingDirty("TRIAL_TRAFFIC_STRATEGY")}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_TRAFFIC_STRATEGY")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
</div>
</div>
</section>
<section class="admin-settings-field-group" class:is-dirty={dirtyCount(TRIAL_SQUAD_KEYS)}>
<header class="admin-settings-field-group-head">
<div class="admin-settings-field-group-head-copy">
<strong>{at("tariffs_trial_group_squads", {}, "Сквады")}</strong>
<small>
{at(
"tariffs_trial_group_squads_hint",
{},
"Сквады, которые будут назначены пользователю при активации триала."
)}
</small>
</div>
{#if dirtyCount(TRIAL_SQUAD_KEYS)}
<AdminBadge variant="warning">
{at(
"settings_dirty_count",
{ count: dirtyCount(TRIAL_SQUAD_KEYS) },
`Изменений: ${dirtyCount(TRIAL_SQUAD_KEYS)}`
)}
</AdminBadge>
{/if}
</header>
<div class="admin-settings-field-group-body">
<div
class="admin-setting admin-trial-setting-row"
class:is-dirty={isSettingDirty("TRIAL_SQUAD_UUIDS")}
>
<div class="admin-setting-meta">
<strong>
{at("tariffs_trial_squads", {}, "Internal Squads для триала")}
{#if isSettingDirty("TRIAL_SQUAD_UUIDS")}
<AdminBadge variant="warning"
>{at("settings_badge_dirty", {}, "Изменено")}</AdminBadge
>
{/if}
</strong>
<code>TRIAL_SQUAD_UUIDS</code>
<small>
{at(
"tariffs_trial_squads_hint",
{},
"Эти сквады применяются при активации триала. Если поле пустое, используются USER_SQUAD_UUIDS."
)}
</small>
</div>
<div class="admin-setting-control admin-trial-squad-control">
{#key trialSquadSelectKey}
<AdminSelect
value={selectedTrialSquad}
items={panelSquadOptions}
disabled={panelSquadsLoading || !panelSquadOptions.length}
placeholder={panelSquadsLoading
? at("loading", {}, "Загрузка...")
: at("tariffs_trial_add_squad", {}, "Добавить сквад из панели")}
ariaLabel={at("tariffs_trial_add_squad", {}, "Добавить сквад из панели")}
onValueChange={handleTrialSquadSelect}
/>
{/key}
<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)}
/>
{#if isSettingDirty("TRIAL_SQUAD_UUIDS")}
<AdminButton
size="sm"
variant="ghost"
onclick={() => resetSetting("TRIAL_SQUAD_UUIDS")}
>
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
<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>
</div>
</div>
</div>
</section>
</div>
</div>
</article>
+40
View File
@@ -2045,6 +2045,10 @@
background: color-mix(in srgb, var(--admin-bg) 52%, transparent);
}
.admin-settings-field-group.is-dirty {
border-color: color-mix(in srgb, var(--warning-border) 74%, var(--admin-border));
}
.admin-settings-field-group-head {
display: grid;
gap: 3px;
@@ -2082,6 +2086,10 @@
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.admin-setting.is-dirty {
background: color-mix(in srgb, var(--warning) 7%, transparent);
}
.admin-setting-meta {
display: grid;
gap: 4px;
@@ -2822,6 +2830,38 @@
gap: 14px;
}
.admin-trial-settings-body .admin-settings-field-groups {
padding: 0;
}
.admin-trial-settings-groups .admin-settings-field-group-head {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
}
.admin-settings-field-group-head-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.admin-trial-setting-row .input,
.admin-trial-setting-row .admin-select-trigger {
width: 100%;
}
.admin-trial-squad-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.admin-trial-squad-control .admin-select-trigger,
.admin-trial-squad-control .admin-chip-list {
grid-column: 1 / -1;
}
.admin-tariff-settings-save-row {
justify-content: flex-end;
margin-bottom: 12px;