...
-Ваша ссылка подключения и срок доступа
+Ваша ссылка подключения и срок доступа
From b9cb1fec0635e0dcc25619225d78248d84e98bb4 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 23 Apr 2026 14:06:21 +0300 Subject: [PATCH] feat: tune webapp visual, use telegram widget for login --- .env.example | 2 +- README.md | 21 +- bot/app/web/subscription_webapp.py | 329 +- bot/app/web/templates/subscription_webapp.css | 836 ++++ .../web/templates/subscription_webapp.html | 1161 +----- bot/app/web/templates/subscription_webapp.js | 888 +++++ bot/app/web/templates/telegram-web-app.js | 3392 +++++++++++++++++ bot/app/web/templates/telegram-widget.js | 575 +++ bot/app/web/web_server.py | 27 +- bot/app/web/webapp_auth.py | 92 +- bot/handlers/user/start.py | 17 +- bot/main_bot.py | 10 +- scripts/update_telegram_web_app_js.py | 57 + scripts/update_telegram_widget_js.py | 70 + 14 files changed, 6265 insertions(+), 1212 deletions(-) create mode 100644 bot/app/web/templates/subscription_webapp.css create mode 100644 bot/app/web/templates/subscription_webapp.js create mode 100644 bot/app/web/templates/telegram-web-app.js create mode 100644 bot/app/web/templates/telegram-widget.js create mode 100644 scripts/update_telegram_web_app_js.py create mode 100644 scripts/update_telegram_widget_js.py diff --git a/.env.example b/.env.example index 0d9320a..aed8619 100644 --- a/.env.example +++ b/.env.example @@ -38,7 +38,7 @@ WEBAPP_SERVER_HOST=0.0.0.0 # WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="Моя подписка" # Mini App title WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color -WEBAPP_LOGO_URL= # Optional logo URL +WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime diff --git a/README.md b/README.md index 58b76c7..6007701 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,14 @@ | `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` | | `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` | | `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` | +| `PRIVACY_POLICY_URL` | (Опционально) Ссылка на политику конфиденциальности, показывается внизу Web App. | `https://example.com/privacy` | +| `USER_AGREEMENT_URL` | (Опционально) Ссылка на пользовательское соглашение, показывается внизу Web App. | `https://example.com/agreement` | | `SUBSCRIPTION_MINI_APP_URL` | (Опционально) Публичный URL Mini App для показа подписки. Если задан, кнопка «Моя подписка» откроет Web App. | `https://app.domain.com/` | | `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` | | `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` | | `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` | | `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` | -| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` | +| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. Если значение пустое, логотип не показывается вообще; если задано, он отображается в шапке и на экране логина. | `https://domain.com/logo.png` | | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | | `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` | @@ -192,7 +194,7 @@ ### Настройка Web App / Mini App -Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется fallback через deep-link в бота. +Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. После успешного входа страница обновляет данные сразу, без сообщений боту. 1. Укажите в `.env` публичный URL Web App и порт: @@ -240,7 +242,7 @@ Web App запускается в том же контейнере, что и б } ``` -4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. +4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget. 5. Перезапустите контейнер: @@ -250,6 +252,19 @@ Web App запускается в том же контейнере, что и б После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками. +Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов. + +Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям. + +Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды: + +```bash +python scripts/update_telegram_web_app_js.py +python scripts/update_telegram_widget_js.py +``` + +При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник. + ## Подробная инструкция для развертывания на сервере с панелью Remnawave ### 1. Клонирование репозитория diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 731091c..691b7c2 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -4,16 +4,15 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional -from aiohttp import web +from aiohttp import ClientSession, ClientTimeout, web from aiogram import Bot, Dispatcher from aiogram.types import LabeledPrice from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from bot.app.web.webapp_auth import ( - consume_authorized_webapp_auth_token, - create_pending_webapp_auth_token, create_webapp_session_token, + validate_telegram_login_widget_data, validate_telegram_webapp_init_data, verify_webapp_session_token, ) @@ -31,6 +30,16 @@ from db.models import Payment, User logger = logging.getLogger(__name__) TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html" +ASSET_DIR = TEMPLATE_PATH.parent +TELEGRAM_WEB_APP_SDK_URL = "https://telegram.org/js/telegram-web-app.js" +TELEGRAM_WEB_APP_SDK_PATH = ASSET_DIR / "telegram-web-app.js" +TELEGRAM_WIDGET_SDK_URL = "https://telegram.org/js/telegram-widget.js?23" +TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js" +_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n""" +_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n""" +WEBAPP_CONFIG_PLACEHOLDER = "" +DEV_MOCK_START_MARKER = "" +DEV_MOCK_END_MARKER = "" def create_subscription_webapp_application( @@ -57,6 +66,9 @@ def create_subscription_webapp_application( if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] app[key] = dp.workflow_data[key] # type: ignore[index] + if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined] + app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index] + setup_subscription_webapp_routes(app) return app @@ -64,9 +76,11 @@ def create_subscription_webapp_application( def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/", index_route) app.router.add_get("/health", health_route) + app.router.add_get("/telegram-web-app.js", telegram_web_app_asset_route) + app.router.add_get("/telegram-widget.js", telegram_widget_asset_route) + app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get("/subscription_webapp.js", js_asset_route) app.router.add_post("/api/auth/token", auth_token_route) - app.router.add_get("/api/auth/request-token", auth_request_token_route) - app.router.add_get("/api/auth/check-token/{token}", auth_check_token_route) app.router.add_get("/api/me", me_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) @@ -76,6 +90,171 @@ async def health_route(request: web.Request) -> web.Response: return web.json_response({"ok": True}) +async def css_asset_route(request: web.Request) -> web.Response: + return await _serve_template_asset(request, "subscription_webapp.css", "text/css") + + +async def telegram_web_app_asset_route(request: web.Request) -> web.Response: + if not TELEGRAM_WEB_APP_SDK_PATH.exists(): + await refresh_telegram_web_app_sdk() + + try: + response = await _serve_template_asset( + request, + "telegram-web-app.js", + "application/javascript", + ) + except FileNotFoundError: + logger.exception( + "Telegram Web App SDK is unavailable at %s", + TELEGRAM_WEB_APP_SDK_PATH, + ) + raise web.HTTPServiceUnavailable(text="telegram_web_app_sdk_unavailable") + + response.headers["Cache-Control"] = "no-cache" + return response + + +async def telegram_widget_asset_route(request: web.Request) -> web.Response: + if not TELEGRAM_WIDGET_SDK_PATH.exists(): + await refresh_telegram_login_widget_sdk() + + try: + data = TELEGRAM_WIDGET_SDK_PATH.read_bytes() + except FileNotFoundError: + logger.exception( + "Telegram Login Widget SDK is unavailable at %s", + TELEGRAM_WIDGET_SDK_PATH, + ) + raise web.HTTPServiceUnavailable(text="telegram_widget_sdk_unavailable") + + data = _normalize_telegram_login_widget_sdk(data) + response = web.Response(body=data, content_type="application/javascript") + response.headers["Cache-Control"] = "no-cache" + return response + + +async def refresh_telegram_web_app_sdk() -> bool: + """Best-effort refresh of the vendored Telegram Web App SDK.""" + try: + timeout = ClientTimeout(total=30) + async with ClientSession( + timeout=timeout, + headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "application/javascript,text/javascript,*/*;q=0.8", + }, + ) as session: + async with session.get(TELEGRAM_WEB_APP_SDK_URL) as response: + if response.status != 200: + logger.warning( + "Telegram Web App SDK refresh returned HTTP %s; keeping the bundled copy.", + response.status, + ) + return False + data = await response.read() + except Exception as exc: + logger.warning("Failed to refresh Telegram Web App SDK: %s", exc) + return False + + try: + TELEGRAM_WEB_APP_SDK_PATH.parent.mkdir(parents=True, exist_ok=True) + existing_data = ( + TELEGRAM_WEB_APP_SDK_PATH.read_bytes() + if TELEGRAM_WEB_APP_SDK_PATH.exists() + else None + ) + if existing_data == data: + logger.info("Telegram Web App SDK is already up to date.") + return True + + temp_path = TELEGRAM_WEB_APP_SDK_PATH.with_name( + f"{TELEGRAM_WEB_APP_SDK_PATH.name}.tmp" + ) + temp_path.write_bytes(data) + temp_path.replace(TELEGRAM_WEB_APP_SDK_PATH) + logger.info( + "Telegram Web App SDK updated at %s (%d bytes).", + TELEGRAM_WEB_APP_SDK_PATH, + len(data), + ) + return True + except Exception as exc: + logger.warning("Failed to store Telegram Web App SDK locally: %s", exc) + return False + + +async def refresh_telegram_login_widget_sdk() -> bool: + """Best-effort refresh of the vendored Telegram Login Widget SDK.""" + try: + timeout = ClientTimeout(total=30) + async with ClientSession( + timeout=timeout, + headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "application/javascript,text/javascript,*/*;q=0.8", + }, + ) as session: + async with session.get(TELEGRAM_WIDGET_SDK_URL) as response: + if response.status != 200: + logger.warning( + "Telegram Login Widget SDK refresh returned HTTP %s; keeping the bundled copy.", + response.status, + ) + return False + data = await response.read() + except Exception as exc: + logger.warning("Failed to refresh Telegram Login Widget SDK: %s", exc) + return False + + try: + data = _normalize_telegram_login_widget_sdk(data) + TELEGRAM_WIDGET_SDK_PATH.parent.mkdir(parents=True, exist_ok=True) + existing_data = ( + TELEGRAM_WIDGET_SDK_PATH.read_bytes() + if TELEGRAM_WIDGET_SDK_PATH.exists() + else None + ) + if existing_data == data: + logger.info("Telegram Login Widget SDK is already up to date.") + return True + + temp_path = TELEGRAM_WIDGET_SDK_PATH.with_name( + f"{TELEGRAM_WIDGET_SDK_PATH.name}.tmp" + ) + temp_path.write_bytes(data) + temp_path.replace(TELEGRAM_WIDGET_SDK_PATH) + logger.info( + "Telegram Login Widget SDK updated at %s (%d bytes).", + TELEGRAM_WIDGET_SDK_PATH, + len(data), + ) + return True + except Exception as exc: + logger.warning("Failed to store Telegram Login Widget SDK locally: %s", exc) + return False + + +def _normalize_telegram_login_widget_sdk(data: bytes) -> bytes: + # Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin. + text = data.decode("utf-8") + normalized = text.replace( + _UNPATCHED_WIDGET_ORIGIN_SNIPPET, + _PATCHED_WIDGET_ORIGIN_SNIPPET, + 1, + ) + return normalized.encode("utf-8") + + +async def js_asset_route(request: web.Request) -> web.Response: + return await _serve_template_asset( + request, + "subscription_webapp.js", + "application/javascript", + strip_dev_mock=True, + ) + + async def index_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: @@ -87,25 +266,76 @@ async def index_route(request: web.Request) -> web.Response: "primaryColor": settings.WEBAPP_PRIMARY_COLOR, "logoUrl": settings.WEBAPP_LOGO_URL or "", "apiBase": "/api", + "telegramLoginBotUsername": request.app.get("bot_username") or "", "supportUrl": settings.SUPPORT_LINK or "", + "privacyPolicyUrl": settings.PRIVACY_POLICY_URL or "", + "userAgreementUrl": settings.USER_AGREEMENT_URL or "", "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "language": _normalize_language(settings.DEFAULT_LANGUAGE), } + html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) html = html.replace( - "__WEBAPP_CONFIG__", - json.dumps(config, ensure_ascii=False, separators=(",", ":")), + WEBAPP_CONFIG_PLACEHOLDER, + ( + "" + ), ) return web.Response(text=html, content_type="text/html", charset="utf-8") +async def _serve_template_asset( + request: web.Request, + filename: str, + content_type: str, + *, + strip_dev_mock: bool = False, +) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + path = ASSET_DIR / filename + text = path.read_text(encoding="utf-8") + if strip_dev_mock: + text = _strip_marked_block( + text, + "/* WEBAPP_DEV_MOCK_START */", + "/* WEBAPP_DEV_MOCK_END */", + ) + return web.Response(text=text, content_type=content_type, charset="utf-8") + + +def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: + start = html.find(start_marker) + if start == -1: + return html + end = html.find(end_marker, start) + if end == -1: + return html[:start] + return html[:start] + html[end + len(end_marker):] + + async def auth_token_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) init_data = str(payload.get("init_data") or "") - telegram_user = validate_telegram_webapp_init_data( - init_data, - settings.BOT_TOKEN, - max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, - ) + auth_data = payload.get("auth_data") + telegram_user = None + if init_data: + telegram_user = validate_telegram_webapp_init_data( + init_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + elif auth_data is not None: + telegram_user = validate_telegram_login_widget_data( + auth_data, + settings.BOT_TOKEN, + max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, + ) + if not telegram_user: return _json_error(401, "invalid_auth", "Invalid Telegram auth data") @@ -126,40 +356,6 @@ async def auth_token_route(request: web.Request) -> web.Response: return web.json_response({"ok": True, "token": token}) -async def auth_request_token_route(request: web.Request) -> web.Response: - settings: Settings = request.app["settings"] - token = create_pending_webapp_auth_token(settings) - bot: Bot = request.app["bot"] - bot_info = await bot.get_me() - auth_url = f"https://t.me/{bot_info.username}?start=webapp_auth_{token}" - return web.json_response({"ok": True, "token": token, "auth_url": auth_url}) - - -async def auth_check_token_route(request: web.Request) -> web.Response: - settings: Settings = request.app["settings"] - token = request.match_info.get("token", "") - user_id = consume_authorized_webapp_auth_token(settings, token) - if not user_id: - return web.json_response({"ok": True, "authorized": False}) - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or db_user.is_banned: - return web.json_response( - {"ok": True, "authorized": False, "error": "Access denied"} - ) - - session_token = create_webapp_session_token(settings, user_id) - return web.json_response( - { - "ok": True, - "authorized": True, - "token": session_token, - } - ) - - async def me_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) data = await _build_user_payload(request, user_id) @@ -317,15 +513,16 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A except Exception: await session.rollback() + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) return { "user": { "id": user_id, "username": db_user.username, "first_name": db_user.first_name, - "language_code": db_user.language_code or settings.DEFAULT_LANGUAGE, + "language_code": lang, }, - "subscription": _serialize_subscription(active, local_sub), - "plans": _serialize_plans(settings), + "subscription": _serialize_subscription(active, local_sub, lang), + "plans": _serialize_plans(settings, lang), "payment_methods": _serialize_payment_methods(settings, request.app), "settings": { "support_url": settings.SUPPORT_LINK, @@ -337,12 +534,13 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A def _serialize_subscription( active: Optional[Dict[str, Any]], local_sub: Optional[Any], + lang: str, ) -> Dict[str, Any]: if not active: return { "active": False, "status": "INACTIVE", - "remaining_text": "Нет активной подписки", + "remaining_text": _format_remaining(0, lang), "days_left": 0, "config_link": None, "connect_url": None, @@ -365,7 +563,7 @@ def _serialize_subscription( "end_date": end_date.isoformat() if end_date else None, "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A", "days_left": seconds_left // 86400, - "remaining_text": _format_remaining(seconds_left), + "remaining_text": _format_remaining(seconds_left, lang), "config_link": active.get("config_link"), "connect_url": active.get("connect_button_url") or active.get("config_link"), "traffic_limit": _format_bytes(active.get("traffic_limit_bytes")), @@ -375,14 +573,14 @@ def _serialize_subscription( } -def _serialize_plans(settings: Settings) -> List[Dict[str, Any]]: +def _serialize_plans(settings: Settings, lang: str) -> List[Dict[str, Any]]: plans: List[Dict[str, Any]] = [] for months, price in sorted(settings.subscription_options.items()): plan = { "months": int(months), "price": float(price), "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "title": _format_months_title(int(months)), + "title": _format_months_title(int(months), lang), } stars_price = settings.stars_subscription_options.get(months) if stars_price is not None and int(stars_price) > 0: @@ -843,12 +1041,25 @@ async def _create_stars_payment( return _json_error(502, "payment_failed", "Failed to create invoice") -def _format_remaining(seconds: int) -> str: +def _normalize_language(lang: Optional[str]) -> str: + value = (lang or "ru").split("-")[0].lower() + return value if value in {"ru", "en"} else "ru" + + +def _format_remaining(seconds: int, lang: str) -> str: if seconds <= 0: + if lang == "en": + return "Subscription inactive" return "Подписка не активна" days, rem = divmod(seconds, 86400) hours, rem = divmod(rem, 3600) minutes = rem // 60 + if lang == "en": + if days > 0: + return f"{days} d. {hours} h." + if hours > 0: + return f"{hours} h. {minutes} min." + return f"{max(1, minutes)} min." if days > 0: return f"{days} д. {hours} ч." if hours > 0: @@ -873,7 +1084,11 @@ def _format_bytes(value: Optional[Any]) -> str: return f"{size:.2f} {units[index]}" -def _format_months_title(months: int) -> str: +def _format_months_title(months: int, lang: str) -> str: + if lang == "en": + if months == 1: + return "1 month" + return f"{months} months" if months == 1: return "1 месяц" if 2 <= months <= 4: @@ -883,5 +1098,5 @@ def _format_months_title(months: int) -> str: def _payment_description(months: int, lang: str) -> str: if lang == "en": - return f"Subscription for {months} month(s)" - return f"Подписка на {_format_months_title(months)}" + return f"Subscription for {_format_months_title(months, lang)}" + return f"Подписка на {_format_months_title(months, lang)}" diff --git a/bot/app/web/templates/subscription_webapp.css b/bot/app/web/templates/subscription_webapp.css new file mode 100644 index 0000000..2748170 --- /dev/null +++ b/bot/app/web/templates/subscription_webapp.css @@ -0,0 +1,836 @@ +:root { + color-scheme: dark; + --bg-primary: #05070a; + --bg-secondary: #0a0e17; + --bg-card: #0f1521; + --bg-card-hover: #161e2e; + --surface: #1e2940; + --border: #1e2940; + --border-strong: #2a3752; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent: #00fe7a; + --accent-hover: #5aff9f; + --success: #10b981; + --warning: #f59e0b; + --danger: #ef4444; + --font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 8px; + --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --app-safe-top-extra: 10px; + --app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top)); + --app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom)); + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + html, + body { + min-height: 100%; + margin: 0; + letter-spacing: 0; + } + + body { + background: var(--bg-primary); + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, transparent), transparent 320px), + var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + line-height: 1.5; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + } + + body.modal-open { + overflow: hidden; + } + + button, + a { + font: inherit; + } + + button { + border: 0; + cursor: pointer; + } + + button:disabled { + cursor: progress; + opacity: 0.58; + } + + .app { + width: min(100%, 560px); + margin: 0 auto; + padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 18px); + display: flex; + flex-direction: column; + gap: 14px; + } + + .topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 48px; + } + + .brand { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; + } + + .brand-logo, + .icon-btn { + width: 38px; + height: 38px; + flex: 0 0 auto; + } + + .brand-logo { + display: block; + object-fit: contain; + } + + .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + } + + .brand-logo--lg { + width: 84px; + height: 84px; + } + + .brand-title { + max-width: 260px; + color: var(--accent); + font-family: var(--font-mono); + font-size: 17px; + font-weight: 800; + line-height: 1.15; + letter-spacing: 0.04em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .top-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + } + + .icon-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + color: var(--text-secondary); + transition: var(--transition); + } + + .icon-btn:active, + .btn:active, + .plan:active, + .method:active { + transform: translateY(1px); + } + + .icon, + .icon-btn svg, + .btn svg { + width: 16px; + height: 16px; + display: block; + fill: currentColor; + flex: 0 0 auto; + } + + .panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)), var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38); + } + + .subscription-panel { + padding: 16px; + display: grid; + gap: 16px; + } + + .badge-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .mono-pill, + .badge { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + white-space: nowrap; + } + + .mono-pill { + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--accent) 9%, transparent); + color: var(--accent); + } + + .badge { + background: rgba(16, 185, 129, 0.1); + color: var(--success); + } + + .badge.off { + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + } + + .status-title { + margin: 0; + font-size: 32px; + font-weight: 850; + line-height: 1.08; + overflow-wrap: anywhere; + } + + .gradient-text { + color: var(--text-primary); + background: linear-gradient(135deg, var(--text-primary), color-mix(in srgb, var(--accent) 68%, #ffffff)); + -webkit-background-clip: text; + background-clip: text; + } + + @supports ((-webkit-background-clip: text) or (background-clip: text)) { + .gradient-text { + color: transparent; + } + } + + @supports (-webkit-text-fill-color: transparent) { + .gradient-text { + -webkit-text-fill-color: transparent; + } + } + + @supports not (color: color-mix(in srgb, #000 50%, #fff)) { + body { + background: var(--bg-primary); + } + + .mono-pill, + .btn.primary, + .step.active, + .plan.active, + .method.active { + border-color: var(--accent); + } + + .mono-pill, + .step.active, + .plan.active, + .method.active { + background: rgba(0, 254, 122, 0.08); + } + + .btn.primary { + background: var(--accent); + } + + .gradient-text { + background: none; + color: var(--text-primary); + -webkit-text-fill-color: currentColor; + } + } + + @supports not ((-webkit-background-clip: text) or (background-clip: text)) { + .gradient-text { + color: var(--text-primary); + -webkit-text-fill-color: currentColor; + } + } + + .status-subtitle { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 14px; + } + + .metrics { + display: grid; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + } + + .metric { + display: grid; + grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr); + gap: 10px; + min-height: 48px; + align-items: center; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.02); + } + + .metric + .metric { + border-top: 1px solid var(--border); + } + + .metric-label { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + } + + .metric-value { + color: var(--text-primary); + font-size: 14px; + font-weight: 750; + text-align: right; + overflow-wrap: anywhere; + } + + .actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 48px; + gap: 8px; + } + + .step-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } + + .btn { + min-height: 46px; + border-radius: var(--radius-md); + padding: 11px 13px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-primary); + font-weight: 800; + text-align: center; + text-decoration: none; + transition: transform var(--transition), border-color var(--transition), background var(--transition), color var(--transition), box-shadow var(--transition); + } + + .btn.primary { + border-color: color-mix(in srgb, var(--accent) 80%, var(--border)); + background: + linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, #00c95f)); + color: #04110a; + box-shadow: 0 10px 26px color-mix(in srgb, var(--accent) 20%, transparent); + } + + .btn.ghost { + color: var(--text-secondary); + } + + .btn.full { + width: 100%; + } + + .btn.icon-only { + width: 48px; + min-width: 48px; + padding: 0; + } + + .logout-btn { + min-height: 38px; + padding: 8px 11px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1; + } + + .extend-row { + margin-top: 2px; + padding-top: 14px; + border-top: 1px solid var(--border); + } + + .extend-btn { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background: color-mix(in srgb, var(--accent) 5%, transparent); + color: var(--accent); + box-shadow: none; + } + + .support-link { + margin-top: 2px; + } + + .legal-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px 16px; + margin-top: 4px; + } + + #legal-links-login { + width: min(100%, 560px); + } + + .legal-link { + flex: 0 1 auto; + display: inline; + padding: 0; + border: 0; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 650; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor); + text-underline-offset: 3px; + transition: color var(--transition), opacity var(--transition); + } + + .legal-link:hover:not(:disabled) { + color: var(--accent); + opacity: 0.98; + } + + .modal { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + align-items: center; + justify-items: center; + padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; + } + + .modal.show { + opacity: 1; + pointer-events: auto; + } + + .modal-backdrop { + position: absolute; + inset: 0; + padding: 0; + border: 0; + background: rgba(0, 0, 0, 0.62); + backdrop-filter: blur(7px); + } + + .modal-card { + position: relative; + width: min(100%, 560px); + max-height: min(86vh, 760px); + overflow: auto; + transform: translateY(18px) scale(0.98); + transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); + } + + .modal.show .modal-card { + transform: translateY(0) scale(1); + } + + .payment-flow { + padding: 14px; + display: grid; + gap: 14px; + } + + .flow-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .flow-title { + font-size: 18px; + font-weight: 850; + line-height: 1.2; + } + + .flow-caption { + margin-top: 3px; + color: var(--text-secondary); + font-size: 13px; + } + + .stepper { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + + .step { + min-height: 58px; + padding: 9px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.02); + color: var(--text-muted); + } + + .step-num { + display: block; + color: inherit; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; + line-height: 1.1; + } + + .step-name { + display: block; + margin-top: 5px; + color: inherit; + font-size: 12px; + font-weight: 800; + line-height: 1.15; + } + + .step.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 9%, transparent); + color: var(--accent); + } + + .step.done { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + color: var(--text-secondary); + } + + .step-pane { + display: grid; + gap: 10px; + } + + .section-label { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; + } + + .plans, + .methods { + display: grid; + gap: 8px; + } + + .plan, + .method { + width: 100%; + min-height: 64px; + padding: 13px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + transition: transform var(--transition), border-color var(--transition), background var(--transition), box-shadow var(--transition); + } + + .plan.active, + .method.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 58%, transparent); + } + + .plan-name, + .method-name, + .plan-price { + display: block; + font-size: 15px; + font-weight: 850; + line-height: 1.2; + overflow-wrap: anywhere; + } + + .plan-meta, + .method-meta { + display: block; + margin-top: 4px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + line-height: 1.3; + } + + .plan-price { + flex: 0 0 auto; + color: var(--accent); + font-family: var(--font-mono); + text-align: right; + } + + .notice, + .empty { + padding: 13px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text-secondary); + font-size: 14px; + line-height: 1.45; + } + + .result-box { + display: grid; + gap: 10px; + } + + .loader { + min-height: 78vh; + display: grid; + place-items: center; + color: var(--text-muted); + font-family: var(--font-mono); + font-weight: 800; + } + + .login { + min-height: 100vh; + padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 18px); + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + } + + .login.show { + display: flex; + } + + .login-card { + width: min(100%, 560px); + padding: 18px; + display: grid; + gap: 13px; + text-align: center; + } + + .login-brand { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + min-width: 0; + } + + .login-brand-title { + max-width: 260px; + color: var(--accent); + font-family: var(--font-mono); + font-size: 24px; + font-weight: 800; + line-height: 1.1; + letter-spacing: 0.04em; + text-align: left; + overflow-wrap: anywhere; + } + + .login-text { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.45; + } + + .telegram-login-widget { + min-height: 56px; + display: flex; + align-items: center; + justify-content: center; + } + + .login-status { + min-height: 20px; + margin: 0; + } + + .login-status.error { + color: var(--danger); + } + + .toast { + position: fixed; + left: 50%; + bottom: max(var(--app-safe-bottom), 18px); + z-index: 40; + width: max-content; + max-width: calc(100vw - 28px); + padding: 11px 13px; + border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); + border-radius: var(--radius-md); + background: rgba(15, 21, 33, 0.96); + color: var(--text-primary); + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42); + font-size: 13px; + font-weight: 750; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 8px); + transition: opacity var(--transition), transform var(--transition); + } + + .toast.show { + opacity: 1; + transform: translate(-50%, 0); + } + + .hidden { + display: none !important; + } + + @media (hover: hover) and (pointer: fine) { + .btn:hover:not(:disabled), + .icon-btn:hover:not(:disabled), + .plan:hover:not(:disabled), + .method:hover:not(:disabled) { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: rgba(255, 255, 255, 0.055); + } + + .btn.primary:hover:not(:disabled) { + background: + linear-gradient(135deg, var(--accent-hover), color-mix(in srgb, var(--accent) 78%, #00c95f)); + box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 24%, transparent); + } + + .extend-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 10%, transparent); + box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent); + } + } + + @media (max-width: 380px) { + .app { + padding-left: 10px; + padding-right: 10px; + } + + .brand-title { + max-width: 164px; + } + + .status-title { + font-size: 28px; + } + + .metric { + grid-template-columns: 1fr; + } + + .metric-value { + text-align: left; + } + + .step-actions { + grid-template-columns: 1fr; + } + + .stepper { + gap: 6px; + } + + .step { + min-height: 54px; + padding: 8px 7px; + } + + .logout-btn span { + display: none; + } + + .brand-logo--lg { + width: 68px; + height: 68px; + } + + .login-brand { + gap: 10px; + } + + .login-brand-title { + max-width: 200px; + font-size: 21px; + } + + } + + @media (max-width: 560px) { + .modal { + align-items: stretch; + justify-items: stretch; + top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)); + bottom: var(--app-safe-bottom); + padding: 0; + } + + .modal-card { + width: 100vw; + max-height: none; + height: 100%; + border-radius: 0; + transform: translateY(12px); + } + + .modal.show .modal-card { + transform: translateY(0); + } + + .payment-flow { + min-height: 100%; + border-radius: 0; + } + + .legal-link { + flex: 1 1 100%; + } + } diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index 7fa2019..152c803 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -3,1176 +3,155 @@
- +