webapp: harden mini app rendering and controls
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -29,6 +33,7 @@ from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from bot.utils.request_security import request_client_ip
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
@@ -46,8 +51,15 @@ WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
_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 -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
|
||||
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
|
||||
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
@@ -56,7 +68,7 @@ def create_subscription_webapp_application(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
) -> web.Application:
|
||||
app = web.Application()
|
||||
app = web.Application(middlewares=[_security_headers_middleware])
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings
|
||||
@@ -65,6 +77,18 @@ def create_subscription_webapp_application(
|
||||
app["email_auth_service"] = EmailAuthService(settings)
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
|
||||
app["webapp_rate_limit_buckets"] = {}
|
||||
app["webapp_rate_limit_lock"] = asyncio.Lock()
|
||||
|
||||
async def _startup(app_obj: web.Application) -> None:
|
||||
await _ensure_shared_http_session()
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
|
||||
app.on_startup.append(_startup)
|
||||
app.on_shutdown.append(_shutdown)
|
||||
|
||||
for key in (
|
||||
"subscription_service",
|
||||
@@ -120,15 +144,9 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
return ""
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme in {"http", "https"} or raw_logo_url.startswith("//"):
|
||||
if parsed_logo_url.scheme == "https" and parsed_logo_url.hostname:
|
||||
return WEBAPP_LOGO_PROXY_PATH
|
||||
return raw_logo_url
|
||||
|
||||
|
||||
def _normalize_webapp_logo_source_url(raw_logo_url: str) -> str:
|
||||
if raw_logo_url.startswith("//"):
|
||||
return f"https:{raw_logo_url}"
|
||||
return raw_logo_url
|
||||
return ""
|
||||
|
||||
|
||||
async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
@@ -138,10 +156,13 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme not in {"http", "https"} and not raw_logo_url.startswith("//"):
|
||||
if parsed_logo_url.scheme != "https" or not parsed_logo_url.hostname:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
||||
|
||||
source_logo_url = _normalize_webapp_logo_source_url(raw_logo_url)
|
||||
if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname):
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
||||
|
||||
source_logo_url = raw_logo_url
|
||||
logo_cache: Optional[Tuple[bytes, str]] = request.app.get("webapp_logo_cache")
|
||||
if logo_cache is None:
|
||||
cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"]
|
||||
@@ -163,41 +184,206 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||
"""Fetch and cache the configured logo on the server side."""
|
||||
try:
|
||||
timeout = ClientTimeout(total=15)
|
||||
async with ClientSession(
|
||||
timeout=timeout,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
},
|
||||
) as session:
|
||||
async with session.get(logo_url, allow_redirects=True) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(
|
||||
"WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.",
|
||||
response.status,
|
||||
)
|
||||
session = await _get_shared_http_session()
|
||||
timeout = ClientTimeout(total=5)
|
||||
async with session.get(logo_url, allow_redirects=False, timeout=timeout) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(
|
||||
"WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.",
|
||||
response.status,
|
||||
)
|
||||
return None
|
||||
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
logger.warning(
|
||||
"WEBAPP_LOGO_URL returned non-image content type %s; keeping the logo hidden.",
|
||||
content_type,
|
||||
)
|
||||
return None
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
logger.warning("WEBAPP_LOGO_URL exceeded the 2 MiB limit.")
|
||||
return None
|
||||
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
logger.warning(
|
||||
"WEBAPP_LOGO_URL returned non-image content type %s; keeping the logo hidden.",
|
||||
content_type,
|
||||
)
|
||||
return None
|
||||
if not body:
|
||||
logger.warning("WEBAPP_LOGO_URL returned an empty response body.")
|
||||
return None
|
||||
|
||||
body = await response.read()
|
||||
if not body:
|
||||
logger.warning("WEBAPP_LOGO_URL returned an empty response body.")
|
||||
return None
|
||||
|
||||
return body, content_type or "image/png"
|
||||
return bytes(body), content_type or "image/png"
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch WEBAPP_LOGO_URL: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_shared_http_session() -> ClientSession:
|
||||
global _SHARED_HTTP_SESSION
|
||||
async with _SHARED_HTTP_SESSION_LOCK:
|
||||
if _SHARED_HTTP_SESSION is None or _SHARED_HTTP_SESSION.closed:
|
||||
_SHARED_HTTP_SESSION = ClientSession(
|
||||
timeout=ClientTimeout(total=30),
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
return _SHARED_HTTP_SESSION
|
||||
|
||||
|
||||
async def _ensure_shared_http_session() -> None:
|
||||
await _get_shared_http_session()
|
||||
|
||||
|
||||
async def _close_shared_http_session() -> None:
|
||||
global _SHARED_HTTP_SESSION
|
||||
async with _SHARED_HTTP_SESSION_LOCK:
|
||||
if _SHARED_HTTP_SESSION and not _SHARED_HTTP_SESSION.closed:
|
||||
await _SHARED_HTTP_SESSION.close()
|
||||
_SHARED_HTTP_SESSION = None
|
||||
|
||||
|
||||
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(hostname)
|
||||
return not (
|
||||
ip_obj.is_private
|
||||
or ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
found_public_ip = False
|
||||
for entry in resolved:
|
||||
sockaddr = entry[4]
|
||||
if not sockaddr:
|
||||
continue
|
||||
candidate = sockaddr[0]
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if (
|
||||
ip_obj.is_private
|
||||
or ip_obj.is_loopback
|
||||
or ip_obj.is_link_local
|
||||
or ip_obj.is_unspecified
|
||||
or ip_obj.is_reserved
|
||||
):
|
||||
return False
|
||||
found_public_ip = True
|
||||
|
||||
return found_public_ip
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def _security_headers_middleware(request: web.Request, handler):
|
||||
try:
|
||||
response = await handler(request)
|
||||
except web.HTTPException as exc:
|
||||
response = exc
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
(
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' https://telegram.org; "
|
||||
"frame-ancestors https://web.telegram.org https://t.me; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
),
|
||||
)
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
(
|
||||
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
|
||||
"encrypted-media=(), geolocation=(), gyroscope=(), magnetometer=(), "
|
||||
"microphone=(), midi=(), payment=(), usb=()"
|
||||
),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
cache = request.app["webapp_settings_cache"]
|
||||
now = time.monotonic()
|
||||
if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"):
|
||||
cache["data"] = {
|
||||
"logo_url": _resolve_webapp_logo_url(settings),
|
||||
"subscription_options": settings.subscription_options,
|
||||
"stars_subscription_options": settings.stars_subscription_options,
|
||||
"support_url": settings.SUPPORT_LINK or "",
|
||||
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
|
||||
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
|
||||
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
|
||||
}
|
||||
cache["ts"] = now
|
||||
return cache["data"]
|
||||
|
||||
|
||||
async def _enforce_webapp_rate_limit(
|
||||
request: web.Request,
|
||||
*,
|
||||
user_id: int,
|
||||
action: str,
|
||||
) -> Optional[web.Response]:
|
||||
settings: Settings = request.app["settings"]
|
||||
ip_address = request_client_ip(request, trusted_proxies=settings.trusted_proxies) or request.remote or "unknown"
|
||||
key = f"{action}:{ip_address}:{int(user_id)}"
|
||||
buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"]
|
||||
lock: asyncio.Lock = request.app["webapp_rate_limit_lock"]
|
||||
now = time.monotonic()
|
||||
|
||||
async with lock:
|
||||
bucket = buckets.setdefault(key, deque())
|
||||
while bucket and now - bucket[0] >= WEBAPP_RATE_LIMIT_WINDOW_SECONDS:
|
||||
bucket.popleft()
|
||||
if not bucket:
|
||||
buckets.pop(key, None)
|
||||
bucket = buckets.setdefault(key, deque())
|
||||
if len(bucket) >= WEBAPP_RATE_LIMIT_MAX_REQUESTS:
|
||||
retry_after = max(
|
||||
1,
|
||||
int(WEBAPP_RATE_LIMIT_WINDOW_SECONDS - (now - bucket[0])),
|
||||
) if bucket else WEBAPP_RATE_LIMIT_WINDOW_SECONDS
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "rate_limited",
|
||||
"retry_after": retry_after,
|
||||
},
|
||||
status=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
bucket.append(now)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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()
|
||||
@@ -241,22 +427,15 @@ async def telegram_widget_asset_route(request: web.Request) -> web.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()
|
||||
session = await _get_shared_http_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
|
||||
@@ -291,22 +470,15 @@ async def refresh_telegram_web_app_sdk() -> bool:
|
||||
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()
|
||||
session = await _get_shared_http_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
|
||||
@@ -365,26 +537,38 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
raise web.HTTPNotFound(text="webapp_disabled")
|
||||
|
||||
html = TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
config = {
|
||||
"title": settings.WEBAPP_TITLE,
|
||||
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
||||
"logoUrl": _resolve_webapp_logo_url(settings),
|
||||
"logoUrl": cached["logo_url"],
|
||||
"apiBase": "/api",
|
||||
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
||||
"supportUrl": settings.SUPPORT_LINK or "",
|
||||
"privacyPolicyUrl": settings.PRIVACY_POLICY_URL or "",
|
||||
"userAgreementUrl": settings.USER_AGREEMENT_URL or "",
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
|
||||
"emailAuthEnabled": settings.email_auth_configured,
|
||||
"supportUrl": cached["support_url"],
|
||||
"termsUrl": cached["terms_url"],
|
||||
"privacyPolicyUrl": cached["privacy_policy_url"],
|
||||
"userAgreementUrl": cached["user_agreement_url"],
|
||||
"currency": cached["currency"],
|
||||
"language": cached["language"],
|
||||
"emailAuthEnabled": cached["email_auth_enabled"],
|
||||
}
|
||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||
i18n_instance: Optional[object] = request.app.get("i18n")
|
||||
i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
|
||||
html = html.replace(
|
||||
WEBAPP_CONFIG_PLACEHOLDER,
|
||||
(
|
||||
"<script>window.__WEBAPP_CONFIG__="
|
||||
"<script id=\"webapp-config\" type=\"application/json\">"
|
||||
+ json.dumps(config, ensure_ascii=False, separators=(",", ":"))
|
||||
+ ";</script>"
|
||||
+ "</script>"
|
||||
),
|
||||
)
|
||||
html = html.replace(
|
||||
WEBAPP_I18N_PLACEHOLDER,
|
||||
(
|
||||
"<script id=\"i18n\" type=\"application/json\">"
|
||||
+ json.dumps(i18n_payload, ensure_ascii=False, separators=(",", ":"))
|
||||
+ "</script>"
|
||||
),
|
||||
)
|
||||
return web.Response(text=html, content_type="text/html", charset="utf-8")
|
||||
@@ -445,6 +629,14 @@ async def auth_token_route(request: web.Request) -> web.Response:
|
||||
if not telegram_user:
|
||||
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
|
||||
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=int(telegram_user.get("id") or 0),
|
||||
action="auth_token",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
authenticated_user_id: Optional[int] = None
|
||||
async with async_session_factory() as session:
|
||||
@@ -607,6 +799,14 @@ async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="account_email_verify",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
payload = await _read_json(request)
|
||||
email = normalize_email(str(payload.get("email") or ""))
|
||||
code = str(payload.get("code") or "")
|
||||
@@ -912,6 +1112,14 @@ async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def create_payment_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="payments_create",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
payload = await _read_json(request)
|
||||
method = str(payload.get("method") or "").strip().lower()
|
||||
try:
|
||||
@@ -920,8 +1128,9 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "invalid_plan", "Invalid subscription period")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
price = settings.subscription_options.get(months)
|
||||
stars_price = settings.stars_subscription_options.get(months)
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
price = cached["subscription_options"].get(months)
|
||||
stars_price = cached["stars_subscription_options"].get(months)
|
||||
if price is None and method != "stars":
|
||||
return _json_error(400, "invalid_plan", "Subscription period is not available")
|
||||
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
|
||||
@@ -1433,6 +1642,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -1496,7 +1706,12 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(settings, lang),
|
||||
"plans": _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"settings": {
|
||||
"support_url": settings.SUPPORT_LINK,
|
||||
@@ -1591,16 +1806,24 @@ def _serialize_subscription(
|
||||
}
|
||||
|
||||
|
||||
def _serialize_plans(settings: Settings, lang: str) -> List[Dict[str, Any]]:
|
||||
def _serialize_plans(
|
||||
settings: Settings,
|
||||
lang: str,
|
||||
*,
|
||||
subscription_options: Optional[Dict[int, float]] = None,
|
||||
stars_subscription_options: Optional[Dict[int, int]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
active_subscription_options = subscription_options or settings.subscription_options
|
||||
active_stars_subscription_options = stars_subscription_options or settings.stars_subscription_options
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for months, price in sorted(settings.subscription_options.items()):
|
||||
for months, price in sorted(active_subscription_options.items()):
|
||||
plan = {
|
||||
"months": int(months),
|
||||
"price": float(price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_months_title(int(months), lang),
|
||||
}
|
||||
stars_price = settings.stars_subscription_options.get(months)
|
||||
stars_price = active_stars_subscription_options.get(months)
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="lang-menu">
|
||||
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" onclick="toggleLangMenu()">
|
||||
<button id="lang-chip" class="lang-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="lang-dropdown" aria-label="Language" data-action="toggle-lang-menu">
|
||||
<span id="lang-chip-flag" class="lang-chip-flag" aria-hidden="true">🇷🇺</span>
|
||||
<span id="lang-chip-label" class="lang-chip-label">RU</span>
|
||||
<svg class="lang-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
@@ -47,7 +47,7 @@
|
||||
<div id="lang-dropdown" class="lang-dropdown hidden" role="menu"></div>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" onclick="toggleUserMenu()">
|
||||
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" data-action="toggle-user-menu">
|
||||
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
|
||||
<span id="user-chip-name" class="user-chip-name">...</span>
|
||||
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
@@ -72,7 +72,7 @@
|
||||
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="user-dropdown-logout" type="button" role="menuitem" onclick="logout()">
|
||||
<button class="user-dropdown-logout" type="button" role="menuitem" data-action="logout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
|
||||
</svg>
|
||||
@@ -102,11 +102,11 @@
|
||||
</div>
|
||||
|
||||
<div id="connect-actions">
|
||||
<button id="connect-btn" class="btn-primary w-full" type="button" onclick="openConnectLink()" data-i18n="connect">Подключиться</button>
|
||||
<button id="connect-btn" class="btn-primary w-full" type="button" data-action="open-connect-link" data-i18n="connect">Подключиться</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
|
||||
<button id="extend-btn" class="btn-ghost w-full" type="button" onclick="togglePaymentFlow()" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
<button id="extend-btn" class="btn-ghost w-full" type="button" data-action="toggle-payment-flow" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -125,11 +125,11 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-link-send-btn" class="btn" type="button" onclick="requestEmailLinkCode()" data-i18n="send_code">Отправить код</button>
|
||||
<button id="email-link-send-btn" class="btn" type="button" data-action="request-email-link-code" data-i18n="send_code">Отправить код</button>
|
||||
</div>
|
||||
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||
<button id="email-link-verify-btn" class="btn-primary" type="button" onclick="verifyEmailLinkCode()" data-i18n="confirm">Подтвердить</button>
|
||||
<button id="email-link-verify-btn" class="btn-primary" type="button" data-action="verify-email-link-code" data-i18n="confirm">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,8 +144,8 @@
|
||||
</section>
|
||||
|
||||
<section class="grid min-w-0 grid-cols-2 gap-2">
|
||||
<button class="btn w-full" type="button" onclick="openReferralModal()" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn w-full" type="button" onclick="openPromoModal()" data-i18n="promo_title">Промокод</button>
|
||||
<button class="btn w-full" type="button" data-action="open-referral-modal" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn w-full" type="button" data-action="open-promo-modal" data-i18n="promo_title">Промокод</button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -159,13 +159,13 @@
|
||||
</main>
|
||||
|
||||
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" onclick="closePaymentFlow()"></button>
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow"></button>
|
||||
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" onclick="closePaymentFlow()">×</button>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" data-action="close-payment-flow">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2.5">
|
||||
@@ -186,35 +186,35 @@
|
||||
</div>
|
||||
|
||||
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closePromoModal()"></button>
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal"></button>
|
||||
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="promo-title" class="section-title text-[var(--accent)]" data-i18n="promo_title">Промокод</div>
|
||||
<div class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closePromoModal()">×</button>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-promo-modal">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn-primary" type="button" onclick="applyPromoCode()" data-i18n="apply_promo">Применить</button>
|
||||
<button id="promo-apply-btn" class="btn-primary" type="button" data-action="apply-promo-code" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeReferralModal()"></button>
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-referral-modal"></button>
|
||||
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeAccountMergeModal()"></button>
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-account-merge-modal"></button>
|
||||
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal"></button>
|
||||
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
||||
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
|
||||
<div class="panel-head">
|
||||
@@ -222,7 +222,7 @@
|
||||
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()">×</button>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" data-action="close-email-login-code-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
|
||||
@@ -245,10 +245,10 @@
|
||||
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" onclick="verifyEmailLoginCode()" data-i18n="login">Войти</button>
|
||||
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" data-action="verify-email-login-code" data-i18n="login">Войти</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="email-login-resend-btn" class="code-modal-resend" type="button" onclick="resendEmailLoginCode()" data-i18n="resend_code">Отправить еще раз</button>
|
||||
<button id="email-login-resend-btn" class="code-modal-resend" type="button" data-action="resend-email-login-code" data-i18n="resend_code">Отправить еще раз</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,14 +264,14 @@
|
||||
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
|
||||
<div class="grid gap-2.5">
|
||||
<div class="auth-tabs-wrapper" role="tablist">
|
||||
<button id="email-auth-tab" class="auth-tab active" type="button" onclick="setAuthMode('email')" data-i18n="email_login_tab">Email</button>
|
||||
<button id="telegram-auth-tab" class="auth-tab" type="button" onclick="setAuthMode('telegram')" data-i18n="telegram_login_tab">Telegram</button>
|
||||
<button id="email-auth-tab" class="auth-tab active" type="button" data-action="set-auth-mode" data-mode="email" data-i18n="email_login_tab">Email</button>
|
||||
<button id="telegram-auth-tab" class="auth-tab" type="button" data-action="set-auth-mode" data-mode="telegram" data-i18n="telegram_login_tab">Telegram</button>
|
||||
</div>
|
||||
<div class="login-auth-body">
|
||||
<div id="email-login-pane" class="grid gap-2.5">
|
||||
<div class="grid gap-2.5">
|
||||
<input id="email-login-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-login-send-btn" class="btn-primary w-full" type="button" onclick="requestEmailLoginCode()" data-i18n="login_continue">Продолжить</button>
|
||||
<button id="email-login-send-btn" class="btn-primary w-full" type="button" data-action="request-email-login-code" data-i18n="login_continue">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="telegram-login-pane" class="hidden grid gap-2.5">
|
||||
@@ -289,6 +289,7 @@
|
||||
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- WEBAPP_I18N_SCRIPT -->
|
||||
<!-- WEBAPP_CONFIG_SCRIPT -->
|
||||
<script src="./subscription_webapp.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -77,7 +77,18 @@ const MOCK = (() => {
|
||||
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) || {};
|
||||
function readJsonScript(id) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node || !node.textContent) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse JSON config from #' + id, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const CFG = readJsonScript('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 = {
|
||||
@@ -148,7 +159,7 @@ const MOCK = (() => {
|
||||
stepName: 'step-name'
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
const FALLBACK_I18N = {
|
||||
ru: {
|
||||
page_title: 'Моя подписка',
|
||||
loading: 'Загрузка...',
|
||||
@@ -424,6 +435,7 @@ const MOCK = (() => {
|
||||
referral_bonus_explanation: 'Bonuses are awarded once for each invited user when they purchase a subscription.'
|
||||
}
|
||||
};
|
||||
const I18N = readJsonScript('i18n') || (MOCK && MOCK.i18n) || FALLBACK_I18N;
|
||||
|
||||
const accent = CFG.primaryColor || '#00fe7a';
|
||||
document.documentElement.style.setProperty('--accent', accent);
|
||||
@@ -468,6 +480,7 @@ const MOCK = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', handleDocumentActionClick);
|
||||
document.addEventListener('click', handleDocumentClickForUserMenu);
|
||||
document.addEventListener('click', handleDocumentClickForLangMenu);
|
||||
renderLangMenu();
|
||||
@@ -1283,6 +1296,93 @@ const MOCK = (() => {
|
||||
toggleLangMenu(false);
|
||||
}
|
||||
|
||||
function handleDocumentActionClick(event) {
|
||||
const target = event.target instanceof Element ? event.target.closest('[data-action]') : null;
|
||||
if (!target || !document.contains(target) || target.disabled) return;
|
||||
|
||||
const action = String(target.dataset.action || '');
|
||||
if (!action) return;
|
||||
|
||||
switch (action) {
|
||||
case 'toggle-user-menu':
|
||||
toggleUserMenu();
|
||||
break;
|
||||
case 'toggle-lang-menu':
|
||||
toggleLangMenu();
|
||||
break;
|
||||
case 'logout':
|
||||
logout();
|
||||
break;
|
||||
case 'open-connect-link':
|
||||
openConnectLink();
|
||||
break;
|
||||
case 'toggle-payment-flow':
|
||||
togglePaymentFlow();
|
||||
break;
|
||||
case 'open-referral-modal':
|
||||
openReferralModal();
|
||||
break;
|
||||
case 'open-promo-modal':
|
||||
openPromoModal();
|
||||
break;
|
||||
case 'close-payment-flow':
|
||||
closePaymentFlow();
|
||||
break;
|
||||
case 'close-promo-modal':
|
||||
closePromoModal();
|
||||
break;
|
||||
case 'close-referral-modal':
|
||||
closeReferralModal();
|
||||
break;
|
||||
case 'close-account-merge-modal':
|
||||
closeAccountMergeModal();
|
||||
break;
|
||||
case 'close-email-login-code-modal':
|
||||
closeEmailLoginCodeModal();
|
||||
break;
|
||||
case 'request-email-link-code':
|
||||
requestEmailLinkCode();
|
||||
break;
|
||||
case 'verify-email-link-code':
|
||||
verifyEmailLinkCode();
|
||||
break;
|
||||
case 'request-email-login-code':
|
||||
requestEmailLoginCode();
|
||||
break;
|
||||
case 'verify-email-login-code':
|
||||
verifyEmailLoginCode();
|
||||
break;
|
||||
case 'resend-email-login-code':
|
||||
resendEmailLoginCode();
|
||||
break;
|
||||
case 'set-auth-mode':
|
||||
setAuthMode(target.dataset.mode || 'email');
|
||||
break;
|
||||
case 'apply-promo-code':
|
||||
applyPromoCode();
|
||||
break;
|
||||
case 'select-plan':
|
||||
selectPlan(target.dataset.months || '');
|
||||
break;
|
||||
case 'create-payment':
|
||||
createAndOpenPayment(target.dataset.method || '');
|
||||
break;
|
||||
case 'copy-referral-link':
|
||||
copyReferralLink(target.dataset.kind || 'webapp');
|
||||
break;
|
||||
case 'copy-config-link':
|
||||
copyConfigLink();
|
||||
break;
|
||||
case 'check-payment':
|
||||
checkPayment();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function openPromoModal() {
|
||||
state.promoModalOpen = true;
|
||||
clearPromoStatus();
|
||||
@@ -1438,7 +1538,7 @@ const MOCK = (() => {
|
||||
<div id="account-merge-title" class="${TW.sectionTitle} text-[var(--accent)]">${escapeHtml(t('account_merge_title'))}</div>
|
||||
<div id="account-merge-caption" class="${TW.flowCaption}">${escapeHtml(t('account_merge_caption'))}</div>
|
||||
</div>
|
||||
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" onclick="closeAccountMergeModal()">×</button>
|
||||
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" data-action="close-account-merge-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="notice grid gap-2">
|
||||
@@ -1498,7 +1598,7 @@ const MOCK = (() => {
|
||||
<div class="${TW.sectionTitle} text-[var(--accent)]">${escapeHtml(t('referral_title'))}</div>
|
||||
<div class="${TW.flowCaption}">${escapeHtml(t('referral_caption'))}</div>
|
||||
</div>
|
||||
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" onclick="closeReferralModal()">×</button>
|
||||
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" data-action="close-referral-modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 mt-1">
|
||||
@@ -1530,7 +1630,7 @@ const MOCK = (() => {
|
||||
<div class="${TW.metricLabel}">${escapeHtml(label)}</div>
|
||||
<div class="${TW.referralLinkValue}">${escapeHtml(link || t('not_available'))}</div>
|
||||
</div>
|
||||
<button class="${TW.btnBase} w-12 px-0" type="button" onclick="copyReferralLink('${escapeAttr(kind)}')" ${link ? '' : 'disabled'} data-title-i18n="copy_link">
|
||||
<button class="${TW.btnBase} w-12 px-0" type="button" data-action="copy-referral-link" data-kind="${escapeAttr(kind)}" ${link ? '' : 'disabled'} data-title-i18n="copy_link">
|
||||
<svg class="h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M352 512L128 512L128 288L176 288L176 224L128 224C92.7 224 64 252.7 64 288L64 512C64 547.3 92.7 576 128 576L352 576C387.3 576 416 547.3 416 512L416 464L352 464L352 512zM288 416L512 416C547.3 416 576 387.3 576 352L576 128C576 92.7 547.3 64 512 64L288 64C252.7 64 224 92.7 224 128L224 352C224 387.3 252.7 416 288 416z"/>
|
||||
</svg>
|
||||
@@ -1841,7 +1941,7 @@ const MOCK = (() => {
|
||||
const planMonths = Number(plan.months);
|
||||
const isActive = state.selectedPlan && Number(state.selectedPlan.months) === planMonths;
|
||||
return `
|
||||
<button class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" onclick="selectPlan(${planMonths})" ${state.creatingPayment ? 'disabled' : ''}>
|
||||
<button class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" data-action="select-plan" data-months="${planMonths}" ${state.creatingPayment ? 'disabled' : ''}>
|
||||
${escapeHtml(plan.title)}
|
||||
</button>
|
||||
`;
|
||||
@@ -1880,7 +1980,7 @@ const MOCK = (() => {
|
||||
? TW.paymentMethodCardPlatega
|
||||
: TW.paymentMethodCardCryptopay;
|
||||
return `
|
||||
<button class="${TW.paymentMethodCard} ${variantClass}" type="button" data-i18n="${labelKey}" onclick="createAndOpenPayment('${escapeAttr(method.id)}')" ${state.creatingPayment ? 'disabled' : ''}>
|
||||
<button class="${TW.paymentMethodCard} ${variantClass}" type="button" data-action="create-payment" data-method="${escapeAttr(method.id)}" data-i18n="${labelKey}" ${state.creatingPayment ? 'disabled' : ''}>
|
||||
${escapeHtml(t(labelKey))}
|
||||
</button>
|
||||
`;
|
||||
@@ -2271,7 +2371,7 @@ const MOCK = (() => {
|
||||
}
|
||||
|
||||
function setBrand() {
|
||||
const title = CFG.title || t('page_title');
|
||||
const title = CFG.title || document.title || t('page_title');
|
||||
const logoUrl = CFG.logoUrl || '';
|
||||
document.title = title;
|
||||
setFavicon(logoUrl);
|
||||
|
||||
Reference in New Issue
Block a user