diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py
index 58fe5f8..447c9fd 100644
--- a/backend/bot/app/web/admin_settings_manifest.py
+++ b/backend/bot/app/web/admin_settings_manifest.py
@@ -8,6 +8,7 @@ the API, even by an admin.
from __future__ import annotations
+import re
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
@@ -28,6 +29,7 @@ class SettingField:
subsection: Optional[str] = None # group label inside a section
i18n_label_key: Optional[str] = None
i18n_description_key: Optional[str] = None
+ i18n_subsection_key: Optional[str] = None
SETTINGS_MANIFEST: List[SettingField] = [
@@ -72,10 +74,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Ссылка на канал",
"Имя пользователя или invite-link.",
),
+ SettingField(
+ "PANEL_API_URL",
+ "url",
+ "general",
+ "URL API Remnawave",
+ "Например, https://panel.example.com/api.",
+ subsection="Remnawave",
+ ),
+ SettingField(
+ "PANEL_API_KEY",
+ "string",
+ "general",
+ "API-ключ Remnawave",
+ "Секретный ключ API панели.",
+ secret=True,
+ subsection="Remnawave",
+ ),
+ SettingField(
+ "PANEL_WEBHOOK_SECRET",
+ "string",
+ "general",
+ "Секрет вебхуков Remnawave",
+ "Используется для проверки входящих вебхуков панели.",
+ secret=True,
+ subsection="Remnawave",
+ ),
+ SettingField(
+ "USER_SQUAD_UUIDS",
+ "string",
+ "general",
+ "Internal Squads по умолчанию",
+ "UUID через запятую для legacy-режима без JSON-каталога тарифов.",
+ subsection="Remnawave",
+ ),
+ SettingField(
+ "USER_EXTERNAL_SQUAD_UUID",
+ "string",
+ "general",
+ "External Squad по умолчанию",
+ "Необязательный UUID External Squad для новых пользователей.",
+ subsection="Remnawave",
+ ),
# ─── Web app appearance ────────────────────────────────────────
SettingField(
"WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"
),
+ SettingField(
+ "SUBSCRIPTION_MINI_APP_URL",
+ "url",
+ "appearance",
+ "Публичный URL Mini App",
+ "Например, https://app.example.com/.",
+ ),
SettingField(
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
),
@@ -131,7 +182,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"string",
"pricing",
"Порядок методов оплаты",
- "Через запятую, например: severpay,freekassa,yookassa",
+ "Через запятую, например: severpay,freekassa,yookassa,heleket",
),
SettingField(
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
@@ -156,14 +207,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
# ─── Payment providers (toggles) ───────────────────────────────
# Common
- SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
+ SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
"payments",
"Порядок методов оплаты",
- "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay",
- subsection="Общие",
+ "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
+ subsection="common",
),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
@@ -373,6 +424,9 @@ def _provider_field_to_setting_field(spec: Any, manifest_field: Any) -> SettingF
max=manifest_field.max,
choices=tuple(manifest_field.choices) if manifest_field.choices else None,
subsection=manifest_field.subsection,
+ i18n_label_key=getattr(manifest_field, "i18n_label_key", None),
+ i18n_description_key=getattr(manifest_field, "i18n_description_key", None),
+ i18n_subsection_key=getattr(manifest_field, "i18n_subsection_key", None),
)
@@ -439,6 +493,11 @@ def coerce_value(field: SettingField, raw: Any) -> Any:
return str(raw)
+def _i18n_slug(value: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
+ return slug or "default"
+
+
def manifest_payload() -> List[dict]:
"""Serialize the manifest for the admin UI.
@@ -465,6 +524,11 @@ def manifest_payload() -> List[dict]:
for field in aggregated_manifest():
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"admin_settings_field_{field.key.lower()}_description"
+ auto_subsection_i18n_key = (
+ f"admin_settings_subsection_{_i18n_slug(field.subsection)}"
+ if field.subsection
+ else None
+ )
default_value: Optional[str] = None
owner = find_manifest_owner(field.key)
@@ -487,6 +551,10 @@ def manifest_payload() -> List[dict]:
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
"i18n_description_key": field.i18n_description_key
or (auto_description_i18n_key if field.description else None),
+ "i18n_subsection_key": field.i18n_subsection_key or auto_subsection_i18n_key,
+ "i18n_placeholder_key": (
+ f"admin_settings_field_{field.key.lower()}_placeholder" if placeholder else None
+ ),
"placeholder": placeholder,
"optional": field.optional,
"secret": field.secret,
@@ -494,6 +562,15 @@ def manifest_payload() -> List[dict]:
if default_value is not None:
item["default"] = default_value
if field.choices:
- item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
+ item["choices"] = [
+ {
+ "value": v,
+ "label": lbl,
+ "i18n_label_key": (
+ f"admin_settings_field_{field.key.lower()}_choice_{_i18n_slug(str(v))}"
+ ),
+ }
+ for v, lbl in field.choices
+ ]
items.append(item)
return items
diff --git a/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte
index 6d89201..1837d56 100644
--- a/frontend/src/admin/sections/AppearanceSection.svelte
+++ b/frontend/src/admin/sections/AppearanceSection.svelte
@@ -23,6 +23,7 @@
const themesStore = getContext("themesStore");
const APPEARANCE_SETTING_KEYS = new Set([
"WEBAPP_TITLE",
+ "SUBSCRIPTION_MINI_APP_URL",
"WEBAPP_PRIMARY_COLOR",
"WEBAPP_LOGO_URL",
"WEBAPP_LOGO_USE_EMOJI",
@@ -76,7 +77,7 @@
};
$: emojiFontItems = (fieldMap.get("WEBAPP_LOGO_EMOJI_FONT")?.choices || []).map((item) => ({
value: item.value,
- label: item.label,
+ label: item.i18n_label_key ? adminText(item.i18n_label_key, {}, item.label) : item.label,
}));
$: dirtyCount = Object.keys(settingsDirty || {}).filter((key) =>
isAppearanceSettingKey(key)
@@ -135,6 +136,15 @@
return Boolean(value);
}
+ function adminLocaleKey(key) {
+ const raw = String(key || "");
+ return raw.startsWith("admin_") ? raw.slice("admin_".length) : raw;
+ }
+
+ function adminText(key, params = {}, fallback = "") {
+ return key ? at(adminLocaleKey(key), params, fallback) : fallback;
+ }
+
function withLogoCacheBust(url) {
return withCacheBust(url, logoPreviewNonce);
}
diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte
index dce8e3b..1c01d90 100644
--- a/frontend/src/admin/sections/SettingsSection.svelte
+++ b/frontend/src/admin/sections/SettingsSection.svelte
@@ -80,9 +80,9 @@
}
function secretPlaceholder(field) {
- if (settingsDirty[field.key]?.deleted) return field.placeholder || "••••••••";
+ if (settingsDirty[field.key]?.deleted) return fieldPlaceholderText(field) || "********";
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
- return field.placeholder || at("settings_secret_empty", {}, "Not set");
+ return fieldPlaceholderText(field) || at("settings_secret_empty", {}, "Not set");
}
function iconComponent(name) {
@@ -127,29 +127,45 @@
const groups = new Map();
for (const field of section.fields || []) {
const key = field.subsection || "_root";
- if (!groups.has(key)) groups.set(key, []);
- groups.get(key).push(field);
+ if (!groups.has(key)) {
+ groups.set(key, { fields: [], i18nLabelKey: field.i18n_subsection_key || null });
+ }
+ const group = groups.get(key);
+ group.fields.push(field);
+ if (!group.i18nLabelKey && field.i18n_subsection_key) {
+ group.i18nLabelKey = field.i18n_subsection_key;
+ }
}
- return Array.from(groups.entries()).map(([id, fields]) => ({
+ return Array.from(groups.entries()).map(([id, group]) => ({
id,
label: id === "_root" ? null : id,
- fields,
+ i18nLabelKey: group.i18nLabelKey,
+ fields: group.fields,
}));
}
+ function adminLocaleKey(key) {
+ const raw = String(key || "");
+ return raw.startsWith("admin_") ? raw.slice("admin_".length) : raw;
+ }
+
+ function adminText(key, params = {}, fallback = "") {
+ return key ? at(adminLocaleKey(key), params, fallback) : fallback;
+ }
+
function sectionTitle(id) {
const map = {
- general: at("admin_settings_section_general", {}, "Общие"),
- appearance: at("admin_settings_section_appearance", {}, "Внешний вид"),
- pricing: at("admin_settings_section_pricing", {}, "Тарифы и цены"),
- payments: at("admin_settings_section_payments", {}, "Платёжные системы"),
- trial: at("admin_settings_section_trial", {}, "Триал"),
- referral: at("admin_settings_section_referral", {}, "Реферальная программа"),
- notifications: at("admin_settings_section_notifications", {}, "Уведомления"),
- support: at("admin_settings_section_support", {}, "Поддержка"),
- devices: at("admin_settings_section_devices", {}, "Устройства"),
+ general: "Общие",
+ appearance: "Внешний вид",
+ pricing: "Тарифы и цены",
+ payments: "Платёжные системы",
+ trial: "Триал",
+ referral: "Реферальная программа",
+ notifications: "Уведомления",
+ support: "Поддержка",
+ devices: "Устройства",
};
- return map[id] || id;
+ return adminText(`settings_section_${id}`, {}, map[id] || id);
}
function englishFieldLabelFallback(key, originalLabel) {
@@ -173,7 +189,33 @@
.toLowerCase()
.startsWith("en");
const fallback = isEnglish ? englishFieldLabelFallback(field.key, field.label) : field.label;
- return field.i18n_label_key ? at(field.i18n_label_key, {}, fallback) : fallback;
+ return field.i18n_label_key ? adminText(field.i18n_label_key, {}, fallback) : fallback;
+ }
+
+ function fieldDescriptionText(field) {
+ if (!field.description) return "";
+ return field.i18n_description_key
+ ? adminText(field.i18n_description_key, {}, field.description)
+ : field.description;
+ }
+
+ function fieldPlaceholderText(field) {
+ const fallback = field.placeholder || "";
+ return field.i18n_placeholder_key ? adminText(field.i18n_placeholder_key, {}, fallback) : fallback;
+ }
+
+ function subsectionTitle(group) {
+ if (!group?.label) return "";
+ return group.i18nLabelKey ? adminText(group.i18nLabelKey, {}, group.label) : group.label;
+ }
+
+ function choiceItems(field) {
+ return (field.choices || []).map((choice) => ({
+ ...choice,
+ label: choice.i18n_label_key
+ ? adminText(choice.i18n_label_key, {}, choice.label)
+ : choice.label,
+ }));
}
@@ -191,12 +233,8 @@
{/if}
{field.key}
- {#if field.description}
- {field.i18n_description_key
- ? at(field.i18n_description_key, {}, field.description)
- : field.description}
+ {#if fieldDescriptionText(field)}
+ {fieldDescriptionText(field)}
{/if}