feat: add runtime locale overrides
This commit is contained in:
@@ -16,6 +16,7 @@ frontend/node_modules/
|
|||||||
deploy/compose/docker-compose-dev.yml
|
deploy/compose/docker-compose-dev.yml
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!data/tariffs.example.json
|
||||||
|
!data/locales-overrides.example.json
|
||||||
|
|
||||||
|
|
||||||
# CI
|
# CI
|
||||||
|
|||||||
@@ -63,3 +63,4 @@ locales/en_backup.json
|
|||||||
db/models_old.py
|
db/models_old.py
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!data/tariffs.example.json
|
||||||
|
!data/locales-overrides.example.json
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ docker compose logs -f backend worker frontend
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||||
|
touch data/locales-overrides.json
|
||||||
chown -R 10001:10001 data
|
chown -R 10001:10001 data
|
||||||
chmod -R u+rwX data
|
chmod -R u+rwX data
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def build_core_services(
|
|||||||
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
|
||||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
|
||||||
email_auth_service = EmailAuthService(settings)
|
email_auth_service = EmailAuthService(settings, i18n)
|
||||||
notification_service = NotificationService(
|
notification_service = NotificationService(
|
||||||
bot,
|
bot,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from bot.app.web.admin_api_impl import (
|
|||||||
sync as _sync,
|
sync as _sync,
|
||||||
tariffs as _tariffs,
|
tariffs as _tariffs,
|
||||||
themes as _themes,
|
themes as _themes,
|
||||||
|
translations as _translations,
|
||||||
users as _users,
|
users as _users,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ _MODULES = (
|
|||||||
_settings,
|
_settings,
|
||||||
_tariffs,
|
_tariffs,
|
||||||
_themes,
|
_themes,
|
||||||
|
_translations,
|
||||||
_panel,
|
_panel,
|
||||||
_routes,
|
_routes,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from config.tariffs_config import TariffsConfig
|
|||||||
from db.dal import (
|
from db.dal import (
|
||||||
ad_dal,
|
ad_dal,
|
||||||
app_settings_dal,
|
app_settings_dal,
|
||||||
|
locale_overrides_dal,
|
||||||
message_log_dal,
|
message_log_dal,
|
||||||
panel_sync_dal,
|
panel_sync_dal,
|
||||||
payment_dal,
|
payment_dal,
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
|
|
||||||
router.add_get("/api/admin/settings", admin_settings_get_route)
|
router.add_get("/api/admin/settings", admin_settings_get_route)
|
||||||
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
router.add_patch("/api/admin/settings", admin_settings_patch_route)
|
||||||
|
router.add_get("/api/admin/translations", admin_translations_get_route)
|
||||||
|
router.add_patch("/api/admin/translations", admin_translations_patch_route)
|
||||||
|
|
||||||
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
|
||||||
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
|
||||||
|
from bot.services.locale_override_service import (
|
||||||
|
LOCALE_OVERRIDES_PATH,
|
||||||
|
audience_for_locale_key,
|
||||||
|
group_id_for_locale_key,
|
||||||
|
locale_group_catalog,
|
||||||
|
load_locale_overrides,
|
||||||
|
update_locale_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _locale_languages(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
overrides: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
base_languages = set((i18n.base_locales_data or {}).keys())
|
||||||
|
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
|
||||||
|
override_languages.update((i18n.locale_overrides or {}).keys())
|
||||||
|
return locale_language_options(
|
||||||
|
base_languages | override_languages,
|
||||||
|
base_languages=base_languages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
|
||||||
|
result: Dict[Tuple[str, str], Dict] = {}
|
||||||
|
for entry in overrides:
|
||||||
|
lang = str(entry.get("lang") or "")
|
||||||
|
raw_key = str(entry.get("key") or "")
|
||||||
|
key = resolve_locale_key(raw_key)
|
||||||
|
if lang and key:
|
||||||
|
if raw_key != key and (lang, key) in result:
|
||||||
|
continue
|
||||||
|
result[(lang, key)] = entry
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_translations_payload(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
overrides: List[Dict[str, Any]],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
base_data = i18n.base_locales_data or i18n.locales_data or {}
|
||||||
|
effective_data = i18n.locales_data or {}
|
||||||
|
override_meta = _locale_override_meta_map(overrides)
|
||||||
|
language_items = _locale_languages(i18n, overrides)
|
||||||
|
languages = [item["code"] for item in language_items]
|
||||||
|
all_keys = sorted(
|
||||||
|
{key for messages in base_data.values() for key in messages.keys()}
|
||||||
|
| {key for _, key in override_meta.keys()}
|
||||||
|
)
|
||||||
|
|
||||||
|
groups_by_id = {
|
||||||
|
group["id"]: {
|
||||||
|
**group,
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
for group in locale_group_catalog()
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in all_keys:
|
||||||
|
values: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for lang in languages:
|
||||||
|
meta = override_meta.get((lang, key))
|
||||||
|
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
|
||||||
|
values[lang] = {
|
||||||
|
"base": base_data.get(lang, {}).get(key, ""),
|
||||||
|
"fallback": fallback_base,
|
||||||
|
"effective": effective_data.get(lang, {}).get(key, ""),
|
||||||
|
"override": meta.get("value") if meta else "",
|
||||||
|
"overridden": bool(meta),
|
||||||
|
"updated_at": meta.get("updated_at") if meta else None,
|
||||||
|
"updated_by": meta.get("updated_by") if meta else None,
|
||||||
|
}
|
||||||
|
group_id = group_id_for_locale_key(key)
|
||||||
|
groups_by_id.setdefault(
|
||||||
|
group_id,
|
||||||
|
{"id": group_id, "title": group_id, "description": "", "items": []},
|
||||||
|
)
|
||||||
|
groups_by_id[group_id]["items"].append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"audience": audience_for_locale_key(key),
|
||||||
|
"values": values,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = [group for group in groups_by_id.values() if group["items"]]
|
||||||
|
return {
|
||||||
|
"languages": language_items,
|
||||||
|
"groups": groups,
|
||||||
|
"path": str(LOCALE_OVERRIDES_PATH),
|
||||||
|
"override_count": len(overrides),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_translations_get_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||||
|
if i18n is None:
|
||||||
|
return _error(503, "i18n_unavailable")
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
|
await load_locale_overrides(i18n, async_session_factory)
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
|
||||||
|
|
||||||
|
return _ok(_admin_translations_payload(i18n, overrides))
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_translations_patch_route(request: web.Request) -> web.Response:
|
||||||
|
actor_id = _require_admin_user_id(request)
|
||||||
|
i18n: Optional[JsonI18n] = request.app.get("i18n")
|
||||||
|
if i18n is None:
|
||||||
|
return _error(503, "i18n_unavailable")
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
payload = await _read_json(request)
|
||||||
|
updates = payload.get("updates") or {}
|
||||||
|
deletes = payload.get("deletes") or []
|
||||||
|
if not isinstance(updates, dict):
|
||||||
|
return _error(400, "invalid_updates")
|
||||||
|
if not isinstance(deletes, list):
|
||||||
|
return _error(400, "invalid_deletes")
|
||||||
|
|
||||||
|
result = await update_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
async_session_factory,
|
||||||
|
updates=updates,
|
||||||
|
deletes=deletes,
|
||||||
|
actor_id=actor_id,
|
||||||
|
)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"applied": result.get("applied", 0),
|
||||||
|
"reverted": result.get("reverted", 0),
|
||||||
|
"file_written": result.get("file_written", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -488,6 +488,11 @@ async def account_language_route(request: web.Request) -> web.Response:
|
|||||||
return validation_error
|
return validation_error
|
||||||
|
|
||||||
language = _normalize_language(str(language_payload.language or ""))
|
language = _normalize_language(str(language_payload.language or ""))
|
||||||
|
i18n = request.app.get("i18n")
|
||||||
|
if i18n and hasattr(i18n, "reload_overrides_from_file"):
|
||||||
|
i18n.reload_overrides_from_file()
|
||||||
|
if i18n and language not in getattr(i18n, "locales_data", {}):
|
||||||
|
return _json_error(400, "unsupported_language", "Unsupported language")
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def create_subscription_webapp_application(
|
|||||||
app["settings"] = settings
|
app["settings"] = settings
|
||||||
app["async_session_factory"] = async_session_factory
|
app["async_session_factory"] = async_session_factory
|
||||||
app["i18n"] = dp.get("i18n_instance")
|
app["i18n"] = dp.get("i18n_instance")
|
||||||
app["email_auth_service"] = EmailAuthService(settings)
|
app["email_auth_service"] = EmailAuthService(settings, app["i18n"])
|
||||||
app["webapp_logo_cache"] = None
|
app["webapp_logo_cache"] = None
|
||||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from config.webapp_themes_config import (
|
|||||||
public_theme_payload,
|
public_theme_payload,
|
||||||
public_themes_catalog_payload,
|
public_themes_catalog_payload,
|
||||||
)
|
)
|
||||||
|
from bot.middlewares.i18n import locale_language_options
|
||||||
|
|
||||||
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
|
_TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {}
|
||||||
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
|
_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {}
|
||||||
@@ -1157,7 +1158,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
|||||||
preview_key = ""
|
preview_key = ""
|
||||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||||
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
|
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
|
||||||
|
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
|
||||||
|
i18n_instance.reload_overrides_from_file()
|
||||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||||
|
base_locales_data = getattr(i18n_instance, "base_locales_data", {}) if i18n_instance else {}
|
||||||
return {
|
return {
|
||||||
"config": {
|
"config": {
|
||||||
"title": settings.WEBAPP_TITLE,
|
"title": settings.WEBAPP_TITLE,
|
||||||
@@ -1188,6 +1192,10 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
|||||||
"userAgreementUrl": cached["user_agreement_url"],
|
"userAgreementUrl": cached["user_agreement_url"],
|
||||||
"currency": cached["currency"],
|
"currency": cached["currency"],
|
||||||
"language": cached["language"],
|
"language": cached["language"],
|
||||||
|
"languages": locale_language_options(
|
||||||
|
locales_data.keys(),
|
||||||
|
base_languages=base_locales_data.keys(),
|
||||||
|
),
|
||||||
"emailAuthEnabled": cached["email_auth_enabled"],
|
"emailAuthEnabled": cached["email_auth_enabled"],
|
||||||
"appVersion": _resolve_app_version(),
|
"appVersion": _resolve_app_version(),
|
||||||
"appRepositoryUrl": APP_REPOSITORY_URL,
|
"appRepositoryUrl": APP_REPOSITORY_URL,
|
||||||
@@ -1204,6 +1212,8 @@ async def bootstrap_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
async def i18n_route(request: web.Request) -> web.Response:
|
async def i18n_route(request: web.Request) -> web.Response:
|
||||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||||
|
if i18n_instance and hasattr(i18n_instance, "reload_overrides_from_file"):
|
||||||
|
i18n_instance.reload_overrides_from_file()
|
||||||
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
|
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
|
||||||
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||||
response = web.json_response(
|
response = web.json_response(
|
||||||
|
|||||||
@@ -1202,11 +1202,9 @@ def _apply_telegram_profile_to_user(
|
|||||||
telegram_user: Dict[str, Any],
|
telegram_user: Dict[str, Any],
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
language_code = (
|
language_code = _normalize_language(
|
||||||
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
if language_code not in {"ru", "en"}:
|
|
||||||
language_code = user.language_code or settings.DEFAULT_LANGUAGE
|
|
||||||
|
|
||||||
user.telegram_id = int(telegram_user["id"])
|
user.telegram_id = int(telegram_user["id"])
|
||||||
user.username = sanitize_username(telegram_user.get("username"))
|
user.username = sanitize_username(telegram_user.get("username"))
|
||||||
@@ -1252,13 +1250,11 @@ async def _link_telegram_to_user(
|
|||||||
return merged_user
|
return merged_user
|
||||||
|
|
||||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||||
language_code = (
|
language_code = _normalize_language(
|
||||||
telegram_user.get("language_code")
|
telegram_user.get("language_code")
|
||||||
or current_user.language_code
|
or current_user.language_code
|
||||||
or settings.DEFAULT_LANGUAGE
|
or settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
if language_code not in {"ru", "en"}:
|
|
||||||
language_code = current_user.language_code or settings.DEFAULT_LANGUAGE
|
|
||||||
target_user, _ = await user_dal.create_user(
|
target_user, _ = await user_dal.create_user(
|
||||||
session,
|
session,
|
||||||
{
|
{
|
||||||
@@ -1404,9 +1400,9 @@ async def _ensure_user_from_telegram(
|
|||||||
referral_param: Optional[str] = None,
|
referral_param: Optional[str] = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
user_id = int(telegram_user["id"])
|
user_id = int(telegram_user["id"])
|
||||||
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
language_code = _normalize_language(
|
||||||
if language_code not in {"ru", "en"}:
|
telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
|
||||||
language_code = settings.DEFAULT_LANGUAGE
|
)
|
||||||
|
|
||||||
update_data = {
|
update_data = {
|
||||||
"telegram_id": user_id,
|
"telegram_id": user_id,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from ._runtime import * # noqa: F403,F405
|
|||||||
from bot.app.web.webapp.cache_helpers import (
|
from bot.app.web.webapp.cache_helpers import (
|
||||||
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
invalidate_webapp_user_caches as _invalidate_user_payload_caches,
|
||||||
)
|
)
|
||||||
|
from bot.middlewares.i18n import (
|
||||||
|
is_valid_locale_language_code,
|
||||||
|
normalize_locale_language_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||||
@@ -73,8 +77,8 @@ def _validate_model_payload(
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_language(lang: Optional[str]) -> str:
|
def _normalize_language(lang: Optional[str]) -> str:
|
||||||
value = (lang or "ru").split("-")[0].lower()
|
value = normalize_locale_language_code(lang, prefer_known_base=False)
|
||||||
return value if value in {"ru", "en"} else "ru"
|
return value if is_valid_locale_language_code(value) else "ru"
|
||||||
|
|
||||||
|
|
||||||
def _format_remaining(seconds: int, lang: str) -> str:
|
def _format_remaining(seconds: int, lang: str) -> str:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
(
|
(
|
||||||
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
|
||||||
"appearance|settings|support}"
|
"appearance|settings|translations|support}"
|
||||||
),
|
),
|
||||||
index_route,
|
index_route,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_language_selection_keyboard,
|
get_language_selection_keyboard,
|
||||||
get_main_menu_inline_keyboard,
|
get_main_menu_inline_keyboard,
|
||||||
)
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n, normalize_locale_language_code
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
@@ -913,7 +913,12 @@ async def select_language_callback_handler(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
lang_payload = callback.data.split("_", 2)[2]
|
lang_payload = callback.data.split("_", 2)[2]
|
||||||
lang_code, _, return_target = lang_payload.partition(":")
|
raw_lang_code, _, return_target = lang_payload.partition(":")
|
||||||
|
lang_code = normalize_locale_language_code(
|
||||||
|
raw_lang_code,
|
||||||
|
set(i18n.locales_data.keys()),
|
||||||
|
prefer_known_base=True,
|
||||||
|
)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
await safe_answer_callback(
|
await safe_answer_callback(
|
||||||
callback,
|
callback,
|
||||||
@@ -921,6 +926,13 @@ async def select_language_callback_handler(
|
|||||||
show_alert=True,
|
show_alert=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if lang_code not in i18n.locales_data:
|
||||||
|
await safe_answer_callback(
|
||||||
|
callback,
|
||||||
|
"Unsupported language.",
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import locale_language_options
|
||||||
from bot.utils.install_links import bot_install_guide_url
|
from bot.utils.install_links import bot_install_guide_url
|
||||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -246,13 +247,17 @@ def get_language_selection_keyboard(
|
|||||||
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs)
|
||||||
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
callback_suffix = ":bot" if back_callback == "main_action:bot_interface" else ""
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
if hasattr(i18n_instance, "language_options"):
|
||||||
|
languages = i18n_instance.language_options()
|
||||||
|
else:
|
||||||
|
locales_data = getattr(i18n_instance, "locales_data", {}) or {"ru": {}, "en": {}}
|
||||||
|
languages = locale_language_options(locales_data.keys(), base_languages=locales_data.keys())
|
||||||
|
for language in languages:
|
||||||
|
lang_code = language["code"]
|
||||||
|
checked = " ✅" if current_lang == lang_code else ""
|
||||||
builder.button(
|
builder.button(
|
||||||
text=f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",
|
text=f"{language['flag']} {language['label']}{checked}",
|
||||||
callback_data=f"set_lang_en{callback_suffix}",
|
callback_data=f"set_lang_{lang_code}{callback_suffix}",
|
||||||
)
|
|
||||||
builder.button(
|
|
||||||
text=f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",
|
|
||||||
callback_data=f"set_lang_ru{callback_suffix}",
|
|
||||||
)
|
)
|
||||||
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
builder.button(text=_(key="back_to_main_menu_button"), callback_data=back_callback)
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from bot.app.web.web_server import build_and_start_web_app
|
|||||||
from bot.infra.redis import close_redis
|
from bot.infra.redis import close_redis
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.routers import build_root_router
|
from bot.routers import build_root_router
|
||||||
|
from bot.services.locale_override_service import load_locale_overrides
|
||||||
from bot.services.settings_override_service import load_overrides_from_db
|
from bot.services.settings_override_service import load_overrides_from_db
|
||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -269,6 +270,7 @@ async def run_bot(settings_param: Settings):
|
|||||||
await load_overrides_from_db(settings_param, local_async_session_factory)
|
await load_overrides_from_db(settings_param, local_async_session_factory)
|
||||||
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
|
||||||
i18n_instance = extra["i18n_instance"]
|
i18n_instance = extra["i18n_instance"]
|
||||||
|
await load_locale_overrides(i18n_instance, local_async_session_factory)
|
||||||
|
|
||||||
# Get bot username for YooKassa default return URL if needed
|
# Get bot username for YooKassa default return URL if needed
|
||||||
actual_bot_username = "your_bot_username"
|
actual_bot_username = "your_bot_username"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import Update, User
|
from aiogram.types import Update, User
|
||||||
@@ -10,14 +13,309 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
|
||||||
|
LocaleOverrides = Dict[str, Dict[str, str]]
|
||||||
|
|
||||||
|
_LOCALE_LANGUAGE_CODE_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
|
||||||
|
LANGUAGE_LABELS: Dict[str, str] = {
|
||||||
|
"ru": "Русский",
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"es": "Español",
|
||||||
|
"fr": "Français",
|
||||||
|
"pt-br": "Português (BR)",
|
||||||
|
"tr": "Türkçe",
|
||||||
|
"uk": "Українська",
|
||||||
|
}
|
||||||
|
LANGUAGE_FLAGS: Dict[str, str] = {
|
||||||
|
"ru": "🇷🇺",
|
||||||
|
"en": "🇬🇧",
|
||||||
|
"de": "🇩🇪",
|
||||||
|
"es": "🇪🇸",
|
||||||
|
"fr": "🇫🇷",
|
||||||
|
"pt-br": "🇧🇷",
|
||||||
|
"tr": "🇹🇷",
|
||||||
|
"uk": "🇺🇦",
|
||||||
|
}
|
||||||
|
DEFAULT_LANGUAGE_ORDER = ("ru", "en")
|
||||||
|
LOCALE_KEY_ALIASES: Dict[str, str] = {
|
||||||
|
"admin_apply": "wa_apply",
|
||||||
|
"admin_ads_col_status": "admin_status",
|
||||||
|
"admin_ad_label_source": "admin_ads_col_source",
|
||||||
|
"admin_back": "wa_back",
|
||||||
|
"admin_btn_refresh": "admin_refresh",
|
||||||
|
"admin_btn_save": "admin_save",
|
||||||
|
"admin_btn_saving": "admin_saving",
|
||||||
|
"admin_close": "wa_close",
|
||||||
|
"admin_copied": "wa_copied",
|
||||||
|
"admin_copy": "wa_copy",
|
||||||
|
"admin_csv_amount": "admin_amount",
|
||||||
|
"admin_csv_description": "admin_description",
|
||||||
|
"admin_csv_payment_id": "admin_id",
|
||||||
|
"admin_csv_status": "admin_status",
|
||||||
|
"admin_link_copied": "wa_link_copied",
|
||||||
|
"admin_next": "wa_next",
|
||||||
|
"admin_payment_detail_copied": "wa_copied",
|
||||||
|
"admin_payment_detail_provider": "admin_provider",
|
||||||
|
"admin_payment_detail_provider_section": "admin_provider",
|
||||||
|
"admin_payment_detail_user_section": "admin_user",
|
||||||
|
"admin_payments_col_user_id": "admin_id",
|
||||||
|
"admin_promo_col_code": "admin_promo_csv_code",
|
||||||
|
"admin_promo_col_status": "admin_status",
|
||||||
|
"admin_promo_csv_is_active": "admin_badge_active",
|
||||||
|
"admin_promo_csv_status": "admin_status",
|
||||||
|
"admin_promo_label_code": "admin_promo_csv_code",
|
||||||
|
"admin_promo_unlimited_validity": "admin_promo_unlimited",
|
||||||
|
"admin_stats_revenue_custom_range_apply": "wa_apply",
|
||||||
|
"admin_stats_revenue_tooltip_amount": "admin_amount",
|
||||||
|
"admin_stats_sync_status": "admin_status",
|
||||||
|
"admin_status_active": "admin_badge_active",
|
||||||
|
"admin_support_category": "wa_support_category",
|
||||||
|
"admin_support_category_account": "wa_support_category_account",
|
||||||
|
"admin_support_category_billing": "wa_support_category_billing",
|
||||||
|
"admin_support_category_other": "wa_support_category_other",
|
||||||
|
"admin_support_category_technical": "wa_support_category_technical",
|
||||||
|
"admin_support_close_ticket": "wa_close",
|
||||||
|
"admin_support_empty": "wa_support_empty",
|
||||||
|
"admin_support_filter_active": "wa_support_filter_active",
|
||||||
|
"admin_support_filter_all": "wa_support_filter_all",
|
||||||
|
"admin_support_internal_note": "wa_support_internal_note",
|
||||||
|
"admin_support_no_messages": "wa_support_no_messages",
|
||||||
|
"admin_support_priority": "wa_support_priority",
|
||||||
|
"admin_support_priority_high": "wa_support_priority_high",
|
||||||
|
"admin_support_priority_low": "wa_support_priority_low",
|
||||||
|
"admin_support_priority_normal": "wa_support_priority_normal",
|
||||||
|
"admin_support_priority_urgent": "wa_support_priority_urgent",
|
||||||
|
"admin_support_role_system": "wa_support_role_system",
|
||||||
|
"admin_support_role_user": "admin_user",
|
||||||
|
"admin_support_search": "admin_search",
|
||||||
|
"admin_support_status": "admin_status",
|
||||||
|
"admin_support_status_awaiting_admin": "wa_support_status_awaiting_admin",
|
||||||
|
"admin_support_status_awaiting_user": "wa_support_status_awaiting_user",
|
||||||
|
"admin_support_status_closed": "wa_support_status_closed",
|
||||||
|
"admin_support_status_open": "wa_support_status_open",
|
||||||
|
"admin_support_status_resolved": "wa_support_status_resolved",
|
||||||
|
"admin_support_ticket_number": "wa_support_ticket_number",
|
||||||
|
"admin_support_user_context": "admin_user",
|
||||||
|
"admin_tariffs_legacy_traffic_packages": "admin_tariff_traffic_packages",
|
||||||
|
"admin_tariffs_stat_enabled": "admin_enabled",
|
||||||
|
"admin_user_btn_cancel": "wa_cancel",
|
||||||
|
"admin_user_history_until": "wa_until_date",
|
||||||
|
"admin_user_label_provider": "admin_provider",
|
||||||
|
"admin_user_short": "admin_user",
|
||||||
|
"admin_user_stats_total_label": "admin_total",
|
||||||
|
"back_to_autopay_method_choice_button": "back_to_main_menu_button",
|
||||||
|
"back_to_payment_methods_button": "back_to_main_menu_button",
|
||||||
|
"cancel_broadcast_button": "cancel_button",
|
||||||
|
"csv_no": "no_button",
|
||||||
|
"csv_yes": "yes_button",
|
||||||
|
"user_premium_override_status_unlimited": "user_regular_override_status_unlimited",
|
||||||
|
"user_regular_override_save": "admin_save",
|
||||||
|
"wa_devices_disconnect_title": "wa_devices_disconnect",
|
||||||
|
"wa_install_link_copied": "wa_link_copied",
|
||||||
|
"wa_link_email_modal_title": "wa_settings_link_email_action",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_locale_key(key: object) -> str:
|
||||||
|
value = str(key or "").strip()
|
||||||
|
seen: Set[str] = set()
|
||||||
|
while value in LOCALE_KEY_ALIASES and value not in seen:
|
||||||
|
seen.add(value)
|
||||||
|
value = LOCALE_KEY_ALIASES[value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_locale_language_code(value: str) -> bool:
|
||||||
|
return 2 <= len(value) <= 16 and bool(_LOCALE_LANGUAGE_CODE_RE.fullmatch(value))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_locale_language_code(
|
||||||
|
raw: object,
|
||||||
|
valid_languages: Optional[Set[str]] = None,
|
||||||
|
*,
|
||||||
|
prefer_known_base: bool = True,
|
||||||
|
) -> str:
|
||||||
|
value = str(raw or "").strip().lower().replace("_", "-")
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if prefer_known_base and valid_languages and value not in valid_languages:
|
||||||
|
base = value.split("-", 1)[0]
|
||||||
|
if base in valid_languages:
|
||||||
|
return base
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_language_code(raw: object, valid_languages: Optional[Set[str]] = None) -> str:
|
||||||
|
return normalize_locale_language_code(raw, valid_languages)
|
||||||
|
|
||||||
|
|
||||||
|
def locale_language_label(code: object) -> str:
|
||||||
|
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||||
|
return LANGUAGE_LABELS.get(value, value.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def locale_language_flag(code: object) -> str:
|
||||||
|
value = normalize_locale_language_code(code, prefer_known_base=False)
|
||||||
|
return LANGUAGE_FLAGS.get(value, "🏳️")
|
||||||
|
|
||||||
|
|
||||||
|
def sort_locale_language_codes(codes: Iterable[object]) -> List[str]:
|
||||||
|
normalized = {
|
||||||
|
normalize_locale_language_code(code, prefer_known_base=False)
|
||||||
|
for code in codes
|
||||||
|
}
|
||||||
|
normalized = {code for code in normalized if code and is_valid_locale_language_code(code)}
|
||||||
|
preferred = [code for code in DEFAULT_LANGUAGE_ORDER if code in normalized]
|
||||||
|
rest = sorted(code for code in normalized if code not in DEFAULT_LANGUAGE_ORDER)
|
||||||
|
return [*preferred, *rest]
|
||||||
|
|
||||||
|
|
||||||
|
def locale_language_options(
|
||||||
|
codes: Iterable[object],
|
||||||
|
*,
|
||||||
|
base_languages: Iterable[object] = (),
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
base_set = set(sort_locale_language_codes(base_languages))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"label": locale_language_label(code),
|
||||||
|
"flag": locale_language_flag(code),
|
||||||
|
"base": code in base_set,
|
||||||
|
}
|
||||||
|
for code in sort_locale_language_codes(codes)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_locale_keys_by_language(
|
||||||
|
locales_data: Dict[str, Dict[str, str]],
|
||||||
|
) -> Dict[str, Set[str]]:
|
||||||
|
return {
|
||||||
|
lang: {str(key) for key in messages.keys()}
|
||||||
|
for lang, messages in locales_data.items()
|
||||||
|
if isinstance(messages, dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_locale_overrides_payload(
|
||||||
|
payload: object,
|
||||||
|
*,
|
||||||
|
valid_languages: Optional[Iterable[str]] = None,
|
||||||
|
valid_keys_by_language: Optional[Dict[str, Set[str]]] = None,
|
||||||
|
allow_extra_languages: bool = False,
|
||||||
|
key_aliases: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Tuple[LocaleOverrides, Dict[str, str]]:
|
||||||
|
"""Normalize a user/admin supplied locale override JSON payload.
|
||||||
|
|
||||||
|
The canonical shape is ``{"ru": {"welcome": "..."}, "en": {...}}``.
|
||||||
|
For convenience, files may also wrap it as ``{"overrides": {...}}`` or
|
||||||
|
``{"locales": {...}}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}, {"_payload": "invalid_payload"}
|
||||||
|
|
||||||
|
raw_payload = payload
|
||||||
|
for wrapper_key in ("overrides", "locales"):
|
||||||
|
wrapped = raw_payload.get(wrapper_key)
|
||||||
|
if isinstance(wrapped, dict):
|
||||||
|
raw_payload = wrapped
|
||||||
|
break
|
||||||
|
|
||||||
|
valid_lang_set = {str(lang).lower() for lang in valid_languages or []}
|
||||||
|
aliases = key_aliases or LOCALE_KEY_ALIASES
|
||||||
|
|
||||||
|
def resolve_payload_key(raw_key: str) -> str:
|
||||||
|
value = raw_key
|
||||||
|
seen: Set[str] = set()
|
||||||
|
while value in aliases and value not in seen:
|
||||||
|
seen.add(value)
|
||||||
|
value = aliases[value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
all_valid_keys: Set[str] = set()
|
||||||
|
if valid_keys_by_language:
|
||||||
|
for keys in valid_keys_by_language.values():
|
||||||
|
all_valid_keys.update(str(key) for key in keys)
|
||||||
|
|
||||||
|
overrides: LocaleOverrides = {}
|
||||||
|
errors: Dict[str, str] = {}
|
||||||
|
|
||||||
|
for raw_lang, raw_messages in raw_payload.items():
|
||||||
|
lang = normalize_locale_language_code(
|
||||||
|
raw_lang,
|
||||||
|
valid_lang_set or None,
|
||||||
|
prefer_known_base=not allow_extra_languages,
|
||||||
|
)
|
||||||
|
error_key = str(raw_lang or "_language")
|
||||||
|
if not lang:
|
||||||
|
errors[error_key] = "invalid_language"
|
||||||
|
continue
|
||||||
|
if valid_lang_set and lang not in valid_lang_set:
|
||||||
|
if not allow_extra_languages:
|
||||||
|
errors[error_key] = "unknown_language"
|
||||||
|
continue
|
||||||
|
if not is_valid_locale_language_code(lang):
|
||||||
|
errors[error_key] = "invalid_language"
|
||||||
|
continue
|
||||||
|
elif allow_extra_languages and not is_valid_locale_language_code(lang):
|
||||||
|
errors[error_key] = "invalid_language"
|
||||||
|
continue
|
||||||
|
if not isinstance(raw_messages, dict):
|
||||||
|
errors[lang] = "invalid_language_bucket"
|
||||||
|
continue
|
||||||
|
|
||||||
|
lang_keys = valid_keys_by_language.get(lang, set()) if valid_keys_by_language else set()
|
||||||
|
bucket: Dict[str, str] = {}
|
||||||
|
for raw_key, raw_value in raw_messages.items():
|
||||||
|
raw_key_text = str(raw_key or "").strip()
|
||||||
|
key = resolve_payload_key(raw_key_text)
|
||||||
|
item_error_key = f"{lang}.{raw_key_text or '_key'}"
|
||||||
|
if not raw_key_text or not key:
|
||||||
|
errors[item_error_key] = "invalid_key"
|
||||||
|
continue
|
||||||
|
if all_valid_keys and key not in all_valid_keys and key not in lang_keys:
|
||||||
|
errors[item_error_key] = "unknown_key"
|
||||||
|
continue
|
||||||
|
if raw_value is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(raw_value, str):
|
||||||
|
errors[item_error_key] = "invalid_value"
|
||||||
|
continue
|
||||||
|
if len(raw_value) > 20000:
|
||||||
|
errors[item_error_key] = "value_too_long"
|
||||||
|
continue
|
||||||
|
if raw_key_text in aliases and key in bucket:
|
||||||
|
continue
|
||||||
|
bucket[key] = raw_value
|
||||||
|
if bucket:
|
||||||
|
overrides[lang] = dict(sorted(bucket.items()))
|
||||||
|
|
||||||
|
return dict(sorted(overrides.items())), errors
|
||||||
|
|
||||||
|
|
||||||
class JsonI18n:
|
class JsonI18n:
|
||||||
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
default: str = "en",
|
||||||
|
domain: str = "bot",
|
||||||
|
overrides_path: Optional[str] = None,
|
||||||
|
):
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
self.path = path
|
self.path = path
|
||||||
self.default_lang = default
|
self.default_lang = default
|
||||||
|
self.base_locales_data: Dict[str, Dict[str, str]] = {}
|
||||||
|
self.locale_overrides: LocaleOverrides = {}
|
||||||
self.locales_data: Dict[str, Dict[str, str]] = {}
|
self.locales_data: Dict[str, Dict[str, str]] = {}
|
||||||
|
self._overrides_path: Optional[Path] = None
|
||||||
|
self._overrides_file_mtime_ns: Optional[int] = None
|
||||||
|
self._overrides_file_next_check = 0.0
|
||||||
|
self._overrides_file_check_interval_seconds = 1.0
|
||||||
self._load_locales()
|
self._load_locales()
|
||||||
|
if overrides_path:
|
||||||
|
self.configure_overrides_file(overrides_path)
|
||||||
|
self.reload_overrides_from_file(force=True)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
|
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
|
||||||
)
|
)
|
||||||
@@ -26,13 +324,26 @@ class JsonI18n:
|
|||||||
if not os.path.isdir(self.path):
|
if not os.path.isdir(self.path):
|
||||||
logging.error(f"Locales path not found or not a directory: {self.path}")
|
logging.error(f"Locales path not found or not a directory: {self.path}")
|
||||||
return
|
return
|
||||||
|
loaded: Dict[str, Dict[str, str]] = {}
|
||||||
for item in os.listdir(self.path):
|
for item in os.listdir(self.path):
|
||||||
if item.endswith(".json"):
|
if item.endswith(".json"):
|
||||||
lang_code = item.split(".")[0]
|
lang_code = item.split(".")[0]
|
||||||
file_path = os.path.join(self.path, item)
|
file_path = os.path.join(self.path, item)
|
||||||
try:
|
try:
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
self.locales_data[lang_code] = json.load(f)
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
loaded[lang_code] = {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in data.items()
|
||||||
|
if isinstance(value, str)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
"Locale %s from %s is not a JSON object",
|
||||||
|
lang_code,
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
except json.JSONDecodeError as e_json_load:
|
except json.JSONDecodeError as e_json_load:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
|
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
|
||||||
@@ -42,24 +353,143 @@ class JsonI18n:
|
|||||||
f"Error loading locale {lang_code} from {file_path}: {e_load}",
|
f"Error loading locale {lang_code} from {file_path}: {e_load}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
self.base_locales_data = loaded
|
||||||
|
self._rebuild_effective_locales()
|
||||||
|
|
||||||
|
def _rebuild_effective_locales(self) -> None:
|
||||||
|
effective: Dict[str, Dict[str, str]] = {}
|
||||||
|
for lang, messages in self.base_locales_data.items():
|
||||||
|
merged = dict(messages)
|
||||||
|
merged.update(self.locale_overrides.get(lang, {}))
|
||||||
|
effective[lang] = merged
|
||||||
|
fallback_base = (
|
||||||
|
self.base_locales_data.get(self.default_lang)
|
||||||
|
or self.base_locales_data.get("en")
|
||||||
|
or next(iter(self.base_locales_data.values()), {})
|
||||||
|
)
|
||||||
|
for lang, messages in self.locale_overrides.items():
|
||||||
|
if lang in effective:
|
||||||
|
continue
|
||||||
|
merged = dict(fallback_base)
|
||||||
|
merged.update(messages)
|
||||||
|
effective[lang] = merged
|
||||||
|
self.locales_data = effective
|
||||||
|
|
||||||
|
def _valid_keys_by_language(self) -> Dict[str, Set[str]]:
|
||||||
|
return _valid_locale_keys_by_language(self.base_locales_data)
|
||||||
|
|
||||||
|
def language_options(self) -> List[Dict[str, Any]]:
|
||||||
|
self.reload_overrides_from_file()
|
||||||
|
return locale_language_options(
|
||||||
|
self.locales_data.keys(),
|
||||||
|
base_languages=self.base_locales_data.keys(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_locale_overrides(self, overrides: object) -> Dict[str, str]:
|
||||||
|
normalized, errors = normalize_locale_overrides_payload(
|
||||||
|
overrides,
|
||||||
|
valid_languages=set(self.base_locales_data.keys()),
|
||||||
|
valid_keys_by_language=self._valid_keys_by_language(),
|
||||||
|
allow_extra_languages=True,
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
logging.warning("Some locale overrides were skipped: %s", errors)
|
||||||
|
self.locale_overrides = normalized
|
||||||
|
self._rebuild_effective_locales()
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def configure_overrides_file(self, path: str | Path) -> None:
|
||||||
|
self._overrides_path = Path(path)
|
||||||
|
try:
|
||||||
|
self._overrides_file_mtime_ns = self._overrides_path.stat().st_mtime_ns
|
||||||
|
except FileNotFoundError:
|
||||||
|
self._overrides_file_mtime_ns = None
|
||||||
|
except OSError as exc:
|
||||||
|
logging.warning("Failed to stat locale overrides file %s: %s", path, exc)
|
||||||
|
self._overrides_file_mtime_ns = None
|
||||||
|
|
||||||
|
def reload_overrides_from_file(self, *, force: bool = False) -> bool:
|
||||||
|
if self._overrides_path is None:
|
||||||
|
return False
|
||||||
|
now = time.monotonic()
|
||||||
|
if not force and now < self._overrides_file_next_check:
|
||||||
|
return False
|
||||||
|
self._overrides_file_next_check = now + self._overrides_file_check_interval_seconds
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat = self._overrides_path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
if self._overrides_file_mtime_ns is None:
|
||||||
|
return False
|
||||||
|
self._overrides_file_mtime_ns = None
|
||||||
|
logging.info(
|
||||||
|
"Locale overrides file removed; keeping current in-memory overrides until "
|
||||||
|
"the DB fallback is reloaded"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except OSError as exc:
|
||||||
|
logging.warning(
|
||||||
|
"Failed to stat locale overrides file %s: %s",
|
||||||
|
self._overrides_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not force and stat.st_mtime_ns == self._overrides_file_mtime_ns:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(self._overrides_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logging.warning(
|
||||||
|
"Failed to parse locale overrides file %s: %s",
|
||||||
|
self._overrides_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||||
|
return False
|
||||||
|
except OSError as exc:
|
||||||
|
logging.warning(
|
||||||
|
"Failed to read locale overrides file %s: %s",
|
||||||
|
self._overrides_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._overrides_file_mtime_ns = stat.st_mtime_ns
|
||||||
|
self.set_locale_overrides(payload)
|
||||||
|
logging.info("Locale overrides reloaded from %s", self._overrides_path)
|
||||||
|
return True
|
||||||
|
|
||||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||||
|
self.reload_overrides_from_file()
|
||||||
|
lookup_key = resolve_locale_key(key)
|
||||||
|
|
||||||
|
requested_lang_code = normalize_locale_language_code(
|
||||||
|
lang_code,
|
||||||
|
set(self.locales_data.keys()),
|
||||||
|
prefer_known_base=False,
|
||||||
|
)
|
||||||
|
requested_base_lang_code = requested_lang_code.split("-", 1)[0]
|
||||||
|
|
||||||
# Determine effective language with robust fallback
|
# Determine effective language with robust fallback
|
||||||
if lang_code and lang_code in self.locales_data:
|
if requested_lang_code and requested_lang_code in self.locales_data:
|
||||||
effective_lang_code = lang_code
|
effective_lang_code = requested_lang_code
|
||||||
|
elif requested_base_lang_code and requested_base_lang_code in self.locales_data:
|
||||||
|
effective_lang_code = requested_base_lang_code
|
||||||
elif self.default_lang in self.locales_data:
|
elif self.default_lang in self.locales_data:
|
||||||
effective_lang_code = self.default_lang
|
effective_lang_code = self.default_lang
|
||||||
elif "en" in self.locales_data:
|
elif "en" in self.locales_data:
|
||||||
effective_lang_code = "en"
|
effective_lang_code = "en"
|
||||||
else:
|
else:
|
||||||
effective_lang_code = lang_code or self.default_lang
|
effective_lang_code = requested_lang_code or self.default_lang
|
||||||
|
|
||||||
lang_data = self.locales_data.get(effective_lang_code)
|
lang_data = self.locales_data.get(effective_lang_code)
|
||||||
if lang_data is None:
|
if lang_data is None:
|
||||||
# Try explicit fallback to English if available
|
# Try explicit fallback to English if available
|
||||||
fallback_data = self.locales_data.get("en")
|
fallback_data = self.locales_data.get("en")
|
||||||
if fallback_data is not None:
|
if fallback_data is not None:
|
||||||
text = fallback_data.get(key)
|
text = fallback_data.get(lookup_key)
|
||||||
if text is not None:
|
if text is not None:
|
||||||
try:
|
try:
|
||||||
return text.format(**kwargs) if kwargs else text
|
return text.format(**kwargs) if kwargs else text
|
||||||
@@ -70,11 +500,11 @@ class JsonI18n:
|
|||||||
)
|
)
|
||||||
return key.format(**kwargs) if kwargs else key
|
return key.format(**kwargs) if kwargs else key
|
||||||
|
|
||||||
text = lang_data.get(key)
|
text = lang_data.get(lookup_key)
|
||||||
if text is None:
|
if text is None:
|
||||||
if effective_lang_code != self.default_lang:
|
if effective_lang_code != self.default_lang:
|
||||||
default_lang_data = self.locales_data.get(self.default_lang, {})
|
default_lang_data = self.locales_data.get(self.default_lang, {})
|
||||||
text = default_lang_data.get(key)
|
text = default_lang_data.get(lookup_key)
|
||||||
|
|
||||||
if text is None:
|
if text is None:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_templates import EmailContent, render_login_code
|
from bot.services.email_templates import EmailContent, render_login_code
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import security_dal
|
from db.dal import security_dal
|
||||||
@@ -70,8 +71,9 @@ def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optiona
|
|||||||
|
|
||||||
|
|
||||||
class EmailAuthService:
|
class EmailAuthService:
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self.i18n = i18n
|
||||||
|
|
||||||
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
def _smtp_attempts(self) -> list[SmtpAttempt]:
|
||||||
attempts: list[SmtpAttempt] = []
|
attempts: list[SmtpAttempt] = []
|
||||||
@@ -465,6 +467,7 @@ class EmailAuthService:
|
|||||||
language_code=language_code,
|
language_code=language_code,
|
||||||
magic_link=magic_link,
|
magic_link=magic_link,
|
||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
|
i18n=self.i18n,
|
||||||
)
|
)
|
||||||
|
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Optional, Sequence, Tuple
|
from typing import Optional, Sequence, Tuple
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n, get_i18n_instance
|
from bot.middlewares.i18n import JsonI18n, get_i18n_instance, normalize_locale_language_code
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
_BG = "#05070a"
|
_BG = "#05070a"
|
||||||
@@ -64,7 +64,10 @@ def _brand_title(settings: Settings) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
def _normalize_lang(language_code: Optional[str], settings: Settings) -> str:
|
||||||
return (language_code or settings.DEFAULT_LANGUAGE or "ru").split("-")[0]
|
return normalize_locale_language_code(
|
||||||
|
language_code or settings.DEFAULT_LANGUAGE or "ru",
|
||||||
|
prefer_known_base=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
def _resolve_i18n(i18n: Optional[JsonI18n]) -> JsonI18n:
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
"""Load, persist and apply runtime overrides for localization strings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from bot.middlewares.i18n import (
|
||||||
|
JsonI18n,
|
||||||
|
LocaleOverrides,
|
||||||
|
is_valid_locale_language_code,
|
||||||
|
normalize_locale_language_code,
|
||||||
|
normalize_locale_overrides_payload,
|
||||||
|
resolve_locale_key,
|
||||||
|
)
|
||||||
|
from db.dal import locale_overrides_dal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
LOCALE_OVERRIDES_PATH = APP_ROOT / "data" / "locales-overrides.json"
|
||||||
|
|
||||||
|
LOCALE_GROUPS = [
|
||||||
|
{
|
||||||
|
"id": "admin_navigation",
|
||||||
|
"title": "Admin navigation and shared UI",
|
||||||
|
"description": "Sidebar, section headers, toolbar actions, filters, and shared controls.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_nav_",
|
||||||
|
"admin_section_",
|
||||||
|
"admin_panel_title",
|
||||||
|
"admin_back_to_panel",
|
||||||
|
"admin_sidebar_",
|
||||||
|
"admin_exit",
|
||||||
|
"admin_menu",
|
||||||
|
"admin_language",
|
||||||
|
"admin_page_",
|
||||||
|
"admin_close",
|
||||||
|
"admin_collapse",
|
||||||
|
"admin_expand",
|
||||||
|
"admin_show",
|
||||||
|
"admin_hide",
|
||||||
|
"admin_loading",
|
||||||
|
"admin_btn_",
|
||||||
|
"admin_filter_",
|
||||||
|
"admin_sort_",
|
||||||
|
"admin_status_",
|
||||||
|
"admin_badge_",
|
||||||
|
"admin_aria_",
|
||||||
|
"admin_search",
|
||||||
|
"admin_clear",
|
||||||
|
"admin_save",
|
||||||
|
"admin_saving",
|
||||||
|
"admin_add",
|
||||||
|
"admin_apply",
|
||||||
|
"admin_reset",
|
||||||
|
"admin_copy",
|
||||||
|
"admin_copied",
|
||||||
|
"admin_error",
|
||||||
|
"admin_unknown_action",
|
||||||
|
"back_to_admin_panel_button",
|
||||||
|
"back_to_ads_list_button",
|
||||||
|
"back_to_stats_monitoring_button",
|
||||||
|
"back_to_user_management_button",
|
||||||
|
"prev_page_button",
|
||||||
|
"next_page_button",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_dashboard",
|
||||||
|
"title": "Admin dashboard and stats",
|
||||||
|
"description": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_stats_",
|
||||||
|
"admin_financial_",
|
||||||
|
"admin_enhanced_",
|
||||||
|
"admin_panel_stats_",
|
||||||
|
"admin_panel_traffic_",
|
||||||
|
"admin_queue_",
|
||||||
|
"admin_sync_status_",
|
||||||
|
"admin_stats_button",
|
||||||
|
"admin_sync_panel_button",
|
||||||
|
"admin_sync_initiated_from_panel",
|
||||||
|
"admin_total",
|
||||||
|
"error_displaying_statistics",
|
||||||
|
"inline_admin_",
|
||||||
|
"inline_user_stats_",
|
||||||
|
"inline_financial_",
|
||||||
|
"inline_system_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_users",
|
||||||
|
"title": "Admin users",
|
||||||
|
"description": (
|
||||||
|
"User lists, user cards, bans, grants, premium overrides, and direct messages."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_user_",
|
||||||
|
"admin_users_",
|
||||||
|
"admin_ban_",
|
||||||
|
"admin_unban_",
|
||||||
|
"admin_banned_",
|
||||||
|
"admin_premium_override_",
|
||||||
|
"admin_traffic_grant_",
|
||||||
|
"admin_view_banned_",
|
||||||
|
"user_card_",
|
||||||
|
"user_premium_",
|
||||||
|
"user_regular_",
|
||||||
|
"user_traffic_",
|
||||||
|
"user_override_",
|
||||||
|
"premium_override_",
|
||||||
|
"regular_override_",
|
||||||
|
"traffic_grant_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_payments",
|
||||||
|
"title": "Admin payments",
|
||||||
|
"description": (
|
||||||
|
"Payment tables, payment details, exports, provider labels, and payment stats."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_payment_",
|
||||||
|
"admin_payments_",
|
||||||
|
"admin_no_payments",
|
||||||
|
"admin_view_payments",
|
||||||
|
"admin_refresh_payments",
|
||||||
|
"admin_export_payments",
|
||||||
|
"admin_export_sent",
|
||||||
|
"admin_amount",
|
||||||
|
"admin_provider",
|
||||||
|
"admin_description",
|
||||||
|
"admin_date",
|
||||||
|
"admin_csv_payment_",
|
||||||
|
"admin_csv_amount",
|
||||||
|
"admin_csv_currency",
|
||||||
|
"admin_csv_provider",
|
||||||
|
"admin_csv_status",
|
||||||
|
"admin_csv_description",
|
||||||
|
"admin_csv_units",
|
||||||
|
"admin_csv_months",
|
||||||
|
"admin_csv_created_at",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_promos_marketing",
|
||||||
|
"title": "Admin promos, ads, and broadcasts",
|
||||||
|
"description": "Promo management, ad campaigns, marketing tools, and broadcast workflows.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_promo_",
|
||||||
|
"admin_promos_",
|
||||||
|
"admin_bulk_promo_",
|
||||||
|
"admin_ads_",
|
||||||
|
"admin_ad_",
|
||||||
|
"admin_broadcast_",
|
||||||
|
"admin_create_promo_",
|
||||||
|
"admin_create_bulk_promo_",
|
||||||
|
"admin_active_promos_",
|
||||||
|
"broadcast_",
|
||||||
|
"confirm_broadcast_",
|
||||||
|
"cancel_broadcast_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_tariffs",
|
||||||
|
"title": "Admin tariffs",
|
||||||
|
"description": (
|
||||||
|
"Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": ("admin_tariff_", "admin_tariffs_", "admin_trial"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_support",
|
||||||
|
"title": "Admin support inbox",
|
||||||
|
"description": "Support ticket inbox, ticket filters, admin replies, and support statuses.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": ("admin_support_",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_appearance",
|
||||||
|
"title": "Admin appearance",
|
||||||
|
"description": "Theme catalog, branding, logo, favicon, and public page links.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_themes_",
|
||||||
|
"admin_appearance",
|
||||||
|
"admin_settings_icon_",
|
||||||
|
"admin_settings_field_webapp_",
|
||||||
|
"admin_settings_field_subscription_mini_app_url",
|
||||||
|
"admin_settings_field_support_link",
|
||||||
|
"admin_settings_field_server_status_url",
|
||||||
|
"admin_settings_field_terms_",
|
||||||
|
"admin_settings_field_privacy_",
|
||||||
|
"admin_settings_field_user_agreement_",
|
||||||
|
"appearance_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_settings_payments",
|
||||||
|
"title": "Admin payment settings",
|
||||||
|
"description": (
|
||||||
|
"Payment method toggles, prices, provider credentials, and webhook settings."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_settings_field_default_currency_",
|
||||||
|
"admin_settings_field_month_",
|
||||||
|
"admin_settings_field_rub_",
|
||||||
|
"admin_settings_field_stars_",
|
||||||
|
"admin_settings_field_traffic_packages_",
|
||||||
|
"admin_settings_field_payment_methods_",
|
||||||
|
"admin_settings_field_subscription_purchase_",
|
||||||
|
"admin_settings_field_yookassa_",
|
||||||
|
"admin_settings_field_freekassa_",
|
||||||
|
"admin_settings_field_platega_",
|
||||||
|
"admin_settings_field_severpay_",
|
||||||
|
"admin_settings_field_cryptopay_",
|
||||||
|
"admin_settings_field_wata_",
|
||||||
|
"admin_settings_field_heleket_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_settings_subscriptions",
|
||||||
|
"title": "Admin subscription settings",
|
||||||
|
"description": (
|
||||||
|
"Panel connection, default squads, trials, referrals, device limits, and guides."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_settings_field_panel_",
|
||||||
|
"admin_settings_field_user_",
|
||||||
|
"admin_settings_field_trial_",
|
||||||
|
"admin_settings_field_referral_",
|
||||||
|
"admin_settings_field_legacy_refs",
|
||||||
|
"admin_settings_field_my_devices_",
|
||||||
|
"admin_settings_field_subscription_guides_",
|
||||||
|
"admin_settings_field_subscription_page_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_settings_notifications",
|
||||||
|
"title": "Admin notifications and logs",
|
||||||
|
"description": "Logging, required channel, subscription notifications, and support limits.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_settings_field_log_",
|
||||||
|
"admin_settings_field_support_",
|
||||||
|
"admin_settings_field_subscription_notifications_",
|
||||||
|
"admin_settings_field_subscription_notify_",
|
||||||
|
"admin_settings_field_required_",
|
||||||
|
"admin_settings_field_disable_welcome_",
|
||||||
|
"admin_settings_field_start_command_",
|
||||||
|
"admin_settings_field_default_language_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_settings",
|
||||||
|
"title": "Admin settings",
|
||||||
|
"description": (
|
||||||
|
"Settings screen groups, subsections, helper text, and uncategorized settings."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": ("admin_settings_",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_translations",
|
||||||
|
"title": "Admin translations",
|
||||||
|
"description": "Translation override screen, language controls, and locale group labels.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": ("admin_translations_",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_logs",
|
||||||
|
"title": "Admin logs and exports",
|
||||||
|
"description": "Activity logs, log exports, CSV headers, and event detail labels.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": (
|
||||||
|
"admin_logs_",
|
||||||
|
"admin_log_",
|
||||||
|
"admin_all_logs_",
|
||||||
|
"admin_view_logs_",
|
||||||
|
"admin_export_logs_",
|
||||||
|
"admin_no_logs",
|
||||||
|
"admin_csv_header_",
|
||||||
|
"admin_event",
|
||||||
|
"admin_content",
|
||||||
|
"csv_yes",
|
||||||
|
"csv_no",
|
||||||
|
"error_displaying_logs_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin_misc",
|
||||||
|
"title": "Admin miscellaneous",
|
||||||
|
"description": (
|
||||||
|
"Older bot-admin labels and admin-only strings that do not fit another section."
|
||||||
|
),
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": ("admin_",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "webapp",
|
||||||
|
"title": "Mini App",
|
||||||
|
"description": "User-facing Mini App screens, navigation, settings, and toasts.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": ("wa_",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bot_menu",
|
||||||
|
"title": "Telegram bot menu",
|
||||||
|
"description": "Start menu, inline buttons, language selector, and bot-only flows.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": (
|
||||||
|
"main_menu_",
|
||||||
|
"menu_",
|
||||||
|
"bot_interface_",
|
||||||
|
"choose_language",
|
||||||
|
"language_",
|
||||||
|
"back_",
|
||||||
|
"cancel_",
|
||||||
|
"connect_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "subscriptions",
|
||||||
|
"title": "Subscriptions and devices",
|
||||||
|
"description": (
|
||||||
|
"Subscription status, install guides, traffic packages, trials, and devices."
|
||||||
|
),
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": (
|
||||||
|
"subscription_",
|
||||||
|
"trial_",
|
||||||
|
"tariff_",
|
||||||
|
"traffic_",
|
||||||
|
"device_",
|
||||||
|
"devices_",
|
||||||
|
"my_devices_",
|
||||||
|
"install_",
|
||||||
|
"config_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "payments",
|
||||||
|
"title": "Payments",
|
||||||
|
"description": "Payment provider flows, invoices, payment methods, and checkout messages.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": (
|
||||||
|
"payment_",
|
||||||
|
"pay_",
|
||||||
|
"yookassa_",
|
||||||
|
"free_kassa_",
|
||||||
|
"freekassa_",
|
||||||
|
"wata_",
|
||||||
|
"heleket_",
|
||||||
|
"cryptopay_",
|
||||||
|
"platega_",
|
||||||
|
"stars_",
|
||||||
|
"autorenew_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "support",
|
||||||
|
"title": "Support",
|
||||||
|
"description": "Support links, ticket inbox copy, ticket statuses, and notifications.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": ("support_", "ticket_"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "referrals_promos",
|
||||||
|
"title": "Referrals and promos",
|
||||||
|
"description": "Referral program, invite copy, promo codes, and bonuses.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": ("referral_", "promo_", "invite_", "inline_referral_"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "auth_security",
|
||||||
|
"title": "Auth and security",
|
||||||
|
"description": "Login, email verification, account linking, and security messages.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": (
|
||||||
|
"auth_",
|
||||||
|
"login_",
|
||||||
|
"password_",
|
||||||
|
"security_",
|
||||||
|
"webapp_auth_",
|
||||||
|
"channel_subscription_",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "emails",
|
||||||
|
"title": "Emails",
|
||||||
|
"description": "Transactional emails sent to users: login codes, payments, and reminders.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": ("email_",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notifications_sync",
|
||||||
|
"title": "Notifications and sync",
|
||||||
|
"description": "Admin notifications, panel sync, logs, and background status messages.",
|
||||||
|
"audience": "internal",
|
||||||
|
"prefixes": ("notification_", "notifications_", "sync_", "log_", "panel_"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_LOCALE_GROUP = {
|
||||||
|
"id": "common",
|
||||||
|
"title": "Common",
|
||||||
|
"description": "Shared buttons, statuses, validation errors, and uncategorized strings.",
|
||||||
|
"audience": "user",
|
||||||
|
"prefixes": (),
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERNAL_LOCALE_KEY_PREFIXES = (
|
||||||
|
"admin_",
|
||||||
|
"log_",
|
||||||
|
"notification_",
|
||||||
|
"notifications_",
|
||||||
|
"panel_",
|
||||||
|
"sync_",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocaleOverridesFileState:
|
||||||
|
exists: bool
|
||||||
|
readable: bool
|
||||||
|
overrides: LocaleOverrides
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_languages(i18n: JsonI18n) -> set[str]:
|
||||||
|
return set((i18n.base_locales_data or i18n.locales_data or {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_keys_by_language(i18n: JsonI18n) -> Dict[str, set[str]]:
|
||||||
|
source = i18n.base_locales_data or i18n.locales_data or {}
|
||||||
|
return {
|
||||||
|
lang: {str(key) for key in messages}
|
||||||
|
for lang, messages in source.items()
|
||||||
|
if isinstance(messages, dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_i18n(i18n: JsonI18n, payload: object) -> tuple[LocaleOverrides, Dict[str, str]]:
|
||||||
|
return normalize_locale_overrides_payload(
|
||||||
|
payload,
|
||||||
|
valid_languages=_valid_languages(i18n),
|
||||||
|
valid_keys_by_language=_valid_keys_by_language(i18n),
|
||||||
|
allow_extra_languages=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(overrides: LocaleOverrides) -> Iterable[Tuple[str, str, str]]:
|
||||||
|
for lang, messages in overrides.items():
|
||||||
|
for key, value in messages.items():
|
||||||
|
yield lang, key, value
|
||||||
|
|
||||||
|
|
||||||
|
def _flat_map(overrides: LocaleOverrides) -> Dict[Tuple[str, str], str]:
|
||||||
|
return {(lang, key): value for lang, key, value in _flatten(overrides)}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_overrides(overrides: LocaleOverrides) -> int:
|
||||||
|
return sum(len(messages) for messages in overrides.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _read_locale_overrides_file_state(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
*,
|
||||||
|
path: Path = LOCALE_OVERRIDES_PATH,
|
||||||
|
) -> LocaleOverridesFileState:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return LocaleOverridesFileState(exists=False, readable=False, overrides={})
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Failed to read locale overrides from %s: %s", path, exc)
|
||||||
|
return LocaleOverridesFileState(exists=True, readable=False, overrides={})
|
||||||
|
|
||||||
|
overrides, errors = _normalize_for_i18n(i18n, payload)
|
||||||
|
if errors:
|
||||||
|
logger.warning("Skipping invalid locale override entries from %s: %s", path, errors)
|
||||||
|
return LocaleOverridesFileState(exists=True, readable=True, overrides=overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def read_locale_overrides_file(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
*,
|
||||||
|
path: Path = LOCALE_OVERRIDES_PATH,
|
||||||
|
) -> LocaleOverrides:
|
||||||
|
return _read_locale_overrides_file_state(i18n, path=path).overrides
|
||||||
|
|
||||||
|
|
||||||
|
def write_locale_overrides_file(
|
||||||
|
overrides: LocaleOverrides,
|
||||||
|
*,
|
||||||
|
path: Path = LOCALE_OVERRIDES_PATH,
|
||||||
|
) -> bool:
|
||||||
|
payload = {
|
||||||
|
lang: dict(sorted(messages.items()))
|
||||||
|
for lang, messages in sorted(overrides.items())
|
||||||
|
if messages
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Failed to write locale overrides to %s: %s", path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_db_overrides(
|
||||||
|
session: AsyncSession,
|
||||||
|
desired_overrides: LocaleOverrides,
|
||||||
|
*,
|
||||||
|
updated_by: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
current = _flat_map(await locale_overrides_dal.get_all_overrides(session))
|
||||||
|
desired = _flat_map(desired_overrides)
|
||||||
|
changes: Dict[Tuple[str, str], Tuple[bool, str]] = {}
|
||||||
|
|
||||||
|
for identity, value in desired.items():
|
||||||
|
if current.get(identity) != value:
|
||||||
|
changes[identity] = (True, value)
|
||||||
|
for identity in current:
|
||||||
|
if identity not in desired:
|
||||||
|
changes[identity] = (False, "")
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
await locale_overrides_dal.bulk_apply(session, updates=changes, updated_by=updated_by)
|
||||||
|
return len(changes)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_locale_overrides(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
*,
|
||||||
|
overrides_path: Path = LOCALE_OVERRIDES_PATH,
|
||||||
|
) -> int:
|
||||||
|
"""Load locale overrides and keep the DB mirror in sync.
|
||||||
|
|
||||||
|
A valid JSON file is the source of truth. The DB is used as a fallback only
|
||||||
|
when the file is missing or cannot be read/parsed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
i18n.configure_overrides_file(overrides_path)
|
||||||
|
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
if file_state.readable:
|
||||||
|
async with session.begin():
|
||||||
|
changed = await _replace_db_overrides(
|
||||||
|
session,
|
||||||
|
file_state.overrides,
|
||||||
|
updated_by=None,
|
||||||
|
)
|
||||||
|
i18n.set_locale_overrides(file_state.overrides)
|
||||||
|
i18n.configure_overrides_file(overrides_path)
|
||||||
|
logger.info(
|
||||||
|
"Applied %s locale overrides from %s and synced %s DB rows",
|
||||||
|
_count_overrides(file_state.overrides),
|
||||||
|
overrides_path,
|
||||||
|
changed,
|
||||||
|
)
|
||||||
|
return _count_overrides(file_state.overrides)
|
||||||
|
|
||||||
|
db_overrides = await locale_overrides_dal.get_all_overrides(session)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not load locale overrides from DB: %s", exc)
|
||||||
|
if file_state.readable:
|
||||||
|
i18n.set_locale_overrides(file_state.overrides)
|
||||||
|
return _count_overrides(file_state.overrides)
|
||||||
|
i18n.set_locale_overrides({})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
normalized, errors = _normalize_for_i18n(i18n, db_overrides)
|
||||||
|
if errors:
|
||||||
|
logger.warning("Skipping invalid DB locale override entries: %s", errors)
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
changed = await _replace_db_overrides(session, normalized, updated_by=None)
|
||||||
|
if changed:
|
||||||
|
logger.info("Canonicalized %s DB locale override rows", changed)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not canonicalize DB locale overrides: %s", exc)
|
||||||
|
i18n.set_locale_overrides(normalized)
|
||||||
|
if not file_state.exists:
|
||||||
|
logger.info(
|
||||||
|
"Locale overrides file %s is missing; trying to bootstrap it from DB state",
|
||||||
|
overrides_path,
|
||||||
|
)
|
||||||
|
file_written = write_locale_overrides_file(normalized, path=overrides_path)
|
||||||
|
if file_written:
|
||||||
|
i18n.configure_overrides_file(overrides_path)
|
||||||
|
logger.info("Created locale overrides file %s from DB state", overrides_path)
|
||||||
|
logger.info("Applied %s locale overrides from DB fallback", _count_overrides(normalized))
|
||||||
|
return _count_overrides(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_locale_overrides(
|
||||||
|
i18n: JsonI18n,
|
||||||
|
async_session_factory: sessionmaker,
|
||||||
|
*,
|
||||||
|
updates: Dict[str, Dict[str, Any]],
|
||||||
|
deletes: Optional[List[Dict[str, str]]] = None,
|
||||||
|
actor_id: Optional[int] = None,
|
||||||
|
overrides_path: Path = LOCALE_OVERRIDES_PATH,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
deletes = list(deletes or [])
|
||||||
|
normalized_updates, errors = _normalize_for_i18n(i18n, updates)
|
||||||
|
|
||||||
|
normalized_deletes: List[Tuple[str, str]] = []
|
||||||
|
valid_languages = _valid_languages(i18n)
|
||||||
|
valid_keys = {key for keys in _valid_keys_by_language(i18n).values() for key in keys}
|
||||||
|
for item in deletes:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors.setdefault("_deletes", "invalid_delete")
|
||||||
|
continue
|
||||||
|
lang = normalize_locale_language_code(
|
||||||
|
item.get("lang"),
|
||||||
|
valid_languages=None,
|
||||||
|
prefer_known_base=False,
|
||||||
|
)
|
||||||
|
key = resolve_locale_key(item.get("key"))
|
||||||
|
error_key = f"{lang or '_language'}.{key or '_key'}"
|
||||||
|
if lang not in valid_languages and not is_valid_locale_language_code(lang):
|
||||||
|
errors.setdefault(error_key, "invalid_language")
|
||||||
|
continue
|
||||||
|
if key not in valid_keys:
|
||||||
|
errors.setdefault(error_key, "unknown_key")
|
||||||
|
continue
|
||||||
|
normalized_deletes.append((lang, key))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return {"ok": False, "errors": errors}
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
db_overrides = await locale_overrides_dal.get_all_overrides(session)
|
||||||
|
|
||||||
|
file_state = _read_locale_overrides_file_state(i18n, path=overrides_path)
|
||||||
|
source_overrides = file_state.overrides if file_state.readable else db_overrides
|
||||||
|
desired, source_errors = _normalize_for_i18n(i18n, source_overrides)
|
||||||
|
if source_errors:
|
||||||
|
logger.warning("Skipping invalid locale override entries before update: %s", source_errors)
|
||||||
|
|
||||||
|
for lang, messages in normalized_updates.items():
|
||||||
|
desired.setdefault(lang, {}).update(messages)
|
||||||
|
for lang, key in normalized_deletes:
|
||||||
|
if lang in desired:
|
||||||
|
desired[lang].pop(key, None)
|
||||||
|
if not desired[lang]:
|
||||||
|
desired.pop(lang, None)
|
||||||
|
desired = {
|
||||||
|
lang: dict(sorted(messages.items()))
|
||||||
|
for lang, messages in sorted(desired.items())
|
||||||
|
if messages
|
||||||
|
}
|
||||||
|
|
||||||
|
file_written = write_locale_overrides_file(desired, path=overrides_path)
|
||||||
|
if not file_written and file_state.exists and file_state.readable:
|
||||||
|
return {"ok": False, "errors": {"_file": "write_failed"}}
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
await _replace_db_overrides(session, desired, updated_by=actor_id)
|
||||||
|
|
||||||
|
i18n.set_locale_overrides(desired)
|
||||||
|
if file_written:
|
||||||
|
i18n.configure_overrides_file(overrides_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"applied": sum(len(messages) for messages in normalized_updates.values()),
|
||||||
|
"reverted": len(normalized_deletes),
|
||||||
|
"file_written": file_written,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def group_id_for_locale_key(key: str) -> str:
|
||||||
|
for group in LOCALE_GROUPS:
|
||||||
|
if any(key.startswith(prefix) or key == prefix for prefix in group["prefixes"]):
|
||||||
|
return str(group["id"])
|
||||||
|
return str(DEFAULT_LOCALE_GROUP["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def audience_for_locale_key(key: str) -> str:
|
||||||
|
if key.startswith(INTERNAL_LOCALE_KEY_PREFIXES):
|
||||||
|
return "internal"
|
||||||
|
group_id = group_id_for_locale_key(key)
|
||||||
|
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
|
||||||
|
if group["id"] == group_id:
|
||||||
|
return str(group.get("audience") or "user")
|
||||||
|
return "user"
|
||||||
|
|
||||||
|
|
||||||
|
def locale_group_catalog() -> List[Dict[str, Any]]:
|
||||||
|
catalog: List[Dict[str, Any]] = []
|
||||||
|
for group in [*LOCALE_GROUPS, DEFAULT_LOCALE_GROUP]:
|
||||||
|
item = {key: value for key, value in group.items() if key != "prefixes"}
|
||||||
|
item["title_key"] = f"translations_group_{item['id']}"
|
||||||
|
item["description_key"] = f"translations_group_{item['id']}_hint"
|
||||||
|
catalog.append(item)
|
||||||
|
return catalog
|
||||||
@@ -206,8 +206,9 @@ class PanelWebhookService:
|
|||||||
days_left=days_left,
|
days_left=days_left,
|
||||||
end_date_text=end_date_text,
|
end_date_text=end_date_text,
|
||||||
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
|
||||||
|
i18n=self.i18n,
|
||||||
)
|
)
|
||||||
email_service = EmailAuthService(self.settings)
|
email_service = EmailAuthService(self.settings, self.i18n)
|
||||||
await email_service.send_rendered_email(email=recipient, content=content)
|
await email_service.send_rendered_email(email=recipient, content=content)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
logging.exception("Failed to send subscription-expiring email to %s", recipient)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class PaymentContextMixin:
|
|||||||
except Exception:
|
except Exception:
|
||||||
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
|
||||||
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
|
||||||
|
i18n = getattr(self, "i18n", None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = render_payment_success(
|
content = render_payment_success(
|
||||||
@@ -108,8 +109,9 @@ class PaymentContextMixin:
|
|||||||
end_date_text=end_date_text,
|
end_date_text=end_date_text,
|
||||||
dashboard_url=dashboard_url,
|
dashboard_url=dashboard_url,
|
||||||
provider_label=provider_label,
|
provider_label=provider_label,
|
||||||
|
i18n=i18n,
|
||||||
)
|
)
|
||||||
email_service = EmailAuthService(self.settings)
|
email_service = EmailAuthService(self.settings, i18n)
|
||||||
await email_service.send_rendered_email(email=recipient, content=content)
|
await email_service.send_rendered_email(email=recipient, content=content)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
|
logging.exception("Failed to send payment success email to user %s", db_user.user_id)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class SupportService:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.email_auth_service = email_auth_service or EmailAuthService(settings)
|
self.email_auth_service = email_auth_service or EmailAuthService(settings, i18n)
|
||||||
self.notification_service = notification_service or NotificationService(
|
self.notification_service = notification_service or NotificationService(
|
||||||
bot,
|
bot,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from . import (
|
from . import (
|
||||||
ad_dal,
|
ad_dal,
|
||||||
app_settings_dal,
|
app_settings_dal,
|
||||||
|
locale_overrides_dal,
|
||||||
message_log_dal,
|
message_log_dal,
|
||||||
panel_sync_dal,
|
panel_sync_dal,
|
||||||
payment_dal,
|
payment_dal,
|
||||||
@@ -23,5 +24,6 @@ __all__ = (
|
|||||||
"ad_dal",
|
"ad_dal",
|
||||||
"security_dal",
|
"security_dal",
|
||||||
"app_settings_dal",
|
"app_settings_dal",
|
||||||
|
"locale_overrides_dal",
|
||||||
"support_dal",
|
"support_dal",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Persistent overrides for localization strings."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.models import LocaleOverride
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_overrides(session: AsyncSession) -> Dict[str, Dict[str, str]]:
|
||||||
|
rows = (await session.execute(select(LocaleOverride))).scalars().all()
|
||||||
|
result: Dict[str, Dict[str, str]] = {}
|
||||||
|
for row in rows:
|
||||||
|
result.setdefault(row.lang, {})[row.key] = row.value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, object]]:
|
||||||
|
rows = (await session.execute(select(LocaleOverride))).scalars().all()
|
||||||
|
items: List[Dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"lang": row.lang,
|
||||||
|
"key": row.key,
|
||||||
|
"value": row.value,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
"updated_by": row.updated_by,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_override(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
lang: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
updated_by: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stmt = (
|
||||||
|
pg_insert(LocaleOverride)
|
||||||
|
.values(lang=lang, key=key, value=value, updated_at=now, updated_by=updated_by)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[LocaleOverride.lang, LocaleOverride.key],
|
||||||
|
set_={
|
||||||
|
"value": value,
|
||||||
|
"updated_at": now,
|
||||||
|
"updated_by": updated_by,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_override(session: AsyncSession, *, lang: str, key: str) -> bool:
|
||||||
|
stmt = delete(LocaleOverride).where(
|
||||||
|
LocaleOverride.lang == lang,
|
||||||
|
LocaleOverride.key == key,
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return bool(result.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_apply(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
updates: Dict[Tuple[str, str], Tuple[bool, str]],
|
||||||
|
updated_by: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
for (lang, key), (set_flag, value) in updates.items():
|
||||||
|
if set_flag:
|
||||||
|
await upsert_override(
|
||||||
|
session,
|
||||||
|
lang=lang,
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
updated_by=updated_by,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await delete_override(session, lang=lang, key=key)
|
||||||
@@ -902,6 +902,23 @@ def _migration_0027_add_subscription_install_share_token(connection: Connection)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0028_add_locale_overrides(connection: Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS locale_overrides (
|
||||||
|
lang VARCHAR(16) NOT NULL,
|
||||||
|
key VARCHAR(255) NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by BIGINT,
|
||||||
|
PRIMARY KEY (lang, key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -1049,6 +1066,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Add stable public share tokens for install instructions",
|
description="Add stable public share tokens for install instructions",
|
||||||
upgrade=_migration_0027_add_subscription_install_share_token,
|
upgrade=_migration_0027_add_subscription_install_share_token,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0028_add_locale_overrides",
|
||||||
|
description="Persist runtime overrides for localization strings",
|
||||||
|
upgrade=_migration_0028_add_locale_overrides,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -489,3 +489,18 @@ class AppSettingOverride(Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
updated_by = Column(BigInteger, nullable=True)
|
updated_by = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LocaleOverride(Base):
|
||||||
|
__tablename__ = "locale_overrides"
|
||||||
|
|
||||||
|
lang = Column(String(16), primary_key=True)
|
||||||
|
key = Column(String(255), primary_key=True)
|
||||||
|
value = Column(Text, nullable=False)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_by = Column(BigInteger, nullable=True)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from bot.payment_providers.yookassa import (
|
|||||||
process_cancelled_payment,
|
process_cancelled_payment,
|
||||||
process_successful_payment,
|
process_successful_payment,
|
||||||
)
|
)
|
||||||
|
from bot.services.locale_override_service import load_locale_overrides
|
||||||
from bot.services.settings_override_service import load_overrides_from_db
|
from bot.services.settings_override_service import load_overrides_from_db
|
||||||
from bot.services.tariff_worker import TariffTrafficWorker
|
from bot.services.tariff_worker import TariffTrafficWorker
|
||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
@@ -37,6 +38,7 @@ async def _build_worker_context(settings):
|
|||||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
)
|
)
|
||||||
i18n = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
|
i18n = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
|
||||||
|
await load_locale_overrides(i18n, session_factory)
|
||||||
bot_username = "your_bot_username"
|
bot_username = "your_bot_username"
|
||||||
try:
|
try:
|
||||||
bot_info = await bot.get_me()
|
bot_info = await bot.get_me()
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ru": {
|
||||||
|
"menu_personal_account_button": "Личный кабинет"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"menu_personal_account_button": "Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,9 @@ COPY data ./data
|
|||||||
COPY --from=version-builder /build-version /app/.build-version
|
COPY --from=version-builder /build-version /app/.build-version
|
||||||
COPY --from=version-builder /build-tag /app/.build-tag
|
COPY --from=version-builder /build-tag /app/.build-tag
|
||||||
COPY --from=version-builder /build-commit /app/.build-commit
|
COPY --from=version-builder /build-commit /app/.build-commit
|
||||||
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
|
RUN mkdir -p /app/logs /app/data \
|
||||||
|
&& if [ ! -f /app/data/locales-overrides.json ]; then printf '{}\n' > /app/data/locales-overrides.json; fi \
|
||||||
|
&& chown -R appuser:appuser /app/logs /app/data
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,27 @@
|
|||||||
|
|
||||||
Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT_<METHOD>_WEBAPP_LABEL_RU`, `PAYMENT_<METHOD>_WEBAPP_LABEL_EN`, `PAYMENT_<METHOD>_WEBAPP_ICON`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` и `PAYMENT_<METHOD>_TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычный дефолт из модуля платежного провайдера. Иконка Web App выбирается из уже подключённых lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке.
|
Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT_<METHOD>_WEBAPP_LABEL_RU`, `PAYMENT_<METHOD>_WEBAPP_LABEL_EN`, `PAYMENT_<METHOD>_WEBAPP_ICON`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` и `PAYMENT_<METHOD>_TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычный дефолт из модуля платежного провайдера. Иконка Web App выбирается из уже подключённых lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке.
|
||||||
|
|
||||||
|
## Переводы
|
||||||
|
|
||||||
|
Раздел **Система -> Переводы** позволяет переопределять отдельные строки из `locales/ru.json` и `locales/en.json` без монтирования полного файла локализации. Строки сгруппированы по месту применения: админка, Mini App, Telegram-бот, платежи, подписки, поддержка и другие группы.
|
||||||
|
|
||||||
|
В разделе строки разделены по аудитории: пользовательские тексты Mini App/бота/платежей и внутренние тексты админки, логов и синхронизации. Дополнительные языки можно добавлять прямо в интерфейсе по коду локали, например `uk`, `de` или `pt-BR`; для таких языков оверрайды хранятся без отдельного базового файла локали, а отсутствующие строки берутся из языка по умолчанию.
|
||||||
|
|
||||||
|
Файл `data/locales-overrides.json` считается источником правды, а таблица `locale_overrides` хранит его DB-зеркало. Если валидный JSON-файл есть, при старте backend и worker полностью синхронизируют БД с файлом, включая удаления строк. Если файл отсутствует, но каталог доступен для записи, он автоматически создается из текущих DB-оверрайдов или как пустой `{}`. Если файл не примонтирован, недоступен или временно сломан по JSON, используется fallback из БД. Сохранение из админки записывает полный итоговый снапшот и в JSON-файл, и в БД; если активный JSON-файл есть, но его нельзя перезаписать, сохранение отклоняется, чтобы БД не разъехалась с главным файлом.
|
||||||
|
|
||||||
|
Формат файла:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ru": {
|
||||||
|
"menu_personal_account_button": "Личный кабинет"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"menu_personal_account_button": "Account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Инструкции подключения
|
### Инструкции подключения
|
||||||
|
|
||||||
Секция **Система -> Настройки -> Инструкции подключения** управляет встроенным экраном установки. `SUBSCRIPTION_GUIDES_ENABLED` включает `/install` в личном кабинете, а `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` заставляет кнопки подключения в Telegram-боте открывать Mini App вместо финальной Remnawave Subscription Page. Оба переключателя включены по умолчанию.
|
Секция **Система -> Настройки -> Инструкции подключения** управляет встроенным экраном установки. `SUBSCRIPTION_GUIDES_ENABLED` включает `/install` в личном кабинете, а `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED` заставляет кнопки подключения в Telegram-боте открывать Mini App вместо финальной Remnawave Subscription Page. Оба переключателя включены по умолчанию.
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ openssl rand -hex 32
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
||||||
|
touch data/locales-overrides.json
|
||||||
chown -R 10001:10001 data
|
chown -R 10001:10001 data
|
||||||
chmod -R u+rwX data
|
chmod -R u+rwX data
|
||||||
docker compose up -d --force-recreate backend worker
|
docker compose up -d --force-recreate backend worker
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ distributed lock; код подготовлен к нескольким репл
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs
|
||||||
|
touch data/locales-overrides.json
|
||||||
chown -R 10001:10001 data
|
chown -R 10001:10001 data
|
||||||
chmod -R u+rwX data
|
chmod -R u+rwX data
|
||||||
docker compose up -d --force-recreate backend worker
|
docker compose up -d --force-recreate backend worker
|
||||||
|
|||||||
+38
-4
@@ -35,6 +35,7 @@
|
|||||||
TELEGRAM_SDK_ACTION_TIMEOUT_MS,
|
TELEGRAM_SDK_ACTION_TIMEOUT_MS,
|
||||||
TELEGRAM_SDK_BOOT_TIMEOUT_MS,
|
TELEGRAM_SDK_BOOT_TIMEOUT_MS,
|
||||||
TELEGRAM_WEBAPP_SCRIPT_URL,
|
TELEGRAM_WEBAPP_SCRIPT_URL,
|
||||||
|
uniqueLanguageCodes,
|
||||||
WEBAPP_LANGUAGE_ORDER,
|
WEBAPP_LANGUAGE_ORDER,
|
||||||
} from "./lib/webapp/constants.js";
|
} from "./lib/webapp/constants.js";
|
||||||
|
|
||||||
@@ -423,11 +424,20 @@
|
|||||||
}
|
}
|
||||||
$: referral = data?.referral || DEV_MOCK.data.referral;
|
$: referral = data?.referral || DEV_MOCK.data.referral;
|
||||||
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru");
|
$: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru");
|
||||||
$: languageOptions = WEBAPP_LANGUAGE_ORDER.map((code) => ({
|
$: languageCodes = uniqueLanguageCodes(
|
||||||
|
WEBAPP_LANGUAGE_ORDER,
|
||||||
|
CFG.languages,
|
||||||
|
Object.keys(I18N || {}),
|
||||||
|
[currentLang]
|
||||||
|
);
|
||||||
|
$: languageOptions = languageCodes.map((code) => {
|
||||||
|
const serverLanguage = (CFG.languages || []).find((language) => language.code === code);
|
||||||
|
return {
|
||||||
value: code,
|
value: code,
|
||||||
label: LANGUAGE_LABELS[code] || code.toUpperCase(),
|
label: serverLanguage?.label || LANGUAGE_LABELS[code] || code.toUpperCase(),
|
||||||
flag: LANGUAGE_FLAGS[code] || "🏳️",
|
flag: serverLanguage?.flag || LANGUAGE_FLAGS[code] || "🏳️",
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
$: currentLanguageOption =
|
$: currentLanguageOption =
|
||||||
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
||||||
$: userLanguage = languageName(currentLang);
|
$: userLanguage = languageName(currentLang);
|
||||||
@@ -1000,6 +1010,7 @@
|
|||||||
onSettingsSaved: handleAdminPersistedSaved,
|
onSettingsSaved: handleAdminPersistedSaved,
|
||||||
onTariffsSaved: handleAdminPersistedSaved,
|
onTariffsSaved: handleAdminPersistedSaved,
|
||||||
onThemesSaved: handleAdminPersistedSaved,
|
onThemesSaved: handleAdminPersistedSaved,
|
||||||
|
onTranslationsSaved: handleAdminTranslationsSaved,
|
||||||
brandTitle,
|
brandTitle,
|
||||||
brand,
|
brand,
|
||||||
appFaviconUrl: CFG.faviconUrl,
|
appFaviconUrl: CFG.faviconUrl,
|
||||||
@@ -1670,6 +1681,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshI18nScope(scope) {
|
||||||
|
if (MOCK) return;
|
||||||
|
const apiBase = String(CFG.apiBase || "/api").replace(/\/+$/, "");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase}/i18n?scope=${encodeURIComponent(scope)}`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
if (payload?.ok && payload.i18n) i18n.mergeMessages(payload.i18n);
|
||||||
|
if (scope === "admin") adminI18nLoaded = true;
|
||||||
|
} catch (_error) {
|
||||||
|
void _error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminTranslationsSaved(options = {}) {
|
||||||
|
adminI18nLoaded = false;
|
||||||
|
await Promise.all([refreshI18nScope("webapp"), refreshI18nScope("admin")]);
|
||||||
|
await handleAdminPersistedSaved({ ...options, deferFrontendReload: true });
|
||||||
|
}
|
||||||
|
|
||||||
function selectTariff(tariff) {
|
function selectTariff(tariff) {
|
||||||
billingStore.selectTariff(tariff, plans);
|
billingStore.selectTariff(tariff, plans);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Globe2,
|
Globe2,
|
||||||
|
Languages,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
import SupportSection from "./sections/SupportSection.svelte";
|
import SupportSection from "./sections/SupportSection.svelte";
|
||||||
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
|
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
|
||||||
import TariffsSection from "./sections/TariffsSection.svelte";
|
import TariffsSection from "./sections/TariffsSection.svelte";
|
||||||
|
import TranslationsSection from "./sections/TranslationsSection.svelte";
|
||||||
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
||||||
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
||||||
import UsersSection from "./sections/UsersSection.svelte";
|
import UsersSection from "./sections/UsersSection.svelte";
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js";
|
import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js";
|
||||||
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
|
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
|
||||||
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
|
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
|
||||||
|
import { createTranslationsStore } from "../lib/admin/stores/translationsStore.js";
|
||||||
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
|
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
|
||||||
import {
|
import {
|
||||||
fmtDate,
|
fmtDate,
|
||||||
@@ -83,6 +86,7 @@
|
|||||||
export let onSettingsSaved = () => {};
|
export let onSettingsSaved = () => {};
|
||||||
export let onTariffsSaved = () => {};
|
export let onTariffsSaved = () => {};
|
||||||
export let onThemesSaved = () => {};
|
export let onThemesSaved = () => {};
|
||||||
|
export let onTranslationsSaved = () => {};
|
||||||
export let brand = {};
|
export let brand = {};
|
||||||
export let brandTitle = "/minishop";
|
export let brandTitle = "/minishop";
|
||||||
export let appFaviconUrl = "";
|
export let appFaviconUrl = "";
|
||||||
@@ -128,6 +132,7 @@
|
|||||||
items: [
|
items: [
|
||||||
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
||||||
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
||||||
|
{ id: "translations", label: at("nav_translations", {}, "Переводы"), icon: Languages },
|
||||||
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -178,6 +183,14 @@
|
|||||||
title: at("section_appearance_title", {}, "Внешний вид"),
|
title: at("section_appearance_title", {}, "Внешний вид"),
|
||||||
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
|
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
|
||||||
},
|
},
|
||||||
|
translations: {
|
||||||
|
title: at("section_translations_title", {}, "Переводы"),
|
||||||
|
subtitle: at(
|
||||||
|
"section_translations_subtitle",
|
||||||
|
{},
|
||||||
|
"Оверрайды строк локализации из базы данных и data/locales-overrides.json"
|
||||||
|
),
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: at("section_settings_title", {}, "Настройки приложения"),
|
title: at("section_settings_title", {}, "Настройки приложения"),
|
||||||
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
||||||
@@ -223,6 +236,7 @@
|
|||||||
const supportStore = createAdminSupportStore({ api, onToast: flash, at });
|
const supportStore = createAdminSupportStore({ api, onToast: flash, at });
|
||||||
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
|
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
|
||||||
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
|
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
|
||||||
|
const translationsStore = createTranslationsStore({ api, onToast: flash, at });
|
||||||
const usersStore = createUsersStore({ api, onToast: flash, at });
|
const usersStore = createUsersStore({ api, onToast: flash, at });
|
||||||
|
|
||||||
setContext("promosStore", promosStore);
|
setContext("promosStore", promosStore);
|
||||||
@@ -236,13 +250,16 @@
|
|||||||
setContext("usersStore", usersStore);
|
setContext("usersStore", usersStore);
|
||||||
setContext("tariffsStore", tariffsStore);
|
setContext("tariffsStore", tariffsStore);
|
||||||
setContext("themesStore", themesStore);
|
setContext("themesStore", themesStore);
|
||||||
|
setContext("translationsStore", translationsStore);
|
||||||
|
|
||||||
$: usersStore.setActive(active);
|
$: usersStore.setActive(active);
|
||||||
$: paymentsStore.setActive(active);
|
$: paymentsStore.setActive(active);
|
||||||
$: supportStore.setActive(active);
|
$: supportStore.setActive(active);
|
||||||
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
||||||
|
$: translationsDirtyCount = Object.keys($translationsStore.translationsDirty || {}).length;
|
||||||
$: syncBusy = $statsStore.syncBusy;
|
$: syncBusy = $statsStore.syncBusy;
|
||||||
$: settingsSaving = $settingsStore.settingsSaving;
|
$: settingsSaving = $settingsStore.settingsSaving;
|
||||||
|
$: translationsSaving = $translationsStore.translationsSaving;
|
||||||
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
|
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
|
||||||
$: currentLanguageOption =
|
$: currentLanguageOption =
|
||||||
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
||||||
@@ -676,6 +693,27 @@
|
|||||||
: at("btn_save", {}, "Сохранить")}
|
: at("btn_save", {}, "Сохранить")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if active === "translations"}
|
||||||
|
{#if translationsDirtyCount}
|
||||||
|
<AdminBadge variant="warning"
|
||||||
|
>{at(
|
||||||
|
"settings_dirty_count",
|
||||||
|
{ count: translationsDirtyCount },
|
||||||
|
"Изменений: " + translationsDirtyCount
|
||||||
|
)}</AdminBadge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<AdminButton
|
||||||
|
variant="primary"
|
||||||
|
onclick={() => translationsStore.saveTranslations(onTranslationsSaved)}
|
||||||
|
disabled={!translationsDirtyCount || translationsSaving}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
{translationsSaving
|
||||||
|
? at("btn_saving", {}, "Сохранение...")
|
||||||
|
: at("btn_save", {}, "Сохранить")}
|
||||||
|
</AdminButton>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -751,6 +789,10 @@
|
|||||||
{#if active === "settings"}
|
{#if active === "settings"}
|
||||||
<SettingsSection {at} {onSettingsSaved} {currentLang} />
|
<SettingsSection {at} {onSettingsSaved} {currentLang} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if active === "translations"}
|
||||||
|
<TranslationsSection {at} {onTranslationsSaved} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,636 @@
|
|||||||
|
<script>
|
||||||
|
import { ChevronRight, Languages, Plus, Search, X } from "$components/ui/icons.js";
|
||||||
|
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||||
|
import { getContext, onDestroy, onMount } from "svelte";
|
||||||
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
|
export let at;
|
||||||
|
export let onTranslationsSaved;
|
||||||
|
|
||||||
|
const translationsStore = getContext("translationsStore");
|
||||||
|
const AUDIENCE_ORDER = ["user", "internal"];
|
||||||
|
const AUDIENCE_FILTERS = ["all", ...AUDIENCE_ORDER];
|
||||||
|
|
||||||
|
$: ({
|
||||||
|
translationGroups,
|
||||||
|
translationLanguages,
|
||||||
|
translationsLoading,
|
||||||
|
translationsDirty,
|
||||||
|
translationsSaving,
|
||||||
|
translationsPath,
|
||||||
|
} = $translationsStore);
|
||||||
|
|
||||||
|
let openGroups = [];
|
||||||
|
let readyGroups = [];
|
||||||
|
let openLocaleEditors = [];
|
||||||
|
let closedLocaleEditors = [];
|
||||||
|
let search = "";
|
||||||
|
let audienceFilter = "all";
|
||||||
|
let newLanguageCode = "";
|
||||||
|
const readyTimers = new Map();
|
||||||
|
|
||||||
|
$: openGroupSet = new Set(openGroups);
|
||||||
|
$: readyGroupSet = new Set(readyGroups);
|
||||||
|
$: openLocaleEditorSet = new Set(openLocaleEditors);
|
||||||
|
$: closedLocaleEditorSet = new Set(closedLocaleEditors);
|
||||||
|
$: filteredTranslationGroups = filteredGroups(translationGroups, search, translationLanguages);
|
||||||
|
$: audienceSections = buildAudienceSections(filteredTranslationGroups, audienceFilter);
|
||||||
|
$: visibleGroupKeys = audienceSections.flatMap((section) =>
|
||||||
|
section.groups.map((group) => groupPanelId(section.id, group.id))
|
||||||
|
);
|
||||||
|
$: allOpen =
|
||||||
|
visibleGroupKeys.length > 0 && visibleGroupKeys.every((key) => openGroups.includes(key));
|
||||||
|
$: scheduleReadyGroups(openGroups);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
translationsStore.loadTranslations();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const timer of readyTimers.values()) clearTimeout(timer);
|
||||||
|
readyTimers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dirtyKey(lang, key) {
|
||||||
|
return `${lang}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyFor(lang, key) {
|
||||||
|
return translationsDirty[dirtyKey(lang, key)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBaseValue(item) {
|
||||||
|
for (const values of Object.values(item.values || {})) {
|
||||||
|
if (values?.fallback) return values.fallback;
|
||||||
|
}
|
||||||
|
const baseLanguage = (translationLanguages || []).find((language) => language.base);
|
||||||
|
const baseCode = baseLanguage?.code || translationLanguages?.[0]?.code || "";
|
||||||
|
const values = item.values?.[baseCode] || {};
|
||||||
|
return values.base || values.fallback || values.effective || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueRecord(item, lang) {
|
||||||
|
return (
|
||||||
|
item.values?.[lang] || {
|
||||||
|
base: "",
|
||||||
|
fallback: defaultBaseValue(item),
|
||||||
|
effective: defaultBaseValue(item),
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeValue(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||||
|
if (dirty?.deleted) return "";
|
||||||
|
if (dirty) return dirty.value;
|
||||||
|
return valueRecord(item, lang).override || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverridden(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||||
|
return Boolean(valueRecord(item, lang).overridden) && !dirty?.deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||||
|
return Boolean(dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseValue(item, lang) {
|
||||||
|
const values = valueRecord(item, lang);
|
||||||
|
return values.base || values.fallback || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseKind(item, lang) {
|
||||||
|
return valueRecord(item, lang).base
|
||||||
|
? at("translations_base_value", {}, "Base")
|
||||||
|
: at("translations_fallback_value", {}, "Fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveValue(item, lang) {
|
||||||
|
return valueRecord(item, lang).effective || baseValue(item, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localePreview(item, lang, dirty = dirtyFor(lang, item.key)) {
|
||||||
|
return (
|
||||||
|
localeValue(item, lang, dirty) || effectiveValue(item, lang) || baseValue(item, lang) || "-"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAudience(item, group = null) {
|
||||||
|
return item.audience || group?.audience || "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
function audienceLabel(id) {
|
||||||
|
if (id === "internal") {
|
||||||
|
return at("translations_audience_internal", {}, "Admin/internal");
|
||||||
|
}
|
||||||
|
if (id === "user") {
|
||||||
|
return at("translations_audience_user", {}, "User-visible");
|
||||||
|
}
|
||||||
|
return at("translations_audience_all", {}, "All");
|
||||||
|
}
|
||||||
|
|
||||||
|
function audienceHint(id) {
|
||||||
|
if (id === "internal") {
|
||||||
|
return at("translations_audience_internal_hint", {}, "Admin panel, logs, and sync copy");
|
||||||
|
}
|
||||||
|
return at("translations_audience_user_hint", {}, "Mini App, bot, payment, and support copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPanelId(sectionId, groupId) {
|
||||||
|
return `${sectionId}:${groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localePanelId(key, lang) {
|
||||||
|
return `${key}:${lang}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocaleEditor(item, lang) {
|
||||||
|
const id = localePanelId(item.key, lang);
|
||||||
|
const defaultOpen = isOverridden(item, lang) || isDirty(item, lang);
|
||||||
|
const openByUser = openLocaleEditors.includes(id);
|
||||||
|
const closedByUser = closedLocaleEditors.includes(id);
|
||||||
|
const currentlyOpen = openByUser || (defaultOpen && !closedByUser);
|
||||||
|
|
||||||
|
if (currentlyOpen) {
|
||||||
|
openLocaleEditors = openLocaleEditors.filter((itemId) => itemId !== id);
|
||||||
|
if (!closedByUser) closedLocaleEditors = [...closedLocaleEditors, id];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closedLocaleEditors = closedLocaleEditors.filter((itemId) => itemId !== id);
|
||||||
|
if (!openByUser) openLocaleEditors = [...openLocaleEditors, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupDirtyCount(
|
||||||
|
group,
|
||||||
|
dirtyState = translationsDirty,
|
||||||
|
languages = translationLanguages
|
||||||
|
) {
|
||||||
|
return (group.items || []).reduce(
|
||||||
|
(count, item) =>
|
||||||
|
count +
|
||||||
|
languages.filter((lang) => Boolean(dirtyState[dirtyKey(lang.code, item.key)])).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOverrideCount(
|
||||||
|
group,
|
||||||
|
dirtyState = translationsDirty,
|
||||||
|
languages = translationLanguages
|
||||||
|
) {
|
||||||
|
return (group.items || []).reduce(
|
||||||
|
(count, item) =>
|
||||||
|
count +
|
||||||
|
languages.filter((lang) =>
|
||||||
|
isOverridden(item, lang.code, dirtyState[dirtyKey(lang.code, item.key)])
|
||||||
|
).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemHasOverride(item, dirtyState = translationsDirty, languages = translationLanguages) {
|
||||||
|
return languages.some((lang) =>
|
||||||
|
isOverridden(item, lang.code, dirtyState[dirtyKey(lang.code, item.key)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemHasDirty(item, dirtyState = translationsDirty, languages = translationLanguages) {
|
||||||
|
return languages.some((lang) => Boolean(dirtyState[dirtyKey(lang.code, item.key)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredGroups(groups, query, languages = translationLanguages) {
|
||||||
|
const needle = String(query || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!needle) return groups || [];
|
||||||
|
return (groups || [])
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: (group.items || []).filter((item) => itemMatches(item, group, needle, languages)),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatches(item, group, needle, languages = translationLanguages) {
|
||||||
|
if (
|
||||||
|
String(item.key || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(needle)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (audienceLabel(itemAudience(item, group)).toLowerCase().includes(needle)) return true;
|
||||||
|
return languages.some((lang) => {
|
||||||
|
const values = valueRecord(item, lang.code);
|
||||||
|
return [values.base, values.fallback, values.override, values.effective]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(needle));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudienceSections(groups, filter) {
|
||||||
|
return AUDIENCE_ORDER.map((audience) => ({
|
||||||
|
id: audience,
|
||||||
|
title: audienceLabel(audience),
|
||||||
|
hint: audienceHint(audience),
|
||||||
|
groups: (groups || [])
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
audience,
|
||||||
|
items: (group.items || []).filter((item) => itemAudience(item, group) === audience),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length),
|
||||||
|
})).filter((section) => (filter === "all" || section.id === filter) && section.groups.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllGroups() {
|
||||||
|
openGroups = allOpen ? [] : visibleGroupKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupOpen(id) {
|
||||||
|
return openGroups.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(id) {
|
||||||
|
if (isGroupOpen(id)) {
|
||||||
|
openGroups = openGroups.filter((groupId) => groupId !== id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openGroups = [...openGroups, id];
|
||||||
|
queueGroupReady(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReadyGroups(groups) {
|
||||||
|
const openSet = new Set(groups);
|
||||||
|
const nextReady = readyGroups.filter((id) => openSet.has(id));
|
||||||
|
if (nextReady.length !== readyGroups.length) readyGroups = nextReady;
|
||||||
|
for (const [id, timer] of readyTimers.entries()) {
|
||||||
|
if (!openSet.has(id)) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
readyTimers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of groups) {
|
||||||
|
queueGroupReady(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueGroupReady(id) {
|
||||||
|
if (readyGroups.includes(id) || readyTimers.has(id)) return;
|
||||||
|
readyTimers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => {
|
||||||
|
readyTimers.delete(id);
|
||||||
|
if (!readyGroups.includes(id)) {
|
||||||
|
readyGroups = [...readyGroups, id];
|
||||||
|
}
|
||||||
|
}, 70)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupTitle(group) {
|
||||||
|
return group.title_key ? at(group.title_key, {}, group.title) : group.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupDescription(group) {
|
||||||
|
return group.description_key
|
||||||
|
? at(group.description_key, {}, group.description)
|
||||||
|
: group.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAddLanguage(code) {
|
||||||
|
const normalized = String(code || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-");
|
||||||
|
return (
|
||||||
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized) &&
|
||||||
|
normalized.length >= 2 &&
|
||||||
|
normalized.length <= 16 &&
|
||||||
|
!translationLanguages.some((lang) => lang.code === normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLanguage() {
|
||||||
|
if (translationsStore.addTranslationLanguage(newLanguageCode)) {
|
||||||
|
newLanguageCode = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet renderTranslationsSkeleton()}
|
||||||
|
<div class="admin-translations-skeleton">
|
||||||
|
<div class="admin-translations-toolbar">
|
||||||
|
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
|
||||||
|
</div>
|
||||||
|
{#each Array(4) as _, index (index)}
|
||||||
|
<div class="admin-card admin-translation-skeleton-card">
|
||||||
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet renderGroupSkeleton(group)}
|
||||||
|
<div class="admin-translation-group-skeleton" aria-label={at("loading", {}, "Loading")}>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
|
||||||
|
{#each Array(Math.min(3, Math.max(1, group.items.length))) as _, index (index)}
|
||||||
|
<div class="admin-translation-row admin-translation-row-skeleton">
|
||||||
|
<span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"></span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line"></span>
|
||||||
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-soft"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet renderLocaleEditor(item, language)}
|
||||||
|
{@const lang = language.code}
|
||||||
|
{@const dirtyEntry = translationsDirty[dirtyKey(lang, item.key)] || null}
|
||||||
|
{@const overridden = isOverridden(item, lang, dirtyEntry)}
|
||||||
|
{@const dirty = isDirty(item, lang, dirtyEntry)}
|
||||||
|
{@const localeId = localePanelId(item.key, lang)}
|
||||||
|
{@const expanded =
|
||||||
|
openLocaleEditorSet.has(localeId) ||
|
||||||
|
((overridden || dirty) && !closedLocaleEditorSet.has(localeId))}
|
||||||
|
<div
|
||||||
|
class="admin-translation-locale"
|
||||||
|
class:is-overridden={overridden}
|
||||||
|
class:is-dirty={dirty}
|
||||||
|
class:is-expanded={expanded}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-translation-locale-toggle"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => toggleLocaleEditor(item, lang)}
|
||||||
|
>
|
||||||
|
<span class="admin-translation-locale-main">
|
||||||
|
<strong>{language.label}</strong>
|
||||||
|
<code>{lang}</code>
|
||||||
|
</span>
|
||||||
|
<span class="admin-translation-locale-badges">
|
||||||
|
{#if !language.base}
|
||||||
|
<AdminBadge variant="muted">{at("translations_language_custom", {}, "Custom")}</AdminBadge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if overridden}
|
||||||
|
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
|
||||||
|
{/if}
|
||||||
|
{#if dirty}
|
||||||
|
<AdminBadge variant="warning">{at("settings_badge_dirty", {}, "Dirty")}</AdminBadge>
|
||||||
|
{/if}
|
||||||
|
<ChevronRight size={14} class="admin-accordion-chev" />
|
||||||
|
</span>
|
||||||
|
<small>{localePreview(item, lang, dirtyEntry)}</small>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="admin-translation-locale-body" transition:slide={{ duration: 130 }}>
|
||||||
|
<textarea
|
||||||
|
class="admin-setting-textarea admin-translation-textarea"
|
||||||
|
rows="3"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={baseValue(item, lang)}
|
||||||
|
value={localeValue(item, lang, dirtyEntry)}
|
||||||
|
oninput={(event) =>
|
||||||
|
translationsStore.markDirty(lang, item.key, event.currentTarget.value)}
|
||||||
|
></textarea>
|
||||||
|
<div class="admin-translation-base">
|
||||||
|
<small>{baseKind(item, lang)}</small>
|
||||||
|
<span title={baseValue(item, lang)}>{baseValue(item, lang) || "-"}</span>
|
||||||
|
</div>
|
||||||
|
{#if overridden || dirty}
|
||||||
|
<AdminButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => translationsStore.resetField(lang, item.key, overridden)}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
{at("reset", {}, "Reset")}
|
||||||
|
</AdminButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet renderTranslationItem(item, group)}
|
||||||
|
{@const audience = itemAudience(item, group)}
|
||||||
|
<div class="admin-translation-row">
|
||||||
|
<div class="admin-setting-meta">
|
||||||
|
<strong>
|
||||||
|
{item.key}
|
||||||
|
<AdminBadge variant={audience === "internal" ? "warning" : "success"}>
|
||||||
|
{audienceLabel(audience)}
|
||||||
|
</AdminBadge>
|
||||||
|
{#if itemHasOverride(item, translationsDirty, translationLanguages)}
|
||||||
|
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
|
||||||
|
{/if}
|
||||||
|
{#if itemHasDirty(item, translationsDirty, translationLanguages)}
|
||||||
|
<AdminBadge variant="warning">{at("settings_badge_dirty", {}, "Dirty")}</AdminBadge>
|
||||||
|
{/if}
|
||||||
|
</strong>
|
||||||
|
<code>{item.key}</code>
|
||||||
|
<small>{effectiveValue(item, translationLanguages[0]?.code)}</small>
|
||||||
|
</div>
|
||||||
|
<div class="admin-translation-locales">
|
||||||
|
{#each translationLanguages as language (language.code)}
|
||||||
|
{@render renderLocaleEditor(item, language)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if translationsLoading || !translationGroups.length}
|
||||||
|
{@render renderTranslationsSkeleton()}
|
||||||
|
{:else}
|
||||||
|
<div class="admin-translations-toolbar">
|
||||||
|
<label class="admin-translations-search">
|
||||||
|
<Search size={15} />
|
||||||
|
<input
|
||||||
|
bind:value={search}
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
placeholder={at("translations_search_placeholder", {}, "Search keys and text")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="admin-translations-actions">
|
||||||
|
<AdminButton size="sm" variant="ghost" onclick={toggleAllGroups}>
|
||||||
|
{allOpen ? at("collapse_all", {}, "Collapse all") : at("expand_all", {}, "Expand all")}
|
||||||
|
</AdminButton>
|
||||||
|
{#if Object.keys(translationsDirty).length > 0}
|
||||||
|
<AdminButton
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onclick={() => translationsStore.saveTranslations(onTranslationsSaved)}
|
||||||
|
disabled={translationsSaving}
|
||||||
|
>
|
||||||
|
{translationsSaving ? at("saving", {}, "Saving...") : at("save", {}, "Save")}
|
||||||
|
</AdminButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-translations-language-panel">
|
||||||
|
<div class="admin-translations-language-head">
|
||||||
|
<Languages size={17} />
|
||||||
|
<strong>{at("translations_languages_title", {}, "Languages")}</strong>
|
||||||
|
<small>{at("translations_languages_hint", {}, "Override any locale code")}</small>
|
||||||
|
</div>
|
||||||
|
<div class="admin-translations-language-list">
|
||||||
|
{#each translationLanguages as language (language.code)}
|
||||||
|
<span class="admin-translations-language-chip" class:is-custom={!language.base}>
|
||||||
|
<strong>{language.label}</strong>
|
||||||
|
<code>{language.code}</code>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="admin-translations-language-add"
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
addLanguage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
bind:value={newLanguageCode}
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
inputmode="latin"
|
||||||
|
placeholder={at("translations_language_placeholder", {}, "de, uk, pt-BR")}
|
||||||
|
/>
|
||||||
|
<AdminButton type="submit" size="sm" disabled={!canAddLanguage(newLanguageCode)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
{at("add", {}, "Add")}
|
||||||
|
</AdminButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-translations-audience-tabs" role="tablist">
|
||||||
|
{#each AUDIENCE_FILTERS as option (option)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:is-active={audienceFilter === option}
|
||||||
|
onclick={() => {
|
||||||
|
audienceFilter = option;
|
||||||
|
openGroups = [];
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{audienceLabel(option)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="admin-muted admin-translations-path">
|
||||||
|
{at(
|
||||||
|
"translations_hint",
|
||||||
|
{ path: translationsPath },
|
||||||
|
`Overrides are stored in DB and mirrored to ${translationsPath}.`
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if audienceSections.length}
|
||||||
|
<div class="admin-translations-accordion-root">
|
||||||
|
{#each audienceSections as section (section.id)}
|
||||||
|
<section class="admin-translations-audience-section">
|
||||||
|
<div class="admin-translations-audience-head">
|
||||||
|
<span>
|
||||||
|
<strong>{section.title}</strong>
|
||||||
|
<small>{section.hint}</small>
|
||||||
|
</span>
|
||||||
|
<AdminBadge variant={section.id === "internal" ? "warning" : "success"}>
|
||||||
|
{section.groups.reduce((count, group) => count + group.items.length, 0)}
|
||||||
|
</AdminBadge>
|
||||||
|
</div>
|
||||||
|
<div class="admin-accordion">
|
||||||
|
{#each section.groups as group (groupPanelId(section.id, group.id))}
|
||||||
|
{@const dirtyCount = groupDirtyCount(group, translationsDirty, translationLanguages)}
|
||||||
|
{@const overrideCount = groupOverrideCount(
|
||||||
|
group,
|
||||||
|
translationsDirty,
|
||||||
|
translationLanguages
|
||||||
|
)}
|
||||||
|
{@const panelId = groupPanelId(section.id, group.id)}
|
||||||
|
{@const groupOpen = openGroupSet.has(panelId)}
|
||||||
|
{@const groupReady = readyGroupSet.has(panelId)}
|
||||||
|
<div
|
||||||
|
class="admin-accordion-item admin-card"
|
||||||
|
data-state={groupOpen ? "open" : "closed"}
|
||||||
|
>
|
||||||
|
<div class="admin-accordion-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-accordion-trigger"
|
||||||
|
data-state={groupOpen ? "open" : "closed"}
|
||||||
|
aria-expanded={groupOpen}
|
||||||
|
onclick={() => toggleGroup(panelId)}
|
||||||
|
>
|
||||||
|
<span class="admin-accordion-title admin-translation-title-line">
|
||||||
|
{groupTitle(group)}
|
||||||
|
<AdminBadge variant={section.id === "internal" ? "warning" : "success"}>
|
||||||
|
{section.title}
|
||||||
|
</AdminBadge>
|
||||||
|
</span>
|
||||||
|
<span class="admin-accordion-meta">
|
||||||
|
{at(
|
||||||
|
"translations_keys_count",
|
||||||
|
{ count: group.items.length },
|
||||||
|
`${group.items.length} keys`
|
||||||
|
)}{#if overrideCount}
|
||||||
|
/ {at(
|
||||||
|
"settings_overridden_count",
|
||||||
|
{ count: overrideCount },
|
||||||
|
`${overrideCount} override`
|
||||||
|
)}{/if}{#if dirtyCount}
|
||||||
|
/ {at(
|
||||||
|
"settings_dirty_count",
|
||||||
|
{ count: dirtyCount },
|
||||||
|
`${dirtyCount} changed`
|
||||||
|
)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} class="admin-accordion-chev" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if groupOpen}
|
||||||
|
<div
|
||||||
|
class="admin-accordion-content"
|
||||||
|
data-state="open"
|
||||||
|
transition:slide={{ duration: 140 }}
|
||||||
|
>
|
||||||
|
{#if groupReady}
|
||||||
|
{#if groupDescription(group)}
|
||||||
|
<p class="admin-muted admin-translation-group-description">
|
||||||
|
{groupDescription(group)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="admin-translation-list">
|
||||||
|
{#each group.items as item (item.key)}
|
||||||
|
{@render renderTranslationItem(item, group)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render renderGroupSkeleton(group)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<AdminEmptyState>{at("translations_no_matches", {}, "No matching strings")}</AdminEmptyState>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
function dirtyId(lang, key) {
|
||||||
|
return `${lang}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLanguageCode(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidLanguageCode(value) {
|
||||||
|
return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value) && value.length >= 2 && value.length <= 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageLabel(code) {
|
||||||
|
const labels = {
|
||||||
|
ru: "Русский",
|
||||||
|
en: "English",
|
||||||
|
de: "Deutsch",
|
||||||
|
es: "Español",
|
||||||
|
fr: "Français",
|
||||||
|
"pt-br": "Português (BR)",
|
||||||
|
uk: "Українська",
|
||||||
|
};
|
||||||
|
return labels[code] || code.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTranslationsStore({ api, onToast, at }) {
|
||||||
|
const state = writable({
|
||||||
|
translationGroups: [],
|
||||||
|
translationLanguages: [],
|
||||||
|
translationsLoading: false,
|
||||||
|
translationsDirty: {},
|
||||||
|
translationsSaving: false,
|
||||||
|
translationsPath: "",
|
||||||
|
translationsOverrideCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTranslations() {
|
||||||
|
state.update((s) => ({ ...s, translationsLoading: true, translationsDirty: {} }));
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/translations");
|
||||||
|
if (data?.ok) {
|
||||||
|
state.update((s) => ({
|
||||||
|
...s,
|
||||||
|
translationGroups: data.groups || [],
|
||||||
|
translationLanguages: data.languages || [],
|
||||||
|
translationsPath: data.path || "",
|
||||||
|
translationsOverrideCount: data.override_count || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.update((s) => ({ ...s, translationsLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty(lang, key, value, deleted = false) {
|
||||||
|
state.update((s) => ({
|
||||||
|
...s,
|
||||||
|
translationsDirty: {
|
||||||
|
...s.translationsDirty,
|
||||||
|
[dirtyId(lang, key)]: { lang, key, value, deleted },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDirty(lang, key) {
|
||||||
|
state.update((s) => {
|
||||||
|
const next = { ...s.translationsDirty };
|
||||||
|
delete next[dirtyId(lang, key)];
|
||||||
|
return { ...s, translationsDirty: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetField(lang, key, overridden) {
|
||||||
|
if (overridden) {
|
||||||
|
markDirty(lang, key, "", true);
|
||||||
|
} else {
|
||||||
|
clearDirty(lang, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTranslationLanguage(rawCode) {
|
||||||
|
const code = normalizeLanguageCode(rawCode);
|
||||||
|
if (!isValidLanguageCode(code)) {
|
||||||
|
onToast(at("translations_language_invalid", {}, "Invalid language code"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let exists = false;
|
||||||
|
state.update((s) => {
|
||||||
|
exists = (s.translationLanguages || []).some((lang) => lang.code === code);
|
||||||
|
if (exists) return s;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
translationLanguages: [
|
||||||
|
...(s.translationLanguages || []),
|
||||||
|
{ code, label: languageLabel(code), base: false },
|
||||||
|
].sort((a, b) => a.code.localeCompare(b.code)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
onToast(at("translations_language_exists", { code }, `${code} already exists`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTranslations(onTranslationsSaved) {
|
||||||
|
let dirty = {};
|
||||||
|
state.update((s) => {
|
||||||
|
dirty = s.translationsDirty;
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
if (!Object.keys(dirty).length) return true;
|
||||||
|
|
||||||
|
state.update((s) => ({ ...s, translationsSaving: true }));
|
||||||
|
try {
|
||||||
|
const updates = {};
|
||||||
|
const deletes = [];
|
||||||
|
for (const change of Object.values(dirty)) {
|
||||||
|
if (change.deleted || String(change.value ?? "") === "") {
|
||||||
|
deletes.push({ lang: change.lang, key: change.key });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!updates[change.lang]) updates[change.lang] = {};
|
||||||
|
updates[change.lang][change.key] = change.value;
|
||||||
|
}
|
||||||
|
const res = await api("/admin/translations", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ updates, deletes }),
|
||||||
|
});
|
||||||
|
if (res?.ok) {
|
||||||
|
onToast(
|
||||||
|
res.file_written === false
|
||||||
|
? at(
|
||||||
|
"translations_file_write_warning",
|
||||||
|
{},
|
||||||
|
"Translations saved in DB, but JSON file was not updated"
|
||||||
|
)
|
||||||
|
: at("translations_saved", {}, "Translations saved")
|
||||||
|
);
|
||||||
|
state.update((s) => ({ ...s, translationsDirty: {} }));
|
||||||
|
if (onTranslationsSaved) await onTranslationsSaved({ updates, deletes });
|
||||||
|
await loadTranslations();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (res?.errors) {
|
||||||
|
const summary = Object.entries(res.errors)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join("; ");
|
||||||
|
onToast(at("translations_validation_errors", { errors: summary }, `Errors: ${summary}`));
|
||||||
|
} else {
|
||||||
|
onToast(at("translations_save_error", { error: res?.error || "" }, res?.error || "Error"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.update((s) => ({ ...s, translationsSaving: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: state.subscribe,
|
||||||
|
set: state.set,
|
||||||
|
update: state.update,
|
||||||
|
loadTranslations,
|
||||||
|
markDirty,
|
||||||
|
clearDirty,
|
||||||
|
resetField,
|
||||||
|
addTranslationLanguage,
|
||||||
|
saveTranslations,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export {
|
|||||||
Home,
|
Home,
|
||||||
Info,
|
Info,
|
||||||
Key,
|
Key,
|
||||||
|
Languages,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Lock,
|
Lock,
|
||||||
|
|||||||
@@ -1,23 +1,134 @@
|
|||||||
export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
|
export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
|
||||||
export const LANGUAGE_LABELS = {
|
export const LANGUAGE_LABELS = {
|
||||||
ru: "Русский",
|
|
||||||
en: "English",
|
en: "English",
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
es: "Español",
|
es: "Español",
|
||||||
fr: "Français",
|
fr: "Français",
|
||||||
|
"pt-br": "Português (BR)",
|
||||||
|
ru: "Русский",
|
||||||
tr: "Türkçe",
|
tr: "Türkçe",
|
||||||
uk: "Українська",
|
uk: "Українська",
|
||||||
};
|
};
|
||||||
export const LANGUAGE_FLAGS = {
|
export const LANGUAGE_FLAGS = {
|
||||||
ru: "🇷🇺",
|
|
||||||
en: "🇬🇧",
|
en: "🇬🇧",
|
||||||
de: "🇩🇪",
|
de: "🇩🇪",
|
||||||
es: "🇪🇸",
|
es: "🇪🇸",
|
||||||
fr: "🇫🇷",
|
fr: "🇫🇷",
|
||||||
|
"pt-br": "🇧🇷",
|
||||||
|
ru: "🇷🇺",
|
||||||
tr: "🇹🇷",
|
tr: "🇹🇷",
|
||||||
uk: "🇺🇦",
|
uk: "🇺🇦",
|
||||||
};
|
};
|
||||||
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
|
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
|
||||||
|
export const LOCALE_KEY_ALIASES = {
|
||||||
|
admin_apply: "wa_apply",
|
||||||
|
admin_ads_col_status: "admin_status",
|
||||||
|
admin_ad_label_source: "admin_ads_col_source",
|
||||||
|
admin_back: "wa_back",
|
||||||
|
admin_btn_refresh: "admin_refresh",
|
||||||
|
admin_btn_save: "admin_save",
|
||||||
|
admin_btn_saving: "admin_saving",
|
||||||
|
admin_close: "wa_close",
|
||||||
|
admin_copied: "wa_copied",
|
||||||
|
admin_copy: "wa_copy",
|
||||||
|
admin_csv_amount: "admin_amount",
|
||||||
|
admin_csv_description: "admin_description",
|
||||||
|
admin_csv_payment_id: "admin_id",
|
||||||
|
admin_csv_status: "admin_status",
|
||||||
|
admin_link_copied: "wa_link_copied",
|
||||||
|
admin_next: "wa_next",
|
||||||
|
admin_payment_detail_copied: "wa_copied",
|
||||||
|
admin_payment_detail_provider: "admin_provider",
|
||||||
|
admin_payment_detail_provider_section: "admin_provider",
|
||||||
|
admin_payment_detail_user_section: "admin_user",
|
||||||
|
admin_payments_col_user_id: "admin_id",
|
||||||
|
admin_promo_col_code: "admin_promo_csv_code",
|
||||||
|
admin_promo_col_status: "admin_status",
|
||||||
|
admin_promo_csv_is_active: "admin_badge_active",
|
||||||
|
admin_promo_csv_status: "admin_status",
|
||||||
|
admin_promo_label_code: "admin_promo_csv_code",
|
||||||
|
admin_promo_unlimited_validity: "admin_promo_unlimited",
|
||||||
|
admin_stats_revenue_custom_range_apply: "wa_apply",
|
||||||
|
admin_stats_revenue_tooltip_amount: "admin_amount",
|
||||||
|
admin_stats_sync_status: "admin_status",
|
||||||
|
admin_status_active: "admin_badge_active",
|
||||||
|
admin_support_category: "wa_support_category",
|
||||||
|
admin_support_category_account: "wa_support_category_account",
|
||||||
|
admin_support_category_billing: "wa_support_category_billing",
|
||||||
|
admin_support_category_other: "wa_support_category_other",
|
||||||
|
admin_support_category_technical: "wa_support_category_technical",
|
||||||
|
admin_support_close_ticket: "wa_close",
|
||||||
|
admin_support_empty: "wa_support_empty",
|
||||||
|
admin_support_filter_active: "wa_support_filter_active",
|
||||||
|
admin_support_filter_all: "wa_support_filter_all",
|
||||||
|
admin_support_internal_note: "wa_support_internal_note",
|
||||||
|
admin_support_no_messages: "wa_support_no_messages",
|
||||||
|
admin_support_priority: "wa_support_priority",
|
||||||
|
admin_support_priority_high: "wa_support_priority_high",
|
||||||
|
admin_support_priority_low: "wa_support_priority_low",
|
||||||
|
admin_support_priority_normal: "wa_support_priority_normal",
|
||||||
|
admin_support_priority_urgent: "wa_support_priority_urgent",
|
||||||
|
admin_support_role_system: "wa_support_role_system",
|
||||||
|
admin_support_role_user: "admin_user",
|
||||||
|
admin_support_search: "admin_search",
|
||||||
|
admin_support_status: "admin_status",
|
||||||
|
admin_support_status_awaiting_admin: "wa_support_status_awaiting_admin",
|
||||||
|
admin_support_status_awaiting_user: "wa_support_status_awaiting_user",
|
||||||
|
admin_support_status_closed: "wa_support_status_closed",
|
||||||
|
admin_support_status_open: "wa_support_status_open",
|
||||||
|
admin_support_status_resolved: "wa_support_status_resolved",
|
||||||
|
admin_support_ticket_number: "wa_support_ticket_number",
|
||||||
|
admin_support_user_context: "admin_user",
|
||||||
|
admin_tariffs_legacy_traffic_packages: "admin_tariff_traffic_packages",
|
||||||
|
admin_tariffs_stat_enabled: "admin_enabled",
|
||||||
|
admin_user_btn_cancel: "wa_cancel",
|
||||||
|
admin_user_history_until: "wa_until_date",
|
||||||
|
admin_user_label_provider: "admin_provider",
|
||||||
|
admin_user_short: "admin_user",
|
||||||
|
admin_user_stats_total_label: "admin_total",
|
||||||
|
back_to_autopay_method_choice_button: "back_to_main_menu_button",
|
||||||
|
back_to_payment_methods_button: "back_to_main_menu_button",
|
||||||
|
cancel_broadcast_button: "cancel_button",
|
||||||
|
csv_no: "no_button",
|
||||||
|
csv_yes: "yes_button",
|
||||||
|
user_premium_override_status_unlimited: "user_regular_override_status_unlimited",
|
||||||
|
user_regular_override_save: "admin_save",
|
||||||
|
wa_devices_disconnect_title: "wa_devices_disconnect",
|
||||||
|
wa_install_link_copied: "wa_link_copied",
|
||||||
|
wa_link_email_modal_title: "wa_settings_link_email_action",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveLocaleKey(key) {
|
||||||
|
let value = String(key || "").trim();
|
||||||
|
const seen = new Set();
|
||||||
|
while (LOCALE_KEY_ALIASES[value] && !seen.has(value)) {
|
||||||
|
seen.add(value);
|
||||||
|
value = LOCALE_KEY_ALIASES[value];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLanguageCode(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueLanguageCodes(...sources) {
|
||||||
|
const seen = new Set();
|
||||||
|
const result = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const item of source || []) {
|
||||||
|
const code = normalizeLanguageCode(typeof item === "string" ? item : item?.code);
|
||||||
|
if (!code || seen.has(code)) continue;
|
||||||
|
seen.add(code);
|
||||||
|
result.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const APP_SECTION_PATHS = {
|
export const APP_SECTION_PATHS = {
|
||||||
home: "/home",
|
home: "/home",
|
||||||
install: "/install",
|
install: "/install",
|
||||||
@@ -39,6 +150,7 @@ export const ADMIN_SECTIONS = new Set([
|
|||||||
"support",
|
"support",
|
||||||
"tariffs",
|
"tariffs",
|
||||||
"appearance",
|
"appearance",
|
||||||
|
"translations",
|
||||||
"settings",
|
"settings",
|
||||||
]);
|
]);
|
||||||
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
|
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LANGUAGE_LABELS } from "./constants.js";
|
import { LANGUAGE_LABELS, normalizeLanguageCode, resolveLocaleKey } from "./constants.js";
|
||||||
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
|
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
|
||||||
import { unitPluralBucket } from "./plurals.js";
|
import { unitPluralBucket } from "./plurals.js";
|
||||||
|
|
||||||
@@ -21,14 +21,13 @@ export function createI18n({
|
|||||||
mergeMessages(initialMessages);
|
mergeMessages(initialMessages);
|
||||||
|
|
||||||
function normalizeLangCode(lang) {
|
function normalizeLangCode(lang) {
|
||||||
const key = String(lang || "")
|
const key = normalizeLanguageCode(lang);
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!key) return defaultLang;
|
if (!key) return defaultLang;
|
||||||
const base = key.split("-")[0];
|
const base = key.split("-")[0];
|
||||||
if (LANGUAGE_LABELS[base]) return base;
|
|
||||||
if (messages[base]) return base;
|
|
||||||
if (messages[key]) return key;
|
if (messages[key]) return key;
|
||||||
|
if (messages[base]) return base;
|
||||||
|
if (LANGUAGE_LABELS[key]) return key;
|
||||||
|
if (LANGUAGE_LABELS[base]) return base;
|
||||||
return defaultLang;
|
return defaultLang;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +37,11 @@ export function createI18n({
|
|||||||
|
|
||||||
function t(key, params = {}, fallback = "") {
|
function t(key, params = {}, fallback = "") {
|
||||||
const lang = currentLang();
|
const lang = currentLang();
|
||||||
|
const lookupKey = resolveLocaleKey(key);
|
||||||
const variants = [
|
const variants = [
|
||||||
messages?.[lang]?.[key],
|
messages?.[lang]?.[lookupKey],
|
||||||
messages?.en?.[key],
|
messages?.en?.[lookupKey],
|
||||||
messages?.ru?.[key],
|
messages?.ru?.[lookupKey],
|
||||||
fallback,
|
fallback,
|
||||||
key,
|
key,
|
||||||
];
|
];
|
||||||
@@ -50,9 +50,7 @@ export function createI18n({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function languageName(code) {
|
function languageName(code) {
|
||||||
const key = String(code || "")
|
const key = normalizeLanguageCode(code);
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!key) return t("wa_language_default");
|
if (!key) return t("wa_language_default");
|
||||||
return LANGUAGE_LABELS[key] || key.toUpperCase();
|
return LANGUAGE_LABELS[key] || key.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -472,6 +472,78 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
}
|
}
|
||||||
return { ok: true, applied: 1, reverted: 0 };
|
return { ok: true, applied: 1, reverted: 0 };
|
||||||
}
|
}
|
||||||
|
if (path === "/admin/translations" && String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||||
|
return { ok: true, applied: 1, reverted: 0, file_written: true };
|
||||||
|
}
|
||||||
|
if (path === "/admin/translations") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: "data/locales-overrides.json",
|
||||||
|
override_count: 1,
|
||||||
|
languages: [
|
||||||
|
{ code: "en", label: "English", base: true },
|
||||||
|
{ code: "ru", label: "Русский", base: true },
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: "webapp",
|
||||||
|
title: "Mini App",
|
||||||
|
description: "User-facing Mini App strings.",
|
||||||
|
audience: "user",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "wa_nav_home",
|
||||||
|
audience: "user",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Главная",
|
||||||
|
fallback: "Главная",
|
||||||
|
effective: "Главная",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Home",
|
||||||
|
fallback: "Главная",
|
||||||
|
effective: "Dashboard",
|
||||||
|
override: "Dashboard",
|
||||||
|
overridden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin",
|
||||||
|
title: "Admin panel",
|
||||||
|
description: "Admin navigation and labels.",
|
||||||
|
audience: "internal",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "admin_nav_settings",
|
||||||
|
audience: "internal",
|
||||||
|
values: {
|
||||||
|
ru: {
|
||||||
|
base: "Настройки",
|
||||||
|
fallback: "Настройки",
|
||||||
|
effective: "Настройки",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
base: "Settings",
|
||||||
|
fallback: "Настройки",
|
||||||
|
effective: "Settings",
|
||||||
|
override: "",
|
||||||
|
overridden: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (path === "/admin/settings")
|
if (path === "/admin/settings")
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ export const DEV_MOCK = {
|
|||||||
userAgreementUrl: "https://example.com/agreement",
|
userAgreementUrl: "https://example.com/agreement",
|
||||||
currency: "RUB",
|
currency: "RUB",
|
||||||
language: "ru",
|
language: "ru",
|
||||||
|
languages: [
|
||||||
|
{ code: "ru", label: "Русский", flag: "🇷🇺", base: true },
|
||||||
|
{ code: "en", label: "English", flag: "🇬🇧", base: true },
|
||||||
|
{ code: "uk", label: "Українська", flag: "🇺🇦", base: false },
|
||||||
|
],
|
||||||
emailAuthEnabled: true,
|
emailAuthEnabled: true,
|
||||||
telegramLoginBotUsername: "preview_bot",
|
telegramLoginBotUsername: "preview_bot",
|
||||||
telegramLoginBotId: 1234567890,
|
telegramLoginBotId: 1234567890,
|
||||||
|
|||||||
@@ -60,6 +60,12 @@
|
|||||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-screen-wrap .admin-translations-search .input,
|
||||||
|
.admin-screen-wrap .admin-icon-picker-search .input,
|
||||||
|
.admin-screen-wrap .support-admin-search .input {
|
||||||
|
padding-left: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-screen-wrap .input::placeholder,
|
.admin-screen-wrap .input::placeholder,
|
||||||
.admin-screen-wrap input::placeholder,
|
.admin-screen-wrap input::placeholder,
|
||||||
.admin-screen-wrap textarea::placeholder,
|
.admin-screen-wrap textarea::placeholder,
|
||||||
|
|||||||
@@ -2307,6 +2307,393 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-translations-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-search {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-search > svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-search .input {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-path {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-skeleton-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.2fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
gap: 4px 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-head > svg {
|
||||||
|
grid-row: span 2;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-head strong {
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-head small {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-list {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 180px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-chip.is-custom {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--admin-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-chip strong,
|
||||||
|
.admin-translations-language-chip code {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-chip code {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-add {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 150px) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-tabs button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.12s ease,
|
||||||
|
border-color 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-tabs button:hover,
|
||||||
|
.admin-translations-audience-tabs button.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--admin-border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 11%, transparent);
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-head span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-head strong {
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-audience-head small {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-title-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-group-description {
|
||||||
|
margin: 12px 18px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-group-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-list {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1.35fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--admin-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-row-skeleton {
|
||||||
|
grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1.35fr);
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-row-skeleton > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locales {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--admin-bg) 70%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale.is-overridden {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 38%, var(--admin-border));
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, var(--admin-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale.is-dirty {
|
||||||
|
border-color: color-mix(in srgb, var(--warning-border) 72%, var(--admin-border));
|
||||||
|
background: color-mix(in srgb, var(--warning) 7%, var(--admin-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 11px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-toggle:hover,
|
||||||
|
.admin-translation-locale-toggle:focus-visible {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-main strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-main code {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale.is-expanded .admin-accordion-chev {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-toggle small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-locale-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 11px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 82px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-base {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-base small {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translation-base span {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-height: 4.2em;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.admin-translation-row,
|
||||||
|
.admin-translations-toolbar,
|
||||||
|
.admin-translations-language-panel {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-actions,
|
||||||
|
.admin-translations-language-add {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-translations-language-add {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-bottom: 1px solid var(--admin-border);
|
border-bottom: 1px solid var(--admin-border);
|
||||||
|
|||||||
+78
-76
@@ -46,9 +46,7 @@
|
|||||||
"yookassa_autopay_pay_new_card_button": "➕ Pay with new card",
|
"yookassa_autopay_pay_new_card_button": "➕ Pay with new card",
|
||||||
"yookassa_autopay_choose_saved_card": "Choose a saved card to charge:",
|
"yookassa_autopay_choose_saved_card": "Choose a saved card to charge:",
|
||||||
"yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.",
|
"yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.",
|
||||||
"back_to_autopay_method_choice_button": "⬅️ Back",
|
|
||||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||||
"back_to_payment_methods_button": "⬅️ Back",
|
|
||||||
"connect_button": "🔗 Connect",
|
"connect_button": "🔗 Connect",
|
||||||
"install_guide_share_button": "🔗 Share install guide",
|
"install_guide_share_button": "🔗 Share install guide",
|
||||||
"install_guide_share_link_line": "\n\nInstall guide for sharing:\n<code>{install_share_link}</code>",
|
"install_guide_share_link_line": "\n\nInstall guide for sharing:\n<code>{install_share_link}</code>",
|
||||||
@@ -156,22 +154,17 @@
|
|||||||
"admin_no_payments_to_export": "No payments to export.",
|
"admin_no_payments_to_export": "No payments to export.",
|
||||||
"admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}",
|
"admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}",
|
||||||
"admin_export_sent": "File sent!",
|
"admin_export_sent": "File sent!",
|
||||||
"admin_csv_payment_id": "ID",
|
|
||||||
"admin_csv_user_id": "User ID",
|
"admin_csv_user_id": "User ID",
|
||||||
"admin_csv_username": "Username",
|
"admin_csv_username": "Username",
|
||||||
"admin_csv_first_name": "First Name",
|
"admin_csv_first_name": "First Name",
|
||||||
"admin_csv_amount": "Amount",
|
|
||||||
"admin_csv_currency": "Currency",
|
"admin_csv_currency": "Currency",
|
||||||
"admin_csv_provider": "Provider",
|
"admin_csv_provider": "Provider",
|
||||||
"admin_csv_status": "Status",
|
|
||||||
"admin_csv_description": "Description",
|
|
||||||
"admin_csv_units": "Months/GB",
|
"admin_csv_units": "Months/GB",
|
||||||
"admin_csv_months": "Months",
|
"admin_csv_months": "Months",
|
||||||
"admin_csv_created_at": "Created At",
|
"admin_csv_created_at": "Created At",
|
||||||
"admin_csv_provider_payment_id": "Provider Payment ID",
|
"admin_csv_provider_payment_id": "Provider Payment ID",
|
||||||
"admin_stats_last_sync_header": "Last Panel Sync:",
|
"admin_stats_last_sync_header": "Last Panel Sync:",
|
||||||
"admin_stats_sync_time": "Time",
|
"admin_stats_sync_time": "Time",
|
||||||
"admin_stats_sync_status": "Status",
|
|
||||||
"admin_stats_sync_users_processed": "Users Processed",
|
"admin_stats_sync_users_processed": "Users Processed",
|
||||||
"admin_stats_sync_subs_synced": "Subscriptions Synced",
|
"admin_stats_sync_subs_synced": "Subscriptions Synced",
|
||||||
"admin_stats_sync_details_label": "Details",
|
"admin_stats_sync_details_label": "Details",
|
||||||
@@ -182,7 +175,6 @@
|
|||||||
"broadcast_target_active_button": "✅ Active",
|
"broadcast_target_active_button": "✅ Active",
|
||||||
"broadcast_target_inactive_button": "⌛ Inactive",
|
"broadcast_target_inactive_button": "⌛ Inactive",
|
||||||
"confirm_broadcast_send_button": "✅ Send",
|
"confirm_broadcast_send_button": "✅ Send",
|
||||||
"cancel_broadcast_button": "❌ Cancel",
|
|
||||||
"admin_broadcast_sending_started": "Starting broadcast...",
|
"admin_broadcast_sending_started": "Starting broadcast...",
|
||||||
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
|
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
|
||||||
"admin_broadcast_error_no_message_alert": "Broadcast message is empty!",
|
"admin_broadcast_error_no_message_alert": "Broadcast message is empty!",
|
||||||
@@ -220,13 +212,9 @@
|
|||||||
"admin_promo_csv_bonus_days": "Bonus Days",
|
"admin_promo_csv_bonus_days": "Bonus Days",
|
||||||
"admin_promo_csv_max_activations": "Max Activations",
|
"admin_promo_csv_max_activations": "Max Activations",
|
||||||
"admin_promo_csv_current_activations": "Current Activations",
|
"admin_promo_csv_current_activations": "Current Activations",
|
||||||
"admin_promo_csv_status": "Status",
|
|
||||||
"admin_promo_csv_is_active": "Active",
|
|
||||||
"admin_promo_csv_valid_until": "Valid Until",
|
"admin_promo_csv_valid_until": "Valid Until",
|
||||||
"admin_promo_csv_created_at": "Created",
|
"admin_promo_csv_created_at": "Created",
|
||||||
"admin_promo_csv_created_by_admin_id": "Created By (Admin ID)",
|
"admin_promo_csv_created_by_admin_id": "Created By (Admin ID)",
|
||||||
"csv_yes": "Yes",
|
|
||||||
"csv_no": "No",
|
|
||||||
"admin_promo_edit_select_field": "Select a field to edit:",
|
"admin_promo_edit_select_field": "Select a field to edit:",
|
||||||
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
|
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
|
||||||
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
|
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
|
||||||
@@ -417,7 +405,6 @@
|
|||||||
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
|
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
|
||||||
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
|
||||||
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
|
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
|
||||||
"admin_promo_unlimited_validity": "♾️ Unlimited",
|
|
||||||
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
|
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
|
||||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||||
"admin_user_name_label": "👤 <b>Name:</b>",
|
"admin_user_name_label": "👤 <b>Name:</b>",
|
||||||
@@ -494,7 +481,6 @@
|
|||||||
"inline_referral_description": "Share referral link to get bonuses",
|
"inline_referral_description": "Share referral link to get bonuses",
|
||||||
"inline_financial_description": "Today: {today} RUB",
|
"inline_financial_description": "Today: {today} RUB",
|
||||||
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
|
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
|
||||||
"admin_user_stats_total_label": "Total",
|
|
||||||
"admin_user_stats_paid_subs_label": "With paid subscription",
|
"admin_user_stats_paid_subs_label": "With paid subscription",
|
||||||
"admin_user_stats_trial_label": "On trial period",
|
"admin_user_stats_trial_label": "On trial period",
|
||||||
"admin_user_stats_inactive_label": "Inactive",
|
"admin_user_stats_inactive_label": "Inactive",
|
||||||
@@ -819,7 +805,6 @@
|
|||||||
"wa_devices_platform_unknown": "Platform unknown",
|
"wa_devices_platform_unknown": "Platform unknown",
|
||||||
"wa_devices_connected_at": "Connected",
|
"wa_devices_connected_at": "Connected",
|
||||||
"wa_devices_disconnect": "Disconnect device",
|
"wa_devices_disconnect": "Disconnect device",
|
||||||
"wa_devices_disconnect_title": "Disconnect device",
|
|
||||||
"wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.",
|
"wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.",
|
||||||
"wa_devices_disconnect_confirm": "Disconnect",
|
"wa_devices_disconnect_confirm": "Disconnect",
|
||||||
"wa_device_disconnected": "Device disconnected",
|
"wa_device_disconnected": "Device disconnected",
|
||||||
@@ -845,7 +830,6 @@
|
|||||||
"wa_settings_link_email_action": "Link email",
|
"wa_settings_link_email_action": "Link email",
|
||||||
"wa_settings_email_linked_title": "Email linked",
|
"wa_settings_email_linked_title": "Email linked",
|
||||||
"wa_activate_promo_title": "Activate promo code",
|
"wa_activate_promo_title": "Activate promo code",
|
||||||
"wa_link_email_modal_title": "Link email",
|
|
||||||
"wa_traffic_reset_none": "No reset",
|
"wa_traffic_reset_none": "No reset",
|
||||||
"wa_traffic_reset_monthly": "Monthly reset",
|
"wa_traffic_reset_monthly": "Monthly reset",
|
||||||
"wa_traffic_reset_weekly": "Weekly reset",
|
"wa_traffic_reset_weekly": "Weekly reset",
|
||||||
@@ -940,8 +924,6 @@
|
|||||||
"admin_users_col_registration": "Registered",
|
"admin_users_col_registration": "Registered",
|
||||||
"admin_page": "Page",
|
"admin_page": "Page",
|
||||||
"admin_page_short": "Page",
|
"admin_page_short": "Page",
|
||||||
"admin_back": "Back",
|
|
||||||
"admin_next": "Next",
|
|
||||||
"admin_user": "User",
|
"admin_user": "User",
|
||||||
"admin_amount": "Amount",
|
"admin_amount": "Amount",
|
||||||
"admin_provider": "Provider",
|
"admin_provider": "Provider",
|
||||||
@@ -949,7 +931,6 @@
|
|||||||
"admin_status": "Status",
|
"admin_status": "Status",
|
||||||
"admin_date": "Date",
|
"admin_date": "Date",
|
||||||
"admin_payments_empty": "No payments",
|
"admin_payments_empty": "No payments",
|
||||||
"admin_payments_col_user_id": "ID",
|
|
||||||
"admin_payments_col_traffic_regular": "Main traffic",
|
"admin_payments_col_traffic_regular": "Main traffic",
|
||||||
"admin_payments_col_traffic_premium": "Premium traffic",
|
"admin_payments_col_traffic_premium": "Premium traffic",
|
||||||
"admin_payments_col_actions": "",
|
"admin_payments_col_actions": "",
|
||||||
@@ -958,7 +939,6 @@
|
|||||||
"admin_payments_desc_traffic_package_premium": "Traffic package {gb} GB (premium)",
|
"admin_payments_desc_traffic_package_premium": "Traffic package {gb} GB (premium)",
|
||||||
"admin_payment_detail_open": "Open payment",
|
"admin_payment_detail_open": "Open payment",
|
||||||
"admin_payment_detail_title": "Payment #{id}",
|
"admin_payment_detail_title": "Payment #{id}",
|
||||||
"admin_payment_detail_copied": "Copied",
|
|
||||||
"admin_payment_load_failed": "Failed to load payment",
|
"admin_payment_load_failed": "Failed to load payment",
|
||||||
"admin_payment_detail_updated_at": "Updated",
|
"admin_payment_detail_updated_at": "Updated",
|
||||||
"admin_payment_detail_provider_payment_id": "Provider ID",
|
"admin_payment_detail_provider_payment_id": "Provider ID",
|
||||||
@@ -973,13 +953,9 @@
|
|||||||
"admin_payment_detail_purchased_gb": "Purchased GB",
|
"admin_payment_detail_purchased_gb": "Purchased GB",
|
||||||
"admin_payment_detail_hwid_devices": "HWID devices",
|
"admin_payment_detail_hwid_devices": "HWID devices",
|
||||||
"admin_payment_detail_promo_code": "Promo code",
|
"admin_payment_detail_promo_code": "Promo code",
|
||||||
"admin_payment_detail_provider": "Provider",
|
|
||||||
"admin_payment_detail_user_section": "User",
|
|
||||||
"admin_payment_detail_payment_section": "Payment",
|
"admin_payment_detail_payment_section": "Payment",
|
||||||
"admin_payment_detail_provider_section": "Provider",
|
|
||||||
"admin_payment_detail_purchase_section": "Purchase",
|
"admin_payment_detail_purchase_section": "Purchase",
|
||||||
"admin_logs_user_filter_placeholder": "Filter by user ID",
|
"admin_logs_user_filter_placeholder": "Filter by user ID",
|
||||||
"admin_apply": "Apply",
|
|
||||||
"admin_reset": "Reset",
|
"admin_reset": "Reset",
|
||||||
"admin_event": "Event",
|
"admin_event": "Event",
|
||||||
"admin_content": "Content",
|
"admin_content": "Content",
|
||||||
@@ -1009,14 +985,11 @@
|
|||||||
"admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.",
|
"admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.",
|
||||||
"admin_settings_provider_admin_only_label": "Only for admins",
|
"admin_settings_provider_admin_only_label": "Only for admins",
|
||||||
"admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.",
|
"admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.",
|
||||||
"admin_copy": "Copy",
|
|
||||||
"admin_copied": "Copied",
|
|
||||||
"admin_settings_validation_errors": "Errors: {errors}",
|
"admin_settings_validation_errors": "Errors: {errors}",
|
||||||
"admin_settings_save_error": "Error: {error}",
|
"admin_settings_save_error": "Error: {error}",
|
||||||
"admin_sync_started": "Synchronization started",
|
"admin_sync_started": "Synchronization started",
|
||||||
"admin_sync_error": "Synchronization error",
|
"admin_sync_error": "Synchronization error",
|
||||||
"admin_error": "Error",
|
"admin_error": "Error",
|
||||||
"admin_link_copied": "Link copied",
|
|
||||||
"admin_user_banned": "User banned",
|
"admin_user_banned": "User banned",
|
||||||
"admin_user_unbanned": "User unbanned",
|
"admin_user_unbanned": "User unbanned",
|
||||||
"admin_message_sent": "Message sent",
|
"admin_message_sent": "Message sent",
|
||||||
@@ -1067,7 +1040,6 @@
|
|||||||
"admin_stats_revenue_period_365": "1 yr",
|
"admin_stats_revenue_period_365": "1 yr",
|
||||||
"admin_stats_revenue_period_custom": "Custom",
|
"admin_stats_revenue_period_custom": "Custom",
|
||||||
"admin_stats_revenue_custom_range_title": "Date range (UTC)",
|
"admin_stats_revenue_custom_range_title": "Date range (UTC)",
|
||||||
"admin_stats_revenue_custom_range_apply": "Apply",
|
|
||||||
"admin_stats_revenue_granularity_aria": "Revenue chart step",
|
"admin_stats_revenue_granularity_aria": "Revenue chart step",
|
||||||
"admin_stats_revenue_granularity_day": "By day",
|
"admin_stats_revenue_granularity_day": "By day",
|
||||||
"admin_stats_revenue_granularity_week": "By week",
|
"admin_stats_revenue_granularity_week": "By week",
|
||||||
@@ -1077,7 +1049,6 @@
|
|||||||
"admin_stats_revenue_chart_bucket_count": "Points: {count}",
|
"admin_stats_revenue_chart_bucket_count": "Points: {count}",
|
||||||
"admin_stats_revenue_chart_custom_span": "Range: {days} d.",
|
"admin_stats_revenue_chart_custom_span": "Range: {days} d.",
|
||||||
"admin_stats_revenue_tooltip_day": "Day",
|
"admin_stats_revenue_tooltip_day": "Day",
|
||||||
"admin_stats_revenue_tooltip_amount": "Amount",
|
|
||||||
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
|
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
|
||||||
"admin_stats_revenue_avg_none": "No successful payments today",
|
"admin_stats_revenue_avg_none": "No successful payments today",
|
||||||
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
|
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
|
||||||
@@ -1141,7 +1112,6 @@
|
|||||||
"admin_user_tg_profile_link_sent": "Link sent to Telegram",
|
"admin_user_tg_profile_link_sent": "Link sent to Telegram",
|
||||||
"admin_user_tg_profile_link_failed": "Failed to send link",
|
"admin_user_tg_profile_link_failed": "Failed to send link",
|
||||||
"admin_user_profile_link_message": "User profile: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\n\nTap the button below to open the profile in Telegram.",
|
"admin_user_profile_link_message": "User profile: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\n\nTap the button below to open the profile in Telegram.",
|
||||||
"admin_close": "Close",
|
|
||||||
"admin_loading": "Loading…",
|
"admin_loading": "Loading…",
|
||||||
"admin_badge_banned": "Banned",
|
"admin_badge_banned": "Banned",
|
||||||
"admin_badge_active": "Active",
|
"admin_badge_active": "Active",
|
||||||
@@ -1166,7 +1136,6 @@
|
|||||||
"admin_user_label_active_until": "Active until",
|
"admin_user_label_active_until": "Active until",
|
||||||
"admin_user_label_tariff": "Tariff",
|
"admin_user_label_tariff": "Tariff",
|
||||||
"admin_user_label_auto_renew": "Auto-renew",
|
"admin_user_label_auto_renew": "Auto-renew",
|
||||||
"admin_user_label_provider": "Provider",
|
|
||||||
"admin_user_label_main_traffic": "Main Traffic",
|
"admin_user_label_main_traffic": "Main Traffic",
|
||||||
"admin_user_traffic_left": "Left: {left}",
|
"admin_user_traffic_left": "Left: {left}",
|
||||||
"admin_user_label_premium_squads": "Premium Squads",
|
"admin_user_label_premium_squads": "Premium Squads",
|
||||||
@@ -1175,7 +1144,6 @@
|
|||||||
"user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.",
|
"user_premium_override_card_hint": "Unlimited access and extra volume for premium squads on top of the tariff.",
|
||||||
"user_regular_override_card_title": "Main traffic",
|
"user_regular_override_card_title": "Main traffic",
|
||||||
"user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
|
"user_regular_override_card_hint": "Unlimited-style ceiling and a persistent bonus on the main traffic limit.",
|
||||||
"user_regular_override_save": "Save",
|
|
||||||
"user_regular_override_status_unlimited": "Current: unlimited",
|
"user_regular_override_status_unlimited": "Current: unlimited",
|
||||||
"regular_override_saved": "Main traffic override saved",
|
"regular_override_saved": "Main traffic override saved",
|
||||||
"user_traffic_override_title": "Traffic overrides",
|
"user_traffic_override_title": "Traffic overrides",
|
||||||
@@ -1191,7 +1159,6 @@
|
|||||||
"user_premium_override_unlimited": "Unlimited premium",
|
"user_premium_override_unlimited": "Unlimited premium",
|
||||||
"user_premium_override_bonus": "Extra premium traffic, GB",
|
"user_premium_override_bonus": "Extra premium traffic, GB",
|
||||||
"user_premium_override_save": "Save override",
|
"user_premium_override_save": "Save override",
|
||||||
"user_premium_override_status_unlimited": "Current: unlimited",
|
|
||||||
"user_premium_override_status_bonus": "Current: +{gb} GB",
|
"user_premium_override_status_bonus": "Current: +{gb} GB",
|
||||||
"user_premium_override_status_none": "No premium override",
|
"user_premium_override_status_none": "No premium override",
|
||||||
"user_premium_unlimited_value": "∞ (used {used})",
|
"user_premium_unlimited_value": "∞ (used {used})",
|
||||||
@@ -1211,7 +1178,6 @@
|
|||||||
"admin_user_no_active_subscription": "No active subscription",
|
"admin_user_no_active_subscription": "No active subscription",
|
||||||
"admin_user_history_title": "Subscription History · {count}",
|
"admin_user_history_title": "Subscription History · {count}",
|
||||||
"admin_user_history_no_tariff": "No tariff",
|
"admin_user_history_no_tariff": "No tariff",
|
||||||
"admin_user_history_until": "until {date}",
|
|
||||||
"admin_user_history_active": "Active",
|
"admin_user_history_active": "Active",
|
||||||
"admin_user_history_status_panel": "History",
|
"admin_user_history_status_panel": "History",
|
||||||
"admin_user_recent_payments_title": "Recent Payments · {count}",
|
"admin_user_recent_payments_title": "Recent Payments · {count}",
|
||||||
@@ -1232,7 +1198,6 @@
|
|||||||
"admin_user_btn_delete_account": "Delete account",
|
"admin_user_btn_delete_account": "Delete account",
|
||||||
"admin_user_msg_confirm_title": "Send message to user?",
|
"admin_user_msg_confirm_title": "Send message to user?",
|
||||||
"admin_user_msg_confirm_recipient": "Recipient: {name}",
|
"admin_user_msg_confirm_recipient": "Recipient: {name}",
|
||||||
"admin_user_btn_cancel": "Cancel",
|
|
||||||
"admin_user_btn_confirm_send": "Confirm Send",
|
"admin_user_btn_confirm_send": "Confirm Send",
|
||||||
"admin_user_ban_confirm_title": "Ban user?",
|
"admin_user_ban_confirm_title": "Ban user?",
|
||||||
"admin_user_ban_confirm_subtitle": "{name} will no longer be able to interact with the bot. This can be undone later.",
|
"admin_user_ban_confirm_subtitle": "{name} will no longer be able to interact with the bot. This can be undone later.",
|
||||||
@@ -1249,7 +1214,6 @@
|
|||||||
"admin_tariff_label_hwid_count_full": "How many devices does this package add",
|
"admin_tariff_label_hwid_count_full": "How many devices does this package add",
|
||||||
"admin_tariff_label_price_rub": "Price in Rubles",
|
"admin_tariff_label_price_rub": "Price in Rubles",
|
||||||
"admin_tariff_label_price_stars": "Price in Telegram Stars",
|
"admin_tariff_label_price_stars": "Price in Telegram Stars",
|
||||||
"admin_status_active": "Active",
|
|
||||||
"admin_aria_label_main_traffic": "Main traffic usage",
|
"admin_aria_label_main_traffic": "Main traffic usage",
|
||||||
"admin_aria_label_premium_traffic": "Premium traffic usage",
|
"admin_aria_label_premium_traffic": "Premium traffic usage",
|
||||||
"admin_btn_delete": "Delete",
|
"admin_btn_delete": "Delete",
|
||||||
@@ -1259,24 +1223,18 @@
|
|||||||
"admin_btn_tariff": "Tariff",
|
"admin_btn_tariff": "Tariff",
|
||||||
"admin_btn_sync": "Synchronize",
|
"admin_btn_sync": "Synchronize",
|
||||||
"admin_btn_syncing": "Synchronizing...",
|
"admin_btn_syncing": "Synchronizing...",
|
||||||
"admin_btn_save": "Save",
|
|
||||||
"admin_btn_saving": "Saving...",
|
|
||||||
"admin_btn_enable": "On",
|
"admin_btn_enable": "On",
|
||||||
"admin_btn_disable": "Off",
|
"admin_btn_disable": "Off",
|
||||||
"admin_btn_show_more": "Show more",
|
"admin_btn_show_more": "Show more",
|
||||||
"admin_settings_dirty_count": "Changes: {count}",
|
"admin_settings_dirty_count": "Changes: {count}",
|
||||||
"admin_promo_col_code": "Code",
|
|
||||||
"admin_promo_col_bonus": "Bonus",
|
"admin_promo_col_bonus": "Bonus",
|
||||||
"admin_promo_col_activations": "Activations",
|
"admin_promo_col_activations": "Activations",
|
||||||
"admin_promo_col_valid_until": "Valid until",
|
"admin_promo_col_valid_until": "Valid until",
|
||||||
"admin_promo_col_status": "Status",
|
|
||||||
"admin_promo_create_title": "Create promo code",
|
"admin_promo_create_title": "Create promo code",
|
||||||
"admin_promo_label_code": "Code",
|
|
||||||
"admin_promo_label_bonus_days": "Bonus (days)",
|
"admin_promo_label_bonus_days": "Bonus (days)",
|
||||||
"admin_promo_label_max_activations": "Max activations",
|
"admin_promo_label_max_activations": "Max activations",
|
||||||
"admin_promo_label_valid_days": "Validity (days)",
|
"admin_promo_label_valid_days": "Validity (days)",
|
||||||
"admin_ad_create_title": "New campaign",
|
"admin_ad_create_title": "New campaign",
|
||||||
"admin_ad_label_source": "Source",
|
|
||||||
"admin_ad_label_param": "start parameter",
|
"admin_ad_label_param": "start parameter",
|
||||||
"admin_ad_hint_param": "Unique identifier for the referral link",
|
"admin_ad_hint_param": "Unique identifier for the referral link",
|
||||||
"admin_ad_label_cost": "Cost, RUB",
|
"admin_ad_label_cost": "Cost, RUB",
|
||||||
@@ -1285,7 +1243,6 @@
|
|||||||
"admin_ads_col_cost": "Cost",
|
"admin_ads_col_cost": "Cost",
|
||||||
"admin_ads_col_registrations": "Registrations",
|
"admin_ads_col_registrations": "Registrations",
|
||||||
"admin_ads_col_conversions": "Conversions",
|
"admin_ads_col_conversions": "Conversions",
|
||||||
"admin_ads_col_status": "Status",
|
|
||||||
"admin_no_data": "No data",
|
"admin_no_data": "No data",
|
||||||
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
|
"admin_settings_hint": "Changes in the admin panel take precedence over .env. The 'Reset' button returns the value from environment variables.",
|
||||||
"admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility",
|
"admin_settings_legacy_tariffs_warning_title": "remnawave-tg-shop legacy compatibility",
|
||||||
@@ -1313,7 +1270,6 @@
|
|||||||
"admin_show": "Show",
|
"admin_show": "Show",
|
||||||
"admin_hide": "Hide",
|
"admin_hide": "Hide",
|
||||||
"admin_tariffs_stat_total": "Total tariffs",
|
"admin_tariffs_stat_total": "Total tariffs",
|
||||||
"admin_tariffs_stat_enabled": "Enabled",
|
|
||||||
"admin_tariffs_stat_default": "Default",
|
"admin_tariffs_stat_default": "Default",
|
||||||
"admin_tariffs_stat_default_hint": "Used for new subscriptions",
|
"admin_tariffs_stat_default_hint": "Used for new subscriptions",
|
||||||
"admin_tariffs_stat_disabled": "Disabled",
|
"admin_tariffs_stat_disabled": "Disabled",
|
||||||
@@ -1340,7 +1296,6 @@
|
|||||||
"admin_tariffs_legacy_subtitle": "Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured.",
|
"admin_tariffs_legacy_subtitle": "Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured.",
|
||||||
"admin_tariffs_legacy_period": "Period",
|
"admin_tariffs_legacy_period": "Period",
|
||||||
"admin_tariffs_legacy_enabled": "Enabled",
|
"admin_tariffs_legacy_enabled": "Enabled",
|
||||||
"admin_tariffs_legacy_traffic_packages": "Traffic packages",
|
|
||||||
"admin_tariffs_legacy_stars_traffic_packages": "Traffic packages, Stars",
|
"admin_tariffs_legacy_stars_traffic_packages": "Traffic packages, Stars",
|
||||||
"admin_tariffs_legacy_traffic_hint": "Format: 10:199,50:799",
|
"admin_tariffs_legacy_traffic_hint": "Format: 10:199,50:799",
|
||||||
"admin_tariff_tab_general": "General",
|
"admin_tariff_tab_general": "General",
|
||||||
@@ -1383,7 +1338,6 @@
|
|||||||
"admin_months_short": "mo.",
|
"admin_months_short": "mo.",
|
||||||
"admin_at": "for",
|
"admin_at": "for",
|
||||||
"admin_tariff_traffic_packages": "Traffic packages",
|
"admin_tariff_traffic_packages": "Traffic packages",
|
||||||
"admin_btn_refresh": "Refresh",
|
|
||||||
"admin_btn_create_tariff": "Create Tariff",
|
"admin_btn_create_tariff": "Create Tariff",
|
||||||
"admin_enabled": "Enabled",
|
"admin_enabled": "Enabled",
|
||||||
"admin_disabled": "Disabled",
|
"admin_disabled": "Disabled",
|
||||||
@@ -1398,7 +1352,6 @@
|
|||||||
"admin_broadcast_stat_failed": "Failed",
|
"admin_broadcast_stat_failed": "Failed",
|
||||||
"admin_broadcast_started": "Broadcast started",
|
"admin_broadcast_started": "Broadcast started",
|
||||||
"admin_broadcast_failed": "Broadcast failed",
|
"admin_broadcast_failed": "Broadcast failed",
|
||||||
"admin_user_short": "User",
|
|
||||||
"admin_target_short": "Target",
|
"admin_target_short": "Target",
|
||||||
"admin_settings_field_default_language_label": "Default Language",
|
"admin_settings_field_default_language_label": "Default Language",
|
||||||
"admin_settings_field_default_language_description": "Controls the 'Default Language' setting in admin overrides.",
|
"admin_settings_field_default_language_description": "Controls the 'Default Language' setting in admin overrides.",
|
||||||
@@ -1667,45 +1620,18 @@
|
|||||||
"admin_nav_support": "Support",
|
"admin_nav_support": "Support",
|
||||||
"admin_section_support_title": "Support",
|
"admin_section_support_title": "Support",
|
||||||
"admin_section_support_subtitle": "Ticket inbox and user replies",
|
"admin_section_support_subtitle": "Ticket inbox and user replies",
|
||||||
"admin_support_search": "Search",
|
|
||||||
"admin_support_empty": "No tickets yet",
|
|
||||||
"admin_support_select_ticket": "Select a ticket",
|
"admin_support_select_ticket": "Select a ticket",
|
||||||
"admin_support_close_ticket": "Close",
|
|
||||||
"admin_support_internal_note": "Internal note",
|
|
||||||
"admin_support_reply_placeholder": "Reply",
|
"admin_support_reply_placeholder": "Reply",
|
||||||
"admin_support_no_messages": "No messages yet",
|
|
||||||
"admin_support_filter_all": "All",
|
|
||||||
"admin_support_filter_active": "Active",
|
|
||||||
"admin_support_filter_closed": "Closed",
|
"admin_support_filter_closed": "Closed",
|
||||||
"admin_support_filter_all_priorities": "Any priority",
|
"admin_support_filter_all_priorities": "Any priority",
|
||||||
"admin_support_filter_all_categories": "All categories",
|
"admin_support_filter_all_categories": "All categories",
|
||||||
"admin_support_ticket_number": "Ticket #{id}",
|
|
||||||
"admin_support_priority": "Priority",
|
|
||||||
"admin_support_category": "Category",
|
|
||||||
"admin_support_role_user": "User",
|
|
||||||
"admin_support_role_admin": "Admin",
|
"admin_support_role_admin": "Admin",
|
||||||
"admin_support_role_system": "System",
|
|
||||||
"admin_support_user_context": "User",
|
|
||||||
"admin_support_open_user": "User card",
|
"admin_support_open_user": "User card",
|
||||||
"admin_support_tariff": "Tariff",
|
"admin_support_tariff": "Tariff",
|
||||||
"admin_support_status": "Status",
|
|
||||||
"admin_support_remaining": "Remaining",
|
"admin_support_remaining": "Remaining",
|
||||||
"admin_support_unread": "Unread",
|
"admin_support_unread": "Unread",
|
||||||
"admin_support_summary": "Support summary",
|
"admin_support_summary": "Support summary",
|
||||||
"admin_support_ticket_dialog": "Support conversation",
|
"admin_support_ticket_dialog": "Support conversation",
|
||||||
"admin_support_status_open": "Open",
|
|
||||||
"admin_support_status_awaiting_user": "Awaiting user",
|
|
||||||
"admin_support_status_awaiting_admin": "Awaiting admin",
|
|
||||||
"admin_support_status_resolved": "Resolved",
|
|
||||||
"admin_support_status_closed": "Closed",
|
|
||||||
"admin_support_priority_low": "Low",
|
|
||||||
"admin_support_priority_normal": "Normal",
|
|
||||||
"admin_support_priority_high": "High",
|
|
||||||
"admin_support_priority_urgent": "Urgent",
|
|
||||||
"admin_support_category_billing": "Billing",
|
|
||||||
"admin_support_category_technical": "Technical",
|
|
||||||
"admin_support_category_account": "Account",
|
|
||||||
"admin_support_category_other": "Other",
|
|
||||||
"admin_support_sort_importance_desc": "Most important",
|
"admin_support_sort_importance_desc": "Most important",
|
||||||
"admin_sort_updated_desc": "Newest activity",
|
"admin_sort_updated_desc": "Newest activity",
|
||||||
"admin_sort_updated_asc": "Oldest activity",
|
"admin_sort_updated_asc": "Oldest activity",
|
||||||
@@ -1722,7 +1648,6 @@
|
|||||||
"wa_install_subscription_link_hint": "Scan the QR code or copy the link.",
|
"wa_install_subscription_link_hint": "Scan the QR code or copy the link.",
|
||||||
"wa_install_qr_alt": "Subscription QR code",
|
"wa_install_qr_alt": "Subscription QR code",
|
||||||
"wa_install_copy_subscription_link": "Copy link",
|
"wa_install_copy_subscription_link": "Copy link",
|
||||||
"wa_install_link_copied": "Link copied",
|
|
||||||
"wa_install_share": "Share",
|
"wa_install_share": "Share",
|
||||||
"wa_install_share_copied": "Install guide link copied",
|
"wa_install_share_copied": "Install guide link copied",
|
||||||
"wa_app_launch_title": "Opening app",
|
"wa_app_launch_title": "Opening app",
|
||||||
@@ -1750,5 +1675,82 @@
|
|||||||
"admin_settings_field_subscription_page_config_json_label": "Subscription Page config JSON",
|
"admin_settings_field_subscription_page_config_json_label": "Subscription Page config JSON",
|
||||||
"admin_settings_field_subscription_page_config_json_description": "Optional admin JSON override. It is applied only when the JSON override switch is enabled.",
|
"admin_settings_field_subscription_page_config_json_description": "Optional admin JSON override. It is applied only when the JSON override switch is enabled.",
|
||||||
"admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}",
|
"admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}",
|
||||||
"admin_settings_json_upload": "Load .json"
|
"admin_settings_json_upload": "Load .json",
|
||||||
|
"admin_nav_translations": "Translations",
|
||||||
|
"admin_section_translations_title": "Translations",
|
||||||
|
"admin_section_translations_subtitle": "Runtime localization string overrides from DB and data/locales-overrides.json",
|
||||||
|
"admin_translations_saved": "Translations saved",
|
||||||
|
"admin_translations_validation_errors": "Errors: {errors}",
|
||||||
|
"admin_translations_save_error": "Could not save translations: {error}",
|
||||||
|
"admin_translations_base_value": "Base text",
|
||||||
|
"admin_translations_search_placeholder": "Search keys and text",
|
||||||
|
"admin_translations_hint": "Overrides are stored in DB and mirrored to {path}.",
|
||||||
|
"admin_translations_keys_count": "Keys: {count}",
|
||||||
|
"admin_translations_no_matches": "No matching strings",
|
||||||
|
"admin_translations_group_admin": "Admin panel",
|
||||||
|
"admin_translations_group_admin_hint": "Navigation, tables, forms, dialogs, and admin-only labels.",
|
||||||
|
"admin_translations_group_admin_navigation": "Admin navigation and shared UI",
|
||||||
|
"admin_translations_group_admin_navigation_hint": "Sidebar, section headers, toolbar actions, filters, and shared controls.",
|
||||||
|
"admin_translations_group_admin_dashboard": "Admin dashboard and stats",
|
||||||
|
"admin_translations_group_admin_dashboard_hint": "Dashboard cards, revenue charts, panel sync status, and monitoring copy.",
|
||||||
|
"admin_translations_group_admin_users": "Admin users",
|
||||||
|
"admin_translations_group_admin_users_hint": "User lists, user cards, bans, grants, premium overrides, and direct messages.",
|
||||||
|
"admin_translations_group_admin_payments": "Admin payments",
|
||||||
|
"admin_translations_group_admin_payments_hint": "Payment tables, payment details, exports, provider labels, and payment stats.",
|
||||||
|
"admin_translations_group_admin_promos_marketing": "Admin promos, ads, and broadcasts",
|
||||||
|
"admin_translations_group_admin_promos_marketing_hint": "Promo management, ad campaigns, marketing tools, and broadcast workflows.",
|
||||||
|
"admin_translations_group_admin_tariffs": "Admin tariffs",
|
||||||
|
"admin_translations_group_admin_tariffs_hint": "Tariff catalog, tariff dialogs, legacy tariff rows, and trial tariff widgets.",
|
||||||
|
"admin_translations_group_admin_support": "Admin support inbox",
|
||||||
|
"admin_translations_group_admin_support_hint": "Support ticket inbox, ticket filters, admin replies, and support statuses.",
|
||||||
|
"admin_translations_group_admin_appearance": "Admin appearance",
|
||||||
|
"admin_translations_group_admin_appearance_hint": "Theme catalog, branding, logo, favicon, and public page links.",
|
||||||
|
"admin_translations_group_admin_settings_payments": "Admin payment settings",
|
||||||
|
"admin_translations_group_admin_settings_payments_hint": "Payment method toggles, prices, provider credentials, and webhook settings.",
|
||||||
|
"admin_translations_group_admin_settings_subscriptions": "Admin subscription settings",
|
||||||
|
"admin_translations_group_admin_settings_subscriptions_hint": "Panel connection, default squads, trials, referrals, device limits, and guides.",
|
||||||
|
"admin_translations_group_admin_settings_notifications": "Admin notifications and logs",
|
||||||
|
"admin_translations_group_admin_settings_notifications_hint": "Logging, required channel, subscription notifications, and support limits.",
|
||||||
|
"admin_translations_group_admin_settings": "Admin settings",
|
||||||
|
"admin_translations_group_admin_settings_hint": "Settings screen groups, subsections, helper text, and uncategorized settings.",
|
||||||
|
"admin_translations_group_admin_translations": "Admin translations",
|
||||||
|
"admin_translations_group_admin_translations_hint": "Translation override screen, language controls, and locale group labels.",
|
||||||
|
"admin_translations_group_admin_logs": "Admin logs and exports",
|
||||||
|
"admin_translations_group_admin_logs_hint": "Activity logs, log exports, CSV headers, and event detail labels.",
|
||||||
|
"admin_translations_group_admin_misc": "Admin miscellaneous",
|
||||||
|
"admin_translations_group_admin_misc_hint": "Older bot-admin labels and admin-only strings that do not fit another section.",
|
||||||
|
"admin_translations_group_webapp": "Mini App",
|
||||||
|
"admin_translations_group_webapp_hint": "User-facing Mini App screens, navigation, settings, and toasts.",
|
||||||
|
"admin_translations_group_bot_menu": "Telegram bot",
|
||||||
|
"admin_translations_group_bot_menu_hint": "Start menu, inline buttons, language selector, and bot-only flows.",
|
||||||
|
"admin_translations_group_subscriptions": "Subscriptions and devices",
|
||||||
|
"admin_translations_group_subscriptions_hint": "Subscription status, install guides, traffic packages, trials, and devices.",
|
||||||
|
"admin_translations_group_payments": "Payments",
|
||||||
|
"admin_translations_group_payments_hint": "Payment provider flows, invoices, payment methods, and checkout messages.",
|
||||||
|
"admin_translations_group_support": "Support",
|
||||||
|
"admin_translations_group_support_hint": "Support links, ticket inbox copy, ticket statuses, and notifications.",
|
||||||
|
"admin_translations_group_referrals_promos": "Referrals and promos",
|
||||||
|
"admin_translations_group_referrals_promos_hint": "Referral program, invite copy, promo codes, and bonuses.",
|
||||||
|
"admin_translations_group_auth_security": "Auth and security",
|
||||||
|
"admin_translations_group_auth_security_hint": "Login, email verification, account linking, and security messages.",
|
||||||
|
"admin_translations_group_emails": "Emails",
|
||||||
|
"admin_translations_group_emails_hint": "Transactional emails sent to users: login codes, payments, account merges, and reminders.",
|
||||||
|
"admin_translations_group_notifications_sync": "Notifications and sync",
|
||||||
|
"admin_translations_group_notifications_sync_hint": "Admin notifications, panel sync, logs, and background status messages.",
|
||||||
|
"admin_translations_group_common": "Common",
|
||||||
|
"admin_translations_group_common_hint": "Shared buttons, statuses, validation errors, and uncategorized strings.",
|
||||||
|
"admin_translations_file_write_warning": "Translations saved in DB, but JSON file was not updated",
|
||||||
|
"admin_add": "Add",
|
||||||
|
"admin_translations_fallback_value": "Fallback",
|
||||||
|
"admin_translations_languages_title": "Languages",
|
||||||
|
"admin_translations_languages_hint": "Add override-only languages without adding base locale files.",
|
||||||
|
"admin_translations_language_placeholder": "de, uk, pt-BR",
|
||||||
|
"admin_translations_language_custom": "Custom",
|
||||||
|
"admin_translations_language_invalid": "Invalid language code",
|
||||||
|
"admin_translations_language_exists": "{code} already exists",
|
||||||
|
"admin_translations_audience_all": "All",
|
||||||
|
"admin_translations_audience_user": "User-visible",
|
||||||
|
"admin_translations_audience_internal": "Admin/internal",
|
||||||
|
"admin_translations_audience_user_hint": "Mini App, bot, payment, subscription, support, and auth copy.",
|
||||||
|
"admin_translations_audience_internal_hint": "Admin panel, logs, sync statuses, and service notifications."
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-76
@@ -46,9 +46,7 @@
|
|||||||
"yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой",
|
"yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой",
|
||||||
"yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:",
|
"yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:",
|
||||||
"yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».",
|
"yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».",
|
||||||
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
|
||||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||||
"back_to_payment_methods_button": "⬅️ Назад",
|
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
"install_guide_share_button": "🔗 Поделиться инструкцией",
|
"install_guide_share_button": "🔗 Поделиться инструкцией",
|
||||||
"install_guide_share_link_line": "\n\nИнструкция для передачи:\n<code>{install_share_link}</code>",
|
"install_guide_share_link_line": "\n\nИнструкция для передачи:\n<code>{install_share_link}</code>",
|
||||||
@@ -156,22 +154,17 @@
|
|||||||
"admin_no_payments_to_export": "Нет платежей для экспорта.",
|
"admin_no_payments_to_export": "Нет платежей для экспорта.",
|
||||||
"admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}",
|
"admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}",
|
||||||
"admin_export_sent": "Файл отправлен!",
|
"admin_export_sent": "Файл отправлен!",
|
||||||
"admin_csv_payment_id": "ID",
|
|
||||||
"admin_csv_user_id": "User ID",
|
"admin_csv_user_id": "User ID",
|
||||||
"admin_csv_username": "Логин",
|
"admin_csv_username": "Логин",
|
||||||
"admin_csv_first_name": "Имя",
|
"admin_csv_first_name": "Имя",
|
||||||
"admin_csv_amount": "Сумма",
|
|
||||||
"admin_csv_currency": "Валюта",
|
"admin_csv_currency": "Валюта",
|
||||||
"admin_csv_provider": "Платежная система",
|
"admin_csv_provider": "Платежная система",
|
||||||
"admin_csv_status": "Статус",
|
|
||||||
"admin_csv_description": "Описание",
|
|
||||||
"admin_csv_units": "Месяцы/ГБ",
|
"admin_csv_units": "Месяцы/ГБ",
|
||||||
"admin_csv_months": "Месяцев",
|
"admin_csv_months": "Месяцев",
|
||||||
"admin_csv_created_at": "Дата создания",
|
"admin_csv_created_at": "Дата создания",
|
||||||
"admin_csv_provider_payment_id": "ID платежа в системе",
|
"admin_csv_provider_payment_id": "ID платежа в системе",
|
||||||
"admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
|
"admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
|
||||||
"admin_stats_sync_time": "Время",
|
"admin_stats_sync_time": "Время",
|
||||||
"admin_stats_sync_status": "Статус",
|
|
||||||
"admin_stats_sync_users_processed": "Обработано юзеров с панели",
|
"admin_stats_sync_users_processed": "Обработано юзеров с панели",
|
||||||
"admin_stats_sync_subs_synced": "Синхронизировано подписок",
|
"admin_stats_sync_subs_synced": "Синхронизировано подписок",
|
||||||
"admin_stats_sync_details_label": "Детали",
|
"admin_stats_sync_details_label": "Детали",
|
||||||
@@ -182,7 +175,6 @@
|
|||||||
"broadcast_target_active_button": "✅ Активные",
|
"broadcast_target_active_button": "✅ Активные",
|
||||||
"broadcast_target_inactive_button": "⌛ Неактивные",
|
"broadcast_target_inactive_button": "⌛ Неактивные",
|
||||||
"confirm_broadcast_send_button": "✅ Отправить",
|
"confirm_broadcast_send_button": "✅ Отправить",
|
||||||
"cancel_broadcast_button": "❌ Отмена",
|
|
||||||
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
||||||
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
|
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
|
||||||
"admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!",
|
"admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!",
|
||||||
@@ -229,13 +221,9 @@
|
|||||||
"admin_promo_csv_bonus_days": "Бонусные дни",
|
"admin_promo_csv_bonus_days": "Бонусные дни",
|
||||||
"admin_promo_csv_max_activations": "Максимальные активации",
|
"admin_promo_csv_max_activations": "Максимальные активации",
|
||||||
"admin_promo_csv_current_activations": "Текущие активации",
|
"admin_promo_csv_current_activations": "Текущие активации",
|
||||||
"admin_promo_csv_status": "Статус",
|
|
||||||
"admin_promo_csv_is_active": "Активен",
|
|
||||||
"admin_promo_csv_valid_until": "Действителен до",
|
"admin_promo_csv_valid_until": "Действителен до",
|
||||||
"admin_promo_csv_created_at": "Создан",
|
"admin_promo_csv_created_at": "Создан",
|
||||||
"admin_promo_csv_created_by_admin_id": "Создал (Admin ID)",
|
"admin_promo_csv_created_by_admin_id": "Создал (Admin ID)",
|
||||||
"csv_yes": "Да",
|
|
||||||
"csv_no": "Нет",
|
|
||||||
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
|
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
|
||||||
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
|
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
|
||||||
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
|
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
|
||||||
@@ -417,7 +405,6 @@
|
|||||||
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
|
||||||
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
|
||||||
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
|
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
|
||||||
"admin_promo_unlimited_validity": "♾️ Неограниченно",
|
|
||||||
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
|
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
|
||||||
"admin_user_id_label": "🆔 <b>ID:</b>",
|
"admin_user_id_label": "🆔 <b>ID:</b>",
|
||||||
"admin_user_name_label": "👤 <b>Имя:</b>",
|
"admin_user_name_label": "👤 <b>Имя:</b>",
|
||||||
@@ -494,7 +481,6 @@
|
|||||||
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
|
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
|
||||||
"inline_financial_description": "Сегодня: {today} RUB",
|
"inline_financial_description": "Сегодня: {today} RUB",
|
||||||
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
|
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
|
||||||
"admin_user_stats_total_label": "Всего",
|
|
||||||
"admin_user_stats_paid_subs_label": "С платной подпиской",
|
"admin_user_stats_paid_subs_label": "С платной подпиской",
|
||||||
"admin_user_stats_trial_label": "На пробном периоде",
|
"admin_user_stats_trial_label": "На пробном периоде",
|
||||||
"admin_user_stats_inactive_label": "Неактивных",
|
"admin_user_stats_inactive_label": "Неактивных",
|
||||||
@@ -819,7 +805,6 @@
|
|||||||
"wa_devices_platform_unknown": "Платформа неизвестна",
|
"wa_devices_platform_unknown": "Платформа неизвестна",
|
||||||
"wa_devices_connected_at": "Подключено",
|
"wa_devices_connected_at": "Подключено",
|
||||||
"wa_devices_disconnect": "Отключить устройство",
|
"wa_devices_disconnect": "Отключить устройство",
|
||||||
"wa_devices_disconnect_title": "Отключить устройство",
|
|
||||||
"wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.",
|
"wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.",
|
||||||
"wa_devices_disconnect_confirm": "Отключить",
|
"wa_devices_disconnect_confirm": "Отключить",
|
||||||
"wa_device_disconnected": "Устройство отключено",
|
"wa_device_disconnected": "Устройство отключено",
|
||||||
@@ -845,7 +830,6 @@
|
|||||||
"wa_settings_link_email_action": "Привязать почту",
|
"wa_settings_link_email_action": "Привязать почту",
|
||||||
"wa_settings_email_linked_title": "Почта привязана",
|
"wa_settings_email_linked_title": "Почта привязана",
|
||||||
"wa_activate_promo_title": "Активировать промокод",
|
"wa_activate_promo_title": "Активировать промокод",
|
||||||
"wa_link_email_modal_title": "Привязать почту",
|
|
||||||
"wa_traffic_reset_none": "Без сброса",
|
"wa_traffic_reset_none": "Без сброса",
|
||||||
"wa_traffic_reset_monthly": "Сброс ежемесячно",
|
"wa_traffic_reset_monthly": "Сброс ежемесячно",
|
||||||
"wa_traffic_reset_weekly": "Сброс еженедельно",
|
"wa_traffic_reset_weekly": "Сброс еженедельно",
|
||||||
@@ -940,8 +924,6 @@
|
|||||||
"admin_users_col_registration": "Регистрация",
|
"admin_users_col_registration": "Регистрация",
|
||||||
"admin_page": "Страница",
|
"admin_page": "Страница",
|
||||||
"admin_page_short": "Стр.",
|
"admin_page_short": "Стр.",
|
||||||
"admin_back": "Назад",
|
|
||||||
"admin_next": "Далее",
|
|
||||||
"admin_user": "Пользователь",
|
"admin_user": "Пользователь",
|
||||||
"admin_amount": "Сумма",
|
"admin_amount": "Сумма",
|
||||||
"admin_provider": "Провайдер",
|
"admin_provider": "Провайдер",
|
||||||
@@ -949,7 +931,6 @@
|
|||||||
"admin_status": "Статус",
|
"admin_status": "Статус",
|
||||||
"admin_date": "Дата",
|
"admin_date": "Дата",
|
||||||
"admin_payments_empty": "Нет платежей",
|
"admin_payments_empty": "Нет платежей",
|
||||||
"admin_payments_col_user_id": "ID",
|
|
||||||
"admin_payments_col_traffic_regular": "Основной трафик",
|
"admin_payments_col_traffic_regular": "Основной трафик",
|
||||||
"admin_payments_col_traffic_premium": "Премиум трафик",
|
"admin_payments_col_traffic_premium": "Премиум трафик",
|
||||||
"admin_payments_col_actions": "",
|
"admin_payments_col_actions": "",
|
||||||
@@ -958,7 +939,6 @@
|
|||||||
"admin_payments_desc_traffic_package_premium": "Пакет трафика {gb} ГБ (премиум)",
|
"admin_payments_desc_traffic_package_premium": "Пакет трафика {gb} ГБ (премиум)",
|
||||||
"admin_payment_detail_open": "Открыть платёж",
|
"admin_payment_detail_open": "Открыть платёж",
|
||||||
"admin_payment_detail_title": "Платёж #{id}",
|
"admin_payment_detail_title": "Платёж #{id}",
|
||||||
"admin_payment_detail_copied": "Скопировано",
|
|
||||||
"admin_payment_load_failed": "Не удалось загрузить платёж",
|
"admin_payment_load_failed": "Не удалось загрузить платёж",
|
||||||
"admin_payment_detail_updated_at": "Обновлён",
|
"admin_payment_detail_updated_at": "Обновлён",
|
||||||
"admin_payment_detail_provider_payment_id": "ID у провайдера",
|
"admin_payment_detail_provider_payment_id": "ID у провайдера",
|
||||||
@@ -973,13 +953,9 @@
|
|||||||
"admin_payment_detail_purchased_gb": "Куплено GB",
|
"admin_payment_detail_purchased_gb": "Куплено GB",
|
||||||
"admin_payment_detail_hwid_devices": "HWID-устройства",
|
"admin_payment_detail_hwid_devices": "HWID-устройства",
|
||||||
"admin_payment_detail_promo_code": "Промокод",
|
"admin_payment_detail_promo_code": "Промокод",
|
||||||
"admin_payment_detail_provider": "Провайдер",
|
|
||||||
"admin_payment_detail_user_section": "Пользователь",
|
|
||||||
"admin_payment_detail_payment_section": "Платёж",
|
"admin_payment_detail_payment_section": "Платёж",
|
||||||
"admin_payment_detail_provider_section": "Провайдер",
|
|
||||||
"admin_payment_detail_purchase_section": "Покупка",
|
"admin_payment_detail_purchase_section": "Покупка",
|
||||||
"admin_logs_user_filter_placeholder": "Фильтр по ID пользователя",
|
"admin_logs_user_filter_placeholder": "Фильтр по ID пользователя",
|
||||||
"admin_apply": "Применить",
|
|
||||||
"admin_reset": "Сбросить",
|
"admin_reset": "Сбросить",
|
||||||
"admin_event": "Событие",
|
"admin_event": "Событие",
|
||||||
"admin_content": "Контент",
|
"admin_content": "Контент",
|
||||||
@@ -1009,14 +985,11 @@
|
|||||||
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
||||||
"admin_settings_provider_admin_only_label": "Только для админов",
|
"admin_settings_provider_admin_only_label": "Только для админов",
|
||||||
"admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.",
|
"admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.",
|
||||||
"admin_copy": "Копировать",
|
|
||||||
"admin_copied": "Скопировано",
|
|
||||||
"admin_settings_validation_errors": "Ошибки: {errors}",
|
"admin_settings_validation_errors": "Ошибки: {errors}",
|
||||||
"admin_settings_save_error": "Ошибка: {error}",
|
"admin_settings_save_error": "Ошибка: {error}",
|
||||||
"admin_sync_started": "Синхронизация запущена",
|
"admin_sync_started": "Синхронизация запущена",
|
||||||
"admin_sync_error": "Ошибка синхронизации",
|
"admin_sync_error": "Ошибка синхронизации",
|
||||||
"admin_error": "Ошибка",
|
"admin_error": "Ошибка",
|
||||||
"admin_link_copied": "Ссылка скопирована",
|
|
||||||
"admin_user_banned": "Пользователь забанен",
|
"admin_user_banned": "Пользователь забанен",
|
||||||
"admin_user_unbanned": "Пользователь разбанен",
|
"admin_user_unbanned": "Пользователь разбанен",
|
||||||
"admin_message_sent": "Сообщение отправлено",
|
"admin_message_sent": "Сообщение отправлено",
|
||||||
@@ -1067,7 +1040,6 @@
|
|||||||
"admin_stats_revenue_period_365": "1 г.",
|
"admin_stats_revenue_period_365": "1 г.",
|
||||||
"admin_stats_revenue_period_custom": "Свой период",
|
"admin_stats_revenue_period_custom": "Свой период",
|
||||||
"admin_stats_revenue_custom_range_title": "Диапазон дат (UTC)",
|
"admin_stats_revenue_custom_range_title": "Диапазон дат (UTC)",
|
||||||
"admin_stats_revenue_custom_range_apply": "Применить",
|
|
||||||
"admin_stats_revenue_granularity_aria": "Шаг графика выручки",
|
"admin_stats_revenue_granularity_aria": "Шаг графика выручки",
|
||||||
"admin_stats_revenue_granularity_day": "По дням",
|
"admin_stats_revenue_granularity_day": "По дням",
|
||||||
"admin_stats_revenue_granularity_week": "По неделям",
|
"admin_stats_revenue_granularity_week": "По неделям",
|
||||||
@@ -1077,7 +1049,6 @@
|
|||||||
"admin_stats_revenue_chart_bucket_count": "Точек: {count}",
|
"admin_stats_revenue_chart_bucket_count": "Точек: {count}",
|
||||||
"admin_stats_revenue_chart_custom_span": "Диапазон: {days} дн.",
|
"admin_stats_revenue_chart_custom_span": "Диапазон: {days} дн.",
|
||||||
"admin_stats_revenue_tooltip_day": "День",
|
"admin_stats_revenue_tooltip_day": "День",
|
||||||
"admin_stats_revenue_tooltip_amount": "Сумма",
|
|
||||||
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
|
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
|
||||||
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
|
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
|
||||||
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
|
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
|
||||||
@@ -1141,7 +1112,6 @@
|
|||||||
"admin_user_tg_profile_link_sent": "Ссылка отправлена в Telegram",
|
"admin_user_tg_profile_link_sent": "Ссылка отправлена в Telegram",
|
||||||
"admin_user_tg_profile_link_failed": "Не удалось отправить ссылку",
|
"admin_user_tg_profile_link_failed": "Не удалось отправить ссылку",
|
||||||
"admin_user_profile_link_message": "Профиль пользователя: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\n\nНажмите кнопку ниже, чтобы открыть профиль в Telegram.",
|
"admin_user_profile_link_message": "Профиль пользователя: <b>{name}</b>\nUser ID: <code>{user_id}</code>\nTelegram ID: <code>{telegram_id}</code>\n\nНажмите кнопку ниже, чтобы открыть профиль в Telegram.",
|
||||||
"admin_close": "Закрыть",
|
|
||||||
"admin_loading": "Загрузка…",
|
"admin_loading": "Загрузка…",
|
||||||
"admin_badge_banned": "Бан",
|
"admin_badge_banned": "Бан",
|
||||||
"admin_badge_active": "Активен",
|
"admin_badge_active": "Активен",
|
||||||
@@ -1166,7 +1136,6 @@
|
|||||||
"admin_user_label_active_until": "Активна до",
|
"admin_user_label_active_until": "Активна до",
|
||||||
"admin_user_label_tariff": "Тариф",
|
"admin_user_label_tariff": "Тариф",
|
||||||
"admin_user_label_auto_renew": "Авто-продление",
|
"admin_user_label_auto_renew": "Авто-продление",
|
||||||
"admin_user_label_provider": "Провайдер",
|
|
||||||
"admin_user_label_main_traffic": "Основной трафик",
|
"admin_user_label_main_traffic": "Основной трафик",
|
||||||
"admin_user_traffic_left": "Осталось: {left}",
|
"admin_user_traffic_left": "Осталось: {left}",
|
||||||
"admin_user_label_premium_squads": "Premium-сквады",
|
"admin_user_label_premium_squads": "Premium-сквады",
|
||||||
@@ -1175,7 +1144,6 @@
|
|||||||
"user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.",
|
"user_premium_override_card_hint": "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.",
|
||||||
"user_regular_override_card_title": "Основной трафик",
|
"user_regular_override_card_title": "Основной трафик",
|
||||||
"user_regular_override_card_hint": "Режим безлимита и постоянный бонус к лимиту основного трафика.",
|
"user_regular_override_card_hint": "Режим безлимита и постоянный бонус к лимиту основного трафика.",
|
||||||
"user_regular_override_save": "Сохранить",
|
|
||||||
"user_regular_override_status_unlimited": "Сейчас: безлимит",
|
"user_regular_override_status_unlimited": "Сейчас: безлимит",
|
||||||
"regular_override_saved": "Оверрайд основного трафика сохранён",
|
"regular_override_saved": "Оверрайд основного трафика сохранён",
|
||||||
"user_traffic_override_title": "Оверрайд трафика",
|
"user_traffic_override_title": "Оверрайд трафика",
|
||||||
@@ -1191,7 +1159,6 @@
|
|||||||
"user_premium_override_unlimited": "Безлимит на премиум",
|
"user_premium_override_unlimited": "Безлимит на премиум",
|
||||||
"user_premium_override_bonus": "Доп. премиум-трафик, GB",
|
"user_premium_override_bonus": "Доп. премиум-трафик, GB",
|
||||||
"user_premium_override_save": "Сохранить оверрайд",
|
"user_premium_override_save": "Сохранить оверрайд",
|
||||||
"user_premium_override_status_unlimited": "Сейчас: безлимит",
|
|
||||||
"user_premium_override_status_bonus": "Сейчас: +{gb} GB",
|
"user_premium_override_status_bonus": "Сейчас: +{gb} GB",
|
||||||
"user_premium_override_status_none": "Премиум-оверрайд не задан",
|
"user_premium_override_status_none": "Премиум-оверрайд не задан",
|
||||||
"user_premium_unlimited_value": "∞ (использовано {used})",
|
"user_premium_unlimited_value": "∞ (использовано {used})",
|
||||||
@@ -1211,7 +1178,6 @@
|
|||||||
"admin_user_no_active_subscription": "Активной подписки нет",
|
"admin_user_no_active_subscription": "Активной подписки нет",
|
||||||
"admin_user_history_title": "История подписок · {count}",
|
"admin_user_history_title": "История подписок · {count}",
|
||||||
"admin_user_history_no_tariff": "Без тарифа",
|
"admin_user_history_no_tariff": "Без тарифа",
|
||||||
"admin_user_history_until": "до {date}",
|
|
||||||
"admin_user_history_active": "Активна",
|
"admin_user_history_active": "Активна",
|
||||||
"admin_user_history_status_panel": "История",
|
"admin_user_history_status_panel": "История",
|
||||||
"admin_user_recent_payments_title": "Последние платежи · {count}",
|
"admin_user_recent_payments_title": "Последние платежи · {count}",
|
||||||
@@ -1232,7 +1198,6 @@
|
|||||||
"admin_user_btn_delete_account": "Удалить аккаунт",
|
"admin_user_btn_delete_account": "Удалить аккаунт",
|
||||||
"admin_user_msg_confirm_title": "Отправить сообщение пользователю?",
|
"admin_user_msg_confirm_title": "Отправить сообщение пользователю?",
|
||||||
"admin_user_msg_confirm_recipient": "Получатель: {name}",
|
"admin_user_msg_confirm_recipient": "Получатель: {name}",
|
||||||
"admin_user_btn_cancel": "Отмена",
|
|
||||||
"admin_user_btn_confirm_send": "Подтвердить отправку",
|
"admin_user_btn_confirm_send": "Подтвердить отправку",
|
||||||
"admin_user_ban_confirm_title": "Заблокировать пользователя?",
|
"admin_user_ban_confirm_title": "Заблокировать пользователя?",
|
||||||
"admin_user_ban_confirm_subtitle": "{name} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.",
|
"admin_user_ban_confirm_subtitle": "{name} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.",
|
||||||
@@ -1249,7 +1214,6 @@
|
|||||||
"admin_tariff_label_hwid_count_full": "Сколько устройств добавляет пакет",
|
"admin_tariff_label_hwid_count_full": "Сколько устройств добавляет пакет",
|
||||||
"admin_tariff_label_price_rub": "Цена в рублях",
|
"admin_tariff_label_price_rub": "Цена в рублях",
|
||||||
"admin_tariff_label_price_stars": "Цена в Telegram Stars",
|
"admin_tariff_label_price_stars": "Цена в Telegram Stars",
|
||||||
"admin_status_active": "Активен",
|
|
||||||
"admin_aria_label_main_traffic": "Использование основного трафика",
|
"admin_aria_label_main_traffic": "Использование основного трафика",
|
||||||
"admin_aria_label_premium_traffic": "Использование premium-трафика",
|
"admin_aria_label_premium_traffic": "Использование premium-трафика",
|
||||||
"admin_btn_delete": "Удалить",
|
"admin_btn_delete": "Удалить",
|
||||||
@@ -1259,24 +1223,18 @@
|
|||||||
"admin_btn_tariff": "Тариф",
|
"admin_btn_tariff": "Тариф",
|
||||||
"admin_btn_sync": "Синхронизировать",
|
"admin_btn_sync": "Синхронизировать",
|
||||||
"admin_btn_syncing": "Синхронизация...",
|
"admin_btn_syncing": "Синхронизация...",
|
||||||
"admin_btn_save": "Сохранить",
|
|
||||||
"admin_btn_saving": "Сохранение...",
|
|
||||||
"admin_btn_enable": "Вкл",
|
"admin_btn_enable": "Вкл",
|
||||||
"admin_btn_disable": "Выкл",
|
"admin_btn_disable": "Выкл",
|
||||||
"admin_btn_show_more": "Показать еще",
|
"admin_btn_show_more": "Показать еще",
|
||||||
"admin_settings_dirty_count": "Изменений: {count}",
|
"admin_settings_dirty_count": "Изменений: {count}",
|
||||||
"admin_promo_col_code": "Код",
|
|
||||||
"admin_promo_col_bonus": "Бонус",
|
"admin_promo_col_bonus": "Бонус",
|
||||||
"admin_promo_col_activations": "Активаций",
|
"admin_promo_col_activations": "Активаций",
|
||||||
"admin_promo_col_valid_until": "Действует до",
|
"admin_promo_col_valid_until": "Действует до",
|
||||||
"admin_promo_col_status": "Статус",
|
|
||||||
"admin_promo_create_title": "Создать промокод",
|
"admin_promo_create_title": "Создать промокод",
|
||||||
"admin_promo_label_code": "Код",
|
|
||||||
"admin_promo_label_bonus_days": "Бонус (дней)",
|
"admin_promo_label_bonus_days": "Бонус (дней)",
|
||||||
"admin_promo_label_max_activations": "Макс. активаций",
|
"admin_promo_label_max_activations": "Макс. активаций",
|
||||||
"admin_promo_label_valid_days": "Срок действия (дней)",
|
"admin_promo_label_valid_days": "Срок действия (дней)",
|
||||||
"admin_ad_create_title": "Новая кампания",
|
"admin_ad_create_title": "Новая кампания",
|
||||||
"admin_ad_label_source": "Источник",
|
|
||||||
"admin_ad_label_param": "start-параметр",
|
"admin_ad_label_param": "start-параметр",
|
||||||
"admin_ad_hint_param": "Unique identifier for the referral link",
|
"admin_ad_hint_param": "Unique identifier for the referral link",
|
||||||
"admin_ad_label_cost": "Стоимость, RUB",
|
"admin_ad_label_cost": "Стоимость, RUB",
|
||||||
@@ -1285,7 +1243,6 @@
|
|||||||
"admin_ads_col_cost": "Стоимость",
|
"admin_ads_col_cost": "Стоимость",
|
||||||
"admin_ads_col_registrations": "Регистрации",
|
"admin_ads_col_registrations": "Регистрации",
|
||||||
"admin_ads_col_conversions": "Конверсии",
|
"admin_ads_col_conversions": "Конверсии",
|
||||||
"admin_ads_col_status": "Статус",
|
|
||||||
"admin_no_data": "Нет данных",
|
"admin_no_data": "Нет данных",
|
||||||
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
"admin_settings_hint": "Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения.",
|
||||||
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
"admin_settings_legacy_tariffs_warning_title": "Совместимость с remnawave-tg-shop legacy",
|
||||||
@@ -1313,7 +1270,6 @@
|
|||||||
"admin_show": "Показать",
|
"admin_show": "Показать",
|
||||||
"admin_hide": "Скрыть",
|
"admin_hide": "Скрыть",
|
||||||
"admin_tariffs_stat_total": "Всего тарифов",
|
"admin_tariffs_stat_total": "Всего тарифов",
|
||||||
"admin_tariffs_stat_enabled": "Включено",
|
|
||||||
"admin_tariffs_stat_default": "По умолчанию",
|
"admin_tariffs_stat_default": "По умолчанию",
|
||||||
"admin_tariffs_stat_default_hint": "Используется для новых подписок",
|
"admin_tariffs_stat_default_hint": "Используется для новых подписок",
|
||||||
"admin_tariffs_stat_disabled": "Отключено",
|
"admin_tariffs_stat_disabled": "Отключено",
|
||||||
@@ -1340,7 +1296,6 @@
|
|||||||
"admin_tariffs_legacy_subtitle": "Старые периоды и пакеты трафика remnawave-tg-shop, которые используются только без JSON-каталога.",
|
"admin_tariffs_legacy_subtitle": "Старые периоды и пакеты трафика remnawave-tg-shop, которые используются только без JSON-каталога.",
|
||||||
"admin_tariffs_legacy_period": "Период",
|
"admin_tariffs_legacy_period": "Период",
|
||||||
"admin_tariffs_legacy_enabled": "Включён",
|
"admin_tariffs_legacy_enabled": "Включён",
|
||||||
"admin_tariffs_legacy_traffic_packages": "Пакеты трафика",
|
|
||||||
"admin_tariffs_legacy_stars_traffic_packages": "Пакеты трафика, Stars",
|
"admin_tariffs_legacy_stars_traffic_packages": "Пакеты трафика, Stars",
|
||||||
"admin_tariffs_legacy_traffic_hint": "Формат: 10:199,50:799",
|
"admin_tariffs_legacy_traffic_hint": "Формат: 10:199,50:799",
|
||||||
"admin_tariff_tab_general": "Основное",
|
"admin_tariff_tab_general": "Основное",
|
||||||
@@ -1383,7 +1338,6 @@
|
|||||||
"admin_months_short": "мес.",
|
"admin_months_short": "мес.",
|
||||||
"admin_at": "за",
|
"admin_at": "за",
|
||||||
"admin_tariff_traffic_packages": "Пакеты трафика",
|
"admin_tariff_traffic_packages": "Пакеты трафика",
|
||||||
"admin_btn_refresh": "Обновить",
|
|
||||||
"admin_btn_create_tariff": "Создать тариф",
|
"admin_btn_create_tariff": "Создать тариф",
|
||||||
"admin_enabled": "Включено",
|
"admin_enabled": "Включено",
|
||||||
"admin_disabled": "Выключено",
|
"admin_disabled": "Выключено",
|
||||||
@@ -1398,7 +1352,6 @@
|
|||||||
"admin_broadcast_stat_failed": "Неудач",
|
"admin_broadcast_stat_failed": "Неудач",
|
||||||
"admin_broadcast_started": "Рассылка запущена",
|
"admin_broadcast_started": "Рассылка запущена",
|
||||||
"admin_broadcast_failed": "Ошибка рассылки",
|
"admin_broadcast_failed": "Ошибка рассылки",
|
||||||
"admin_user_short": "Пользователь",
|
|
||||||
"admin_target_short": "Цель",
|
"admin_target_short": "Цель",
|
||||||
"admin_settings_field_default_language_label": "Язык по умолчанию",
|
"admin_settings_field_default_language_label": "Язык по умолчанию",
|
||||||
"admin_settings_field_default_language_description": "Используется для приветственных сообщений и публичных страниц.",
|
"admin_settings_field_default_language_description": "Используется для приветственных сообщений и публичных страниц.",
|
||||||
@@ -1667,45 +1620,18 @@
|
|||||||
"admin_nav_support": "Поддержка",
|
"admin_nav_support": "Поддержка",
|
||||||
"admin_section_support_title": "Поддержка",
|
"admin_section_support_title": "Поддержка",
|
||||||
"admin_section_support_subtitle": "Инбокс тикетов и ответы пользователям",
|
"admin_section_support_subtitle": "Инбокс тикетов и ответы пользователям",
|
||||||
"admin_support_search": "Поиск",
|
|
||||||
"admin_support_empty": "Тикетов пока нет",
|
|
||||||
"admin_support_select_ticket": "Выберите тикет",
|
"admin_support_select_ticket": "Выберите тикет",
|
||||||
"admin_support_close_ticket": "Закрыть",
|
|
||||||
"admin_support_internal_note": "Внутренняя заметка",
|
|
||||||
"admin_support_reply_placeholder": "Ответ",
|
"admin_support_reply_placeholder": "Ответ",
|
||||||
"admin_support_no_messages": "Сообщений пока нет",
|
|
||||||
"admin_support_filter_all": "Все",
|
|
||||||
"admin_support_filter_active": "Активные",
|
|
||||||
"admin_support_filter_closed": "Закрытые",
|
"admin_support_filter_closed": "Закрытые",
|
||||||
"admin_support_filter_all_priorities": "Любой приоритет",
|
"admin_support_filter_all_priorities": "Любой приоритет",
|
||||||
"admin_support_filter_all_categories": "Все категории",
|
"admin_support_filter_all_categories": "Все категории",
|
||||||
"admin_support_ticket_number": "Тикет #{id}",
|
|
||||||
"admin_support_priority": "Приоритет",
|
|
||||||
"admin_support_category": "Категория",
|
|
||||||
"admin_support_role_user": "Пользователь",
|
|
||||||
"admin_support_role_admin": "Админ",
|
"admin_support_role_admin": "Админ",
|
||||||
"admin_support_role_system": "Система",
|
|
||||||
"admin_support_user_context": "Пользователь",
|
|
||||||
"admin_support_open_user": "Карточка",
|
"admin_support_open_user": "Карточка",
|
||||||
"admin_support_tariff": "Тариф",
|
"admin_support_tariff": "Тариф",
|
||||||
"admin_support_status": "Статус",
|
|
||||||
"admin_support_remaining": "Осталось",
|
"admin_support_remaining": "Осталось",
|
||||||
"admin_support_unread": "Непрочитано",
|
"admin_support_unread": "Непрочитано",
|
||||||
"admin_support_summary": "Сводка поддержки",
|
"admin_support_summary": "Сводка поддержки",
|
||||||
"admin_support_ticket_dialog": "Диалог поддержки",
|
"admin_support_ticket_dialog": "Диалог поддержки",
|
||||||
"admin_support_status_open": "Открыт",
|
|
||||||
"admin_support_status_awaiting_user": "Ждёт пользователя",
|
|
||||||
"admin_support_status_awaiting_admin": "Ждёт админа",
|
|
||||||
"admin_support_status_resolved": "Решён",
|
|
||||||
"admin_support_status_closed": "Закрыт",
|
|
||||||
"admin_support_priority_low": "Низкий",
|
|
||||||
"admin_support_priority_normal": "Обычный",
|
|
||||||
"admin_support_priority_high": "Высокий",
|
|
||||||
"admin_support_priority_urgent": "Срочный",
|
|
||||||
"admin_support_category_billing": "Оплата",
|
|
||||||
"admin_support_category_technical": "Техническое",
|
|
||||||
"admin_support_category_account": "Аккаунт",
|
|
||||||
"admin_support_category_other": "Другое",
|
|
||||||
"admin_support_sort_importance_desc": "Важные сверху",
|
"admin_support_sort_importance_desc": "Важные сверху",
|
||||||
"admin_sort_updated_desc": "Сначала новые",
|
"admin_sort_updated_desc": "Сначала новые",
|
||||||
"admin_sort_updated_asc": "Сначала старые",
|
"admin_sort_updated_asc": "Сначала старые",
|
||||||
@@ -1722,7 +1648,6 @@
|
|||||||
"wa_install_subscription_link_hint": "Отсканируйте QR-код или скопируйте ссылку.",
|
"wa_install_subscription_link_hint": "Отсканируйте QR-код или скопируйте ссылку.",
|
||||||
"wa_install_qr_alt": "QR-код подписки",
|
"wa_install_qr_alt": "QR-код подписки",
|
||||||
"wa_install_copy_subscription_link": "Скопировать ссылку",
|
"wa_install_copy_subscription_link": "Скопировать ссылку",
|
||||||
"wa_install_link_copied": "Ссылка скопирована",
|
|
||||||
"wa_install_share": "Поделиться",
|
"wa_install_share": "Поделиться",
|
||||||
"wa_install_share_copied": "Ссылка на инструкцию скопирована",
|
"wa_install_share_copied": "Ссылка на инструкцию скопирована",
|
||||||
"wa_app_launch_title": "Открываем приложение",
|
"wa_app_launch_title": "Открываем приложение",
|
||||||
@@ -1750,5 +1675,82 @@
|
|||||||
"admin_settings_field_subscription_page_config_json_label": "JSON-конфиг Subscription Page",
|
"admin_settings_field_subscription_page_config_json_label": "JSON-конфиг Subscription Page",
|
||||||
"admin_settings_field_subscription_page_config_json_description": "Необязательный JSON-override из админки. Применяется только когда включен соответствующий тумблер.",
|
"admin_settings_field_subscription_page_config_json_description": "Необязательный JSON-override из админки. Применяется только когда включен соответствующий тумблер.",
|
||||||
"admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}",
|
"admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}",
|
||||||
"admin_settings_json_upload": "Загрузить .json"
|
"admin_settings_json_upload": "Загрузить .json",
|
||||||
|
"admin_nav_translations": "Переводы",
|
||||||
|
"admin_section_translations_title": "Переводы",
|
||||||
|
"admin_section_translations_subtitle": "Оверрайды строк локализации из базы данных и data/locales-overrides.json",
|
||||||
|
"admin_translations_saved": "Переводы сохранены",
|
||||||
|
"admin_translations_validation_errors": "Ошибки: {errors}",
|
||||||
|
"admin_translations_save_error": "Не удалось сохранить переводы: {error}",
|
||||||
|
"admin_translations_base_value": "Базовый текст",
|
||||||
|
"admin_translations_search_placeholder": "Поиск по ключам и тексту",
|
||||||
|
"admin_translations_hint": "Оверрайды хранятся в базе данных и зеркалируются в {path}.",
|
||||||
|
"admin_translations_keys_count": "Ключей: {count}",
|
||||||
|
"admin_translations_no_matches": "Подходящих строк нет",
|
||||||
|
"admin_translations_group_admin": "Админ-панель",
|
||||||
|
"admin_translations_group_admin_hint": "Навигация, таблицы, формы, диалоги и подписи только для админов.",
|
||||||
|
"admin_translations_group_admin_navigation": "Навигация и общий UI админки",
|
||||||
|
"admin_translations_group_admin_navigation_hint": "Сайдбар, заголовки разделов, действия тулбаров, фильтры и общие контролы.",
|
||||||
|
"admin_translations_group_admin_dashboard": "Дашборд и статистика",
|
||||||
|
"admin_translations_group_admin_dashboard_hint": "Карточки дашборда, графики выручки, статус синхронизации панели и мониторинг.",
|
||||||
|
"admin_translations_group_admin_users": "Пользователи в админке",
|
||||||
|
"admin_translations_group_admin_users_hint": "Списки и карточки пользователей, баны, начисления, premium-override и личные сообщения.",
|
||||||
|
"admin_translations_group_admin_payments": "Платежи в админке",
|
||||||
|
"admin_translations_group_admin_payments_hint": "Таблицы платежей, детали оплат, экспорт, названия провайдеров и платёжная статистика.",
|
||||||
|
"admin_translations_group_admin_promos_marketing": "Промо, реклама и рассылки",
|
||||||
|
"admin_translations_group_admin_promos_marketing_hint": "Управление промокодами, рекламные кампании, маркетинговые инструменты и сценарии рассылок.",
|
||||||
|
"admin_translations_group_admin_tariffs": "Тарифы в админке",
|
||||||
|
"admin_translations_group_admin_tariffs_hint": "Каталог тарифов, диалоги тарифов, legacy-строки и блоки триала.",
|
||||||
|
"admin_translations_group_admin_support": "Инбокс поддержки",
|
||||||
|
"admin_translations_group_admin_support_hint": "Инбокс тикетов, фильтры, ответы админа и статусы поддержки.",
|
||||||
|
"admin_translations_group_admin_appearance": "Внешний вид админки",
|
||||||
|
"admin_translations_group_admin_appearance_hint": "Каталог тем, брендинг, логотип, favicon и публичные ссылки.",
|
||||||
|
"admin_translations_group_admin_settings_payments": "Платёжные настройки",
|
||||||
|
"admin_translations_group_admin_settings_payments_hint": "Тумблеры способов оплаты, цены, реквизиты провайдеров и настройки вебхуков.",
|
||||||
|
"admin_translations_group_admin_settings_subscriptions": "Настройки подписок",
|
||||||
|
"admin_translations_group_admin_settings_subscriptions_hint": "Подключение панели, squad по умолчанию, триал, рефералы, лимиты устройств и инструкции.",
|
||||||
|
"admin_translations_group_admin_settings_notifications": "Уведомления и логи",
|
||||||
|
"admin_translations_group_admin_settings_notifications_hint": "Логирование, обязательный канал, уведомления о подписке и лимиты поддержки.",
|
||||||
|
"admin_translations_group_admin_settings": "Настройки админки",
|
||||||
|
"admin_translations_group_admin_settings_hint": "Группы настроек, подразделы, подсказки и настройки без отдельной категории.",
|
||||||
|
"admin_translations_group_admin_translations": "Переводы в админке",
|
||||||
|
"admin_translations_group_admin_translations_hint": "Экран оверрайдов переводов, управление языками и названия групп локалей.",
|
||||||
|
"admin_translations_group_admin_logs": "Логи и экспорт",
|
||||||
|
"admin_translations_group_admin_logs_hint": "Логи активности, экспорт логов, CSV-заголовки и подписи деталей событий.",
|
||||||
|
"admin_translations_group_admin_misc": "Прочее в админке",
|
||||||
|
"admin_translations_group_admin_misc_hint": "Старые подписи bot-admin и внутренние строки, которые не попали в другие разделы.",
|
||||||
|
"admin_translations_group_webapp": "Mini App",
|
||||||
|
"admin_translations_group_webapp_hint": "Пользовательские экраны Mini App, навигация, настройки и уведомления.",
|
||||||
|
"admin_translations_group_bot_menu": "Telegram-бот",
|
||||||
|
"admin_translations_group_bot_menu_hint": "Стартовое меню, inline-кнопки, выбор языка и сценарии внутри бота.",
|
||||||
|
"admin_translations_group_subscriptions": "Подписки и устройства",
|
||||||
|
"admin_translations_group_subscriptions_hint": "Статус подписки, инструкции подключения, пакеты трафика, триал и устройства.",
|
||||||
|
"admin_translations_group_payments": "Платежи",
|
||||||
|
"admin_translations_group_payments_hint": "Платежные сценарии, инвойсы, способы оплаты и сообщения checkout.",
|
||||||
|
"admin_translations_group_support": "Поддержка",
|
||||||
|
"admin_translations_group_support_hint": "Ссылки поддержки, тикеты, статусы тикетов и уведомления.",
|
||||||
|
"admin_translations_group_referrals_promos": "Рефералы и промокоды",
|
||||||
|
"admin_translations_group_referrals_promos_hint": "Реферальная программа, приглашения, промокоды и бонусы.",
|
||||||
|
"admin_translations_group_auth_security": "Вход и безопасность",
|
||||||
|
"admin_translations_group_auth_security_hint": "Логин, подтверждение email, привязка аккаунтов и сообщения безопасности.",
|
||||||
|
"admin_translations_group_emails": "Email-письма",
|
||||||
|
"admin_translations_group_emails_hint": "Транзакционные письма пользователям: коды входа, платежи, объединение аккаунтов и напоминания.",
|
||||||
|
"admin_translations_group_notifications_sync": "Уведомления и синхронизация",
|
||||||
|
"admin_translations_group_notifications_sync_hint": "Админские уведомления, синхронизация панели, логи и фоновые статусы.",
|
||||||
|
"admin_translations_group_common": "Общие строки",
|
||||||
|
"admin_translations_group_common_hint": "Общие кнопки, статусы, ошибки валидации и строки без отдельной группы.",
|
||||||
|
"admin_translations_file_write_warning": "Переводы сохранены в БД, но JSON-файл не обновился",
|
||||||
|
"admin_add": "Добавить",
|
||||||
|
"admin_translations_fallback_value": "Фолбэк",
|
||||||
|
"admin_translations_languages_title": "Языки",
|
||||||
|
"admin_translations_languages_hint": "Можно добавлять языки только для оверрайдов, без базовых locale-файлов.",
|
||||||
|
"admin_translations_language_placeholder": "de, uk, pt-BR",
|
||||||
|
"admin_translations_language_custom": "Доп.",
|
||||||
|
"admin_translations_language_invalid": "Неверный код языка",
|
||||||
|
"admin_translations_language_exists": "Язык {code} уже есть",
|
||||||
|
"admin_translations_audience_all": "Все",
|
||||||
|
"admin_translations_audience_user": "Видно пользователю",
|
||||||
|
"admin_translations_audience_internal": "Админка/внутреннее",
|
||||||
|
"admin_translations_audience_user_hint": "Строки Mini App, бота, платежей, подписок, поддержки и входа.",
|
||||||
|
"admin_translations_audience_internal_hint": "Админка, логи, статусы синхронизации и сервисные уведомления."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot.app.web.admin_settings_manifest import manifest_payload
|
from bot.app.web.admin_settings_manifest import manifest_payload
|
||||||
|
from bot.middlewares.i18n import resolve_locale_key
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
@@ -99,6 +100,10 @@ def _locale(language: str) -> dict[str, str]:
|
|||||||
return json.loads((REPO_ROOT / "locales" / f"{language}.json").read_text(encoding="utf-8"))
|
return json.loads((REPO_ROOT / "locales" / f"{language}.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_locale_key(messages: dict[str, str], key: str) -> bool:
|
||||||
|
return resolve_locale_key(key) in messages
|
||||||
|
|
||||||
|
|
||||||
def test_webapp_title_is_first_general_admin_setting():
|
def test_webapp_title_is_first_general_admin_setting():
|
||||||
items = _manifest_items()
|
items = _manifest_items()
|
||||||
manifest = {item["key"]: item for item in items}
|
manifest = {item["key"]: item for item in items}
|
||||||
@@ -219,7 +224,10 @@ def test_platega_settings_share_one_admin_subsection():
|
|||||||
def test_tariff_settings_page_i18n_keys_exist():
|
def test_tariff_settings_page_i18n_keys_exist():
|
||||||
for language in ("ru", "en"):
|
for language in ("ru", "en"):
|
||||||
messages = _locale(language)
|
messages = _locale(language)
|
||||||
assert ADMIN_TARIFF_SETTINGS_PAGE_KEYS <= messages.keys()
|
missing = {
|
||||||
|
key for key in ADMIN_TARIFF_SETTINGS_PAGE_KEYS if not _has_locale_key(messages, key)
|
||||||
|
}
|
||||||
|
assert missing == set()
|
||||||
|
|
||||||
|
|
||||||
def test_visible_admin_russian_labels_do_not_fall_back_to_english():
|
def test_visible_admin_russian_labels_do_not_fall_back_to_english():
|
||||||
@@ -236,7 +244,7 @@ def test_visible_admin_russian_labels_do_not_fall_back_to_english():
|
|||||||
for match in ADMIN_AT_SIMPLE_FALLBACK_RE.finditer(text):
|
for match in ADMIN_AT_SIMPLE_FALLBACK_RE.finditer(text):
|
||||||
locale_key = f"admin_{match.group('key')}"
|
locale_key = f"admin_{match.group('key')}"
|
||||||
fallback = match.group("fallback")
|
fallback = match.group("fallback")
|
||||||
if locale_key in messages:
|
if _has_locale_key(messages, locale_key):
|
||||||
continue
|
continue
|
||||||
has_latin = any("A" <= char <= "Z" or "a" <= char <= "z" for char in fallback)
|
has_latin = any("A" <= char <= "Z" or "a" <= char <= "z" for char in fallback)
|
||||||
has_cyrillic = any("А" <= char <= "я" or char in "Ёё" for char in fallback)
|
has_cyrillic = any("А" <= char <= "я" or char in "Ёё" for char in fallback)
|
||||||
|
|||||||
@@ -0,0 +1,813 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from bot.app.web.webapp.common import _normalize_language
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_language_selection_keyboard
|
||||||
|
from bot.middlewares.i18n import (
|
||||||
|
LOCALE_KEY_ALIASES,
|
||||||
|
JsonI18n,
|
||||||
|
normalize_locale_overrides_payload,
|
||||||
|
resolve_locale_key,
|
||||||
|
)
|
||||||
|
from bot.services import locale_override_service
|
||||||
|
from bot.services.email_templates import render_login_code
|
||||||
|
from bot.services.locale_override_service import audience_for_locale_key, group_id_for_locale_key
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
LOCALE_CALL_RE = re.compile(
|
||||||
|
r"""(?P<fn>\bat\b|\bt\b|\bgettext\b|\bget_text\b|\btranslator\b|(?<![\w.])_)"""
|
||||||
|
r"""\(\s*(?:key\s*=\s*)?["'](?P<key>[^"']+)["']"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Begin:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def begin(self):
|
||||||
|
return _Begin()
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionFactory:
|
||||||
|
def __call__(self):
|
||||||
|
return _Session()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_locale(path: Path, lang: str, messages: dict[str, str]) -> None:
|
||||||
|
(path / f"{lang}.json").write_text(
|
||||||
|
json.dumps(messages, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_files(*roots: str) -> list[Path]:
|
||||||
|
result: list[Path] = []
|
||||||
|
for root in roots:
|
||||||
|
path = REPO_ROOT / root
|
||||||
|
if path.is_file():
|
||||||
|
result.append(path)
|
||||||
|
else:
|
||||||
|
result.extend(
|
||||||
|
child
|
||||||
|
for child in path.rglob("*")
|
||||||
|
if child.suffix in {".py", ".js", ".svelte"}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_locale_usage(
|
||||||
|
paths: list[Path],
|
||||||
|
locale_keys: set[str],
|
||||||
|
*,
|
||||||
|
frontend_admin: bool = False,
|
||||||
|
exclude_admin_frontend: bool = False,
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
usage: dict[str, set[str]] = {}
|
||||||
|
for path in paths:
|
||||||
|
relative = path.relative_to(REPO_ROOT).as_posix()
|
||||||
|
if exclude_admin_frontend and (
|
||||||
|
relative.startswith("frontend/src/admin/")
|
||||||
|
or relative.startswith("frontend/src/lib/admin/")
|
||||||
|
or relative == "frontend/src/adminEntry.js"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
for match in LOCALE_CALL_RE.finditer(text):
|
||||||
|
key = match.group("key")
|
||||||
|
actual_key = f"admin_{key}" if frontend_admin and match.group("fn") == "at" else key
|
||||||
|
resolved_key = resolve_locale_key(actual_key)
|
||||||
|
if resolved_key in locale_keys:
|
||||||
|
usage.setdefault(resolved_key, set()).add(relative)
|
||||||
|
return usage
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_i18n_applies_locale_overrides(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет, {name}!", "plain": "База"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello, {name}!", "plain": "Base"})
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
i18n.set_locale_overrides({"ru": {"welcome": "Добрый день, {name}!"}})
|
||||||
|
|
||||||
|
assert i18n.gettext("ru", "welcome", name="Анна") == "Добрый день, Анна!"
|
||||||
|
assert i18n.gettext("ru", "plain") == "База"
|
||||||
|
assert i18n.base_locales_data["ru"]["welcome"] == "Привет, {name}!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_i18n_resolves_locale_key_aliases(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"wa_back": "Назад"})
|
||||||
|
_write_locale(locales, "en", {"wa_back": "Back"})
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
|
||||||
|
assert resolve_locale_key("admin_back") == "wa_back"
|
||||||
|
assert i18n.gettext("ru", "admin_back") == "Назад"
|
||||||
|
assert i18n.gettext("en", "admin_back") == "Back"
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_i18n_applies_override_only_language_with_default_fallback(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет", "plain": "База"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello", "plain": "Base"})
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
i18n.set_locale_overrides({"de": {"welcome": "Hallo"}})
|
||||||
|
|
||||||
|
assert i18n.gettext("de", "welcome") == "Hallo"
|
||||||
|
assert i18n.gettext("de-DE", "welcome") == "Hallo"
|
||||||
|
assert i18n.gettext("de", "plain") == "База"
|
||||||
|
assert i18n.locales_data["de"]["welcome"] == "Hallo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_options_include_override_only_languages(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
i18n.set_locale_overrides({"uk": {"welcome": "Вітаю"}, "pt-br": {"welcome": "Olá"}})
|
||||||
|
|
||||||
|
options = i18n.language_options()
|
||||||
|
|
||||||
|
assert [item["code"] for item in options] == ["ru", "en", "pt-br", "uk"]
|
||||||
|
assert options[-1]["label"] == "Українська"
|
||||||
|
assert options[-1]["base"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_keyboard_includes_override_only_languages(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"back_to_main_menu_button": "Назад"})
|
||||||
|
_write_locale(locales, "en", {"back_to_main_menu_button": "Back"})
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
i18n.set_locale_overrides({"uk": {"back_to_main_menu_button": "Назад"}})
|
||||||
|
|
||||||
|
keyboard = get_language_selection_keyboard(i18n, "uk")
|
||||||
|
buttons = [button for row in keyboard.inline_keyboard for button in row]
|
||||||
|
|
||||||
|
assert any(button.callback_data == "set_lang_uk" for button in buttons)
|
||||||
|
assert any("Українська" in button.text and "✅" in button.text for button in buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webapp_language_normalizer_accepts_valid_extra_languages():
|
||||||
|
assert _normalize_language("uk") == "uk"
|
||||||
|
assert _normalize_language("pt-BR") == "pt-br"
|
||||||
|
assert _normalize_language("bad code") == "ru"
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_templates_use_override_only_language_without_base_locale_file(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
base_messages = {
|
||||||
|
"email_login_code_subject": "Code {code}",
|
||||||
|
"email_login_code_preheader": "Expires in {minutes}",
|
||||||
|
"email_login_code_heading": "Login",
|
||||||
|
"email_login_code_intro": "Intro",
|
||||||
|
"email_login_code_expiry_html": "Expires in <strong>{minutes}</strong>",
|
||||||
|
"email_login_code_security": "Ignore this email.",
|
||||||
|
"email_footer_auto": "{brand}",
|
||||||
|
"email_login_code_text": "Code {code}; {minutes}",
|
||||||
|
}
|
||||||
|
_write_locale(locales, "ru", base_messages)
|
||||||
|
_write_locale(locales, "en", base_messages)
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
i18n.set_locale_overrides(
|
||||||
|
{
|
||||||
|
"pt-br": {
|
||||||
|
"email_login_code_subject": "Código {code}",
|
||||||
|
"email_login_code_text": "Seu código é {code}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
DEFAULT_LANGUAGE="ru",
|
||||||
|
EMAIL_CODE_TTL_SECONDS=600,
|
||||||
|
WEBAPP_LOGO_URL="",
|
||||||
|
WEBAPP_LOGO_USE_EMOJI=False,
|
||||||
|
WEBAPP_PRIMARY_COLOR="#00fe7a",
|
||||||
|
WEBAPP_TITLE="Remnawave",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = render_login_code(
|
||||||
|
settings,
|
||||||
|
code="123456",
|
||||||
|
language_code="pt-BR",
|
||||||
|
purpose="login",
|
||||||
|
i18n=i18n,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert content.subject == "Código 123456"
|
||||||
|
assert content.text == "Seu código é 123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_override_file_reload_replaces_effective_messages(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
overrides_path.write_text('{"ru":{"welcome":"Первый"}}', encoding="utf-8")
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru", overrides_path=str(overrides_path))
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Первый"
|
||||||
|
|
||||||
|
overrides_path.write_text('{"ru":{"welcome":"Второй"}}', encoding="utf-8")
|
||||||
|
i18n._overrides_file_next_check = 0
|
||||||
|
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Второй"
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_override_file_removal_keeps_current_overrides(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
overrides_path.write_text('{"ru":{"welcome":"Из файла"}}', encoding="utf-8")
|
||||||
|
|
||||||
|
i18n = JsonI18n(str(locales), default="ru", overrides_path=str(overrides_path))
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Из файла"
|
||||||
|
|
||||||
|
overrides_path.unlink()
|
||||||
|
i18n._overrides_file_next_check = 0
|
||||||
|
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Из файла"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locale_overrides_rejects_unknown_keys():
|
||||||
|
overrides, errors = normalize_locale_overrides_payload(
|
||||||
|
{"ru": {"known": "ok", "missing": "bad"}},
|
||||||
|
valid_languages={"ru"},
|
||||||
|
valid_keys_by_language={"ru": {"known"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overrides == {"ru": {"known": "ok"}}
|
||||||
|
assert errors == {"ru.missing": "unknown_key"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locale_overrides_allows_extra_languages_when_enabled():
|
||||||
|
overrides, errors = normalize_locale_overrides_payload(
|
||||||
|
{"pt-BR": {"known": "ok"}, "bad code": {"known": "bad"}},
|
||||||
|
valid_languages={"ru", "en"},
|
||||||
|
valid_keys_by_language={"ru": {"known"}, "en": {"known"}},
|
||||||
|
allow_extra_languages=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overrides == {"pt-br": {"known": "ok"}}
|
||||||
|
assert errors == {"bad code": "invalid_language"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locale_overrides_canonicalizes_alias_keys():
|
||||||
|
overrides, errors = normalize_locale_overrides_payload(
|
||||||
|
{"ru": {"admin_back": "Назад", "wa_back": "Назад!"}},
|
||||||
|
valid_languages={"ru", "en"},
|
||||||
|
valid_keys_by_language={"ru": {"wa_back"}, "en": {"wa_back"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert errors == {}
|
||||||
|
assert overrides == {"ru": {"wa_back": "Назад!"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_locale_files_do_not_store_alias_keys():
|
||||||
|
for lang in ("en", "ru"):
|
||||||
|
messages = json.loads((REPO_ROOT / "locales" / f"{lang}.json").read_text(encoding="utf-8"))
|
||||||
|
assert sorted(set(messages) & set(LOCALE_KEY_ALIASES)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_locale_keys_are_split_into_smaller_internal_groups():
|
||||||
|
expected_groups = {
|
||||||
|
"admin_nav_support": "admin_navigation",
|
||||||
|
"admin_stats_revenue_title": "admin_dashboard",
|
||||||
|
"error_displaying_statistics": "admin_dashboard",
|
||||||
|
"inline_admin_user_stats_title": "admin_dashboard",
|
||||||
|
"inline_user_stats_message": "admin_dashboard",
|
||||||
|
"inline_financial_description": "admin_dashboard",
|
||||||
|
"inline_system_stats_message": "admin_dashboard",
|
||||||
|
"admin_user_card_title": "admin_users",
|
||||||
|
"user_card_open_profile_button": "admin_users",
|
||||||
|
"user_premium_override_card_title": "admin_users",
|
||||||
|
"traffic_grant_regular_done": "admin_users",
|
||||||
|
"admin_payment_detail_title": "admin_payments",
|
||||||
|
"admin_promo_management_title": "admin_promos_marketing",
|
||||||
|
"broadcast_target_all_button": "admin_promos_marketing",
|
||||||
|
"confirm_broadcast_send_button": "admin_promos_marketing",
|
||||||
|
"admin_tariffs_trial_title": "admin_tariffs",
|
||||||
|
"admin_support_ticket_dialog": "admin_support",
|
||||||
|
"admin_themes_catalog_title": "admin_appearance",
|
||||||
|
"appearance_logo_uploaded_pending": "admin_appearance",
|
||||||
|
"admin_settings_field_yookassa_enabled_label": "admin_settings_payments",
|
||||||
|
"admin_settings_field_subscription_guides_enabled_label": (
|
||||||
|
"admin_settings_subscriptions"
|
||||||
|
),
|
||||||
|
"admin_settings_field_log_level_label": "admin_settings_notifications",
|
||||||
|
"back_to_admin_panel_button": "admin_navigation",
|
||||||
|
"admin_translations_languages_title": "admin_translations",
|
||||||
|
"admin_logs_menu_title": "admin_logs",
|
||||||
|
"csv_yes": "admin_logs",
|
||||||
|
"error_displaying_logs_too_long": "admin_logs",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, group_id in expected_groups.items():
|
||||||
|
assert group_id_for_locale_key(key) == group_id
|
||||||
|
assert audience_for_locale_key(key) == "internal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_locale_keys_have_dedicated_user_visible_group():
|
||||||
|
expected_email_keys = [
|
||||||
|
"email_footer_auto",
|
||||||
|
"email_login_code_subject",
|
||||||
|
"email_payment_success_subject",
|
||||||
|
"email_subscription_expiring_subject_today",
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in expected_email_keys:
|
||||||
|
assert group_id_for_locale_key(key) == "emails"
|
||||||
|
assert audience_for_locale_key(key) == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_only_locale_keys_are_internal_by_actual_source_usage():
|
||||||
|
locale_keys = set(json.loads((REPO_ROOT / "locales" / "en.json").read_text(encoding="utf-8")))
|
||||||
|
frontend_admin_usage = _collect_locale_usage(
|
||||||
|
_source_files("frontend/src/admin", "frontend/src/lib/admin"),
|
||||||
|
locale_keys,
|
||||||
|
frontend_admin=True,
|
||||||
|
)
|
||||||
|
backend_admin_usage = _collect_locale_usage(
|
||||||
|
_source_files(
|
||||||
|
"backend/bot/handlers/admin",
|
||||||
|
"backend/bot/keyboards/inline/admin_keyboards.py",
|
||||||
|
),
|
||||||
|
locale_keys,
|
||||||
|
)
|
||||||
|
inline_admin_usage = _collect_locale_usage(
|
||||||
|
_source_files("backend/bot/handlers/inline_mode.py"),
|
||||||
|
locale_keys,
|
||||||
|
)
|
||||||
|
admin_usage = dict(frontend_admin_usage)
|
||||||
|
for key, paths in [*backend_admin_usage.items(), *inline_admin_usage.items()]:
|
||||||
|
admin_usage.setdefault(key, set()).update(paths)
|
||||||
|
|
||||||
|
user_usage = _collect_locale_usage(
|
||||||
|
_source_files(
|
||||||
|
"backend/bot/handlers/user",
|
||||||
|
"backend/bot/keyboards/inline/user_keyboards.py",
|
||||||
|
"backend/bot/payment_providers",
|
||||||
|
"backend/bot/app/web/webapp",
|
||||||
|
"frontend/src",
|
||||||
|
),
|
||||||
|
locale_keys,
|
||||||
|
exclude_admin_frontend=True,
|
||||||
|
)
|
||||||
|
for key in locale_keys:
|
||||||
|
if key.startswith("email_"):
|
||||||
|
user_usage.setdefault(key, set()).add("backend/bot/services/email_templates.py")
|
||||||
|
if key.startswith("inline_referral_"):
|
||||||
|
user_usage.setdefault(key, set()).add("backend/bot/handlers/inline_mode.py")
|
||||||
|
for canonical_key in set(LOCALE_KEY_ALIASES.values()):
|
||||||
|
if canonical_key in locale_keys and audience_for_locale_key(canonical_key) == "user":
|
||||||
|
user_usage.setdefault(canonical_key, set()).add("locale-key-aliases")
|
||||||
|
|
||||||
|
misplaced = {
|
||||||
|
key: sorted(paths)
|
||||||
|
for key, paths in admin_usage.items()
|
||||||
|
if key not in user_usage and audience_for_locale_key(key) != "internal"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert misplaced == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_locale_overrides_treats_file_as_source_of_truth(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет", "plain": "База"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello", "plain": "Base"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
overrides_path.write_text(
|
||||||
|
json.dumps({"ru": {"welcome": "Из файла"}}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
db_state = {"ru": {"welcome": "Из БД", "plain": "Только БД"}}
|
||||||
|
|
||||||
|
async def bulk_apply(_session, *, updates, updated_by):
|
||||||
|
assert updated_by is None
|
||||||
|
for (lang, key), (set_flag, value) in updates.items():
|
||||||
|
if set_flag:
|
||||||
|
db_state.setdefault(lang, {})[key] = value
|
||||||
|
else:
|
||||||
|
db_state.get(lang, {}).pop(key, None)
|
||||||
|
for lang in list(db_state):
|
||||||
|
if not db_state[lang]:
|
||||||
|
db_state.pop(lang)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(side_effect=lambda _session: db_state),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(side_effect=bulk_apply),
|
||||||
|
) as bulk_mock,
|
||||||
|
):
|
||||||
|
count = await locale_override_service.load_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
bulk_mock.assert_awaited_once()
|
||||||
|
return count
|
||||||
|
|
||||||
|
count = asyncio.run(run())
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert db_state == {"ru": {"welcome": "Из файла"}}
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Из файла"
|
||||||
|
assert i18n.gettext("ru", "plain") == "База"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_locale_overrides_empty_file_clears_db_overrides(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
overrides_path.write_text("{}", encoding="utf-8")
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
db_state = {"ru": {"welcome": "Из БД"}}
|
||||||
|
|
||||||
|
async def bulk_apply(_session, *, updates, updated_by):
|
||||||
|
assert updated_by is None
|
||||||
|
for (lang, key), (set_flag, value) in updates.items():
|
||||||
|
if set_flag:
|
||||||
|
db_state.setdefault(lang, {})[key] = value
|
||||||
|
else:
|
||||||
|
db_state.get(lang, {}).pop(key, None)
|
||||||
|
for lang in list(db_state):
|
||||||
|
if not db_state[lang]:
|
||||||
|
db_state.pop(lang)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(side_effect=lambda _session: db_state),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(side_effect=bulk_apply),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return await locale_override_service.load_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = asyncio.run(run())
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert db_state == {}
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Привет"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_locale_overrides_uses_db_when_file_missing(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(return_value={"ru": {"welcome": "Из БД"}}),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(),
|
||||||
|
) as bulk_mock,
|
||||||
|
):
|
||||||
|
count = await locale_override_service.load_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
bulk_mock.assert_not_awaited()
|
||||||
|
return count
|
||||||
|
|
||||||
|
count = asyncio.run(run())
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Из БД"
|
||||||
|
assert json.loads(overrides_path.read_text(encoding="utf-8")) == {
|
||||||
|
"ru": {"welcome": "Из БД"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_locale_overrides_creates_empty_file_when_file_missing_and_db_empty(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(return_value={}),
|
||||||
|
):
|
||||||
|
return await locale_override_service.load_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = asyncio.run(run())
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Привет"
|
||||||
|
assert json.loads(overrides_path.read_text(encoding="utf-8")) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_locale_overrides_uses_db_when_file_is_invalid(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
overrides_path.write_text("{not-json", encoding="utf-8")
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(return_value={"ru": {"welcome": "Из БД"}}),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(),
|
||||||
|
) as bulk_mock,
|
||||||
|
):
|
||||||
|
count = await locale_override_service.load_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
bulk_mock.assert_not_awaited()
|
||||||
|
return count
|
||||||
|
|
||||||
|
count = asyncio.run(run())
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Из БД"
|
||||||
|
assert overrides_path.read_text(encoding="utf-8") == "{not-json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_locale_overrides_persists_applies_and_writes_file(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
db_state = {}
|
||||||
|
|
||||||
|
async def get_all(_session):
|
||||||
|
return db_state
|
||||||
|
|
||||||
|
async def bulk_apply(_session, *, updates, updated_by):
|
||||||
|
assert updated_by == 7
|
||||||
|
for (lang, key), (set_flag, value) in updates.items():
|
||||||
|
if set_flag:
|
||||||
|
db_state.setdefault(lang, {})[key] = value
|
||||||
|
else:
|
||||||
|
db_state.get(lang, {}).pop(key, None)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(side_effect=get_all),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(side_effect=bulk_apply),
|
||||||
|
) as bulk_mock,
|
||||||
|
):
|
||||||
|
result = await locale_override_service.update_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
updates={"ru": {"welcome": "Здравствуйте"}},
|
||||||
|
deletes=[],
|
||||||
|
actor_id=7,
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
bulk_mock.assert_awaited_once()
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["file_written"] is True
|
||||||
|
assert db_state == {"ru": {"welcome": "Здравствуйте"}}
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Здравствуйте"
|
||||||
|
assert json.loads(overrides_path.read_text(encoding="utf-8")) == {
|
||||||
|
"ru": {"welcome": "Здравствуйте"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_locale_overrides_accepts_extra_language(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет", "plain": "База"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello", "plain": "Base"})
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
db_state = {}
|
||||||
|
|
||||||
|
async def get_all(_session):
|
||||||
|
return db_state
|
||||||
|
|
||||||
|
async def bulk_apply(_session, *, updates, updated_by):
|
||||||
|
assert updated_by == 7
|
||||||
|
for (lang, key), (set_flag, value) in updates.items():
|
||||||
|
if set_flag:
|
||||||
|
db_state.setdefault(lang, {})[key] = value
|
||||||
|
else:
|
||||||
|
db_state.get(lang, {}).pop(key, None)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(side_effect=get_all),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(side_effect=bulk_apply),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return await locale_override_service.update_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
updates={"uk": {"welcome": "Вітаю"}},
|
||||||
|
deletes=[],
|
||||||
|
actor_id=7,
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert db_state == {"uk": {"welcome": "Вітаю"}}
|
||||||
|
assert i18n.gettext("uk", "welcome") == "Вітаю"
|
||||||
|
assert i18n.gettext("uk", "plain") == "База"
|
||||||
|
assert json.loads(overrides_path.read_text(encoding="utf-8")) == {
|
||||||
|
"uk": {"welcome": "Вітаю"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_locale_overrides_fails_when_active_file_cannot_be_written(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
overrides_path.write_text(
|
||||||
|
json.dumps({"ru": {"welcome": "Из файла"}}, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(return_value={"ru": {"welcome": "Из БД"}}),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(),
|
||||||
|
) as bulk_mock,
|
||||||
|
patch.object(
|
||||||
|
locale_override_service,
|
||||||
|
"write_locale_overrides_file",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await locale_override_service.update_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
updates={"ru": {"welcome": "Новое"}},
|
||||||
|
deletes=[],
|
||||||
|
actor_id=7,
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
bulk_mock.assert_not_awaited()
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert result == {"ok": False, "errors": {"_file": "write_failed"}}
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Привет"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_locale_overrides_allows_db_only_when_file_is_missing_and_unwritable(tmp_path):
|
||||||
|
locales = tmp_path / "locales"
|
||||||
|
locales.mkdir()
|
||||||
|
_write_locale(locales, "ru", {"welcome": "Привет"})
|
||||||
|
_write_locale(locales, "en", {"welcome": "Hello"})
|
||||||
|
i18n = JsonI18n(str(locales), default="ru")
|
||||||
|
overrides_path = tmp_path / "locales-overrides.json"
|
||||||
|
db_state = {}
|
||||||
|
|
||||||
|
async def get_all(_session):
|
||||||
|
return db_state
|
||||||
|
|
||||||
|
async def bulk_apply(_session, *, updates, updated_by):
|
||||||
|
assert updated_by == 7
|
||||||
|
for (lang, key), (set_flag, value) in updates.items():
|
||||||
|
if set_flag:
|
||||||
|
db_state.setdefault(lang, {})[key] = value
|
||||||
|
else:
|
||||||
|
db_state.get(lang, {}).pop(key, None)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"get_all_overrides",
|
||||||
|
AsyncMock(side_effect=get_all),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service.locale_overrides_dal,
|
||||||
|
"bulk_apply",
|
||||||
|
AsyncMock(side_effect=bulk_apply),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
locale_override_service,
|
||||||
|
"write_locale_overrides_file",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return await locale_override_service.update_locale_overrides(
|
||||||
|
i18n,
|
||||||
|
_SessionFactory(),
|
||||||
|
updates={"ru": {"welcome": "Только БД"}},
|
||||||
|
deletes=[],
|
||||||
|
actor_id=7,
|
||||||
|
overrides_path=overrides_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["file_written"] is False
|
||||||
|
assert db_state == {"ru": {"welcome": "Только БД"}}
|
||||||
|
assert i18n.gettext("ru", "welcome") == "Только БД"
|
||||||
@@ -58,8 +58,9 @@ class _FakeUser(SimpleNamespace):
|
|||||||
class _FakeEmailService:
|
class _FakeEmailService:
|
||||||
instances: List["_FakeEmailService"] = []
|
instances: List["_FakeEmailService"] = []
|
||||||
|
|
||||||
def __init__(self, settings):
|
def __init__(self, settings, i18n=None):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self.i18n = i18n
|
||||||
self.sent: List[dict] = []
|
self.sent: List[dict] = []
|
||||||
_FakeEmailService.instances.append(self)
|
_FakeEmailService.instances.append(self)
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("DELETE", "/api/admin/ads/{campaign_id}"): "admin_ad_delete_route",
|
("DELETE", "/api/admin/ads/{campaign_id}"): "admin_ad_delete_route",
|
||||||
("GET", "/api/admin/settings"): "admin_settings_get_route",
|
("GET", "/api/admin/settings"): "admin_settings_get_route",
|
||||||
("PATCH", "/api/admin/settings"): "admin_settings_patch_route",
|
("PATCH", "/api/admin/settings"): "admin_settings_patch_route",
|
||||||
|
("GET", "/api/admin/translations"): "admin_translations_get_route",
|
||||||
|
("PATCH", "/api/admin/translations"): "admin_translations_patch_route",
|
||||||
("GET", "/api/admin/tariffs"): "admin_tariffs_get_route",
|
("GET", "/api/admin/tariffs"): "admin_tariffs_get_route",
|
||||||
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
|
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
|
||||||
("GET", "/api/admin/themes"): "admin_themes_get_route",
|
("GET", "/api/admin/themes"): "admin_themes_get_route",
|
||||||
@@ -208,6 +210,15 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(match_info.handler.__name__, "index_route")
|
self.assertEqual(match_info.handler.__name__, "index_route")
|
||||||
|
|
||||||
|
def test_admin_translations_page_route_is_registered(self):
|
||||||
|
app = web.Application()
|
||||||
|
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||||
|
|
||||||
|
request = make_mocked_request("GET", "/admin/translations", app=app)
|
||||||
|
match_info = asyncio.run(app.router.resolve(request))
|
||||||
|
|
||||||
|
self.assertEqual(match_info.handler.__name__, "index_route")
|
||||||
|
|
||||||
def test_webapp_favicon_asset_route_is_registered(self):
|
def test_webapp_favicon_asset_route_is_registered(self):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
subscription_webapp.setup_subscription_webapp_routes(app)
|
subscription_webapp.setup_subscription_webapp_routes(app)
|
||||||
|
|||||||
Reference in New Issue
Block a user