feat: tune webapp visual, use telegram widget for login
This commit is contained in:
+1
-1
@@ -38,7 +38,7 @@ WEBAPP_SERVER_HOST=0.0.0.0 #
|
|||||||
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
|
||||||
WEBAPP_TITLE="Моя подписка" # Mini App title
|
WEBAPP_TITLE="Моя подписка" # Mini App title
|
||||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
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_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
|
||||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||||
|
|||||||
@@ -67,12 +67,14 @@
|
|||||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
| `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/` |
|
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) Публичный URL Mini App для показа подписки. Если задан, кнопка «Моя подписка» откроет Web App. | `https://app.domain.com/` |
|
||||||
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
|
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
|
||||||
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
|
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
|
||||||
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
|
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
|
||||||
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
|
| `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` |
|
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||||
@@ -192,7 +194,7 @@
|
|||||||
|
|
||||||
### Настройка Web App / Mini App
|
### Настройка 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 и порт:
|
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. Перезапустите контейнер:
|
5. Перезапустите контейнер:
|
||||||
|
|
||||||
@@ -250,6 +252,19 @@ Web App запускается в том же контейнере, что и б
|
|||||||
|
|
||||||
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
|
После этого кнопка «Моя подписка» в меню бота откроет 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
|
## Подробная инструкция для развертывания на сервере с панелью Remnawave
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
### 1. Клонирование репозитория
|
||||||
|
|||||||
@@ -4,16 +4,15 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.types import LabeledPrice
|
from aiogram.types import LabeledPrice
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from bot.app.web.webapp_auth import (
|
from bot.app.web.webapp_auth import (
|
||||||
consume_authorized_webapp_auth_token,
|
|
||||||
create_pending_webapp_auth_token,
|
|
||||||
create_webapp_session_token,
|
create_webapp_session_token,
|
||||||
|
validate_telegram_login_widget_data,
|
||||||
validate_telegram_webapp_init_data,
|
validate_telegram_webapp_init_data,
|
||||||
verify_webapp_session_token,
|
verify_webapp_session_token,
|
||||||
)
|
)
|
||||||
@@ -31,6 +30,16 @@ from db.models import Payment, User
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
|
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 = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
|
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
|
||||||
|
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||||
|
|
||||||
|
|
||||||
def create_subscription_webapp_application(
|
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]
|
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
|
||||||
app[key] = dp.workflow_data[key] # type: ignore[index]
|
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)
|
setup_subscription_webapp_routes(app)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -64,9 +76,11 @@ def create_subscription_webapp_application(
|
|||||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||||
app.router.add_get("/", index_route)
|
app.router.add_get("/", index_route)
|
||||||
app.router.add_get("/health", health_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_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_get("/api/me", me_route)
|
||||||
app.router.add_post("/api/payments", create_payment_route)
|
app.router.add_post("/api/payments", create_payment_route)
|
||||||
app.router.add_get("/api/payments/{payment_id}", payment_status_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})
|
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:
|
async def index_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
if not settings.WEBAPP_ENABLED:
|
if not settings.WEBAPP_ENABLED:
|
||||||
@@ -87,25 +266,76 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
||||||
"logoUrl": settings.WEBAPP_LOGO_URL or "",
|
"logoUrl": settings.WEBAPP_LOGO_URL or "",
|
||||||
"apiBase": "/api",
|
"apiBase": "/api",
|
||||||
|
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
||||||
"supportUrl": settings.SUPPORT_LINK 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",
|
"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(
|
html = html.replace(
|
||||||
"__WEBAPP_CONFIG__",
|
WEBAPP_CONFIG_PLACEHOLDER,
|
||||||
json.dumps(config, ensure_ascii=False, separators=(",", ":")),
|
(
|
||||||
|
"<script>window.__WEBAPP_CONFIG__="
|
||||||
|
+ json.dumps(config, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
+ ";</script>"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return web.Response(text=html, content_type="text/html", charset="utf-8")
|
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:
|
async def auth_token_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
init_data = str(payload.get("init_data") or "")
|
init_data = str(payload.get("init_data") or "")
|
||||||
|
auth_data = payload.get("auth_data")
|
||||||
|
telegram_user = None
|
||||||
|
if init_data:
|
||||||
telegram_user = validate_telegram_webapp_init_data(
|
telegram_user = validate_telegram_webapp_init_data(
|
||||||
init_data,
|
init_data,
|
||||||
settings.BOT_TOKEN,
|
settings.BOT_TOKEN,
|
||||||
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
|
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:
|
if not telegram_user:
|
||||||
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
|
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})
|
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:
|
async def me_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
data = await _build_user_payload(request, user_id)
|
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:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||||
return {
|
return {
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
"username": db_user.username,
|
"username": db_user.username,
|
||||||
"first_name": db_user.first_name,
|
"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),
|
"subscription": _serialize_subscription(active, local_sub, lang),
|
||||||
"plans": _serialize_plans(settings),
|
"plans": _serialize_plans(settings, lang),
|
||||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||||
"settings": {
|
"settings": {
|
||||||
"support_url": settings.SUPPORT_LINK,
|
"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(
|
def _serialize_subscription(
|
||||||
active: Optional[Dict[str, Any]],
|
active: Optional[Dict[str, Any]],
|
||||||
local_sub: Optional[Any],
|
local_sub: Optional[Any],
|
||||||
|
lang: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
if not active:
|
if not active:
|
||||||
return {
|
return {
|
||||||
"active": False,
|
"active": False,
|
||||||
"status": "INACTIVE",
|
"status": "INACTIVE",
|
||||||
"remaining_text": "Нет активной подписки",
|
"remaining_text": _format_remaining(0, lang),
|
||||||
"days_left": 0,
|
"days_left": 0,
|
||||||
"config_link": None,
|
"config_link": None,
|
||||||
"connect_url": None,
|
"connect_url": None,
|
||||||
@@ -365,7 +563,7 @@ def _serialize_subscription(
|
|||||||
"end_date": end_date.isoformat() if end_date else None,
|
"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",
|
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A",
|
||||||
"days_left": seconds_left // 86400,
|
"days_left": seconds_left // 86400,
|
||||||
"remaining_text": _format_remaining(seconds_left),
|
"remaining_text": _format_remaining(seconds_left, lang),
|
||||||
"config_link": active.get("config_link"),
|
"config_link": active.get("config_link"),
|
||||||
"connect_url": active.get("connect_button_url") or 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")),
|
"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]] = []
|
plans: List[Dict[str, Any]] = []
|
||||||
for months, price in sorted(settings.subscription_options.items()):
|
for months, price in sorted(settings.subscription_options.items()):
|
||||||
plan = {
|
plan = {
|
||||||
"months": int(months),
|
"months": int(months),
|
||||||
"price": float(price),
|
"price": float(price),
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"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)
|
stars_price = settings.stars_subscription_options.get(months)
|
||||||
if stars_price is not None and int(stars_price) > 0:
|
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")
|
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 seconds <= 0:
|
||||||
|
if lang == "en":
|
||||||
|
return "Subscription inactive"
|
||||||
return "Подписка не активна"
|
return "Подписка не активна"
|
||||||
days, rem = divmod(seconds, 86400)
|
days, rem = divmod(seconds, 86400)
|
||||||
hours, rem = divmod(rem, 3600)
|
hours, rem = divmod(rem, 3600)
|
||||||
minutes = rem // 60
|
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:
|
if days > 0:
|
||||||
return f"{days} д. {hours} ч."
|
return f"{days} д. {hours} ч."
|
||||||
if hours > 0:
|
if hours > 0:
|
||||||
@@ -873,7 +1084,11 @@ def _format_bytes(value: Optional[Any]) -> str:
|
|||||||
return f"{size:.2f} {units[index]}"
|
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:
|
if months == 1:
|
||||||
return "1 месяц"
|
return "1 месяц"
|
||||||
if 2 <= months <= 4:
|
if 2 <= months <= 4:
|
||||||
@@ -883,5 +1098,5 @@ def _format_months_title(months: int) -> str:
|
|||||||
|
|
||||||
def _payment_description(months: int, lang: str) -> str:
|
def _payment_description(months: int, lang: str) -> str:
|
||||||
if lang == "en":
|
if lang == "en":
|
||||||
return f"Subscription for {months} month(s)"
|
return f"Subscription for {_format_months_title(months, lang)}"
|
||||||
return f"Подписка на {_format_months_title(months)}"
|
return f"Подписка на {_format_months_title(months, lang)}"
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,888 @@
|
|||||||
|
/* WEBAPP_DEV_MOCK_START */
|
||||||
|
window.__WEBAPP_DEV_MOCK__ = {
|
||||||
|
config: {
|
||||||
|
title: 'Моя подписка',
|
||||||
|
primaryColor: '#00fe7a',
|
||||||
|
logoUrl: '',
|
||||||
|
apiBase: '/api',
|
||||||
|
supportUrl: 'https://t.me/support',
|
||||||
|
privacyPolicyUrl: 'https://example.com/privacy',
|
||||||
|
userAgreementUrl: 'https://example.com/agreement',
|
||||||
|
currency: 'RUB',
|
||||||
|
language: 'ru'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
ok: true,
|
||||||
|
user: {
|
||||||
|
id: 100200300,
|
||||||
|
username: 'preview',
|
||||||
|
first_name: 'Preview',
|
||||||
|
language_code: 'ru'
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
active: true,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
remaining_text: '27 д. 5 ч.',
|
||||||
|
end_date_text: '19.05.2026 22:40',
|
||||||
|
days_left: 27,
|
||||||
|
config_link: 'https://sub.example.com/sub/preview-token',
|
||||||
|
connect_url: 'https://sub.example.com/connect/preview-token',
|
||||||
|
traffic_used: '18.42 GB',
|
||||||
|
traffic_limit: '500.00 GB',
|
||||||
|
auto_renew_enabled: false,
|
||||||
|
provider: null
|
||||||
|
},
|
||||||
|
plans: [
|
||||||
|
{months: 1, price: 190, currency: 'RUB', title: '1 месяц'},
|
||||||
|
{months: 3, price: 550, currency: 'RUB', title: '3 месяца'},
|
||||||
|
{months: 6, price: 1100, currency: 'RUB', title: '6 месяцев'},
|
||||||
|
{months: 12, price: 2000, currency: 'RUB', title: '12 месяцев'}
|
||||||
|
],
|
||||||
|
payment_methods: [
|
||||||
|
{id: 'cryptopay', name: 'CryptoPay'},
|
||||||
|
{id: 'platega', name: 'Platega'},
|
||||||
|
{id: 'freekassa', name: 'FreeKassa / СБП'}
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
support_url: 'https://t.me/support',
|
||||||
|
traffic_mode: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* WEBAPP_DEV_MOCK_END */
|
||||||
|
|
||||||
|
const MOCK = (() => {
|
||||||
|
const mock = window.__WEBAPP_DEV_MOCK__;
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const isLocal = window.location.protocol === 'file:' || host === 'localhost' || host === '127.0.0.1' || host === '';
|
||||||
|
return mock && isLocal ? mock : null;
|
||||||
|
})();
|
||||||
|
const CFG = window.__WEBAPP_CONFIG__ || (MOCK && MOCK.config) || {};
|
||||||
|
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
|
||||||
|
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
|
||||||
|
const state = {
|
||||||
|
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
|
||||||
|
data: null,
|
||||||
|
selectedPlan: null,
|
||||||
|
selectedMethod: null,
|
||||||
|
payment: null,
|
||||||
|
paymentFlowOpen: false,
|
||||||
|
paymentStep: 'plan',
|
||||||
|
creatingPayment: false,
|
||||||
|
authInProgress: false,
|
||||||
|
toastTimer: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
ru: {
|
||||||
|
page_title: 'Моя подписка',
|
||||||
|
loading: 'Загрузка...',
|
||||||
|
refresh: 'Обновить',
|
||||||
|
logout: 'Выйти',
|
||||||
|
subscription: 'Подписка',
|
||||||
|
subscription_subtitle: 'Ваша ссылка подключения и срок доступа',
|
||||||
|
ends_at: 'Окончание',
|
||||||
|
traffic: 'Трафик',
|
||||||
|
connect: 'Подключиться',
|
||||||
|
copy_link: 'Скопировать ссылку',
|
||||||
|
extend_subscription: 'Продлить подписку/Добавить дни',
|
||||||
|
payment_title: 'Оплата подписки',
|
||||||
|
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
|
||||||
|
close_payment: 'Закрыть оплату',
|
||||||
|
payment_steps: 'Шаги оплаты',
|
||||||
|
step_plan: 'Срок',
|
||||||
|
step_method: 'Метод',
|
||||||
|
step_result: 'Платеж',
|
||||||
|
select_period: 'Выберите срок',
|
||||||
|
select_method: 'Выберите способ оплаты',
|
||||||
|
payment_status: 'Статус платежа',
|
||||||
|
choose_payment_method: 'Выбрать способ оплаты',
|
||||||
|
back: 'Назад',
|
||||||
|
create_payment: 'Создать платеж',
|
||||||
|
open_payment: 'Открыть оплату',
|
||||||
|
check_payment: 'Проверить оплату',
|
||||||
|
choose_other_method: 'Выбрать другой способ',
|
||||||
|
support: 'Поддержка',
|
||||||
|
telegram_auth: 'Telegram auth',
|
||||||
|
telegram_auth_verifying: 'Проверяю вход...',
|
||||||
|
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
|
||||||
|
telegram_auth_unavailable: 'Telegram Login Widget недоступен. Проверьте username бота и доступ к telegram.org.',
|
||||||
|
telegram_auth_access_denied: 'Доступ запрещен.',
|
||||||
|
active: 'Активна',
|
||||||
|
inactive: 'Не активна',
|
||||||
|
no_active_subscription: 'Нет активной подписки',
|
||||||
|
not_available: 'N/A',
|
||||||
|
no_plans: 'Тарифы не настроены.',
|
||||||
|
access_period: 'Срок доступа',
|
||||||
|
choose_plan_first: 'Сначала выберите срок подписки.',
|
||||||
|
no_methods: 'Нет доступных способов оплаты.',
|
||||||
|
payment_not_created: 'Платеж еще не создан.',
|
||||||
|
invoice_sent: 'Счет отправлен в чат с ботом. После оплаты вернитесь сюда и проверьте статус.',
|
||||||
|
payment_created: 'Платеж создан. Откройте оплату, завершите ее у провайдера и затем проверьте статус.',
|
||||||
|
updating: 'Обновляю данные',
|
||||||
|
choose_plan_toast: 'Выберите срок подписки',
|
||||||
|
choose_method_toast: 'Выберите способ оплаты',
|
||||||
|
create_payment_toast: 'Создайте платеж',
|
||||||
|
payment_confirmed: 'Оплата подтверждена',
|
||||||
|
payment_pending: 'Платеж пока не подтвержден',
|
||||||
|
link_copied: 'Ссылка скопирована',
|
||||||
|
no_link: 'Ссылка пока недоступна',
|
||||||
|
payment_error: 'Ошибка оплаты',
|
||||||
|
privacy_policy: 'Политика конфиденциальности',
|
||||||
|
user_agreement: 'Пользовательское соглашение'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
page_title: 'My subscription',
|
||||||
|
loading: 'Loading...',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
logout: 'Log out',
|
||||||
|
subscription: 'Subscription',
|
||||||
|
subscription_subtitle: 'Your connection link and access time',
|
||||||
|
ends_at: 'Ends at',
|
||||||
|
traffic: 'Traffic',
|
||||||
|
connect: 'Connect',
|
||||||
|
copy_link: 'Copy link',
|
||||||
|
extend_subscription: 'Renew subscription/Add days',
|
||||||
|
payment_title: 'Subscription payment',
|
||||||
|
payment_caption: 'Choose a period, payment method, and create a payment.',
|
||||||
|
close_payment: 'Close payment',
|
||||||
|
payment_steps: 'Payment steps',
|
||||||
|
step_plan: 'Period',
|
||||||
|
step_method: 'Method',
|
||||||
|
step_result: 'Payment',
|
||||||
|
select_period: 'Select period',
|
||||||
|
select_method: 'Select payment method',
|
||||||
|
payment_status: 'Payment status',
|
||||||
|
choose_payment_method: 'Choose payment method',
|
||||||
|
back: 'Back',
|
||||||
|
create_payment: 'Create payment',
|
||||||
|
open_payment: 'Open payment',
|
||||||
|
check_payment: 'Check payment',
|
||||||
|
choose_other_method: 'Choose another method',
|
||||||
|
support: 'Support',
|
||||||
|
telegram_auth: 'Telegram auth',
|
||||||
|
telegram_auth_verifying: 'Verifying login...',
|
||||||
|
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
|
||||||
|
telegram_auth_unavailable: 'Telegram Login Widget is unavailable. Check the bot username and access to telegram.org.',
|
||||||
|
telegram_auth_access_denied: 'Access denied.',
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
no_active_subscription: 'No active subscription',
|
||||||
|
not_available: 'N/A',
|
||||||
|
no_plans: 'Plans are not configured.',
|
||||||
|
access_period: 'Access period',
|
||||||
|
choose_plan_first: 'Choose a subscription period first.',
|
||||||
|
no_methods: 'No payment methods available.',
|
||||||
|
payment_not_created: 'Payment has not been created yet.',
|
||||||
|
invoice_sent: 'The invoice was sent to the bot chat. Pay it, then return here and check the status.',
|
||||||
|
payment_created: 'Payment was created. Open it, complete payment with the provider, then check the status.',
|
||||||
|
updating: 'Refreshing data',
|
||||||
|
choose_plan_toast: 'Choose a subscription period',
|
||||||
|
choose_method_toast: 'Choose a payment method',
|
||||||
|
create_payment_toast: 'Create a payment',
|
||||||
|
payment_confirmed: 'Payment confirmed',
|
||||||
|
payment_pending: 'Payment is not confirmed yet',
|
||||||
|
link_copied: 'Link copied',
|
||||||
|
no_link: 'Link is not available yet',
|
||||||
|
payment_error: 'Payment error',
|
||||||
|
privacy_policy: 'Privacy policy',
|
||||||
|
user_agreement: 'User agreement'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const accent = CFG.primaryColor || '#00fe7a';
|
||||||
|
document.documentElement.style.setProperty('--accent', accent);
|
||||||
|
setBrand();
|
||||||
|
applyI18n();
|
||||||
|
applyLegalLinks();
|
||||||
|
if (CFG.supportUrl) {
|
||||||
|
const support = document.getElementById('support-link');
|
||||||
|
support.href = CFG.supportUrl;
|
||||||
|
support.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tg) {
|
||||||
|
try {
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
const canSetColors = typeof tg.isVersionAtLeast === 'function' && tg.isVersionAtLeast('6.1');
|
||||||
|
if (canSetColors) {
|
||||||
|
tg.setHeaderColor('#05070a');
|
||||||
|
tg.setBackgroundColor('#05070a');
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Escape' && state.paymentFlowOpen) {
|
||||||
|
closePaymentFlow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
boot();
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
showLoader();
|
||||||
|
if (MOCK) {
|
||||||
|
await loadData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetAuthData = readTelegramLoginWidgetAuthData();
|
||||||
|
if (widgetAuthData) {
|
||||||
|
const authenticated = await finalizeTelegramAuth(widgetAuthData);
|
||||||
|
if (authenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearToken();
|
||||||
|
startExternalAuth({resetStatus: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tg && tg.initData) {
|
||||||
|
try {
|
||||||
|
const authenticated = await finalizeTelegramAuth(tg.initData, 'init_data');
|
||||||
|
if (authenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.token) {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
clearToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await startExternalAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTelegramLoginWidgetAuthData() {
|
||||||
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
|
||||||
|
const authData = {};
|
||||||
|
let hasAuthValue = false;
|
||||||
|
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (!query.has(key)) return;
|
||||||
|
authData[key] = query.get(key) || '';
|
||||||
|
hasAuthValue = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTelegramLoginWidgetQuery() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
|
||||||
|
keys.forEach(key => url.searchParams.delete(key));
|
||||||
|
if (window.history && window.history.replaceState) {
|
||||||
|
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeTelegramAuth(authData, source = 'auth_data') {
|
||||||
|
if (state.authInProgress) return false;
|
||||||
|
state.authInProgress = true;
|
||||||
|
setAuthStatus(t('telegram_auth_verifying'));
|
||||||
|
try {
|
||||||
|
const payload = source === 'init_data'
|
||||||
|
? {init_data: authData}
|
||||||
|
: {auth_data: authData};
|
||||||
|
const response = await fetch(CFG.apiBase + '/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data.ok && data.token) {
|
||||||
|
setToken(data.token);
|
||||||
|
clearTelegramLoginWidgetQuery();
|
||||||
|
try {
|
||||||
|
setAuthStatus('');
|
||||||
|
await loadData();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
clearToken();
|
||||||
|
setAuthStatus(t('telegram_auth_failed'), true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorKey = data.error === 'banned' || data.error === 'access_denied'
|
||||||
|
? 'telegram_auth_access_denied'
|
||||||
|
: 'telegram_auth_failed';
|
||||||
|
setAuthStatus(t(errorKey), true);
|
||||||
|
} catch (e) {
|
||||||
|
setAuthStatus(t('telegram_auth_failed'), true);
|
||||||
|
} finally {
|
||||||
|
state.authInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTelegramLoginWidgetQuery();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTelegramLoginWidgetAuth(user) {
|
||||||
|
if (!user) {
|
||||||
|
setAuthStatus(t('telegram_auth_failed'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await finalizeTelegramAuth(user, 'auth_data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTelegramLoginWidget() {
|
||||||
|
const container = document.getElementById('telegram-login-widget');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
|
||||||
|
if (!botUsername) {
|
||||||
|
setAuthStatus(t('telegram_auth_unavailable'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.onTelegramAuth !== 'function') {
|
||||||
|
window.onTelegramAuth = async function(user) {
|
||||||
|
await handleTelegramLoginWidgetAuth(user);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = TELEGRAM_LOGIN_WIDGET_URL;
|
||||||
|
script.setAttribute('data-telegram-login', botUsername);
|
||||||
|
script.setAttribute('data-size', 'large');
|
||||||
|
script.setAttribute('data-userpic', 'false');
|
||||||
|
script.setAttribute('data-request-access', 'write');
|
||||||
|
script.setAttribute('data-onauth', 'onTelegramAuth(user)');
|
||||||
|
script.onerror = () => setAuthStatus(t('telegram_auth_unavailable'), true);
|
||||||
|
container.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startExternalAuth(options = {}) {
|
||||||
|
const resetStatus = options.resetStatus !== false;
|
||||||
|
showLogin();
|
||||||
|
if (resetStatus) {
|
||||||
|
setAuthStatus('');
|
||||||
|
}
|
||||||
|
renderTelegramLoginWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const previousMonths = state.selectedPlan && state.selectedPlan.months;
|
||||||
|
const data = await api('/me');
|
||||||
|
if (!data.ok) throw new Error(data.error || 'load failed');
|
||||||
|
state.data = data;
|
||||||
|
setBrand();
|
||||||
|
applyI18n();
|
||||||
|
applyLegalLinks();
|
||||||
|
const plans = data.plans || [];
|
||||||
|
state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null;
|
||||||
|
state.selectedMethod = null;
|
||||||
|
state.payment = null;
|
||||||
|
state.paymentStep = 'plan';
|
||||||
|
render();
|
||||||
|
showApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadData() {
|
||||||
|
showToast(t('updating'));
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderSubscription(state.data.subscription);
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubscription(sub) {
|
||||||
|
const badge = document.getElementById('status-badge');
|
||||||
|
badge.textContent = sub.active ? t('active') : t('inactive');
|
||||||
|
badge.classList.toggle('off', !sub.active);
|
||||||
|
document.getElementById('remaining').textContent = sub.remaining_text || t('no_active_subscription');
|
||||||
|
document.getElementById('end-date').textContent = sub.end_date_text || t('not_available');
|
||||||
|
document.getElementById('traffic').textContent = (sub.traffic_used || t('not_available')) + ' / ' + (sub.traffic_limit || t('not_available'));
|
||||||
|
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePaymentFlow() {
|
||||||
|
if (state.paymentFlowOpen) {
|
||||||
|
closePaymentFlow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.paymentFlowOpen = true;
|
||||||
|
state.paymentStep = 'plan';
|
||||||
|
state.payment = null;
|
||||||
|
state.selectedMethod = null;
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePaymentFlow() {
|
||||||
|
state.paymentFlowOpen = false;
|
||||||
|
state.paymentStep = 'plan';
|
||||||
|
state.payment = null;
|
||||||
|
state.selectedMethod = null;
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaymentFlow() {
|
||||||
|
const modal = document.getElementById('payment-modal');
|
||||||
|
if (!state.paymentFlowOpen) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!state.paymentFlowOpen) modal.classList.add('hidden');
|
||||||
|
}, 180);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||||
|
applyI18n(modal);
|
||||||
|
renderStepState();
|
||||||
|
renderPlans(state.data.plans || []);
|
||||||
|
renderMethods();
|
||||||
|
renderPaymentResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepState() {
|
||||||
|
const order = ['plan', 'method', 'result'];
|
||||||
|
order.forEach((step, index) => {
|
||||||
|
const node = document.getElementById('step-' + step);
|
||||||
|
const currentIndex = order.indexOf(state.paymentStep);
|
||||||
|
node.classList.toggle('active', state.paymentStep === step);
|
||||||
|
node.classList.toggle('done', currentIndex > index);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('plan-step').classList.toggle('hidden', state.paymentStep !== 'plan');
|
||||||
|
document.getElementById('method-step').classList.toggle('hidden', state.paymentStep !== 'method');
|
||||||
|
document.getElementById('result-step').classList.toggle('hidden', state.paymentStep !== 'result');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlans(plans) {
|
||||||
|
const wrap = document.getElementById('plans');
|
||||||
|
const nextBtn = document.getElementById('to-methods-btn');
|
||||||
|
if (!plans.length) {
|
||||||
|
wrap.innerHTML = '<div class="empty">' + escapeHtml(t('no_plans')) + '</div>';
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextBtn.disabled = !state.selectedPlan;
|
||||||
|
wrap.innerHTML = plans.map(plan => {
|
||||||
|
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
|
||||||
|
const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
|
||||||
|
return `
|
||||||
|
<button class="plan ${isActive ? 'active' : ''}" type="button" onclick="selectPlan(${plan.months})">
|
||||||
|
<span>
|
||||||
|
<span class="plan-name">${escapeHtml(plan.title)}</span>
|
||||||
|
<span class="plan-meta">${escapeHtml(t('access_period'))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="plan-price">${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMethods() {
|
||||||
|
const methods = document.getElementById('methods');
|
||||||
|
const createBtn = document.getElementById('create-payment-btn');
|
||||||
|
if (!state.selectedPlan) {
|
||||||
|
methods.innerHTML = '<div class="empty">' + escapeHtml(t('choose_plan_first')) + '</div>';
|
||||||
|
createBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = getAvailableMethods();
|
||||||
|
if (state.selectedMethod && !available.some(method => method.id === state.selectedMethod)) {
|
||||||
|
state.selectedMethod = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createBtn.disabled = !state.selectedMethod || state.creatingPayment;
|
||||||
|
if (!available.length) {
|
||||||
|
methods.innerHTML = '<div class="empty">' + escapeHtml(t('no_methods')) + '</div>';
|
||||||
|
createBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
methods.innerHTML = available.map(method => {
|
||||||
|
const isActive = state.selectedMethod === method.id;
|
||||||
|
const amount = method.id === 'stars'
|
||||||
|
? state.selectedPlan.stars_price + ' Stars'
|
||||||
|
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
|
||||||
|
return `
|
||||||
|
<button class="method ${isActive ? 'active' : ''}" type="button" onclick="selectMethod('${escapeAttr(method.id)}')">
|
||||||
|
<span>
|
||||||
|
<span class="method-name">${escapeHtml(method.name)}</span>
|
||||||
|
<span class="method-meta">${escapeHtml(state.selectedPlan.title)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="plan-price">${escapeHtml(amount)}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaymentResult() {
|
||||||
|
const payment = state.payment;
|
||||||
|
const msg = document.getElementById('payment-message');
|
||||||
|
const openBtn = document.getElementById('payment-open-btn');
|
||||||
|
const checkBtn = document.getElementById('payment-check-btn');
|
||||||
|
if (!payment) {
|
||||||
|
msg.textContent = t('payment_not_created');
|
||||||
|
openBtn.classList.add('hidden');
|
||||||
|
checkBtn.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.action === 'invoice_sent') {
|
||||||
|
msg.textContent = t('invoice_sent');
|
||||||
|
openBtn.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
msg.textContent = t('payment_created');
|
||||||
|
openBtn.classList.toggle('hidden', !payment.payment_url);
|
||||||
|
}
|
||||||
|
checkBtn.classList.toggle('hidden', !payment.payment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPlan(months) {
|
||||||
|
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months) || null;
|
||||||
|
state.selectedMethod = null;
|
||||||
|
state.payment = null;
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMethod(method) {
|
||||||
|
state.selectedMethod = method;
|
||||||
|
state.payment = null;
|
||||||
|
renderMethods();
|
||||||
|
renderPaymentResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPaymentStep(step) {
|
||||||
|
if (step === 'method' && !state.selectedPlan) {
|
||||||
|
showToast(t('choose_plan_toast'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 'result' && !state.payment) {
|
||||||
|
showToast(t('create_payment_toast'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.paymentStep = step;
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPaymentFromSelection() {
|
||||||
|
if (!state.selectedPlan) {
|
||||||
|
showToast(t('choose_plan_toast'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.selectedMethod) {
|
||||||
|
showToast(t('choose_method_toast'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.creatingPayment = true;
|
||||||
|
renderMethods();
|
||||||
|
try {
|
||||||
|
const data = await api('/payments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({months: state.selectedPlan.months, method: state.selectedMethod})
|
||||||
|
});
|
||||||
|
if (!data.ok) throw new Error(data.message || t('payment_error'));
|
||||||
|
state.payment = data;
|
||||||
|
state.paymentStep = 'result';
|
||||||
|
renderPaymentFlow();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('payment_error'));
|
||||||
|
} finally {
|
||||||
|
state.creatingPayment = false;
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableMethods() {
|
||||||
|
if (!state.data || !state.selectedPlan) return [];
|
||||||
|
return (state.data.payment_methods || []).filter(method => {
|
||||||
|
if (method.id === 'stars') return Number(state.selectedPlan.stars_price || 0) > 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPaymentUrl() {
|
||||||
|
const payment = state.payment;
|
||||||
|
if (!payment || !payment.payment_url) return;
|
||||||
|
if (payment.action === 'open_invoice' && tg && tg.openInvoice) {
|
||||||
|
tg.openInvoice(payment.payment_url, function(status) {
|
||||||
|
if (status === 'paid') loadData();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tg && tg.openLink) {
|
||||||
|
tg.openLink(payment.payment_url);
|
||||||
|
} else {
|
||||||
|
window.open(payment.payment_url, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPayment() {
|
||||||
|
if (!state.payment || !state.payment.payment_id) return;
|
||||||
|
const data = await api('/payments/' + state.payment.payment_id);
|
||||||
|
if (data.paid) {
|
||||||
|
showToast(t('payment_confirmed'));
|
||||||
|
state.paymentFlowOpen = false;
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
showToast(t('payment_pending'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConnectLink() {
|
||||||
|
const sub = state.data && state.data.subscription;
|
||||||
|
const url = sub && (sub.connect_url || sub.config_link);
|
||||||
|
if (!url) {
|
||||||
|
showToast(t('no_link'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tg && tg.openLink) tg.openLink(url);
|
||||||
|
else window.open(url, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyConfigLink() {
|
||||||
|
const sub = state.data && state.data.subscription;
|
||||||
|
const link = sub && sub.config_link;
|
||||||
|
if (!link) {
|
||||||
|
showToast(t('no_link'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
showToast(t('link_copied'));
|
||||||
|
} catch (e) {
|
||||||
|
const area = document.createElement('textarea');
|
||||||
|
area.value = link;
|
||||||
|
document.body.appendChild(area);
|
||||||
|
area.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
area.remove();
|
||||||
|
showToast(t('link_copied'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
if (MOCK) {
|
||||||
|
return mockApi(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.assign({'Authorization': 'Bearer ' + state.token}, options.headers || {});
|
||||||
|
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||||
|
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
await startExternalAuth();
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockApi(path, options = {}) {
|
||||||
|
await new Promise(resolve => window.setTimeout(resolve, 120));
|
||||||
|
if (path === '/me') {
|
||||||
|
return JSON.parse(JSON.stringify(MOCK.data));
|
||||||
|
}
|
||||||
|
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action: 'open_link',
|
||||||
|
payment_url: 'https://example.com/payment-preview',
|
||||||
|
payment_id: 10001
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path.startsWith('/payments/')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payment_id: 10001,
|
||||||
|
status: 'pending',
|
||||||
|
paid: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {ok: false, error: 'not_found'};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToken(token) {
|
||||||
|
state.token = token;
|
||||||
|
localStorage.setItem('rw_webapp_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken() {
|
||||||
|
state.token = '';
|
||||||
|
localStorage.removeItem('rw_webapp_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken();
|
||||||
|
closePaymentFlow();
|
||||||
|
startExternalAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoader() {
|
||||||
|
document.getElementById('loader').classList.remove('hidden');
|
||||||
|
document.getElementById('app').classList.add('hidden');
|
||||||
|
document.getElementById('login').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
document.getElementById('loader').classList.add('hidden');
|
||||||
|
document.getElementById('login').classList.remove('show');
|
||||||
|
document.getElementById('app').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById('loader').classList.add('hidden');
|
||||||
|
document.getElementById('app').classList.add('hidden');
|
||||||
|
document.getElementById('login').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthStatus(message, isError = false) {
|
||||||
|
const status = document.getElementById('auth-status');
|
||||||
|
if (!status) return;
|
||||||
|
if (!message) {
|
||||||
|
status.textContent = '';
|
||||||
|
status.classList.add('hidden');
|
||||||
|
status.classList.remove('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
status.classList.toggle('error', Boolean(isError));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message) {
|
||||||
|
if (tg && tg.showAlert) {
|
||||||
|
tg.showAlert(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
window.requestAnimationFrame(() => toast.classList.add('show'));
|
||||||
|
if (state.toastTimer) window.clearTimeout(state.toastTimer);
|
||||||
|
state.toastTimer = window.setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
window.setTimeout(() => toast.classList.add('hidden'), 180);
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrand() {
|
||||||
|
const title = CFG.title || t('page_title');
|
||||||
|
const logoUrl = CFG.logoUrl || '';
|
||||||
|
document.title = title;
|
||||||
|
document.querySelectorAll('[data-brand-title]').forEach(node => {
|
||||||
|
node.textContent = title;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-brand-logo]').forEach(logo => {
|
||||||
|
if (!logoUrl) {
|
||||||
|
logo.removeAttribute('src');
|
||||||
|
logo.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logo.onload = () => {
|
||||||
|
logo.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
logo.onerror = () => {
|
||||||
|
logo.removeAttribute('src');
|
||||||
|
logo.classList.add('hidden');
|
||||||
|
};
|
||||||
|
logo.src = logoUrl;
|
||||||
|
if (logo.complete && logo.naturalWidth > 0) {
|
||||||
|
logo.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
logo.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyI18n(root = document) {
|
||||||
|
document.documentElement.lang = getLanguage();
|
||||||
|
root.querySelectorAll('[data-i18n]').forEach(node => {
|
||||||
|
node.textContent = t(node.dataset.i18n);
|
||||||
|
});
|
||||||
|
root.querySelectorAll('[data-title-i18n]').forEach(node => {
|
||||||
|
const value = t(node.dataset.titleI18n);
|
||||||
|
node.setAttribute('title', value);
|
||||||
|
node.setAttribute('aria-label', value);
|
||||||
|
});
|
||||||
|
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
|
||||||
|
node.setAttribute('aria-label', t(node.dataset.ariaI18n));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLegalLinks(root = document) {
|
||||||
|
const urls = {
|
||||||
|
privacyPolicyUrl: CFG.privacyPolicyUrl || '',
|
||||||
|
userAgreementUrl: CFG.userAgreementUrl || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelectorAll('[data-legal-key]').forEach(node => {
|
||||||
|
const key = node.dataset.legalKey;
|
||||||
|
const url = urls[key] || '';
|
||||||
|
node.href = url || '#';
|
||||||
|
node.classList.toggle('hidden', !url);
|
||||||
|
node.setAttribute('aria-hidden', url ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll('.legal-links').forEach(container => {
|
||||||
|
const visible = Array.from(container.querySelectorAll('[data-legal-key]'))
|
||||||
|
.some(node => !node.classList.contains('hidden'));
|
||||||
|
container.classList.toggle('hidden', !visible);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguage() {
|
||||||
|
const raw = (
|
||||||
|
state.data && state.data.user && state.data.user.language_code
|
||||||
|
? state.data.user.language_code
|
||||||
|
: (CFG.language || document.documentElement.lang || 'ru')
|
||||||
|
).toLowerCase();
|
||||||
|
const short = raw.split('-')[0];
|
||||||
|
return I18N[short] ? short : 'ru';
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key, params = {}) {
|
||||||
|
const table = I18N[getLanguage()] || I18N.ru;
|
||||||
|
const fallback = I18N.ru[key] || key;
|
||||||
|
const template = table[key] || fallback;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, name) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : ''
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value, currency) {
|
||||||
|
const numeric = Number(value || 0);
|
||||||
|
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
|
||||||
|
return formatted + ' ' + (currency || CFG.currency || 'RUB');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value).replaceAll('`', '`');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,575 @@
|
|||||||
|
(function(window) {
|
||||||
|
(function(window){
|
||||||
|
window.__parseFunction = function(__func, __attrs) {
|
||||||
|
__attrs = __attrs || [];
|
||||||
|
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
|
||||||
|
return window.execScript ? window.execScript(__func) : eval(__func);
|
||||||
|
}
|
||||||
|
}(window));
|
||||||
|
(function(window){
|
||||||
|
|
||||||
|
function addEvent(el, event, handler) {
|
||||||
|
var events = event.split(/\s+/);
|
||||||
|
for (var i = 0; i < events.length; i++) {
|
||||||
|
if (el.addEventListener) {
|
||||||
|
el.addEventListener(events[i], handler);
|
||||||
|
} else {
|
||||||
|
el.attachEvent('on' + events[i], handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeEvent(el, event, handler) {
|
||||||
|
var events = event.split(/\s+/);
|
||||||
|
for (var i = 0; i < events.length; i++) {
|
||||||
|
if (el.removeEventListener) {
|
||||||
|
el.removeEventListener(events[i], handler);
|
||||||
|
} else {
|
||||||
|
el.detachEvent('on' + events[i], handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getCssProperty(el, prop) {
|
||||||
|
if (window.getComputedStyle) {
|
||||||
|
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
|
||||||
|
} else if (el.currentStyle) {
|
||||||
|
return el.currentStyle[prop] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function geById(el_or_id) {
|
||||||
|
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
|
||||||
|
return document.getElementById(el_or_id);
|
||||||
|
} else if (el_or_id instanceof HTMLElement) {
|
||||||
|
return el_or_id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var getWidgetsOrigin = function(default_origin, dev_origin) {
|
||||||
|
var link = document.createElement('A'), origin;
|
||||||
|
link.href = document.currentScript && document.currentScript.src || default_origin;
|
||||||
|
origin = link.origin || link.protocol + '//' + link.hostname;
|
||||||
|
if (origin == 'https://telegram.org') {
|
||||||
|
origin = default_origin;
|
||||||
|
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
|
||||||
|
origin = dev_origin;
|
||||||
|
} else {
|
||||||
|
origin = default_origin;
|
||||||
|
}
|
||||||
|
return origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getPageCanonical = function() {
|
||||||
|
var a = document.createElement('A'), link, href;
|
||||||
|
if (document.querySelector) {
|
||||||
|
link = document.querySelector('link[rel="canonical"]');
|
||||||
|
if (link && (href = link.getAttribute('href'))) {
|
||||||
|
a.href = href;
|
||||||
|
return a.href;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var links = document.getElementsByTagName('LINK');
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
if ((link = links[i]) &&
|
||||||
|
(link.getAttribute('rel') == 'canonical') &&
|
||||||
|
(href = link.getAttribute('href'))) {
|
||||||
|
a.href = href;
|
||||||
|
return a.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function haveTgAuthResult() {
|
||||||
|
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
|
||||||
|
try {
|
||||||
|
locationHash = location.hash.toString();
|
||||||
|
if (match = locationHash.match(re)) {
|
||||||
|
location.hash = locationHash.replace(re, '');
|
||||||
|
var data = match[1] || '';
|
||||||
|
data = data.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
var pad = data.length % 4;
|
||||||
|
if (pad > 1) {
|
||||||
|
data += new Array(5 - pad).join('=');
|
||||||
|
}
|
||||||
|
return JSON.parse(window.atob(data));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXHR() {
|
||||||
|
if (navigator.appName == "Microsoft Internet Explorer"){
|
||||||
|
return new ActiveXObject("Microsoft.XMLHTTP");
|
||||||
|
} else {
|
||||||
|
return new XMLHttpRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.Telegram) {
|
||||||
|
window.Telegram = {};
|
||||||
|
}
|
||||||
|
if (!window.Telegram.__WidgetUuid) {
|
||||||
|
window.Telegram.__WidgetUuid = 0;
|
||||||
|
}
|
||||||
|
if (!window.Telegram.__WidgetLastId) {
|
||||||
|
window.Telegram.__WidgetLastId = 0;
|
||||||
|
}
|
||||||
|
if (!window.Telegram.__WidgetCallbacks) {
|
||||||
|
window.Telegram.__WidgetCallbacks = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function postMessageToIframe(iframe, event, data, callback) {
|
||||||
|
if (!iframe._ready) {
|
||||||
|
if (!iframe._readyQueue) iframe._readyQueue = [];
|
||||||
|
iframe._readyQueue.push([event, data, callback]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = data || {};
|
||||||
|
data.event = event;
|
||||||
|
if (callback) {
|
||||||
|
data._cb = ++window.Telegram.__WidgetLastId;
|
||||||
|
window.Telegram.__WidgetCallbacks[data._cb] = {
|
||||||
|
iframe: iframe,
|
||||||
|
callback: callback
|
||||||
|
};
|
||||||
|
}
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWidget(widgetEl) {
|
||||||
|
var widgetId, widgetElId, widgetsOrigin, existsEl,
|
||||||
|
src, styles = {}, allowedAttrs = [],
|
||||||
|
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
|
||||||
|
if (!widgetEl.tagName ||
|
||||||
|
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
|
||||||
|
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
|
||||||
|
widgetEl.classList.contains('telegram-post'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (widgetEl._iframe) {
|
||||||
|
return widgetEl._iframe;
|
||||||
|
}
|
||||||
|
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
|
||||||
|
var comment = widgetEl.getAttribute('data-comment') || '';
|
||||||
|
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||||
|
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
|
||||||
|
src = widgetsOrigin + '/' + widgetId + '?embed=1';
|
||||||
|
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
|
||||||
|
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||||
|
defHeight = '';
|
||||||
|
styles.minWidth = '320px';
|
||||||
|
}
|
||||||
|
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
|
||||||
|
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||||
|
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
|
||||||
|
var websitePageUrl = widgetEl.getAttribute('data-page-url');
|
||||||
|
if (!websitePageUrl) {
|
||||||
|
websitePageUrl = getPageCanonical();
|
||||||
|
}
|
||||||
|
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
|
||||||
|
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
|
||||||
|
defWidth = widgetEl.getAttribute('data-width') || '100%';
|
||||||
|
defHeight = widgetEl.getAttribute('data-height') || 0;
|
||||||
|
styles.minWidth = '320px';
|
||||||
|
if (defHeight > 0) {
|
||||||
|
scrollable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (widgetEl.hasAttribute('data-telegram-login')) {
|
||||||
|
widgetId = widgetEl.getAttribute('data-telegram-login');
|
||||||
|
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
|
||||||
|
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
|
||||||
|
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
|
||||||
|
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
|
||||||
|
defWidth = 186;
|
||||||
|
defHeight = 28;
|
||||||
|
if (widgetEl.hasAttribute('data-size')) {
|
||||||
|
var size = widgetEl.getAttribute('data-size');
|
||||||
|
if (size == 'small') defWidth = 148, defHeight = 20;
|
||||||
|
else if (size == 'large') defWidth = 238, defHeight = 40;
|
||||||
|
}
|
||||||
|
if (widgetEl.hasAttribute('data-onauth')) {
|
||||||
|
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
|
||||||
|
}
|
||||||
|
else if (widgetEl.hasAttribute('data-auth-url')) {
|
||||||
|
var a = document.createElement('A');
|
||||||
|
a.href = widgetEl.getAttribute('data-auth-url');
|
||||||
|
onAuthUser = function(user) {
|
||||||
|
var authUrl = a.href;
|
||||||
|
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
|
||||||
|
var params = [];
|
||||||
|
for (var key in user) {
|
||||||
|
params.push(key + '=' + encodeURIComponent(user[key]));
|
||||||
|
}
|
||||||
|
authUrl += params.join('&');
|
||||||
|
location.href = authUrl;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (widgetEl.hasAttribute('data-onunauth')) {
|
||||||
|
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
|
||||||
|
}
|
||||||
|
var auth_result = haveTgAuthResult();
|
||||||
|
if (auth_result && onAuthUser) {
|
||||||
|
onAuthUser(auth_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
|
||||||
|
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
|
||||||
|
widgetElId = 'telegram-share-' + window.btoa(widgetId);
|
||||||
|
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
|
||||||
|
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
|
||||||
|
defWidth = 60;
|
||||||
|
defHeight = 20;
|
||||||
|
if (widgetEl.getAttribute('data-size') == 'large') {
|
||||||
|
defWidth = 76;
|
||||||
|
defHeight = 28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
existsEl = document.getElementById(widgetElId);
|
||||||
|
if (existsEl) {
|
||||||
|
return existsEl;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < allowedAttrs.length; i++) {
|
||||||
|
var attr = allowedAttrs[i];
|
||||||
|
var novalue = attr.substr(-1) == '?';
|
||||||
|
if (novalue) {
|
||||||
|
attr = attr.slice(0, -1);
|
||||||
|
}
|
||||||
|
var data_attr = 'data-' + attr.replace(/_/g, '-');
|
||||||
|
if (widgetEl.hasAttribute(data_attr)) {
|
||||||
|
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
|
||||||
|
src += '&' + attr + '=' + attr_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getCurCoords(iframe) {
|
||||||
|
var docEl = document.documentElement;
|
||||||
|
var frect = iframe.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
frameTop: frect.top,
|
||||||
|
frameBottom: frect.bottom,
|
||||||
|
frameLeft: frect.left,
|
||||||
|
frameRight: frect.right,
|
||||||
|
frameWidth: frect.width,
|
||||||
|
frameHeight: frect.height,
|
||||||
|
scrollTop: window.pageYOffset,
|
||||||
|
scrollLeft: window.pageXOffset,
|
||||||
|
clientWidth: docEl.clientWidth,
|
||||||
|
clientHeight: docEl.clientHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function visibilityHandler() {
|
||||||
|
if (isVisible(iframe, 50)) {
|
||||||
|
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function focusHandler() {
|
||||||
|
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
|
||||||
|
}
|
||||||
|
function postMessageHandler(event) {
|
||||||
|
if (event.source !== iframe.contentWindow ||
|
||||||
|
event.origin != widgetsOrigin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(event.data);
|
||||||
|
} catch(e) {
|
||||||
|
var data = {};
|
||||||
|
}
|
||||||
|
if (data.event == 'resize') {
|
||||||
|
if (data.height) {
|
||||||
|
iframe.style.height = data.height + 'px';
|
||||||
|
}
|
||||||
|
if (data.width) {
|
||||||
|
iframe.style.width = data.width + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (data.event == 'ready') {
|
||||||
|
iframe._ready = true;
|
||||||
|
focusHandler();
|
||||||
|
for (var i = 0; i < iframe._readyQueue.length; i++) {
|
||||||
|
var queue_item = iframe._readyQueue[i];
|
||||||
|
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
|
||||||
|
}
|
||||||
|
iframe._readyQueue = [];
|
||||||
|
}
|
||||||
|
else if (data.event == 'visible_off') {
|
||||||
|
removeEvent(window, 'scroll', visibilityHandler);
|
||||||
|
removeEvent(window, 'resize', visibilityHandler);
|
||||||
|
}
|
||||||
|
else if (data.event == 'get_coords') {
|
||||||
|
postMessageToIframe(iframe, 'callback', {
|
||||||
|
_cb: data._cb,
|
||||||
|
value: getCurCoords(iframe)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (data.event == 'scroll_to') {
|
||||||
|
try {
|
||||||
|
window.scrollTo(data.x || 0, data.y || 0);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
else if (data.event == 'auth_user') {
|
||||||
|
if (data.init) {
|
||||||
|
onInitAuthUser && onInitAuthUser(data.auth_data);
|
||||||
|
} else {
|
||||||
|
onAuthUser && onAuthUser(data.auth_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (data.event == 'unauthorized') {
|
||||||
|
onUnauth && onUnauth();
|
||||||
|
}
|
||||||
|
else if (data.event == 'callback') {
|
||||||
|
var cb_data = null;
|
||||||
|
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
|
||||||
|
if (cb_data.iframe === iframe) {
|
||||||
|
cb_data.callback(data.value);
|
||||||
|
delete window.Telegram.__WidgetCallbacks[data._cb];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Callback #' + data._cb + ' not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var iframe = document.createElement('iframe');
|
||||||
|
iframe.id = widgetElId;
|
||||||
|
iframe.src = src;
|
||||||
|
iframe.width = defWidth;
|
||||||
|
iframe.height = defHeight;
|
||||||
|
iframe.setAttribute('frameborder', '0');
|
||||||
|
if (!scrollable) {
|
||||||
|
iframe.setAttribute('scrolling', 'no');
|
||||||
|
iframe.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
iframe.style.colorScheme = 'light dark';
|
||||||
|
iframe.style.border = 'none';
|
||||||
|
for (var prop in styles) {
|
||||||
|
iframe.style[prop] = styles[prop];
|
||||||
|
}
|
||||||
|
if (widgetEl.parentNode) {
|
||||||
|
widgetEl.parentNode.insertBefore(iframe, widgetEl);
|
||||||
|
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
|
||||||
|
widgetEl.parentNode.removeChild(widgetEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iframe._ready = false;
|
||||||
|
iframe._readyQueue = [];
|
||||||
|
widgetEl._iframe = iframe;
|
||||||
|
addEvent(iframe, 'load', function() {
|
||||||
|
removeEvent(iframe, 'load', visibilityHandler);
|
||||||
|
addEvent(window, 'scroll', visibilityHandler);
|
||||||
|
addEvent(window, 'resize', visibilityHandler);
|
||||||
|
visibilityHandler();
|
||||||
|
});
|
||||||
|
addEvent(window, 'focus blur', focusHandler);
|
||||||
|
addEvent(window, 'message', postMessageHandler);
|
||||||
|
return iframe;
|
||||||
|
}
|
||||||
|
function isVisible(el, padding) {
|
||||||
|
var node = el, val;
|
||||||
|
var visibility = getCssProperty(node, 'visibility');
|
||||||
|
if (visibility == 'hidden') return false;
|
||||||
|
while (node) {
|
||||||
|
if (node === document.documentElement) break;
|
||||||
|
var display = getCssProperty(node, 'display');
|
||||||
|
if (display == 'none') return false;
|
||||||
|
var opacity = getCssProperty(node, 'opacity');
|
||||||
|
if (opacity !== null && opacity < 0.1) return false;
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
if (el.getBoundingClientRect) {
|
||||||
|
padding = +padding || 0;
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var html = document.documentElement;
|
||||||
|
if (rect.bottom < padding ||
|
||||||
|
rect.right < padding ||
|
||||||
|
rect.top > (window.innerHeight || html.clientHeight) - padding ||
|
||||||
|
rect.left > (window.innerWidth || html.clientWidth) - padding) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllWidgets() {
|
||||||
|
var widgets = [];
|
||||||
|
if (document.querySelectorAll) {
|
||||||
|
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
|
||||||
|
} else {
|
||||||
|
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
|
||||||
|
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
|
||||||
|
}
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidgetInfo(el_or_id, callback) {
|
||||||
|
var e = null, iframe = null;
|
||||||
|
if (el = geById(el_or_id)) {
|
||||||
|
if (el.tagName &&
|
||||||
|
el.tagName.toUpperCase() == 'IFRAME') {
|
||||||
|
iframe = el;
|
||||||
|
} else if (el._iframe) {
|
||||||
|
iframe = el._iframe;
|
||||||
|
}
|
||||||
|
if (iframe && callback) {
|
||||||
|
postMessageToIframe(iframe, 'get_info', {}, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidgetOptions(options, el_or_id) {
|
||||||
|
var e = null, iframe = null;
|
||||||
|
if (typeof el_or_id === 'undefined') {
|
||||||
|
var widgets = getAllWidgets();
|
||||||
|
for (var i = 0; i < widgets.length; i++) {
|
||||||
|
if (iframe = widgets[i]._iframe) {
|
||||||
|
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (el = geById(el_or_id)) {
|
||||||
|
if (el.tagName &&
|
||||||
|
el.tagName.toUpperCase() == 'IFRAME') {
|
||||||
|
iframe = el;
|
||||||
|
} else if (el._iframe) {
|
||||||
|
iframe = el._iframe;
|
||||||
|
}
|
||||||
|
if (iframe) {
|
||||||
|
postMessageToIframe(iframe, 'set_options', {options: options});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.currentScript ||
|
||||||
|
!initWidget(document.currentScript)) {
|
||||||
|
var widgets = getAllWidgets();
|
||||||
|
for (var i = 0; i < widgets.length; i++) {
|
||||||
|
initWidget(widgets[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var TelegramLogin = {
|
||||||
|
popups: {},
|
||||||
|
options: null,
|
||||||
|
auth_callback: null,
|
||||||
|
_init: function(options, auth_callback) {
|
||||||
|
TelegramLogin.options = options;
|
||||||
|
TelegramLogin.auth_callback = auth_callback;
|
||||||
|
var auth_result = haveTgAuthResult();
|
||||||
|
if (auth_result && auth_callback) {
|
||||||
|
auth_callback(auth_result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_open: function(callback) {
|
||||||
|
TelegramLogin._auth(TelegramLogin.options, function(authData) {
|
||||||
|
if (TelegramLogin.auth_callback) {
|
||||||
|
TelegramLogin.auth_callback(authData);
|
||||||
|
}
|
||||||
|
if (callback) {
|
||||||
|
callback(authData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
_auth: function(options, callback) {
|
||||||
|
var bot_id = parseInt(options.bot_id);
|
||||||
|
if (!bot_id) {
|
||||||
|
throw new Error('Bot id required');
|
||||||
|
}
|
||||||
|
var width = 550;
|
||||||
|
var height = 470;
|
||||||
|
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
|
||||||
|
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
|
||||||
|
var onMessage = function (event) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(event.data);
|
||||||
|
} catch(e) {
|
||||||
|
var data = {};
|
||||||
|
}
|
||||||
|
if (!TelegramLogin.popups[bot_id]) return;
|
||||||
|
if (event.source !== TelegramLogin.popups[bot_id].window) return;
|
||||||
|
if (data.event == 'auth_result') {
|
||||||
|
onAuthDone(data.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var onAuthDone = function (authData) {
|
||||||
|
if (!TelegramLogin.popups[bot_id]) return;
|
||||||
|
if (TelegramLogin.popups[bot_id].authFinished) return;
|
||||||
|
callback && callback(authData);
|
||||||
|
TelegramLogin.popups[bot_id].authFinished = true;
|
||||||
|
removeEvent(window, 'message', onMessage);
|
||||||
|
};
|
||||||
|
var checkClose = function(bot_id) {
|
||||||
|
if (!TelegramLogin.popups[bot_id]) return;
|
||||||
|
if (!TelegramLogin.popups[bot_id].window ||
|
||||||
|
TelegramLogin.popups[bot_id].window.closed) {
|
||||||
|
return TelegramLogin.getAuthData(options, function(origin, authData) {
|
||||||
|
onAuthDone(authData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTimeout(checkClose, 100, bot_id);
|
||||||
|
}
|
||||||
|
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
|
||||||
|
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
|
||||||
|
TelegramLogin.popups[bot_id] = {
|
||||||
|
window: popup,
|
||||||
|
authFinished: false
|
||||||
|
};
|
||||||
|
if (popup) {
|
||||||
|
addEvent(window, 'message', onMessage);
|
||||||
|
popup.focus();
|
||||||
|
checkClose(bot_id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getAuthData: function(options, callback) {
|
||||||
|
var bot_id = parseInt(options.bot_id);
|
||||||
|
if (!bot_id) {
|
||||||
|
throw new Error('Bot id required');
|
||||||
|
}
|
||||||
|
var xhr = getXHR();
|
||||||
|
var url = Telegram.Login.widgetsOrigin + '/auth/get';
|
||||||
|
xhr.open('POST', url);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState == 4) {
|
||||||
|
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
|
||||||
|
try {
|
||||||
|
var result = JSON.parse(xhr.responseText);
|
||||||
|
} catch(e) {
|
||||||
|
var result = {};
|
||||||
|
}
|
||||||
|
if (result.user) {
|
||||||
|
callback(result.origin, result.user);
|
||||||
|
} else {
|
||||||
|
callback(result.origin, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback('*', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function() {
|
||||||
|
callback('*', false);
|
||||||
|
};
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Telegram.getWidgetInfo = getWidgetInfo;
|
||||||
|
window.Telegram.setWidgetOptions = setWidgetOptions;
|
||||||
|
window.Telegram.Login = {
|
||||||
|
init: TelegramLogin._init,
|
||||||
|
open: TelegramLogin._open,
|
||||||
|
auth: TelegramLogin._auth,
|
||||||
|
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
|
||||||
|
};
|
||||||
|
|
||||||
|
}(window));
|
||||||
|
})(window);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||||
@@ -7,6 +9,8 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
def _inject_shared_instances(
|
def _inject_shared_instances(
|
||||||
app: web.Application,
|
app: web.Application,
|
||||||
@@ -111,8 +115,13 @@ async def build_and_start_web_app(
|
|||||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
telegram_web_app_sdk_refresh_task = None
|
||||||
if settings.WEBAPP_ENABLED:
|
if settings.WEBAPP_ENABLED:
|
||||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
from bot.app.web.subscription_webapp import (
|
||||||
|
create_subscription_webapp_application,
|
||||||
|
refresh_telegram_login_widget_sdk,
|
||||||
|
refresh_telegram_web_app_sdk,
|
||||||
|
)
|
||||||
|
|
||||||
subscription_app = create_subscription_webapp_application(
|
subscription_app = create_subscription_webapp_application(
|
||||||
dp,
|
dp,
|
||||||
@@ -135,9 +144,25 @@ async def build_and_start_web_app(
|
|||||||
settings.WEBAPP_SERVER_PORT,
|
settings.WEBAPP_SERVER_PORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _refresh_telegram_web_assets_forever() -> None:
|
||||||
|
while True:
|
||||||
|
await refresh_telegram_web_app_sdk()
|
||||||
|
await refresh_telegram_login_widget_sdk()
|
||||||
|
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
telegram_web_app_sdk_refresh_task = asyncio.create_task(
|
||||||
|
_refresh_telegram_web_assets_forever(),
|
||||||
|
name="TelegramWebAssetsRefreshTask",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
finally:
|
finally:
|
||||||
|
if telegram_web_app_sdk_refresh_task is not None:
|
||||||
|
telegram_web_app_sdk_refresh_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await telegram_web_app_sdk_refresh_task
|
||||||
|
|
||||||
for runner in reversed(runners):
|
for runner in reversed(runners):
|
||||||
try:
|
try:
|
||||||
await runner.cleanup()
|
await runner.cleanup()
|
||||||
|
|||||||
+51
-41
@@ -3,9 +3,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
@@ -14,15 +12,6 @@ from config.settings import Settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PendingWebAppAuth:
|
|
||||||
created_at: int
|
|
||||||
user_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
_PENDING_AUTH_TOKENS: Dict[str, PendingWebAppAuth] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _urlsafe_b64encode(raw: bytes) -> str:
|
def _urlsafe_b64encode(raw: bytes) -> str:
|
||||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
@@ -134,40 +123,61 @@ def validate_telegram_webapp_init_data(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_pending_auth(settings: Settings) -> None:
|
def validate_telegram_login_widget_data(
|
||||||
now = int(time.time())
|
auth_data: Any,
|
||||||
ttl = max(60, int(settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS))
|
bot_token: str,
|
||||||
expired = [
|
*,
|
||||||
token
|
max_age_seconds: int,
|
||||||
for token, value in _PENDING_AUTH_TOKENS.items()
|
) -> Optional[Dict[str, Any]]:
|
||||||
if now - value.created_at > ttl
|
"""Validate Telegram Login Widget data and return the trusted user payload."""
|
||||||
]
|
|
||||||
for token in expired:
|
|
||||||
_PENDING_AUTH_TOKENS.pop(token, None)
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(auth_data, str):
|
||||||
|
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
|
||||||
|
elif isinstance(auth_data, dict):
|
||||||
|
parsed_data = {
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in auth_data.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
def create_pending_webapp_auth_token(settings: Settings) -> str:
|
received_hash = str(parsed_data.pop("hash", "") or "")
|
||||||
_cleanup_pending_auth(settings)
|
if not received_hash:
|
||||||
token = secrets.token_urlsafe(24)
|
return None
|
||||||
_PENDING_AUTH_TOKENS[token] = PendingWebAppAuth(created_at=int(time.time()))
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
data_check_string = "\n".join(
|
||||||
|
f"{key}={value}" for key, value in sorted(parsed_data.items())
|
||||||
|
)
|
||||||
|
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||||
|
calculated_hash = hmac.new(
|
||||||
|
secret_key,
|
||||||
|
data_check_string.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||||
|
logger.warning("Telegram Login Widget hash mismatch.")
|
||||||
|
return None
|
||||||
|
|
||||||
def authorize_pending_webapp_auth_token(token: str, user_id: int) -> bool:
|
auth_date_raw = parsed_data.get("auth_date")
|
||||||
pending = _PENDING_AUTH_TOKENS.get(token)
|
if auth_date_raw:
|
||||||
if not pending:
|
auth_date = int(auth_date_raw)
|
||||||
return False
|
now = int(time.time())
|
||||||
pending.user_id = int(user_id)
|
max_age = max(60, int(max_age_seconds))
|
||||||
return True
|
if auth_date > now + 300 or now - auth_date > max_age:
|
||||||
|
logger.warning("Telegram Login Widget auth_date is stale.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id_raw = parsed_data.get("id")
|
||||||
|
if not user_id_raw:
|
||||||
|
return None
|
||||||
|
int(user_id_raw)
|
||||||
|
|
||||||
def consume_authorized_webapp_auth_token(
|
if not parsed_data.get("first_name"):
|
||||||
settings: Settings,
|
return None
|
||||||
token: str,
|
|
||||||
) -> Optional[int]:
|
return parsed_data
|
||||||
_cleanup_pending_auth(settings)
|
except Exception as exc:
|
||||||
pending = _PENDING_AUTH_TOKENS.get(token)
|
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
|
||||||
if not pending or pending.user_id is None:
|
|
||||||
return None
|
return None
|
||||||
_PENDING_AUTH_TOKENS.pop(token, None)
|
|
||||||
return int(pending.user_id)
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ from config.settings import Settings
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
|
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
|
||||||
from bot.utils.callback_answer import safe_answer_callback
|
from bot.utils.callback_answer import safe_answer_callback
|
||||||
from bot.app.web.webapp_auth import authorize_pending_webapp_auth_token
|
|
||||||
|
|
||||||
router = Router(name="user_start_router")
|
router = Router(name="user_start_router")
|
||||||
|
|
||||||
|
|
||||||
@@ -308,7 +306,6 @@ async def ensure_required_channel_subscription(
|
|||||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^webapp_auth_([A-Za-z0-9_\-]{16,128})$").as_("webapp_auth_match")))
|
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||||
async def start_command_handler(message: types.Message,
|
async def start_command_handler(message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
@@ -321,8 +318,7 @@ async def start_command_handler(message: types.Message,
|
|||||||
promo_match: Optional[re.Match] = None,
|
promo_match: Optional[re.Match] = None,
|
||||||
page_ref_match: Optional[re.Match] = None,
|
page_ref_match: Optional[re.Match] = None,
|
||||||
ad_param_match: Optional[re.Match] = None,
|
ad_param_match: Optional[re.Match] = None,
|
||||||
admin_user_match: Optional[re.Match] = None,
|
admin_user_match: Optional[re.Match] = None):
|
||||||
webapp_auth_match: Optional[re.Match] = None):
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -388,7 +384,6 @@ async def start_command_handler(message: types.Message,
|
|||||||
promo_code_to_apply: Optional[str] = None
|
promo_code_to_apply: Optional[str] = None
|
||||||
should_open_referral_from_start = False
|
should_open_referral_from_start = False
|
||||||
ad_start_param: Optional[str] = None
|
ad_start_param: Optional[str] = None
|
||||||
webapp_auth_token: Optional[str] = None
|
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
raw_ref_value = ref_match.group(1)
|
raw_ref_value = ref_match.group(1)
|
||||||
@@ -414,9 +409,6 @@ async def start_command_handler(message: types.Message,
|
|||||||
elif page_ref_match:
|
elif page_ref_match:
|
||||||
should_open_referral_from_start = True
|
should_open_referral_from_start = True
|
||||||
logging.info(f"User {user_id} started with page_ref deep-link.")
|
logging.info(f"User {user_id} started with page_ref deep-link.")
|
||||||
elif webapp_auth_match:
|
|
||||||
webapp_auth_token = webapp_auth_match.group(1)
|
|
||||||
logging.info("User %s started WebApp auth flow.", user_id)
|
|
||||||
elif ad_param_match:
|
elif ad_param_match:
|
||||||
ad_start_param = ad_param_match.group(1)
|
ad_start_param = ad_param_match.group(1)
|
||||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||||
@@ -572,13 +564,6 @@ async def start_command_handler(message: types.Message,
|
|||||||
db_user):
|
db_user):
|
||||||
return
|
return
|
||||||
|
|
||||||
if webapp_auth_token:
|
|
||||||
if authorize_pending_webapp_auth_token(webapp_auth_token, user_id):
|
|
||||||
await message.answer(_("webapp_auth_success"))
|
|
||||||
else:
|
|
||||||
await message.answer(_("webapp_auth_expired"))
|
|
||||||
return
|
|
||||||
|
|
||||||
open_referral_page_for_existing_user = (
|
open_referral_page_for_existing_user = (
|
||||||
should_open_referral_from_start and is_existing_user
|
should_open_referral_from_start and is_existing_user
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -243,8 +243,14 @@ async def run_bot(settings_param: Settings):
|
|||||||
actual_bot_username = "your_bot_username"
|
actual_bot_username = "your_bot_username"
|
||||||
try:
|
try:
|
||||||
bot_info = await bot.get_me()
|
bot_info = await bot.get_me()
|
||||||
|
if bot_info.username:
|
||||||
actual_bot_username = bot_info.username
|
actual_bot_username = bot_info.username
|
||||||
|
dp["bot_username"] = actual_bot_username
|
||||||
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"Bot username is empty; Telegram Login Widget will be unavailable."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_URL = "https://telegram.org/js/telegram-web-app.js"
|
||||||
|
TARGET_PATH = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "bot"
|
||||||
|
/ "app"
|
||||||
|
/ "web"
|
||||||
|
/ "templates"
|
||||||
|
/ "telegram-web-app.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _download(source_url: str) -> bytes:
|
||||||
|
request = Request(
|
||||||
|
source_url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download the latest Telegram Web App SDK into the local templates directory."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source-url",
|
||||||
|
default=SOURCE_URL,
|
||||||
|
help="Telegram Web App SDK URL to download from.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--target",
|
||||||
|
type=Path,
|
||||||
|
default=TARGET_PATH,
|
||||||
|
help="Output path for the vendored SDK.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data = _download(args.source_url)
|
||||||
|
args.target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.target.write_bytes(data)
|
||||||
|
print(f"Wrote {len(data)} bytes to {args.target}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23"
|
||||||
|
TARGET_PATH = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "bot"
|
||||||
|
/ "app"
|
||||||
|
/ "web"
|
||||||
|
/ "templates"
|
||||||
|
/ "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"""
|
||||||
|
|
||||||
|
|
||||||
|
def _download(source_url: str) -> bytes:
|
||||||
|
request = Request(
|
||||||
|
source_url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
|
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_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")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download the latest Telegram Login Widget SDK into the local templates directory."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source-url",
|
||||||
|
default=SOURCE_URL,
|
||||||
|
help="Telegram Login Widget SDK URL to download from.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--target",
|
||||||
|
type=Path,
|
||||||
|
default=TARGET_PATH,
|
||||||
|
help="Output path for the vendored SDK.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data = _normalize_widget_sdk(_download(args.source_url))
|
||||||
|
args.target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.target.write_bytes(data)
|
||||||
|
print(f"Wrote {len(data)} bytes to {args.target}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user