diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6b4c94e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +package-lock.json +bot/app/web/templates/ +**/*.min.js +.pytest_cache/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..d378224 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "useTabs": false +} diff --git a/bot/app/controllers/__init__.py b/bot/app/controllers/__init__.py index 139597f..8b13789 100644 --- a/bot/app/controllers/__init__.py +++ b/bot/app/controllers/__init__.py @@ -1,2 +1 @@ - diff --git a/bot/app/controllers/dispatcher_controller.py b/bot/app/controllers/dispatcher_controller.py index 1982e95..ed605b5 100644 --- a/bot/app/controllers/dispatcher_controller.py +++ b/bot/app/controllers/dispatcher_controller.py @@ -1,22 +1,23 @@ -import logging from typing import Dict from aiogram import Bot, Dispatcher -from aiogram.enums import ParseMode from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode from aiogram.fsm.storage.memory import MemoryStorage from sqlalchemy.orm import sessionmaker -from config.settings import Settings -from bot.middlewares.db_session import DBSessionMiddleware -from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n -from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware -from bot.middlewares.profile_sync import ProfileSyncMiddleware +from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware +from bot.middlewares.db_session import DBSessionMiddleware +from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance +from bot.middlewares.profile_sync import ProfileSyncMiddleware +from config.settings import Settings -def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]: +def build_dispatcher( + settings: Settings, async_session_factory: sessionmaker +) -> tuple[Dispatcher, Bot, Dict]: storage = MemoryStorage() default_props = DefaultBotProperties(parse_mode=ParseMode.HTML) bot = Bot(token=settings.BOT_TOKEN, default=default_props) @@ -32,8 +33,9 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings)) dp.update.outer_middleware(ProfileSyncMiddleware()) dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance)) - dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance)) + dp.update.outer_middleware( + ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance) + ) dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings)) return dp, bot, {"i18n_instance": i18n_instance} - diff --git a/bot/app/factories/__init__.py b/bot/app/factories/__init__.py index 139597f..8b13789 100644 --- a/bot/app/factories/__init__.py +++ b/bot/app/factories/__init__.py @@ -1,2 +1 @@ - diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 620313a..ed9363e 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -1,20 +1,20 @@ from aiogram import Bot from sqlalchemy.orm import sessionmaker -from config.settings import Settings from bot.middlewares.i18n import JsonI18n -from bot.services.yookassa_service import YooKassaService -from bot.services.panel_api_service import PanelApiService -from bot.services.subscription_service import SubscriptionService -from bot.services.referral_service import ReferralService -from bot.services.promo_code_service import PromoCodeService -from bot.services.stars_service import StarsService from bot.services.crypto_pay_service import CryptoPayService -from bot.services.panel_webhook_service import PanelWebhookService from bot.services.freekassa_service import FreeKassaService -from bot.services.platega_service import PlategaService -from bot.services.severpay_service import SeverPayService from bot.services.lknpd_service import LknpdService +from bot.services.panel_api_service import PanelApiService +from bot.services.panel_webhook_service import PanelWebhookService +from bot.services.platega_service import PlategaService +from bot.services.promo_code_service import PromoCodeService +from bot.services.referral_service import ReferralService +from bot.services.severpay_service import SeverPayService +from bot.services.stars_service import StarsService +from bot.services.subscription_service import SubscriptionService +from bot.services.yookassa_service import YooKassaService +from config.settings import Settings def build_core_services( @@ -65,7 +65,9 @@ def build_core_services( referral_service=referral_service, default_return_url=bot_username_for_default_return, ) - panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service) + panel_webhook_service = PanelWebhookService( + bot, settings, i18n, async_session_factory, panel_service + ) yookassa_service = YooKassaService( shop_id=settings.YOOKASSA_SHOP_ID, secret_key=settings.YOOKASSA_SECRET_KEY, diff --git a/bot/app/web/__init__.py b/bot/app/web/__init__.py index 139597f..8b13789 100644 --- a/bot/app/web/__init__.py +++ b/bot/app/web/__init__.py @@ -1,2 +1 @@ - diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index 747a03f..e52dfa1 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -15,28 +15,25 @@ import logging from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlsplit, urlunsplit -from aiogram import Bot from aiohttp import web from pydantic import ValidationError -from sqlalchemy import and_, case, cast, Float, func as sa_func, or_, select +from sqlalchemy import Float, and_, case, cast, or_, select +from sqlalchemy import func as sa_func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from bot.app.web.admin_settings_manifest import ( - SETTINGS_MANIFEST, - coerce_value, - get_field_by_key, manifest_payload, ) +from bot.services.referral_service import ReferralService from bot.services.settings_override_service import ( current_value, update_overrides, ) -from bot.services.referral_service import ReferralService from bot.utils import MessageContent, send_message_via_queue from bot.utils.message_queue import get_queue_manager -from urllib.parse import parse_qsl, urlsplit, urlunsplit from config.settings import Settings from config.tariffs_config import TariffsConfig from db.dal import ( @@ -312,7 +309,9 @@ def _serialize_promo(promo: PromoCode) -> Dict[str, Any]: "is_active": bool(promo.is_active), "valid_until": promo.valid_until.isoformat() if promo.valid_until else None, "created_at": promo.created_at.isoformat() if promo.created_at else None, - "created_by_admin_id": int(promo.created_by_admin_id) if promo.created_by_admin_id else None, + "created_by_admin_id": int(promo.created_by_admin_id) + if promo.created_by_admin_id + else None, } @@ -475,7 +474,9 @@ async def admin_stats_route(request: web.Request) -> web.Response: "financial": financial_stats, "panel_sync": { "status": sync_status.status if sync_status else "never_run", - "last_sync_time": sync_status.last_sync_time.isoformat() if sync_status and sync_status.last_sync_time else None, + "last_sync_time": sync_status.last_sync_time.isoformat() + if sync_status and sync_status.last_sync_time + else None, "details": sync_status.details if sync_status else None, "users_processed": sync_status.users_processed_from_panel if sync_status else 0, "subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0, @@ -567,7 +568,9 @@ async def admin_users_list_route(request: web.Request) -> web.Response: statuses = await _bulk_user_statuses(session, [u.user_id for u in users]) cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users]) - active_subs = await _bulk_active_subscriptions_for_users(session, [u.user_id for u in users]) + active_subs = await _bulk_active_subscriptions_for_users( + session, [u.user_id for u in users] + ) serialized = [] for user in users: @@ -607,7 +610,12 @@ async def _bulk_user_statuses( if not user_ids: return {} stmt = ( - select(Subscription.user_id, Subscription.status_from_panel, Subscription.is_active, Subscription.end_date) + select( + Subscription.user_id, + Subscription.status_from_panel, + Subscription.is_active, + Subscription.end_date, + ) .where(Subscription.user_id.in_(user_ids)) .order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast()) ) @@ -630,9 +638,7 @@ async def _bulk_user_statuses( return out -async def _bulk_user_avatar_keys( - session: AsyncSession, user_ids: List[int] -) -> Dict[int, str]: +async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]: """Return ``{user_id: cache_key}`` for users with a cached Telegram avatar. The cache key is the row's ``updated_at`` timestamp — used as a @@ -646,10 +652,7 @@ async def _bulk_user_avatar_keys( UserTelegramAvatar.user_id.in_(user_ids) ) rows = (await session.execute(stmt)).all() - return { - int(uid): (updated_at.isoformat() if updated_at else "") - for uid, updated_at in rows - } + return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows} async def admin_user_avatar_route(request: web.Request) -> web.Response: @@ -916,11 +919,17 @@ def _user_panel_status_condition(panel_status: str): return None normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, "")) - blank_status = or_(Subscription.status_from_panel.is_(None), Subscription.status_from_panel == "") + blank_status = or_( + Subscription.status_from_panel.is_(None), Subscription.status_from_panel == "" + ) if status == "active": - status_cond = or_(normalized_status == "active", blank_status & Subscription.is_active.is_(True)) + status_cond = or_( + normalized_status == "active", blank_status & Subscription.is_active.is_(True) + ) elif status == "expired": - status_cond = or_(normalized_status == "expired", blank_status & Subscription.is_active.is_(False)) + status_cond = or_( + normalized_status == "expired", blank_status & Subscription.is_active.is_(False) + ) else: status_cond = normalized_status == "limited" @@ -1021,7 +1030,9 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: except Exception as exc_panel: # pragma: no cover logger.warning( "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", - target_id, panel_uuid, exc_panel, + target_id, + panel_uuid, + exc_panel, ) serialized_user = _serialize_user(user) @@ -1243,7 +1254,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons return _error(400, "invalid_bonus", "bonus_bytes must be an integer") else: try: - bonus_bytes = int(round(float(bonus_gb_raw) * (1024 ** 3))) + bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3))) except (TypeError, ValueError): return _error(400, "invalid_bonus", "bonus_gb must be a number") @@ -1266,9 +1277,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons { "user_id": actor_id, "event_type": "admin_premium_override_webapp", - "content": ( - f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}" - ), + "content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"), "is_admin_event": True, "target_user_id": target_id, }, @@ -1297,7 +1306,7 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer") else: try: - regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024 ** 3))) + regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3))) except (TypeError, ValueError): return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number") @@ -1360,10 +1369,10 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: try: if bytes_raw is not None: grant_bytes = int(bytes_raw) - gb_value = grant_bytes / (1024 ** 3) + gb_value = grant_bytes / (1024**3) else: gb_value = float(gb_raw) - grant_bytes = int(round(gb_value * (1024 ** 3))) + grant_bytes = int(round(gb_value * (1024**3))) except (TypeError, ValueError): return _error(400, "invalid_amount", "amount must be a positive number") if gb_value <= 0 or grant_bytes <= 0: @@ -1380,9 +1389,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: return _error(404, "no_active_subscription") if kind == "regular": - result = await subscription_service.admin_grant_topup( - session, target_id, gb_value - ) + result = await subscription_service.admin_grant_topup(session, target_id, gb_value) else: result = await subscription_service.admin_grant_premium_topup( session, target_id, gb_value @@ -1407,9 +1414,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: ) await session.commit() - refreshed = await subscription_dal.get_active_subscription_by_user_id( - session, target_id - ) + refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id) return _ok( { @@ -1462,15 +1467,11 @@ async def admin_user_extend_route(request: web.Request) -> web.Response: ) await session.commit() - refreshed = await subscription_dal.get_active_subscription_by_user_id( - session, target_id - ) + refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id) return _ok( { - "subscription": _serialize_subscription(refreshed) - if refreshed - else None, + "subscription": _serialize_subscription(refreshed) if refreshed else None, } ) @@ -1699,7 +1700,7 @@ async def admin_logs_route(request: web.Request) -> web.Response: return _ok( { - "logs": [_serialize_log(l) for l in entries], + "logs": [_serialize_log(entry) for entry in entries], "page": page, "page_size": page_size, "total": int(total or 0), @@ -2015,8 +2016,11 @@ async def admin_panel_internal_squads_route(request: web.Request) -> web.Respons { "uuid": str(uuid), "name": squad.get("name") or squad.get("title") or str(uuid), - "members_count": squad.get("membersCount") or squad.get("usersCount") or squad.get("members_count"), - "active_inbounds_count": squad.get("activeInboundsCount") or squad.get("active_inbounds_count"), + "members_count": squad.get("membersCount") + or squad.get("usersCount") + or squad.get("members_count"), + "active_inbounds_count": squad.get("activeInboundsCount") + or squad.get("active_inbounds_count"), } ) return _ok({"squads": items}) @@ -2035,7 +2039,9 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route) - router.add_post("/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route + ) router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) router.add_post( diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py index 71bc58c..d21f637 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/bot/app/web/admin_settings_manifest.py @@ -9,7 +9,7 @@ the API, even by an admin. from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, List, Optional, Tuple @dataclass(frozen=True) @@ -32,20 +32,53 @@ class SettingField: SETTINGS_MANIFEST: List[SettingField] = [ # ─── General ──────────────────────────────────────────────────── - SettingField("DEFAULT_LANGUAGE", "string", "general", "Язык по умолчанию", "Используется для приветственных сообщений и публичных страниц."), - SettingField("DEFAULT_CURRENCY_SYMBOL", "string", "general", "Валюта", "Например, RUB, USD, EUR.", placeholder="RUB"), - SettingField("SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."), + SettingField( + "DEFAULT_LANGUAGE", + "string", + "general", + "Язык по умолчанию", + "Используется для приветственных сообщений и публичных страниц.", + ), + SettingField( + "DEFAULT_CURRENCY_SYMBOL", + "string", + "general", + "Валюта", + "Например, RUB, USD, EUR.", + placeholder="RUB", + ), + SettingField( + "SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью." + ), SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"), SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"), SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"), SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"), SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"), - SettingField("START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""), - SettingField("REQUIRED_CHANNEL_ID", "int", "general", "ID обязательного канала", "Telegram ID канала, в котором нужно состоять."), - SettingField("REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", "Имя пользователя или invite-link."), + SettingField( + "START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder="" + ), + SettingField( + "REQUIRED_CHANNEL_ID", + "int", + "general", + "ID обязательного канала", + "Telegram ID канала, в котором нужно состоять.", + ), + SettingField( + "REQUIRED_CHANNEL_LINK", + "string", + "general", + "Ссылка на канал", + "Имя пользователя или invite-link.", + ), # ─── Web app appearance ──────────────────────────────────────── - SettingField("WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"), - SettingField("WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"), + SettingField( + "WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка" + ), + SettingField( + "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" + ), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), @@ -62,111 +95,301 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"), SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"), SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"), - SettingField("TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"), + SettingField( + "TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)" + ), SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"), - SettingField("PAYMENT_METHODS_ORDER", "string", "pricing", "Порядок методов оплаты", "Через запятую, например: severpay,freekassa,yookassa"), + SettingField( + "PAYMENT_METHODS_ORDER", + "string", + "pricing", + "Порядок методов оплаты", + "Через запятую, например: severpay,freekassa,yookassa", + ), # ─── Payment providers (toggles) ─────────────────────────────── # Common SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"), - SettingField("PAYMENT_METHODS_ORDER", "string", "payments", "Порядок методов оплаты", - "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay", - subsection="Общие"), - + SettingField( + "PAYMENT_METHODS_ORDER", + "string", + "payments", + "Порядок методов оплаты", + "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay", + subsection="Общие", + ), # YooKassa SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"), SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"), - SettingField("YOOKASSA_SECRET_KEY", "string", "payments", "Secret key", subsection="YooKassa", secret=True), + SettingField( + "YOOKASSA_SECRET_KEY", + "string", + "payments", + "Secret key", + subsection="YooKassa", + secret=True, + ), SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"), - SettingField("YOOKASSA_DEFAULT_RECEIPT_EMAIL", "string", "payments", "Email для чека по умолчанию", - subsection="YooKassa"), - SettingField("YOOKASSA_VAT_CODE", "int", "payments", "VAT code", "1..6 в зависимости от системы налогообложения", - subsection="YooKassa", min=1, max=6), - SettingField("YOOKASSA_AUTOPAYMENTS_ENABLED", "bool", "payments", "Автоплатежи (recurring)", - subsection="YooKassa"), - SettingField("YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", "bool", "payments", - "Принудительная привязка карты", subsection="YooKassa"), - + SettingField( + "YOOKASSA_DEFAULT_RECEIPT_EMAIL", + "string", + "payments", + "Email для чека по умолчанию", + subsection="YooKassa", + ), + SettingField( + "YOOKASSA_VAT_CODE", + "int", + "payments", + "VAT code", + "1..6 в зависимости от системы налогообложения", + subsection="YooKassa", + min=1, + max=6, + ), + SettingField( + "YOOKASSA_AUTOPAYMENTS_ENABLED", + "bool", + "payments", + "Автоплатежи (recurring)", + subsection="YooKassa", + ), + SettingField( + "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", + "bool", + "payments", + "Принудительная привязка карты", + subsection="YooKassa", + ), # FreeKassa SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"), - SettingField("FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"), - SettingField("FREEKASSA_FIRST_SECRET", "string", "payments", "First secret", - subsection="FreeKassa", secret=True), - SettingField("FREEKASSA_SECOND_SECRET", "string", "payments", "Second secret", - "Используется для проверки подписи входящих уведомлений", - subsection="FreeKassa", secret=True), - SettingField("FREEKASSA_API_KEY", "string", "payments", "API key", - subsection="FreeKassa", secret=True), - SettingField("FREEKASSA_PAYMENT_URL", "url", "payments", "Payment URL", - placeholder="https://pay.freekassa.ru/", subsection="FreeKassa"), - SettingField("FREEKASSA_PAYMENT_METHOD_ID", "int", "payments", "Метод оплаты по умолчанию", - subsection="FreeKassa"), - SettingField("FREEKASSA_PAYMENT_IP", "string", "payments", "IP сервера", - "Передаётся в подпись запроса при создании платежа", - subsection="FreeKassa"), - SettingField("FREEKASSA_TRUSTED_IPS", "string", "payments", "Доверенные IP", - "Через запятую — IP-адреса, с которых принимаются нотификации", - subsection="FreeKassa"), - + SettingField( + "FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa" + ), + SettingField( + "FREEKASSA_FIRST_SECRET", + "string", + "payments", + "First secret", + subsection="FreeKassa", + secret=True, + ), + SettingField( + "FREEKASSA_SECOND_SECRET", + "string", + "payments", + "Second secret", + "Используется для проверки подписи входящих уведомлений", + subsection="FreeKassa", + secret=True, + ), + SettingField( + "FREEKASSA_API_KEY", "string", "payments", "API key", subsection="FreeKassa", secret=True + ), + SettingField( + "FREEKASSA_PAYMENT_URL", + "url", + "payments", + "Payment URL", + placeholder="https://pay.freekassa.ru/", + subsection="FreeKassa", + ), + SettingField( + "FREEKASSA_PAYMENT_METHOD_ID", + "int", + "payments", + "Метод оплаты по умолчанию", + subsection="FreeKassa", + ), + SettingField( + "FREEKASSA_PAYMENT_IP", + "string", + "payments", + "IP сервера", + "Передаётся в подпись запроса при создании платежа", + subsection="FreeKassa", + ), + SettingField( + "FREEKASSA_TRUSTED_IPS", + "string", + "payments", + "Доверенные IP", + "Через запятую — IP-адреса, с которых принимаются нотификации", + subsection="FreeKassa", + ), # Platega SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"), - SettingField("PLATEGA_BASE_URL", "url", "payments", "Base URL", - placeholder="https://app.platega.io", subsection="Platega"), + SettingField( + "PLATEGA_BASE_URL", + "url", + "payments", + "Base URL", + placeholder="https://app.platega.io", + subsection="Platega", + ), SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"), - SettingField("PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True), - SettingField("PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"), + SettingField( + "PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True + ), + SettingField( + "PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega" + ), SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"), SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"), - SettingField("PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"), - SettingField("PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"), + SettingField( + "PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega" + ), + SettingField( + "PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega" + ), SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"), SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"), - # SeverPay SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"), SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"), - SettingField("SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True), - SettingField("SEVERPAY_BASE_URL", "url", "payments", "Base URL", - placeholder="https://severpay.io/api/merchant", subsection="SeverPay"), + SettingField( + "SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True + ), + SettingField( + "SEVERPAY_BASE_URL", + "url", + "payments", + "Base URL", + placeholder="https://severpay.io/api/merchant", + subsection="SeverPay", + ), SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"), - SettingField("SEVERPAY_LIFETIME_MINUTES", "int", "payments", "Срок жизни ссылки (мин)", - "30..4320; пусто — значение провайдера", subsection="SeverPay", min=30, max=4320), - + SettingField( + "SEVERPAY_LIFETIME_MINUTES", + "int", + "payments", + "Срок жизни ссылки (мин)", + "30..4320; пусто — значение провайдера", + subsection="SeverPay", + min=30, + max=4320, + ), # CryptoPay SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"), - SettingField("CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True), - SettingField("CRYPTOPAY_NETWORK", "string", "payments", "Network", - "mainnet или testnet", subsection="CryptoPay"), - SettingField("CRYPTOPAY_CURRENCY_TYPE", "string", "payments", "Currency type", - "fiat или crypto", subsection="CryptoPay"), - SettingField("CRYPTOPAY_ASSET", "string", "payments", "Asset", - placeholder="RUB", subsection="CryptoPay"), + SettingField( + "CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True + ), + SettingField( + "CRYPTOPAY_NETWORK", + "string", + "payments", + "Network", + "mainnet или testnet", + subsection="CryptoPay", + ), + SettingField( + "CRYPTOPAY_CURRENCY_TYPE", + "string", + "payments", + "Currency type", + "fiat или crypto", + subsection="CryptoPay", + ), + SettingField( + "CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay" + ), # ─── Trial ───────────────────────────────────────────────────── SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"), SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0), SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0), SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"), # ─── Referral program ────────────────────────────────────────── - SettingField("REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"), - SettingField("REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0), + SettingField( + "REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого" + ), + SettingField( + "REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0 + ), SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), - SettingField("REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", "referral", "Бонус приглашающему: 1 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", "referral", "Бонус приглашающему: 3 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", "referral", "Бонус приглашающему: 6 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", "referral", "Бонус приглашающему: 12 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", "referral", "Бонус приглашённому: 1 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", "referral", "Бонус приглашённому: 3 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", "referral", "Бонус приглашённому: 6 мес.", min=0), - SettingField("REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", "referral", "Бонус приглашённому: 12 мес.", min=0), + SettingField( + "REFERRAL_BONUS_DAYS_INVITER_1_MONTH", + "int", + "referral", + "Бонус приглашающему: 1 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", + "int", + "referral", + "Бонус приглашающему: 3 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", + "int", + "referral", + "Бонус приглашающему: 6 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", + "int", + "referral", + "Бонус приглашающему: 12 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", + "int", + "referral", + "Бонус приглашённому: 1 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", + "int", + "referral", + "Бонус приглашённому: 3 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", + "int", + "referral", + "Бонус приглашённому: 6 мес.", + min=0, + ), + SettingField( + "REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", + "int", + "referral", + "Бонус приглашённому: 12 мес.", + min=0, + ), # ─── Notifications ───────────────────────────────────────────── - SettingField("SUBSCRIPTION_NOTIFICATIONS_ENABLED", "bool", "notifications", "Включены уведомления о подписке"), - SettingField("SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"), - SettingField("SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"), - SettingField("SUBSCRIPTION_NOTIFY_DAYS_BEFORE", "int", "notifications", "За сколько дней предупреждать", min=0), + SettingField( + "SUBSCRIPTION_NOTIFICATIONS_ENABLED", + "bool", + "notifications", + "Включены уведомления о подписке", + ), + SettingField( + "SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении" + ), + SettingField( + "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения" + ), + SettingField( + "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", + "int", + "notifications", + "За сколько дней предупреждать", + min=0, + ), SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"), SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"), - SettingField("LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"), + SettingField( + "LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов" + ), SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"), - SettingField("LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"), + SettingField( + "LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия" + ), SettingField( "LOG_ADMIN_ACTIONS", "bool", @@ -176,12 +399,20 @@ SETTINGS_MANIFEST: List[SettingField] = [ i18n_label_key="settings_field_log_admin_actions_label", i18n_description_key="settings_field_log_admin_actions_description", ), - SettingField("LOG_LEVEL", "string", "notifications", "Глобальный уровень логов", "DEBUG / INFO / WARNING / ERROR"), + SettingField( + "LOG_LEVEL", + "string", + "notifications", + "Глобальный уровень логов", + "DEBUG / INFO / WARNING / ERROR", + ), SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"), SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"), # ─── Devices ─────────────────────────────────────────────────── SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"), - SettingField("USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0), + SettingField( + "USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0 + ), SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"), SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"), ] @@ -267,9 +498,8 @@ def manifest_payload() -> List[dict]: "label": field.label, "description": field.description, "i18n_label_key": field.i18n_label_key or auto_label_i18n_key, - "i18n_description_key": field.i18n_description_key or ( - auto_description_i18n_key if field.description else None - ), + "i18n_description_key": field.i18n_description_key + or (auto_description_i18n_key if field.description else None), "placeholder": field.placeholder, "optional": field.optional, "secret": field.secret, diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 1e81739..85cc505 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -135,15 +135,32 @@ clearToken(); showLogin(); }, - mockApi: MOCK - ? (path, options, context) => runMockApi(path, options, context) - : null, + mockApi: MOCK ? (path, options, context) => runMockApi(path, options, context) : null, getMockContext: () => ({ currentLang, normalizeLangCode, clone: structuredCloneSafe }), }); - const billing = createBillingActions({ api: (path, options) => apiClient.api(path, options), t: (...args) => t(...args) }); + const billing = createBillingActions({ + api: (path, options) => apiClient.api(path, options), + t: (...args) => t(...args), + }); - const authStore = createAuthStore({ publicApi, setToken, loadData, telegramSdk, getTg: () => tg, t, currentLang: () => currentLang, clearManualLogoutFlag }); - const billingStore = createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg }); + const authStore = createAuthStore({ + publicApi, + setToken, + loadData, + telegramSdk, + getTg: () => tg, + t, + currentLang: () => currentLang, + clearManualLogoutFlag, + }); + const billingStore = createBillingStore({ + billing, + loadData, + t, + showToast, + openExternalLink, + tg, + }); const devicesStore = createDevicesStore({ api, t, showToast }); const accountStore = createAccountStore({ api, @@ -171,7 +188,18 @@ setContext("devicesStore", devicesStore); setContext("accountStore", accountStore); - $: ({ authStatus, authIsError, authBusy, telegramLoginBusy, loginEmailFieldError, loginEmailTooltipOpen, authResendCooldown, email, pendingEmail, emailCode } = $authStore); + $: ({ + authStatus, + authIsError, + authBusy, + telegramLoginBusy, + loginEmailFieldError, + loginEmailTooltipOpen, + authResendCooldown, + email, + pendingEmail, + emailCode, + } = $authStore); $: ({ paymentModalOpen, selectedTariffKey, @@ -187,8 +215,29 @@ tariffActionBusy, payBusy, } = $billingStore); - $: ({ devicesData, devicesLoaded, devicesBusy, devicesStatus, devicesIsError, deviceConfirmOpen, deviceToDisconnect, deviceDisconnectBusy } = $devicesStore); - $: ({ linkEmailOpen, linkEmailBusy, linkTelegramBusy, linkEmailValue, linkEmailPending, linkEmailCode, linkEmailStatus, linkEmailIsError, linkEmailFieldError, linkEmailResendCooldown, languageBusy } = $accountStore); + $: ({ + devicesData, + devicesLoaded, + devicesBusy, + devicesStatus, + devicesIsError, + deviceConfirmOpen, + deviceToDisconnect, + deviceDisconnectBusy, + } = $devicesStore); + $: ({ + linkEmailOpen, + linkEmailBusy, + linkTelegramBusy, + linkEmailValue, + linkEmailPending, + linkEmailCode, + linkEmailStatus, + linkEmailIsError, + linkEmailFieldError, + linkEmailResendCooldown, + languageBusy, + } = $accountStore); $: brandTitle = CFG.title || "/minishop"; $: brandEmoji = CFG.logoEmoji || "🫥"; @@ -202,42 +251,51 @@ $: singleTariffMode = tariffMode && tariffCatalog.length === 1; $: hasMultipleTariffs = tariffCatalog.length > 1; $: selectedTariff = tariffCatalog.find((tariff) => tariff.key === selectedTariffKey) || null; - $: selectedTariffPlans = tariffMode ? (selectedTariffKey ? plans.filter((plan) => plan?.tariff_key === selectedTariffKey) : []) : plans; + $: selectedTariffPlans = tariffMode + ? selectedTariffKey + ? plans.filter((plan) => plan?.tariff_key === selectedTariffKey) + : [] + : plans; $: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: subscription = data?.subscription || DEV_MOCK.data.subscription; - $: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key); + $: hasActiveTariffSubscription = Boolean( + tariffMode && subscription?.active && subscription?.tariff_key + ); $: canChangeTariff = Boolean(hasActiveTariffSubscription && hasMultipleTariffs); $: currentTariffName = activeTariffName(subscription, plans); $: canOpenRegularTopupModal = Boolean( hasActiveTariffSubscription && - (subscription?.can_topup_regular_traffic ?? subscription?.can_topup_traffic) && - Number(subscription?.traffic_limit_bytes || 0) > 0, + (subscription?.can_topup_regular_traffic ?? subscription?.can_topup_traffic) && + Number(subscription?.traffic_limit_bytes || 0) > 0 ); $: canOpenPremiumTopupModal = Boolean( hasActiveTariffSubscription && - (subscription?.can_topup_premium_traffic ?? subscription?.can_topup_traffic) && - Number(subscription?.premium_limit_bytes || 0) > 0, + (subscription?.can_topup_premium_traffic ?? subscription?.can_topup_traffic) && + Number(subscription?.premium_limit_bytes || 0) > 0 ); $: activeTariffCatalogEntry = - tariffCatalog.find((entry) => entry.key === String(subscription?.tariff_key || "").trim()) || null; + tariffCatalog.find((entry) => entry.key === String(subscription?.tariff_key || "").trim()) || + null; $: subscriptionIsTrafficTariff = Boolean( - String(subscription?.billing_model || activeTariffCatalogEntry?.billing_model || "") - .toLowerCase() === "traffic", + String( + subscription?.billing_model || activeTariffCatalogEntry?.billing_model || "" + ).toLowerCase() === "traffic" ); $: regularTrafficTopupUnlocked = Boolean( - canOpenRegularTopupModal && trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT, + canOpenRegularTopupModal && trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT ); $: premiumTrafficTopupUnlocked = Boolean( - canOpenPremiumTopupModal && premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT, + canOpenPremiumTopupModal && premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT ); /** Progress-bar card opens top-up immediately on traffic-only tariffs; period tariffs still need 80% usage */ $: regularTrafficTopupBarClickable = Boolean( canOpenRegularTopupModal && - (subscriptionIsTrafficTariff || trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT), + (subscriptionIsTrafficTariff || trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) ); $: premiumTrafficTopupBarClickable = Boolean( canOpenPremiumTopupModal && - (subscriptionIsTrafficTariff || premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT), + (subscriptionIsTrafficTariff || + premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) ); $: user = data?.user || {}; $: isAdmin = Boolean(user?.is_admin); @@ -252,7 +310,8 @@ label: LANGUAGE_LABELS[code] || code.toUpperCase(), flag: LANGUAGE_FLAGS[code] || "🏳️", })); - $: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; + $: currentLanguageOption = + languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; $: userLanguage = languageName(currentLang); $: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked"); $: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email; @@ -271,8 +330,9 @@ $: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); $: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData); $: telegramLoginUnavailable = - (!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"); - $: telegramLoginChecking = telegramLoginBusy || (authBusy && authStatus === t("wa_auth_checking_telegram")); + !telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"; + $: telegramLoginChecking = + telegramLoginBusy || (authBusy && authStatus === t("wa_auth_checking_telegram")); $: telegramLoginLabel = telegramLoginUnavailable ? t("wa_login_telegram_unavailable_button") : telegramLoginChecking @@ -280,12 +340,19 @@ : t("wa_login_telegram_button"); $: telegramLoginUnavailableMessage = telegramLoginUnavailable && telegramSdkStatus === "unavailable" - ? t("wa_auth_telegram_unavailable") - : telegramLoginUnavailable - ? t("wa_auth_telegram_not_configured") - : ""; + ? t("wa_auth_telegram_unavailable") + : telegramLoginUnavailable + ? t("wa_auth_telegram_not_configured") + : ""; $: applyFavicon(CFG.logoUrl, brandEmoji); - $: syncBodyScrollLock(paymentModalOpen || changeModalOpen || changeConfirmOpen || topupModalOpen || deviceTopupModalOpen || linkEmailOpen); + $: syncBodyScrollLock( + paymentModalOpen || + changeModalOpen || + changeConfirmOpen || + topupModalOpen || + deviceTopupModalOpen || + linkEmailOpen + ); $: if (!tariffMode && !$billingStore.selectedPlan && plans.length) { billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] })); } @@ -298,7 +365,11 @@ paymentStep: s.paymentStep === "tariff" ? "checkout" : s.paymentStep, })); } - $: if (tariffMode && selectedTariffKey && !tariffCatalog.some((tariff) => tariff.key === selectedTariffKey)) { + $: if ( + tariffMode && + selectedTariffKey && + !tariffCatalog.some((tariff) => tariff.key === selectedTariffKey) + ) { billingStore.update((s) => ({ ...s, selectedTariffKey: "", @@ -306,7 +377,11 @@ paymentStep: singleTariffMode ? "checkout" : "tariff", })); } - $: if (tariffMode && selectedTariffKey && (!selectedPlan || selectedPlan.tariff_key !== selectedTariffKey)) { + $: if ( + tariffMode && + selectedTariffKey && + (!selectedPlan || selectedPlan.tariff_key !== selectedTariffKey) + ) { billingStore.update((s) => ({ ...s, selectedPlan: selectedTariffPlans[0] || null })); } $: if (!$billingStore.selectedMethod && methods.length) { @@ -357,7 +432,6 @@ }; }); - function syncBodyScrollLock(locked) { if (typeof document === "undefined") return; if (locked && !scrollLockApplied) { @@ -467,7 +541,8 @@ finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source), setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError), t, - getInitDataForBoot: () => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(), + getInitDataForBoot: () => + telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(), getToken: () => token, getCsrfToken: () => csrfToken, }); @@ -494,7 +569,10 @@ paymentStep: "tariff", selectedMethod: payload.payment_methods?.[0]?.id || "", })); - let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname); + let section = + MOCK && query.get("screen") + ? normalizeSection(query.get("screen")) + : sectionFromPath(window.location.pathname); if (section === "admin" && !payload.user?.is_admin) section = "settings"; if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; activeTab = section === "admin" ? "settings" : section; @@ -503,7 +581,7 @@ syncSectionPath( section, true, - section === "admin" ? adminSectionFromPath(window.location.pathname) : null, + section === "admin" ? adminSectionFromPath(window.location.pathname) : null ); if (section === "devices" && payload.settings?.my_devices_enabled) { await devicesStore.loadDevices(true); @@ -520,9 +598,9 @@ const tariffModeLocal = plansList.some((plan) => plan?.tariff_key); const hasTariffSub = Boolean( tariffModeLocal && - sub?.active && - sub?.tariff_key && - tariffCatalogLocal.some((t) => t.key === sub.tariff_key), + sub?.active && + sub?.tariff_key && + tariffCatalogLocal.some((t) => t.key === sub.tariff_key) ); const canRegular = hasTariffSub && @@ -707,7 +785,14 @@ } function openPaymentModal() { - billingStore.openPaymentModal(tariffMode, singleTariffMode, tariffCatalog, subscription, plans, defaultPaymentMethod()); + billingStore.openPaymentModal( + tariffMode, + singleTariffMode, + tariffCatalog, + subscription, + plans, + defaultPaymentMethod() + ); } function openTopupModal(kind) { @@ -767,11 +852,16 @@ function handleAdminSectionChange(adminSection, adminUserId = null) { if (screen !== "admin") return; if (window.location.protocol === "file:") return; - const targetPath = adminSection === "users" && adminUserId - ? `/admin/users/${adminUserId}` - : `/admin/${adminSection}`; + const targetPath = + adminSection === "users" && adminUserId + ? `/admin/users/${adminUserId}` + : `/admin/${adminSection}`; if (window.location.pathname === targetPath) return; - window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`); + window.history.pushState( + null, + "", + `${targetPath}${window.location.search}${window.location.hash}` + ); } async function handleAdminPersistedSaved() { @@ -799,7 +889,6 @@ if (trafficMode || selectedPlan?.sale_mode === "traffic_package") return t("wa_buy_traffic"); return subscription.active ? t("wa_renew") : t("wa_pay_subscription"); } - @@ -812,250 +901,251 @@ {:else}
- {#if mode === "loading"} -
- -
{t("wa_loading")}
-
- {:else if mode === "login"} - authStore.requestEmailCode((s) => (screen = s))} - verifyEmailCode={authStore.verifyEmailCode} - openTelegramLogin={() => authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)} - {openExternalLink} - {submitEmailOnEnter} - onBackToLogin={() => (screen = "login")} - clearLoginEmailError={() => { - loginEmailFieldError = ""; - loginEmailTooltipOpen = false; - }} - /> - {:else if screen === "admin" && isAdmin} - showToast(text)} - initialSection={adminSectionFromPath(window.location.pathname)} - initialUserId={adminUserIdFromPath(window.location.pathname)} - onSectionChange={handleAdminSectionChange} - onSettingsSaved={handleAdminPersistedSaved} - onTariffsSaved={handleAdminPersistedSaved} - brandTitle={brandTitle} - logoUrl={CFG.logoUrl} - logoEmoji={brandEmoji} - appVersion={CFG.appVersion} - appRepositoryUrl={CFG.appRepositoryUrl} - currentLang={currentLang} - languageOptions={languageOptions} - languageBusy={languageBusy} - onLanguageChange={accountStore.updateAccountLanguage} - t={t} - /> - {:else} - - {#if screen === "home"} - + +
{t("wa_loading")}
+
+ {:else if mode === "login"} + - {:else if screen === "invite"} - (promoFieldError = "")} - {copyText} - {t} - /> - {:else if screen === "devices"} - - {:else if screen === "settings"} - authStore.requestEmailCode((s) => (screen = s))} + verifyEmailCode={authStore.verifyEmailCode} + openTelegramLogin={() => + authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)} + {openExternalLink} + {submitEmailOnEnter} + onBackToLogin={() => (screen = "login")} + clearLoginEmailError={() => { + loginEmailFieldError = ""; + loginEmailTooltipOpen = false; + }} + /> + {:else if screen === "admin" && isAdmin} + showToast(text)} + initialSection={adminSectionFromPath(window.location.pathname)} + initialUserId={adminUserIdFromPath(window.location.pathname)} + onSectionChange={handleAdminSectionChange} + onSettingsSaved={handleAdminPersistedSaved} + onTariffsSaved={handleAdminPersistedSaved} + {brandTitle} + logoUrl={CFG.logoUrl} + logoEmoji={brandEmoji} + appVersion={CFG.appVersion} + appRepositoryUrl={CFG.appRepositoryUrl} + {currentLang} + {languageOptions} + {languageBusy} + onLanguageChange={accountStore.updateAccountLanguage} + {t} + /> + {:else} + + {#if screen === "home"} + + {:else if screen === "invite"} + (promoFieldError = "")} + {copyText} + {t} + /> + {:else if screen === "devices"} + + {:else if screen === "settings"} + + {/if} + + + + + {/if} - - - - - {/if} - - {#if toastText} -
{toastText}
- {/if} + {#if toastText} +
{toastText}
+ {/if} {/if} {/key} diff --git a/bot/app/web/frontend/src/BrandMark.svelte b/bot/app/web/frontend/src/BrandMark.svelte index 5ce08c7..9398bd9 100644 --- a/bot/app/web/frontend/src/BrandMark.svelte +++ b/bot/app/web/frontend/src/BrandMark.svelte @@ -52,7 +52,7 @@ "brand-mark", normalizedLogoUrl && !failed && !loaded && "brand-mark-loading", normalizedLogoUrl && !failed && loaded && "brand-mark-loaded", - className, + className )} aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined} > @@ -61,7 +61,7 @@ {/if}
-

Подписка активна

до {sub.end_date_text}

+
+

Подписка активна

+

до {sub.end_date_text}

+
-
Использовано трафика{sub.traffic_used} из {sub.traffic_limit}
+
+ Использовано трафика{sub.traffic_used} из {sub.traffic_limit} +
18%
@@ -93,7 +111,12 @@ -
{logoEmoji}
{title}
+
+
+
{logoEmoji}
+ {title} +
+
{#each tariffs as tariff, index}
@@ -111,12 +134,16 @@
{#each plans as plan, index}
- {plan.title}{money(plan.price)}{money(Math.round(plan.price / plan.months))}/мес + {plan.title}{money(plan.price)}{money(Math.round(plan.price / plan.months))}/мес {#if index === 1}{/if}
{/each}
- Итого
К оплате
790 ₽
+ Итого
К оплате
790 ₽
@@ -126,12 +153,16 @@
{#each traffic as pack, index}
- {pack[0]} ГБ{money(pack[1])}{money(Math.round(pack[1] / pack[0]))}/ГБ + {pack[0]} ГБ{money(pack[1])}{money(Math.round(pack[1] / pack[0]))}/ГБ {#if index === 2}{/if}
{/each}
- Итого
К оплате
990 ₽
+ Итого
К оплате
990 ₽
@@ -139,9 +170,20 @@
-
ПодпискаБезлимитный трафикДоплата 190 ₽
-
ТрафикПакеты гигабайтДоплата не требуется
-
ПремиумМаксимальная скоростьДоплата 390 ₽
+
+ ПодпискаБезлимитный трафикДоплата 190 ₽ +
+
+ ТрафикПакеты гигабайтДоплата не требуется +
+
+ ПремиумМаксимальная скоростьДоплата 390 ₽ +
@@ -154,19 +196,35 @@ -
{logoEmoji}
{title}
+
+
+
{logoEmoji}
+ {title} +
+
Ваша реферальная ссылка
-
https://minishop.app/ref/ABCD1234
+
+ https://minishop.app/ref/ABCD1234 +
-
Ваш бонус+7 дней за каждого друга

Друг получит +3 дня к подписке.

+ +
+ Ваш бонус+7 дней за каждого друга +

Друг получит +3 дня к подписке.

+
-
{logoEmoji}
{title}
+
+
+
{logoEmoji}
+ {title} +
+
{#if previewAvatar} @@ -180,7 +238,8 @@ {previewEmail} {previewTelegramId}
-
+ +
{#each settingsRows as row}
@@ -194,11 +253,15 @@
Вход по email
-
Email
+
+
Email
+ +
или
- - - diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index f78247b..07592e4 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -120,19 +120,49 @@ $: SECTION_META = { stats: { title: at("section_stats_title", {}, "Дашборд"), - subtitle: at("section_stats_subtitle", {}, "Аудитория, доходы, панель Remnawave и последние платежи"), + subtitle: at( + "section_stats_subtitle", + {}, + "Аудитория, доходы, панель Remnawave и последние платежи" + ), + }, + users: { + title: at("section_users_title", {}, "Пользователи"), + subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами"), + }, + payments: { + title: at("section_payments_title", {}, "Платежи"), + subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт"), + }, + promos: { + title: at("section_promos_title", {}, "Промокоды"), + subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами"), + }, + ads: { + title: at("section_ads_title", {}, "Рекламные кампании"), + subtitle: at("section_ads_subtitle", {}, "UTM-источники и атрибуция"), + }, + broadcast: { + title: at("section_broadcast_title", {}, "Рассылка"), + subtitle: at("section_broadcast_subtitle", {}, "Массовая отправка сообщений в Telegram"), + }, + logs: { + title: at("section_logs_title", {}, "Логи активности"), + subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"), + }, + tariffs: { + title: at("section_tariffs_title", {}, "Тарифы"), + subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), + }, + settings: { + title: at("section_settings_title", {}, "Настройки приложения"), + subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), }, - users: { title: at("section_users_title", {}, "Пользователи"), subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами") }, - payments: { title: at("section_payments_title", {}, "Платежи"), subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт") }, - promos: { title: at("section_promos_title", {}, "Промокоды"), subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами") }, - ads: { title: at("section_ads_title", {}, "Рекламные кампании"), subtitle: at("section_ads_subtitle", {}, "UTM-источники и атрибуция") }, - broadcast: { title: at("section_broadcast_title", {}, "Рассылка"), subtitle: at("section_broadcast_subtitle", {}, "Массовая отправка сообщений в Telegram") }, - logs: { title: at("section_logs_title", {}, "Логи активности"), subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия") }, - tariffs: { title: at("section_tariffs_title", {}, "Тарифы"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты") }, - settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно") }, }; - $: VALID_SECTIONS = (NAV_GROUPS || []).flatMap((group) => (group.items || []).map((item) => item.id)); + $: VALID_SECTIONS = (NAV_GROUPS || []).flatMap((group) => + (group.items || []).map((item) => item.id) + ); const normalizeSection = (value) => ((VALID_SECTIONS || []).includes(value) ? value : "stats"); let active = normalizeSection(initialSection); @@ -176,7 +206,8 @@ $: syncBusy = $statsStore.syncBusy; $: settingsSaving = $settingsStore.settingsSaving; $: meta = SECTION_META[active] || { title: active, subtitle: "" }; - $: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; + $: currentLanguageOption = + languageOptions.find((option) => option.value === currentLang) || languageOptions[0]; const gravatarCache = createGravatarCache(() => usersStore.updateState({})); @@ -233,7 +264,10 @@ } function resolvedAvatarUrl(user) { - return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : ""); + return ( + userAvatarUrl(user) || + (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "") + ); } function panelStatusBadge(user) { @@ -245,7 +279,11 @@ case "expired": return { label: user?.panel_status_expired_at - ? at("expired_badge", { date: fmtDateShort(user.panel_status_expired_at) }, `Expired ${fmtDateShort(user.panel_status_expired_at)}`) + ? at( + "expired_badge", + { date: fmtDateShort(user.panel_status_expired_at) }, + `Expired ${fmtDateShort(user.panel_status_expired_at)}` + ) : at("status_expired", {}, "Expired"), variant: "warning", }; @@ -318,7 +356,8 @@ } return () => { if (compactMql) { - if (compactMql.removeEventListener) compactMql.removeEventListener("change", onCompactChange); + if (compactMql.removeEventListener) + compactMql.removeEventListener("change", onCompactChange); else if (compactMql.removeListener) compactMql.removeListener(onCompactChange); } if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState); @@ -326,14 +365,23 @@ }; }); - $: if (active === "users" && initialUserId && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)) { + $: if ( + active === "users" && + initialUserId && + (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId) + ) { usersStore.openUser(initialUserId, { skipPush: true }); }
{#if sidebarOpen} - + {/if} {#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
{/if} - + remnawave-minishop {appVersion || "dev+local"} @@ -420,7 +493,12 @@
-
@@ -431,7 +509,10 @@
{#if active === "stats"} - {syncBusy ? at("btn_syncing", {}, "Синхронизация...") : at("btn_sync", {}, "Синхронизировать")} + + {syncBusy + ? at("btn_syncing", {}, "Синхронизация...") + : at("btn_sync", {}, "Синхронизировать")} {/if} {#if active === "payments"} @@ -441,25 +522,41 @@ {/if} {#if active === "promos"} promosStore.setCreateOpen(true)}> - {at("btn_create", {}, "Создать")} + + {at("btn_create", {}, "Создать")} {/if} {#if active === "ads"} adsStore.setCreateOpen(true)}> - {at("btn_campaign", {}, "Кампания")} + + {at("btn_campaign", {}, "Кампания")} {/if} {#if active === "tariffs"} - {at("btn_tariff", {}, "Тариф")} + + {at("btn_tariff", {}, "Тариф")} {/if} {#if active === "settings"} {#if dirtyCount} - {at("settings_dirty_count", { count: dirtyCount }, "Изменений: " + dirtyCount)} + {at( + "settings_dirty_count", + { count: dirtyCount }, + "Изменений: " + dirtyCount + )} {/if} - settingsStore.saveSettings(onSettingsSaved)} disabled={!dirtyCount || settingsSaving}> - {settingsSaving ? at("btn_saving", {}, "Сохранение...") : at("btn_save", {}, "Сохранить")} + settingsStore.saveSettings(onSettingsSaved)} + disabled={!dirtyCount || settingsSaving} + > + + {settingsSaving + ? at("btn_saving", {}, "Сохранение...") + : at("btn_save", {}, "Сохранить")} {/if}
@@ -483,7 +580,13 @@ {/if} {#if active === "payments"} - + {/if} {#if active === "promos"} diff --git a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte index b9be62f..cdd0ab3 100644 --- a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte @@ -16,13 +16,7 @@ const adsStore = getContext("adsStore"); - $: ({ - ads, - adsTotals, - adsLoading, - adCreateOpen, - adDraft, - } = $adsStore); + $: ({ ads, adsTotals, adsLoading, adCreateOpen, adDraft } = $adsStore); $: adHeaders = [ at("id", {}, "ID"), at("ads_col_source", {}, "Источник"), @@ -41,9 +35,16 @@
{#if adsLoading} - + {:else if !ads.length} - {at("ads_empty", {}, "Кампаний нет")} + {at("ads_empty", {}, "Кампаний нет")} {:else} @@ -63,10 +64,16 @@ #{ad.id} {ad.source} - {ad.start_param} + {ad.start_param} {fmtMoney(ad.cost)} - {ad.stats?.registrations ?? 0} - {ad.stats?.conversions ?? 0} + {ad.stats?.registrations ?? 0} + {ad.stats?.conversions ?? 0} {#if ad.is_active} {at("status_active", {}, "Активна")} @@ -99,23 +106,48 @@
- adsStore.updateDraft({ source: e.target.value })} /> + adsStore.updateDraft({ source: e.target.value })} + /> - adsStore.updateDraft({ start_param: e.target.value })} /> + adsStore.updateDraft({ start_param: e.target.value })} + />
- adsStore.updateDraft({ cost: Number(e.target.value) })} /> + adsStore.updateDraft({ cost: Number(e.target.value) })} + />
- adsStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")} - + adsStore.setCreateOpen(false)} + >{at("btn_cancel", {}, "Отмена")} + {at("btn_create", {}, "Создать")}
diff --git a/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte b/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte index 7d57ee3..4541932 100644 --- a/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte @@ -7,12 +7,7 @@ export let at; const broadcastStore = getContext("broadcastStore"); - $: ({ - broadcastTarget, - broadcastText, - broadcastBusy, - broadcastResult, - } = $broadcastStore); + $: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore); const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS; @@ -36,14 +31,32 @@ {at("broadcast_label_text", {}, "Текст сообщения")} {at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")} - +
- - {broadcastBusy ? at("btn_sending", {}, "Отправка...") : at("btn_queue", {}, "Поставить в очередь")} + + + {broadcastBusy + ? at("btn_sending", {}, "Отправка...") + : at("btn_queue", {}, "Поставить в очередь")} {#if broadcastResult} - {at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed} + {at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at( + "broadcast_stat_failed", + {}, + "Неудач" + )}: {broadcastResult.failed} {/if}
diff --git a/bot/app/web/frontend/src/admin/sections/LogsSection.svelte b/bot/app/web/frontend/src/admin/sections/LogsSection.svelte index 47276c9..c6493e3 100644 --- a/bot/app/web/frontend/src/admin/sections/LogsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/LogsSection.svelte @@ -1,19 +1,19 @@ tariffsStore.updateState({ tariffEditorOpen: false })} class="admin-dialog admin-tariff-dialog" > - {at("tariff_tab_general", {}, "Основное")} - {at("tariff_tab_pricing", {}, "Цены")} - {at("tariff_tab_topup", {}, "Докупки")} - {at("tariff_tab_premium", {}, "Premium")} - {at("tariff_tab_hwid", {}, "Устройства")} + {at("tariff_tab_general", {}, "Основное")} + {at("tariff_tab_pricing", {}, "Цены")} + {at("tariff_tab_topup", {}, "Докупки")} + {at("tariff_tab_premium", {}, "Premium")} + {at("tariff_tab_hwid", {}, "Устройства")}
{at("tariff_label_key", {}, "Ключ тарифа")} - {at("tariff_hint_key", {}, "Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется")} - + {at( + "tariff_hint_key", + {}, + "Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется" + )} +
{at("tariff_label_model", {}, "Модель тарификации")} - {at("tariff_model_period_label", {}, "Период")} — {at("tariff_model_period_desc", {}, "пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)")}. {at("tariff_model_traffic_label", {}, "Трафик")} — {at("tariff_model_traffic_desc", {}, "пользователь покупает пакеты гигабайт по фиксированной цене за GB")} + {at("tariff_model_period_label", {}, "Период")} — {at( + "tariff_model_period_desc", + {}, + "пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)" + )}. {at("tariff_model_traffic_label", {}, "Трафик")} — {at( + "tariff_model_traffic_desc", + {}, + "пользователь покупает пакеты гигабайт по фиксированной цене за GB" + )} - {tariffDraft.enabled ? at("tariff_visible", {}, "Тариф виден на витрине") : at("tariff_hidden", {}, "Тариф скрыт от пользователей")} - {at("tariff_enabled_hint", {}, "Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать")} + {tariffDraft.enabled + ? at("tariff_visible", {}, "Тариф виден на витрине") + : at("tariff_hidden", {}, "Тариф скрыт от пользователей")} + {at( + "tariff_enabled_hint", + {}, + "Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать" + )}
{at("tariff_label_name_ru", {}, "Название · RU")} - + {at("tariff_label_name_en", {}, "Название · EN")} - +
{at("tariff_label_desc_ru", {}, "Описание · RU")} - + {at("tariff_label_desc_en", {}, "Описание · EN")} - +
{at("tariff_label_squads", {}, "Базовые Internal Squads")} - {panelSquadsLoading ? at("loading_squads", {}, "Загружаю список из панели…") : at("tariff_hint_squads", {}, "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько")} + {panelSquadsLoading + ? at("loading_squads", {}, "Загружаю список из панели…") + : at( + "tariff_hint_squads", + {}, + "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько" + )}
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid} - {/each}
@@ -131,20 +207,58 @@
{at("tariff_label_hwid", {}, "Лимит устройств (HWID)")} - {at("tariff_hint_hwid", {}, "Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений")} - + {at( + "tariff_hint_hwid", + {}, + "Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений" + )} + {#if tariffDraft.billing_model === "period"} {at("tariff_label_traffic_limit", {}, "Месячный лимит трафика, GB")} - {at("tariff_hint_traffic_limit", {}, "Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»")} - + {at( + "tariff_hint_traffic_limit", + {}, + "Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»" + )} + {:else} {at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")} - {at("tariff_hint_conversion", {}, "По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»")} - + {at( + "tariff_hint_conversion", + {}, + "По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»" + )} + {/if}
@@ -154,26 +268,62 @@
- {at("tariff_premium_head", {}, "Premium-доступ и отдельный счётчик трафика")} - {at("tariff_premium_subhead", {}, "Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно")} + {at("tariff_premium_head", {}, "Premium-доступ и отдельный счётчик трафика")} + {at( + "tariff_premium_subhead", + {}, + "Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно" + )}
{at("tariff_label_premium_name_ru", {}, "Название premium-раздела, RU")} - {at("tariff_hint_premium_name_ru", {}, "Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов.")} - + {at( + "tariff_hint_premium_name_ru", + {}, + "Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов." + )} + {at("tariff_label_premium_name_en", {}, "Название premium-раздела, EN")} - {at("tariff_hint_premium_name_en", {}, "Опционально для английского интерфейса.")} - + {at( + "tariff_hint_premium_name_en", + {}, + "Опционально для английского интерфейса." + )} +
{at("tariff_label_premium_squads", {}, "Premium Internal Squads")} - {at("tariff_hint_premium_squads", {}, "Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes")} + {at( + "tariff_hint_premium_squads", + {}, + "Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes" + )}
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid} - {/each}
- {at("tariff_label_premium_traffic_limit", {}, "Месячный лимит premium-трафика, GB")} - {at("tariff_hint_premium_traffic_limit", {}, "Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)")} - + {at( + "tariff_label_premium_traffic_limit", + {}, + "Месячный лимит premium-трафика, GB" + )} + {at( + "tariff_hint_premium_traffic_limit", + {}, + "Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)" + )} +
@@ -204,11 +378,26 @@
{at("tariff_premium_topup_title", {}, "Докупка premium-трафика")} - {at("tariff_premium_topup_subtitle", {}, "Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал")} + {at( + "tariff_premium_topup_subtitle", + {}, + "Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал" + )}
- tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")} - tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")} + tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })} + > {at("tariff_btn_package_rub", {}, "Пакет ₽")} + + tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })} + > {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
@@ -223,14 +412,37 @@ {/if} {#each tariffDraft.premiumTopupRubRows as row, index}
- - - tariffsStore.removeDraftRow("premiumTopupRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("premiumTopupRubRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
- {at("payment_stars", {}, "Оплата Telegram Stars")} + {at("payment_stars", {}, "Оплата Telegram Stars")} {#if tariffDraft.premiumTopupStarsRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} @@ -240,9 +452,34 @@ {/if} {#each tariffDraft.premiumTopupStarsRows as row, index}
- - - tariffsStore.removeDraftRow("premiumTopupStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("premiumTopupStarsRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
@@ -256,14 +493,31 @@
{at("tariff_pricing_period_title", {}, "Периоды подписки и цены")} - {at("tariff_pricing_period_subtitle", {}, "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит")} + {at( + "tariff_pricing_period_subtitle", + {}, + "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит" + )}
- tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}> - {at("tariff_btn_period", {}, "Период")} + + tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })} + > + + {at("tariff_btn_period", {}, "Период")}
{#if !tariffDraft.periodRows.length} -

{at("tariff_pricing_empty", {}, "Добавьте хотя бы один период — без него тариф не появится на витрине.")}

+

+ {at( + "tariff_pricing_empty", + {}, + "Добавьте хотя бы один период — без него тариф не появится на витрине." + )} +

{:else}
@@ -274,10 +528,38 @@
{#each tariffDraft.periodRows as row, index}
- - - - tariffsStore.removeDraftRow("periodRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + + tariffsStore.removeDraftRow("periodRows", index)} + aria-label={at("btn_delete", {}, "Удалить")} + >
@@ -290,16 +572,31 @@
{at("tariff_pricing_traffic_title", {}, "Пакеты трафика")} - {at("tariff_pricing_traffic_subtitle", {}, "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»")} + {at( + "tariff_pricing_traffic_subtitle", + {}, + "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»" + )}
- tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")} - tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")} + tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })} + > {at("tariff_btn_package_rub", {}, "Пакет ₽")} + tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })} + > {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
- {at("payment_rub", {}, "Оплата рублями")} + {at("payment_rub", {}, "Оплата рублями")} {#if tariffDraft.trafficRubRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} @@ -309,14 +606,37 @@ {/if} {#each tariffDraft.trafficRubRows as row, index}
- - - tariffsStore.removeDraftRow("trafficRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("trafficRubRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
- {at("payment_stars", {}, "Оплата Telegram Stars")} + {at("payment_stars", {}, "Оплата Telegram Stars")} {#if tariffDraft.trafficStarsRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} @@ -326,9 +646,30 @@ {/if} {#each tariffDraft.trafficStarsRows as row, index}
- - - tariffsStore.removeDraftRow("trafficStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("trafficStarsRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
@@ -342,17 +683,34 @@
- {at("tariff_topup_title", {}, "Докупка трафика поверх месячного лимита")} - {at("tariff_topup_subtitle", {}, "Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки")} + {at("tariff_topup_title", {}, "Докупка трафика поверх месячного лимита")} + {at( + "tariff_topup_subtitle", + {}, + "Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки" + )}
- tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")} - tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")} + tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })} + > {at("tariff_btn_package_rub", {}, "Пакет ₽")} + tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })} + > {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
- {at("payment_rub", {}, "Оплата рублями")} + {at("payment_rub", {}, "Оплата рублями")} {#if tariffDraft.topupRubRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} @@ -362,14 +720,37 @@ {/if} {#each tariffDraft.topupRubRows as row, index}
- - - tariffsStore.removeDraftRow("topupRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("topupRubRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
- {at("payment_stars", {}, "Оплата Telegram Stars")} + {at("payment_stars", {}, "Оплата Telegram Stars")} {#if tariffDraft.topupStarsRows.length}
{at("tariff_col_volume_gb", {}, "Объём, GB")} @@ -379,16 +760,43 @@ {/if} {#each tariffDraft.topupStarsRows as row, index}
- - - tariffsStore.removeDraftRow("topupStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("topupStarsRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
{:else} -

{at("tariff_topup_traffic_hint", {}, "Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания.")}

+

+ {at( + "tariff_topup_traffic_hint", + {}, + "Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания." + )} +

{/if} @@ -396,12 +804,32 @@
- {at("tariff_hwid_packages_title", {}, "Пакеты дополнительных устройств (HWID)")} - {at("tariff_hwid_packages_subtitle", {}, "Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»")} + {at( + "tariff_hwid_packages_title", + {}, + "Пакеты дополнительных устройств (HWID)" + )} + {at( + "tariff_hwid_packages_subtitle", + {}, + "Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»" + )}
- tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")} - tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")} + tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })} + > {at("tariff_btn_package_rub", {}, "Пакет ₽")} + tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })} + > {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
@@ -416,14 +844,41 @@ {/if} {#each tariffDraft.hwidRubRows as row, index}
- - - tariffsStore.removeDraftRow("hwidRubRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("hwidRubRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
- {at("payment_stars", {}, "Оплата Telegram Stars")} + {at("payment_stars", {}, "Оплата Telegram Stars")} {#if tariffDraft.hwidStarsRows.length}
{at("tariff_col_hwid_count", {}, "+ устройств")} @@ -433,9 +888,34 @@ {/if} {#each tariffDraft.hwidStarsRows as row, index}
- - - tariffsStore.removeDraftRow("hwidStarsRows", index)} aria-label={at("btn_delete", {}, "Удалить")}> + + + tariffsStore.removeDraftRow("hwidStarsRows", index)} + aria-label={at("btn_delete", {}, "Удалить")}>
{/each}
@@ -445,9 +925,18 @@
- tariffsStore.updateState({ tariffEditorOpen: false })}>{at("btn_cancel", {}, "Отмена")} - - {tariffsSaving ? at("btn_saving", {}, "Сохранение...") : at("btn_save_tariff", {}, "Сохранить тариф")} + tariffsStore.updateState({ tariffEditorOpen: false })} + >{at("btn_cancel", {}, "Отмена")} + + + {tariffsSaving + ? at("btn_saving", {}, "Сохранение...") + : at("btn_save_tariff", {}, "Сохранить тариф")}
@@ -455,15 +944,24 @@ tariffsStore.updateState({ tariffDeleteOpen: false })} class="admin-dialog" >
- tariffsStore.updateState({ tariffDeleteOpen: false })}>{at("btn_cancel", {}, "Отмена")} + tariffsStore.updateState({ tariffDeleteOpen: false })} + >{at("btn_cancel", {}, "Отмена")} - {at("btn_confirm_delete", {}, "Подтвердить удаление")} + + {at("btn_confirm_delete", {}, "Подтвердить удаление")}
diff --git a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte index d796a80..1effedb 100644 --- a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte @@ -8,12 +8,7 @@ const tariffsStore = getContext("tariffsStore"); - $: ({ - tariffsCatalog, - tariffsLoading, - tariffsPath, - tariffsSaving, - } = $tariffsStore); + $: ({ tariffsCatalog, tariffsLoading, tariffsPath, tariffsSaving } = $tariffsStore); $: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false); $: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length); @@ -26,7 +21,9 @@ if (tariff.billing_model === "traffic") { const rub = tariff.traffic_packages?.rub || []; const first = rub[0]; - return first ? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}` : at("tariff_traffic_packages", {}, "Пакеты трафика"); + return first + ? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}` + : at("tariff_traffic_packages", {}, "Пакеты трафика"); } const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b); return months @@ -52,17 +49,23 @@
{at("tariffs_stat_total", {}, "Всего тарифов")} {tariffsCatalog.tariffs.length} - {at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length} + {at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}
{at("tariffs_stat_default", {}, "По умолчанию")} {tariffsCatalog.default_tariff || "—"} - {at("tariffs_stat_default_hint", {}, "Используется для новых подписок")} + {at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}
{at("tariffs_stat_disabled", {}, "Отключено")} {disabledTariffs} - {at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")} + {at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}
@@ -73,18 +76,33 @@ {tariffsPath || "data/tariffs.json"}
- - {at("btn_refresh", {}, "Обновить")} + + + {at("btn_refresh", {}, "Обновить")} - - {at("btn_create_tariff", {}, "Создать тариф")} + + + {at("btn_create_tariff", {}, "Создать тариф")}
{#if !tariffsCatalog.tariffs.length} - {at("tariffs_catalog_empty", {}, "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.")} + {at( + "tariffs_catalog_empty", + {}, + "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога." + )} {:else}
@@ -95,7 +113,9 @@
{tariffName(tariff)} {#if tariff.key === tariffsCatalog.default_tariff} - {at("status_default", {}, "Default")} + {at("status_default", {}, "Default")} {/if}
{tariff.key} @@ -106,32 +126,60 @@ {at("status_active", {}, "Активен")} {/if}
-

{tariff.descriptions?.ru || tariff.descriptions?.en || at("no_description", {}, "Без описания")}

+

+ {tariff.descriptions?.ru || + tariff.descriptions?.en || + at("no_description", {}, "Без описания")} +

- {tariff.billing_model === "traffic" ? at("tariff_model_traffic", {}, "Трафик") : at("tariff_model_periods", {}, "Периоды")} + {tariff.billing_model === "traffic" + ? at("tariff_model_traffic", {}, "Трафик") + : at("tariff_model_periods", {}, "Периоды")} {tariffPriceSummary(tariff)} - {at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length} - {at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"} - {at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? "env"} + {at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length} + {at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length + ? `${tariff.premium_monthly_gb || 0} GB` + : "—"} + {at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? + "env"}
tariffsStore.openEditTariff(tariff)}> {at("btn_configure", {}, "Настроить")} - tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}> - {tariff.enabled === false ? at("btn_enable", {}, "Включить") : at("btn_disable", {}, "Выключить")} + tariffsStore.toggleTariffEnabled(tariff)} + disabled={tariffsSaving} + > + {tariff.enabled === false + ? at("btn_enable", {}, "Включить") + : at("btn_disable", {}, "Выключить")} tariffsStore.setDefaultTariff(tariff.key)} - disabled={tariffsSaving || tariff.enabled === false || tariff.key === tariffsCatalog.default_tariff} + disabled={tariffsSaving || + tariff.enabled === false || + tariff.key === tariffsCatalog.default_tariff} > {at("btn_set_default", {}, "По умолчанию")} tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })} + onclick={() => + tariffsStore.updateState({ + tariffDeleteTarget: tariff, + tariffDeleteOpen: true, + })} disabled={tariffsSaving} aria-label={at("btn_delete_tariff", {}, "Удалить тариф")} > diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte index 32ab908..50652ab 100644 --- a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte +++ b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte @@ -1,14 +1,39 @@ {#if resolvedAvatarUrl(openedUser)} - + {:else} {userInitials(openedUser)} {/if} @@ -79,9 +110,13 @@ {at("badge_active", {}, "Активен")} {/if} {#if openedUserDetail.active_subscription} - {at("badge_subscription", {}, "Подписка")} + {at("badge_subscription", {}, "Подписка")} {:else} - {at("badge_no_subscription", {}, "Без подписки")} + {at("badge_no_subscription", {}, "Без подписки")} {/if}
@@ -102,10 +137,28 @@
  • ID{openedUser.user_id}
  • Telegram ID{openedUser.telegram_id || "—"}
  • -
  • Username{openedUser.username ? "@" + openedUser.username : "—"}
  • -
  • Email{openedUser.email || "—"}
  • -
  • {at("user_label_registration", {}, "Регистрация")}{fmtDate(openedUser.registration_date)}
  • -
  • {at("user_label_ref_code", {}, "Реф. код")}{openedUserDetail.referral?.code || openedUserDetail.user?.referral_code || "—"}
  • +
  • + Username{openedUser.username ? "@" + openedUser.username : "—"} +
  • +
  • + Email{openedUser.email || "—"} +
  • +
  • + {at("user_label_registration", {}, "Регистрация")}{fmtDate(openedUser.registration_date)} +
  • +
  • + {at("user_label_ref_code", {}, "Реф. код")}{openedUserDetail.referral?.code || + openedUserDetail.user?.referral_code || + "—"} +
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link} @@ -114,12 +167,28 @@ {#if openedUserDetail.subscription_url} @@ -127,12 +196,28 @@ {#if openedUserDetail.referral?.bot_link} @@ -140,12 +225,28 @@ {#if openedUserDetail.referral?.webapp_link} @@ -155,290 +256,522 @@
- + - {at("user_tab_subscription", {}, "Подписка")} - {at("user_tab_activity", {}, "Активность")} - {at("user_tab_actions", {}, "Действия")} + {at("user_tab_subscription", {}, "Подписка")} + {at("user_tab_activity", {}, "Активность")} + {at("user_tab_actions", {}, "Действия")} - {#if openedUserDetail.active_subscription} -
    -
  • {at("user_label_active_until", {}, "Активна до")}{fmtDate(openedUserDetail.active_subscription.end_date)}
  • -
  • {at("user_label_tariff", {}, "Тариф")}{openedUserDetail.active_subscription.tariff_key || "—"}
  • -
  • {at("user_label_auto_renew", {}, "Авто-продление")}{pretty(openedUserDetail.active_subscription.auto_renew_enabled)}
  • -
  • {at("user_label_provider", {}, "Провайдер")}{openedUserDetail.active_subscription.provider || "—"}
  • -
-
- - {#if openedUserDetail.active_subscription.premium_unlimited_override} - - {:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0} - - {/if} -
- {:else} -

{at("user_no_active_subscription", {}, "Активной подписки нет")}

- {/if} - - {#if (openedUserDetail.subscriptions || []).length} - -
{at("user_history_title", { count: openedUserDetail.subscriptions.length }, `История подписок · ${openedUserDetail.subscriptions.length}`)}
-
- {#each openedUserDetail.subscriptions.slice(0, 8) as sub} -
-
- {sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа")} - {at("user_history_until", { date: fmtDate(sub.end_date) }, `до ${fmtDate(sub.end_date)}`)} -
- {#if sub.is_active} - {at("user_history_active", {}, "Активна")} - {:else} - {sub.status_from_panel || at("user_history_status_panel", {}, "История")} - {/if} -
- {/each} -
- {/if} -
- - -
{at("user_recent_payments_title", { count: (openedUserDetail.recent_payments || []).length }, `Последние платежи · ${(openedUserDetail.recent_payments || []).length}`)}
- {#if (openedUserDetail.recent_payments || []).length} -
- {#each openedUserDetail.recent_payments.slice(0, 8) as payment} -
-
- {fmtMoney(payment.amount, payment.currency)} - {payment.provider} · {fmtDateShort(payment.created_at)} -
- {payment.status} -
- {/each} -
- {:else} -

{at("user_no_payments", {}, "Платежей нет")}

- {/if} -
- - -
- - {at("user_btn_reset_trial", {}, "Сбросить триал")} - - - {at("user_label_extend", {}, "Продлить подписку")} -
- - - {at("user_btn_extend", {}, "Продлить")} - -
-
-
- - {#if openedUserDetail?.active_subscription} -
- -
- - {at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")} - {at("user_premium_override_bonus_hint", {}, "")} - +
  • + {at("user_label_active_until", {}, "Активна до")}{fmtDate(openedUserDetail.active_subscription.end_date)} +
  • +
  • + {at("user_label_tariff", {}, "Тариф")}{openedUserDetail.active_subscription.tariff_key || "—"} +
  • +
  • + {at("user_label_auto_renew", {}, "Авто-продление")}{pretty(openedUserDetail.active_subscription.auto_renew_enabled)} +
  • +
  • + {at("user_label_provider", {}, "Провайдер")}{openedUserDetail.active_subscription.provider || "—"} +
  • + +
    + - -
    - -
    + {:else} +

    + {at("user_no_active_subscription", {}, "Активной подписки нет")} +

    + {/if} -
    - -
    - - {at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")} - {at("user_regular_override_bonus_hint", {}, "")} - - -
    -
    + {/if} +
    -
    - -
    + +
    + {at( + "user_recent_payments_title", + { count: (openedUserDetail.recent_payments || []).length }, + `Последние платежи · ${(openedUserDetail.recent_payments || []).length}` + )} +
    + {#if (openedUserDetail.recent_payments || []).length} +
    + {#each openedUserDetail.recent_payments.slice(0, 8) as payment} +
    +
    + {fmtMoney(payment.amount, payment.currency)} + {payment.provider} · {fmtDateShort(payment.created_at)} +
    + {payment.status} +
    + {/each} +
    + {:else} +

    {at("user_no_payments", {}, "Платежей нет")}

    + {/if} +
    + + +
    + + + {at("user_btn_reset_trial", {}, "Сбросить триал")} + - {at("user_traffic_grant_kind", {}, "Тип трафика")} - usersStore.updateState({ grantTrafficKindDraft: v })} - ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")} - /> - - - {at("user_traffic_grant_gb", {}, "ГБ к выдаче")} + {at("user_label_extend", {}, "Продлить подписку")}
    - - {at("user_traffic_grant_submit", {}, "Выдать")} + + + {at("user_btn_extend", {}, "Продлить")}
    -
    - {/if} - - {at("user_label_telegram_msg", {}, "Сообщение в Telegram")} - {at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")} - - -
    - - {at("btn_preview_tg", {}, "Превью в Telegram")} - - - {at("btn_send_msg", {}, "Отправить сообщение")} - -
    + {#if openedUserDetail?.active_subscription} +
    + +
    + + {at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")} + {at("user_premium_override_bonus_hint", {}, "")} + + +
    + +
    -
    -
    - {at("user_danger_zone_title", {}, "Опасные действия")} - {at("user_danger_zone_subtitle", {}, "Эти действия требуют подтверждения и (для удаления) необратимы")} -
    -
    - {#if openedUser.is_banned} - - {at("btn_unban", {}, "Разбанить пользователя")} - - {:else} - - {at("btn_ban", {}, "Заблокировать")} - +
    + +
    + + {at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")} + {at("user_regular_override_bonus_hint", {}, "")} + + +
    + +
    + +
    + +
    + + {at("user_traffic_grant_kind", {}, "Тип трафика")} + usersStore.updateState({ grantTrafficKindDraft: v })} + ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")} + /> + + + {at("user_traffic_grant_gb", {}, "ГБ к выдаче")} +
    + + + + {at("user_traffic_grant_submit", {}, "Выдать")} + +
    +
    +
    +
    {/if} - usersStore.updateState({ userDeleteOpen: true })} disabled={userActionBusy}> - {at("btn_delete_account", {}, "Удалить аккаунт")} - -
    -
    - + + + {at("user_label_telegram_msg", {}, "Сообщение в Telegram")} + {at( + "user_hint_telegram_msg", + {}, + "Поддерживается HTML-разметка Telegram" + )} + + +
    + + + {at("btn_preview_tg", {}, "Превью в Telegram")} + + + + {at("btn_send_msg", {}, "Отправить сообщение")} + +
    + +
    +
    + {at("user_danger_zone_title", {}, "Опасные действия")} + {at( + "user_danger_zone_subtitle", + {}, + "Эти действия требуют подтверждения и (для удаления) необратимы" + )} +
    +
    + {#if openedUser.is_banned} + + + {at("btn_unban", {}, "Разбанить пользователя")} + + {:else} + + + {at("btn_ban", {}, "Заблокировать")} + + {/if} + usersStore.updateState({ userDeleteOpen: true })} + disabled={userActionBusy} + > + + {at("btn_delete_account", {}, "Удалить аккаунт")} + +
    +
    +
    @@ -449,16 +782,29 @@ usersStore.updateState({ userMessageConfirmOpen: false })} class="admin-dialog" >
    {userMessageDraft}
    - usersStore.updateState({ userMessageConfirmOpen: false })}>{at("btn_cancel", {}, "Отмена")} - - {at("btn_confirm_send", {}, "Подтвердить отправку")} + usersStore.updateState({ userMessageConfirmOpen: false })} + >{at("btn_cancel", {}, "Отмена")} + + + {at("btn_confirm_send", {}, "Подтвердить отправку")}
    @@ -466,15 +812,28 @@ usersStore.updateState({ userBanConfirmOpen: false })} class="admin-dialog" >
    - usersStore.updateState({ userBanConfirmOpen: false })}>{at("btn_cancel", {}, "Отмена")} - usersStore.applyBanToggle(true)} disabled={userActionBusy}> - {at("btn_ban", {}, "Заблокировать")} + usersStore.updateState({ userBanConfirmOpen: false })} + >{at("btn_cancel", {}, "Отмена")} + usersStore.applyBanToggle(true)} + disabled={userActionBusy} + > + + {at("btn_ban", {}, "Заблокировать")}
    @@ -482,15 +841,22 @@ usersStore.updateState({ userDeleteOpen: false })} class="admin-dialog" >
    - usersStore.updateState({ userDeleteOpen: false })}>{at("btn_cancel", {}, "Отмена")} + usersStore.updateState({ userDeleteOpen: false })} + >{at("btn_cancel", {}, "Отмена")} - {at("btn_confirm_delete", {}, "Подтвердить удаление")} + + {at("btn_confirm_delete", {}, "Подтвердить удаление")}
    diff --git a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte b/bot/app/web/frontend/src/admin/sections/UsersSection.svelte index 82b7af4..6a7632b 100644 --- a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/UsersSection.svelte @@ -69,7 +69,10 @@ const USERS_PREMIUM_TRAFFIC_OPTIONS = [ { value: "all", label: at("premium_traffic_filter_all", {}, "Все (премиум)") }, { value: "none", label: at("premium_traffic_filter_none", {}, "Без лимита в тарифе") }, - { value: "unlimited", label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)") }, + { + value: "unlimited", + label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)"), + }, { value: "good", label: at("premium_traffic_filter_good", {}, "Премиум: норма") }, { value: "warn", label: at("premium_traffic_filter_warn", {}, "Премиум: мало") }, { value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") }, @@ -110,9 +113,16 @@ placeholder={at("users_search_placeholder", {}, "ID, @username или email")} value={usersQuery} on:input={(e) => usersStore.updateState({ usersQuery: e.target.value })} - on:keydown={(e) => e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())} + on:keydown={(e) => + e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())} /> - { usersStore.updateState({ usersPage: 0 }); usersStore.loadUsers(); }}>{at("find", {}, "Найти")} + { + usersStore.updateState({ usersPage: 0 }); + usersStore.loadUsers(); + }}>{at("find", {}, "Найти")}
    @@ -123,7 +133,10 @@ items={USERS_FILTER_OPTIONS} class="admin-toolbar-select" ariaLabel={at("filter", {}, "Фильтр")} - onValueChange={(value) => { usersStore.updateState({ usersFilter: value, usersPage: 0 }); usersStore.loadUsers(); }} + onValueChange={(value) => { + usersStore.updateState({ usersFilter: value, usersPage: 0 }); + usersStore.loadUsers(); + }} /> @@ -134,18 +147,26 @@ items={USERS_PANEL_STATUS_OPTIONS} class="admin-toolbar-select" ariaLabel={at("panel_status", {}, "Статус панели")} - onValueChange={(value) => { usersStore.updateState({ usersPanelStatus: value, usersPage: 0 }); usersStore.loadUsers(); }} + onValueChange={(value) => { + usersStore.updateState({ usersPanelStatus: value, usersPage: 0 }); + usersStore.loadUsers(); + }} /> - {at("premium_traffic_filter_label", {}, "Премиум трафик")} + {at("premium_traffic_filter_label", {}, "Премиум трафик")} { usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 }); usersStore.loadUsers(); }} + onValueChange={(value) => { + usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 }); + usersStore.loadUsers(); + }} /> @@ -156,7 +177,10 @@ items={USERS_SORT_OPTIONS} class="admin-toolbar-select" ariaLabel={at("sort", {}, "Сортировка")} - onValueChange={(value) => { usersStore.updateState({ usersSort: value, usersPage: 0 }); usersStore.loadUsers(); }} + onValueChange={(value) => { + usersStore.updateState({ usersSort: value, usersPage: 0 }); + usersStore.loadUsers(); + }} /> @@ -175,7 +199,10 @@ widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]} /> {:else if !users.length} - {at("users_empty", {}, "Никого не найдено")} + {at("users_empty", {}, "Никого не найдено")} {:else} @@ -219,19 +246,30 @@
    - + {#if user.premium_traffic && user.premium_traffic.state !== "none"} - + {premiumTrafficBadgeText(user.premium_traffic)} {:else} - {at("premium_traffic_na", {}, "—")} + {at("premium_traffic_na", {}, "—")} {/if} {badge.label} - + {fmtDateShort(user.registration_date)} @@ -247,8 +285,14 @@ nextLabel={at("next", {}, "Далее")} prevDisabled={usersPage === 0} nextDisabled={!usersHasMore} - onPrev={() => { usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); usersStore.loadUsers(); }} - onNext={() => { usersStore.updateState({ usersPage: usersPage + 1 }); usersStore.loadUsers(); }} + onPrev={() => { + usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) }); + usersStore.loadUsers(); + }} + onNext={() => { + usersStore.updateState({ usersPage: usersPage + 1 }); + usersStore.loadUsers(); + }} />