feat: tune web app visual
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
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, Tuple
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from aiohttp import ClientSession, ClientTimeout, web
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
@@ -41,6 +42,7 @@ 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_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_URL = "https://telegram.org/js/telegram-widget.js?23"
|
||||||
TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js"
|
TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js"
|
||||||
|
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"""
|
_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"""
|
_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_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
@@ -61,6 +63,8 @@ def create_subscription_webapp_application(
|
|||||||
app["async_session_factory"] = async_session_factory
|
app["async_session_factory"] = async_session_factory
|
||||||
app["i18n"] = dp.get("i18n_instance")
|
app["i18n"] = dp.get("i18n_instance")
|
||||||
app["email_auth_service"] = EmailAuthService(settings)
|
app["email_auth_service"] = EmailAuthService(settings)
|
||||||
|
app["webapp_logo_cache"] = None
|
||||||
|
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||||
|
|
||||||
for key in (
|
for key in (
|
||||||
"subscription_service",
|
"subscription_service",
|
||||||
@@ -87,6 +91,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
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-web-app.js", telegram_web_app_asset_route)
|
||||||
app.router.add_get("/telegram-widget.js", telegram_widget_asset_route)
|
app.router.add_get("/telegram-widget.js", telegram_widget_asset_route)
|
||||||
|
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||||
app.router.add_get("/subscription_webapp.css", css_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_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)
|
||||||
@@ -109,6 +114,90 @@ async def css_asset_route(request: web.Request) -> web.Response:
|
|||||||
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
|
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||||
|
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||||
|
if not raw_logo_url:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parsed_logo_url = urlsplit(raw_logo_url)
|
||||||
|
if parsed_logo_url.scheme in {"http", "https"} or raw_logo_url.startswith("//"):
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||||
|
if not raw_logo_url:
|
||||||
|
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("//"):
|
||||||
|
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
||||||
|
|
||||||
|
source_logo_url = _normalize_webapp_logo_source_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"]
|
||||||
|
async with cache_lock:
|
||||||
|
logo_cache = request.app.get("webapp_logo_cache")
|
||||||
|
if logo_cache is None:
|
||||||
|
logo_cache = await _fetch_webapp_logo(source_logo_url)
|
||||||
|
request.app["webapp_logo_cache"] = logo_cache
|
||||||
|
|
||||||
|
if not logo_cache:
|
||||||
|
raise web.HTTPNotFound(text="webapp_logo_unavailable")
|
||||||
|
|
||||||
|
body, content_type = logo_cache
|
||||||
|
response = web.Response(body=body, content_type=content_type)
|
||||||
|
response.headers["Cache-Control"] = "no-cache"
|
||||||
|
return 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,
|
||||||
|
)
|
||||||
|
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 = 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"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch WEBAPP_LOGO_URL: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def telegram_web_app_asset_route(request: web.Request) -> web.Response:
|
async def telegram_web_app_asset_route(request: web.Request) -> web.Response:
|
||||||
if not TELEGRAM_WEB_APP_SDK_PATH.exists():
|
if not TELEGRAM_WEB_APP_SDK_PATH.exists():
|
||||||
await refresh_telegram_web_app_sdk()
|
await refresh_telegram_web_app_sdk()
|
||||||
@@ -279,7 +368,7 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
config = {
|
config = {
|
||||||
"title": settings.WEBAPP_TITLE,
|
"title": settings.WEBAPP_TITLE,
|
||||||
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
||||||
"logoUrl": settings.WEBAPP_LOGO_URL or "",
|
"logoUrl": _resolve_webapp_logo_url(settings),
|
||||||
"apiBase": "/api",
|
"apiBase": "/api",
|
||||||
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
||||||
"supportUrl": settings.SUPPORT_LINK or "",
|
"supportUrl": settings.SUPPORT_LINK or "",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<meta name="theme-color" content="#05070a">
|
<meta name="theme-color" content="#05070a">
|
||||||
|
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||||
<title>Моя подписка</title>
|
<title>Моя подписка</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
@@ -20,7 +21,10 @@
|
|||||||
<main id="app" class="app hidden">
|
<main id="app" class="app hidden">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="app-header-title">
|
<div class="app-header-title">
|
||||||
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
<div class="brand-logo-shell hidden" data-brand-logo-shell aria-hidden="true">
|
||||||
|
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||||
|
<img id="brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||||
|
</div>
|
||||||
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
|
<div id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-actions">
|
<div class="app-header-actions">
|
||||||
@@ -138,53 +142,27 @@
|
|||||||
|
|
||||||
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
<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="Закрыть оплату" onclick="closePaymentFlow()"></button>
|
||||||
<section id="payment-flow" class="panel-modal modal-card grid gap-3.5 p-4">
|
<section id="payment-flow" class="panel-modal modal-card grid gap-4 p-4">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<div id="payment-title" class="section-title" data-i18n="payment_title">Оплата подписки</div>
|
<div id="payment-title" class="section-title text-[var(--accent)]" data-i18n="payment_title">Оплата подписки</div>
|
||||||
<div class="section-caption" data-i18n="payment_caption">Выберите срок, способ оплаты и создайте платеж.</div>
|
|
||||||
</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="Закрыть оплату" onclick="closePaymentFlow()">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-2" data-aria-i18n="payment_steps" aria-label="Шаги оплаты">
|
<div class="grid gap-2.5">
|
||||||
<div id="step-plan" class="step step-box">
|
<div class="section-label" data-i18n="select_period">Выберите период</div>
|
||||||
<span class="step-num">01</span>
|
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
|
||||||
<span class="step-name" data-i18n="step_plan">Срок</span>
|
|
||||||
</div>
|
|
||||||
<div id="step-method" class="step step-box">
|
|
||||||
<span class="step-num">02</span>
|
|
||||||
<span class="step-name" data-i18n="step_method">Метод</span>
|
|
||||||
</div>
|
|
||||||
<div id="step-result" class="step step-box">
|
|
||||||
<span class="step-num">03</span>
|
|
||||||
<span class="step-name" data-i18n="step_result">Платеж</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="plan-step" class="grid gap-2.5">
|
<div class="notice grid gap-1.5">
|
||||||
<div class="section-label" data-i18n="select_period">Выберите срок</div>
|
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
|
||||||
<div id="plans" class="plans"></div>
|
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
|
||||||
<button id="to-methods-btn" class="btn-primary w-full" type="button" onclick="goToPaymentStep('method')" data-i18n="choose_payment_method">Выбрать способ оплаты</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="method-step" class="hidden grid gap-2.5">
|
<div class="grid gap-2.5">
|
||||||
<div class="section-label" data-i18n="select_method">Выберите способ оплаты</div>
|
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
|
||||||
<div id="methods" class="methods"></div>
|
<div id="payment-methods" class="grid gap-2"></div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<button class="btn text-[var(--text-secondary)]" type="button" onclick="goToPaymentStep('plan')" data-i18n="back">Назад</button>
|
|
||||||
<button id="create-payment-btn" class="btn-primary" type="button" onclick="createPaymentFromSelection()" data-i18n="create_payment">Создать платеж</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="result-step" class="hidden grid gap-2.5">
|
|
||||||
<div class="section-label" data-i18n="payment_status">Статус платежа</div>
|
|
||||||
<div class="grid gap-2.5">
|
|
||||||
<div id="payment-message" class="notice"></div>
|
|
||||||
<button id="payment-open-btn" class="btn-primary w-full" type="button" onclick="openPaymentUrl()" data-i18n="open_payment">Открыть оплату</button>
|
|
||||||
<button id="payment-check-btn" class="btn w-full" type="button" onclick="checkPayment()" data-i18n="check_payment">Проверить оплату</button>
|
|
||||||
<button class="btn w-full text-[var(--text-secondary)]" type="button" onclick="goToPaymentStep('method')" data-i18n="choose_other_method">Выбрать другой способ</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +172,7 @@
|
|||||||
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
|
<section id="promo-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<div id="promo-title" class="section-title" data-i18n="promo_title">Промокод</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 class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||||
</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="Закрыть" onclick="closePromoModal()">×</button>
|
||||||
@@ -251,26 +229,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="login" class="login login-layout">
|
<section id="login" class="login login-layout gap-5">
|
||||||
<div class="panel-modal w-full grid gap-[17px] p-[18px] text-center">
|
<div class="grid justify-items-center gap-3 text-center">
|
||||||
<div class="login-head">
|
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
|
||||||
<img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true">
|
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
|
||||||
<div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div>
|
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-tabs-wrapper" role="tablist">
|
<div id="login-brand-title" class="max-w-[320px] break-words font-[family-name:var(--font-mono)] text-[28px] font-extrabold leading-[1.05] tracking-[0] text-[var(--accent)] max-[460px]:max-w-[240px] max-[460px]:text-[24px]" data-brand-title>Моя подписка</div>
|
||||||
<button id="email-auth-tab" class="auth-tab active" type="button" onclick="setAuthMode('email')" data-i18n="email_login_tab">Email</button>
|
</div>
|
||||||
<button id="telegram-auth-tab" class="auth-tab" type="button" onclick="setAuthMode('telegram')" data-i18n="telegram_login_tab">Telegram</button>
|
<div class="panel-modal w-full grid gap-5 p-6 text-center">
|
||||||
</div>
|
<h1 class="section-title text-[22px] leading-[1.1]" data-i18n="login_title">Войдите или зарегистрируйтесь</h1>
|
||||||
<div id="email-login-pane" class="grid gap-2.5">
|
<div class="grid gap-2.5">
|
||||||
<div class="grid gap-2.5">
|
<div class="auth-tabs-wrapper" role="tablist">
|
||||||
<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-auth-tab" class="auth-tab active" type="button" onclick="setAuthMode('email')" data-i18n="email_login_tab">Email</button>
|
||||||
<button id="email-login-send-btn" class="btn-primary w-full" type="button" onclick="requestEmailLoginCode()" data-i18n="send_code">Отправить код</button>
|
<button id="telegram-auth-tab" class="auth-tab" type="button" onclick="setAuthMode('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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="telegram-login-pane" class="hidden grid gap-2.5">
|
||||||
|
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="telegram-login-pane" class="hidden grid gap-2.5">
|
|
||||||
<div id="telegram-login-widget" class="telegram-login-widget" aria-live="polite"></div>
|
|
||||||
</div>
|
|
||||||
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="legal-links-login" data-legal-links class="legal-links hidden">
|
<div id="legal-links-login" data-legal-links class="legal-links hidden">
|
||||||
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
<a class="legal-link max-[560px]:flex-[1_1_100%]" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||||
|
|||||||
@@ -84,14 +84,12 @@ const MOCK = (() => {
|
|||||||
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
|
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
|
||||||
data: null,
|
data: null,
|
||||||
selectedPlan: null,
|
selectedPlan: null,
|
||||||
selectedMethod: null,
|
|
||||||
referralParam: readReferralParam(),
|
referralParam: readReferralParam(),
|
||||||
promoApplying: false,
|
promoApplying: false,
|
||||||
payment: null,
|
payment: null,
|
||||||
paymentFlowOpen: false,
|
paymentFlowOpen: false,
|
||||||
promoModalOpen: false,
|
promoModalOpen: false,
|
||||||
referralModalOpen: false,
|
referralModalOpen: false,
|
||||||
paymentStep: 'plan',
|
|
||||||
creatingPayment: false,
|
creatingPayment: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
|
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
|
||||||
@@ -108,7 +106,8 @@ const MOCK = (() => {
|
|||||||
telegramLinkRendered: false,
|
telegramLinkRendered: false,
|
||||||
telegramLinkInProgress: false,
|
telegramLinkInProgress: false,
|
||||||
toastTimer: null,
|
toastTimer: null,
|
||||||
userMenuOpen: false
|
userMenuOpen: false,
|
||||||
|
userMenuCloseTimer: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const TW = {
|
const TW = {
|
||||||
@@ -129,6 +128,9 @@ const MOCK = (() => {
|
|||||||
planName: 'plan-name',
|
planName: 'plan-name',
|
||||||
planMeta: 'plan-meta',
|
planMeta: 'plan-meta',
|
||||||
planPrice: 'plan-price',
|
planPrice: 'plan-price',
|
||||||
|
paymentMethodCard: 'payment-method-card',
|
||||||
|
paymentMethodCardPlatega: 'payment-method-card--platega',
|
||||||
|
paymentMethodCardCryptopay: 'payment-method-card--cryptopay',
|
||||||
notice: 'notice',
|
notice: 'notice',
|
||||||
stepNum: 'step-num',
|
stepNum: 'step-num',
|
||||||
stepName: 'step-name'
|
stepName: 'step-name'
|
||||||
@@ -148,22 +150,25 @@ const MOCK = (() => {
|
|||||||
copy_link: 'Скопировать ссылку',
|
copy_link: 'Скопировать ссылку',
|
||||||
extend_subscription: 'Купить подписку / Добавить дни',
|
extend_subscription: 'Купить подписку / Добавить дни',
|
||||||
payment_title: 'Оплата подписки',
|
payment_title: 'Оплата подписки',
|
||||||
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
|
payment_caption: 'Выберите период и нажмите способ оплаты. Сервис оплаты откроется сразу.',
|
||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
close_payment: 'Закрыть оплату',
|
close_payment: 'Закрыть оплату',
|
||||||
payment_steps: 'Шаги оплаты',
|
payment_steps: 'Шаги оплаты',
|
||||||
step_plan: 'Срок',
|
step_plan: 'Срок',
|
||||||
step_method: 'Метод',
|
step_method: 'Метод',
|
||||||
step_result: 'Платеж',
|
step_result: 'Платеж',
|
||||||
select_period: 'Выберите срок',
|
select_period: 'Выберите период',
|
||||||
select_method: 'Выберите способ оплаты',
|
select_method: 'Выберите способ оплаты',
|
||||||
payment_status: 'Статус платежа',
|
payment_status: 'Статус платежа',
|
||||||
choose_payment_method: 'Выбрать способ оплаты',
|
payment_amount_label: 'Стоимость выбранного периода',
|
||||||
|
choose_payment_method: 'Способ оплаты',
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
create_payment: 'Создать платеж',
|
create_payment: 'Создать платеж',
|
||||||
open_payment: 'Открыть оплату',
|
open_payment: 'Открыть оплату',
|
||||||
check_payment: 'Проверить оплату',
|
check_payment: 'Проверить оплату',
|
||||||
choose_other_method: 'Выбрать другой способ',
|
choose_other_method: 'Выбрать другой способ',
|
||||||
|
pay_with_platega_button: 'Оплатить картой (СБП)',
|
||||||
|
pay_with_cryptopay_button: 'Оплатить криптой',
|
||||||
support: 'Поддержка',
|
support: 'Поддержка',
|
||||||
account_title: 'Аккаунт',
|
account_title: 'Аккаунт',
|
||||||
account_caption: 'Способы входа',
|
account_caption: 'Способы входа',
|
||||||
@@ -179,6 +184,8 @@ const MOCK = (() => {
|
|||||||
resend_code: 'Отправить еще раз',
|
resend_code: 'Отправить еще раз',
|
||||||
confirm: 'Подтвердить',
|
confirm: 'Подтвердить',
|
||||||
login: 'Войти',
|
login: 'Войти',
|
||||||
|
login_title: 'Войдите или зарегистрируйтесь',
|
||||||
|
login_continue: 'Продолжить',
|
||||||
email_code_title: 'Подтвердите вход',
|
email_code_title: 'Подтвердите вход',
|
||||||
email_code_caption: 'Введите 6-значный код из письма.',
|
email_code_caption: 'Введите 6-значный код из письма.',
|
||||||
email_code_aria: 'Код подтверждения',
|
email_code_aria: 'Код подтверждения',
|
||||||
@@ -273,7 +280,7 @@ const MOCK = (() => {
|
|||||||
copy_link: 'Copy link',
|
copy_link: 'Copy link',
|
||||||
extend_subscription: 'Renew subscription/Add days',
|
extend_subscription: 'Renew subscription/Add days',
|
||||||
payment_title: 'Subscription payment',
|
payment_title: 'Subscription payment',
|
||||||
payment_caption: 'Choose a period, payment method, and create a payment.',
|
payment_caption: 'Choose a period and tap a payment method. The payment service will open right away.',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
close_payment: 'Close payment',
|
close_payment: 'Close payment',
|
||||||
payment_steps: 'Payment steps',
|
payment_steps: 'Payment steps',
|
||||||
@@ -283,12 +290,15 @@ const MOCK = (() => {
|
|||||||
select_period: 'Select period',
|
select_period: 'Select period',
|
||||||
select_method: 'Select payment method',
|
select_method: 'Select payment method',
|
||||||
payment_status: 'Payment status',
|
payment_status: 'Payment status',
|
||||||
choose_payment_method: 'Choose payment method',
|
payment_amount_label: 'Selected period cost',
|
||||||
|
choose_payment_method: 'Payment method',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
create_payment: 'Create payment',
|
create_payment: 'Create payment',
|
||||||
open_payment: 'Open payment',
|
open_payment: 'Open payment',
|
||||||
check_payment: 'Check payment',
|
check_payment: 'Check payment',
|
||||||
choose_other_method: 'Choose another method',
|
choose_other_method: 'Choose another method',
|
||||||
|
pay_with_platega_button: 'Pay by card (SBP)',
|
||||||
|
pay_with_cryptopay_button: 'Pay with crypto',
|
||||||
support: 'Support',
|
support: 'Support',
|
||||||
account_title: 'Account',
|
account_title: 'Account',
|
||||||
account_caption: 'Sign-in methods',
|
account_caption: 'Sign-in methods',
|
||||||
@@ -304,6 +314,8 @@ const MOCK = (() => {
|
|||||||
resend_code: 'Send again',
|
resend_code: 'Send again',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
login: 'Log in',
|
login: 'Log in',
|
||||||
|
login_title: 'Log in or sign up',
|
||||||
|
login_continue: 'Continue',
|
||||||
email_code_title: 'Confirm login',
|
email_code_title: 'Confirm login',
|
||||||
email_code_caption: 'Enter the 6-digit code from the email.',
|
email_code_caption: 'Enter the 6-digit code from the email.',
|
||||||
email_code_aria: 'Verification code',
|
email_code_aria: 'Verification code',
|
||||||
@@ -575,6 +587,7 @@ const MOCK = (() => {
|
|||||||
const telegramTab = document.getElementById('telegram-auth-tab');
|
const telegramTab = document.getElementById('telegram-auth-tab');
|
||||||
const emailPane = document.getElementById('email-login-pane');
|
const emailPane = document.getElementById('email-login-pane');
|
||||||
const telegramPane = document.getElementById('telegram-login-pane');
|
const telegramPane = document.getElementById('telegram-login-pane');
|
||||||
|
const authBody = document.querySelector('.login-auth-body');
|
||||||
const emailEnabled = CFG.emailAuthEnabled !== false;
|
const emailEnabled = CFG.emailAuthEnabled !== false;
|
||||||
if (!emailEnabled && state.authMode === 'email') {
|
if (!emailEnabled && state.authMode === 'email') {
|
||||||
state.authMode = 'telegram';
|
state.authMode = 'telegram';
|
||||||
@@ -584,6 +597,9 @@ const MOCK = (() => {
|
|||||||
telegramTab.classList.toggle('active', state.authMode === 'telegram');
|
telegramTab.classList.toggle('active', state.authMode === 'telegram');
|
||||||
emailPane.classList.toggle('hidden', state.authMode !== 'email');
|
emailPane.classList.toggle('hidden', state.authMode !== 'email');
|
||||||
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
|
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
|
||||||
|
if (authBody) {
|
||||||
|
authBody.classList.toggle('login-auth-body--telegram', state.authMode === 'telegram');
|
||||||
|
}
|
||||||
|
|
||||||
emailTab.disabled = !emailEnabled;
|
emailTab.disabled = !emailEnabled;
|
||||||
if (state.authMode === 'telegram') {
|
if (state.authMode === 'telegram') {
|
||||||
@@ -868,7 +884,6 @@ const MOCK = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const previousMonths = state.selectedPlan && state.selectedPlan.months;
|
|
||||||
const data = await api('/me');
|
const data = await api('/me');
|
||||||
if (!data.ok) throw new Error(data.error || 'load failed');
|
if (!data.ok) throw new Error(data.error || 'load failed');
|
||||||
state.data = data;
|
state.data = data;
|
||||||
@@ -876,10 +891,9 @@ const MOCK = (() => {
|
|||||||
applyI18n();
|
applyI18n();
|
||||||
applyLegalLinks();
|
applyLegalLinks();
|
||||||
const plans = data.plans || [];
|
const plans = data.plans || [];
|
||||||
state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null;
|
state.selectedPlan = getDefaultPaymentPlan(plans);
|
||||||
state.selectedMethod = null;
|
|
||||||
state.payment = null;
|
state.payment = null;
|
||||||
state.paymentStep = 'plan';
|
state.creatingPayment = false;
|
||||||
state.telegramLinkRendered = false;
|
state.telegramLinkRendered = false;
|
||||||
render();
|
render();
|
||||||
showApp();
|
showApp();
|
||||||
@@ -1140,8 +1154,31 @@ const MOCK = (() => {
|
|||||||
const chip = document.getElementById('user-chip');
|
const chip = document.getElementById('user-chip');
|
||||||
const dropdown = document.getElementById('user-dropdown');
|
const dropdown = document.getElementById('user-dropdown');
|
||||||
if (!chip || !dropdown) return;
|
if (!chip || !dropdown) return;
|
||||||
|
|
||||||
|
if (state.userMenuCloseTimer) {
|
||||||
|
window.clearTimeout(state.userMenuCloseTimer);
|
||||||
|
state.userMenuCloseTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
|
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
dropdown.classList.toggle('hidden', !open);
|
if (open) {
|
||||||
|
dropdown.classList.remove('hidden');
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (state.userMenuOpen) {
|
||||||
|
dropdown.classList.add('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.classList.remove('show');
|
||||||
|
state.userMenuCloseTimer = window.setTimeout(() => {
|
||||||
|
if (!state.userMenuOpen) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
state.userMenuCloseTimer = null;
|
||||||
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDocumentClickForUserMenu(event) {
|
function handleDocumentClickForUserMenu(event) {
|
||||||
@@ -1557,22 +1594,22 @@ const MOCK = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.paymentFlowOpen = true;
|
state.paymentFlowOpen = true;
|
||||||
state.paymentStep = 'plan';
|
state.creatingPayment = false;
|
||||||
state.payment = null;
|
state.payment = null;
|
||||||
state.selectedMethod = null;
|
state.selectedPlan = getDefaultPaymentPlan((state.data && state.data.plans) || []);
|
||||||
renderPaymentFlow();
|
renderPaymentFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePaymentFlow() {
|
function closePaymentFlow() {
|
||||||
state.paymentFlowOpen = false;
|
state.paymentFlowOpen = false;
|
||||||
state.paymentStep = 'plan';
|
state.creatingPayment = false;
|
||||||
state.payment = null;
|
state.payment = null;
|
||||||
state.selectedMethod = null;
|
|
||||||
renderPaymentFlow();
|
renderPaymentFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPaymentFlow() {
|
function renderPaymentFlow() {
|
||||||
const modal = document.getElementById('payment-modal');
|
const modal = document.getElementById('payment-modal');
|
||||||
|
if (!modal) return;
|
||||||
if (!state.paymentFlowOpen) {
|
if (!state.paymentFlowOpen) {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
syncModalLock();
|
syncModalLock();
|
||||||
@@ -1585,11 +1622,10 @@ const MOCK = (() => {
|
|||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
syncModalLock();
|
syncModalLock();
|
||||||
window.requestAnimationFrame(() => modal.classList.add('show'));
|
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||||
applyI18n(modal);
|
renderPlans((state.data && state.data.plans) || []);
|
||||||
renderStepState();
|
renderPaymentSummary();
|
||||||
renderPlans(state.data.plans || []);
|
|
||||||
renderMethods();
|
renderMethods();
|
||||||
renderPaymentResult();
|
applyI18n(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncModalLock() {
|
function syncModalLock() {
|
||||||
@@ -1604,183 +1640,138 @@ const MOCK = (() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStepState() {
|
function getDefaultPaymentPlan(plans = []) {
|
||||||
const order = ['plan', 'method', 'result'];
|
if (!plans.length) return null;
|
||||||
order.forEach((step, index) => {
|
return plans.find(plan => Number(plan.months) === 3) || plans[0] || null;
|
||||||
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) {
|
function renderPlans(plans) {
|
||||||
const wrap = document.getElementById('plans');
|
const wrap = document.getElementById('plans');
|
||||||
const nextBtn = document.getElementById('to-methods-btn');
|
if (!wrap) return;
|
||||||
if (!plans.length) {
|
if (!plans.length) {
|
||||||
wrap.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_plans')) + '</div>';
|
wrap.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_plans')) + '</div>';
|
||||||
nextBtn.disabled = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextBtn.disabled = !state.selectedPlan;
|
if (!state.selectedPlan || !plans.some(plan => Number(plan.months) === Number(state.selectedPlan.months))) {
|
||||||
|
state.selectedPlan = getDefaultPaymentPlan(plans);
|
||||||
|
}
|
||||||
|
|
||||||
wrap.innerHTML = plans.map(plan => {
|
wrap.innerHTML = plans.map(plan => {
|
||||||
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
|
const planMonths = Number(plan.months);
|
||||||
const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
|
const isActive = state.selectedPlan && Number(state.selectedPlan.months) === planMonths;
|
||||||
return `
|
return `
|
||||||
<button class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" onclick="selectPlan(${plan.months})">
|
<button class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" onclick="selectPlan(${planMonths})" ${state.creatingPayment ? 'disabled' : ''}>
|
||||||
<span class="min-w-0">
|
${escapeHtml(plan.title)}
|
||||||
<span class="${TW.planName}">${escapeHtml(plan.title)}</span>
|
|
||||||
<span class="${TW.planMeta}">${escapeHtml(t('access_period'))}</span>
|
|
||||||
</span>
|
|
||||||
<span class="${TW.planPrice}">${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPaymentSummary() {
|
||||||
|
const price = document.getElementById('selected-plan-price');
|
||||||
|
if (!price) return;
|
||||||
|
if (!state.selectedPlan) {
|
||||||
|
price.textContent = t('no_plans');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
price.textContent = formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
|
||||||
|
}
|
||||||
|
|
||||||
function renderMethods() {
|
function renderMethods() {
|
||||||
const methods = document.getElementById('methods');
|
const methods = document.getElementById('payment-methods');
|
||||||
const createBtn = document.getElementById('create-payment-btn');
|
if (!methods) return;
|
||||||
if (!state.selectedPlan) {
|
if (!state.selectedPlan) {
|
||||||
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('choose_plan_first')) + '</div>';
|
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('choose_plan_first')) + '</div>';
|
||||||
createBtn.disabled = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = getAvailableMethods();
|
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) {
|
if (!available.length) {
|
||||||
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_methods')) + '</div>';
|
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_methods')) + '</div>';
|
||||||
createBtn.disabled = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
methods.innerHTML = available.map(method => {
|
methods.innerHTML = available.map(method => {
|
||||||
const isActive = state.selectedMethod === method.id;
|
const labelKey = method.id === 'platega'
|
||||||
const amount = method.id === 'stars'
|
? 'pay_with_platega_button'
|
||||||
? state.selectedPlan.stars_price + ' Stars'
|
: 'pay_with_cryptopay_button';
|
||||||
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
|
const variantClass = method.id === 'platega'
|
||||||
|
? TW.paymentMethodCardPlatega
|
||||||
|
: TW.paymentMethodCardCryptopay;
|
||||||
return `
|
return `
|
||||||
<button class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" onclick="selectMethod('${escapeAttr(method.id)}')">
|
<button class="${TW.paymentMethodCard} ${variantClass}" type="button" data-i18n="${labelKey}" onclick="createAndOpenPayment('${escapeAttr(method.id)}')" ${state.creatingPayment ? 'disabled' : ''}>
|
||||||
<span class="min-w-0">
|
${escapeHtml(t(labelKey))}
|
||||||
<span class="${TW.planName}">${escapeHtml(method.name)}</span>
|
|
||||||
<span class="${TW.planMeta}">${escapeHtml(state.selectedPlan.title)}</span>
|
|
||||||
</span>
|
|
||||||
<span class="${TW.planPrice}">${escapeHtml(amount)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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) {
|
function selectPlan(months) {
|
||||||
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months) || null;
|
if (state.creatingPayment) return;
|
||||||
state.selectedMethod = null;
|
state.selectedPlan = ((state.data && state.data.plans) || []).find(plan => Number(plan.months) === Number(months)) || null;
|
||||||
state.payment = null;
|
state.payment = null;
|
||||||
renderPaymentFlow();
|
renderPaymentFlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMethod(method) {
|
async function createAndOpenPayment(method) {
|
||||||
state.selectedMethod = method;
|
if (state.creatingPayment) return;
|
||||||
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) {
|
if (!state.selectedPlan) {
|
||||||
showToast(t('choose_plan_toast'));
|
showToast(t('choose_plan_toast'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.selectedMethod) {
|
const available = getAvailableMethods();
|
||||||
showToast(t('choose_method_toast'));
|
if (!available.some(item => item.id === method)) {
|
||||||
|
showToast(t('no_methods'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.creatingPayment = true;
|
state.creatingPayment = true;
|
||||||
renderMethods();
|
state.payment = null;
|
||||||
|
renderPaymentFlow();
|
||||||
try {
|
try {
|
||||||
const data = await api('/payments', {
|
const data = await api('/payments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({months: state.selectedPlan.months, method: state.selectedMethod})
|
body: JSON.stringify({months: state.selectedPlan.months, method})
|
||||||
});
|
});
|
||||||
if (!data.ok) throw new Error(data.message || t('payment_error'));
|
if (!data.ok || !data.payment_url) throw new Error(data.message || t('payment_error'));
|
||||||
state.payment = data;
|
state.payment = data;
|
||||||
state.paymentStep = 'result';
|
const opened = openPaymentUrl(data.payment_url);
|
||||||
renderPaymentFlow();
|
if (opened) {
|
||||||
|
closePaymentFlow();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message || t('payment_error'));
|
showToast(e.message || t('payment_error'));
|
||||||
} finally {
|
} finally {
|
||||||
state.creatingPayment = false;
|
state.creatingPayment = false;
|
||||||
renderPaymentFlow();
|
if (state.paymentFlowOpen) {
|
||||||
|
renderPaymentFlow();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvailableMethods() {
|
function getAvailableMethods() {
|
||||||
if (!state.data || !state.selectedPlan) return [];
|
if (!state.data || !state.selectedPlan) return [];
|
||||||
return (state.data.payment_methods || []).filter(method => {
|
const methodsById = new Map(
|
||||||
if (method.id === 'stars') return Number(state.selectedPlan.stars_price || 0) > 0;
|
(state.data.payment_methods || []).map(method => [String(method.id || '').toLowerCase(), method])
|
||||||
return true;
|
);
|
||||||
});
|
return ['platega', 'cryptopay']
|
||||||
|
.map(methodId => methodsById.get(methodId))
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPaymentUrl() {
|
function openPaymentUrl(url) {
|
||||||
const payment = state.payment;
|
const paymentUrl = String(url || (state.payment && state.payment.payment_url) || '').trim();
|
||||||
if (!payment || !payment.payment_url) return;
|
if (!paymentUrl) return false;
|
||||||
if (payment.action === 'open_invoice' && tg && tg.openInvoice) {
|
|
||||||
tg.openInvoice(payment.payment_url, function(status) {
|
|
||||||
if (status === 'paid') loadData();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tg && tg.openLink) {
|
if (tg && tg.openLink) {
|
||||||
tg.openLink(payment.payment_url);
|
tg.openLink(paymentUrl);
|
||||||
} else {
|
return true;
|
||||||
window.open(payment.payment_url, '_blank', 'noopener');
|
|
||||||
}
|
}
|
||||||
|
const opened = window.open(paymentUrl, '_blank', 'noopener');
|
||||||
|
if (!opened) {
|
||||||
|
window.location.assign(paymentUrl);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkPayment() {
|
async function checkPayment() {
|
||||||
@@ -2041,33 +2032,74 @@ const MOCK = (() => {
|
|||||||
}, 2200);
|
}, 2200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setFavicon(href) {
|
||||||
|
let favicon = document.getElementById('app-favicon');
|
||||||
|
if (!favicon) {
|
||||||
|
favicon = document.createElement('link');
|
||||||
|
favicon.id = 'app-favicon';
|
||||||
|
favicon.rel = 'icon';
|
||||||
|
favicon.setAttribute('sizes', 'any');
|
||||||
|
document.head.appendChild(favicon);
|
||||||
|
}
|
||||||
|
favicon.href = href || 'data:,';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrandLogo(shell, logoUrl) {
|
||||||
|
if (!shell) return;
|
||||||
|
|
||||||
|
const logo = shell.querySelector('[data-brand-logo]');
|
||||||
|
const spinner = shell.querySelector('[data-brand-logo-spinner]');
|
||||||
|
if (!logo) return;
|
||||||
|
|
||||||
|
logo.onload = null;
|
||||||
|
logo.onerror = null;
|
||||||
|
logo.removeAttribute('data-brand-logo-url');
|
||||||
|
|
||||||
|
if (!logoUrl) {
|
||||||
|
logo.removeAttribute('src');
|
||||||
|
logo.classList.add('hidden');
|
||||||
|
if (spinner) spinner.classList.add('hidden');
|
||||||
|
shell.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedUrl = logoUrl;
|
||||||
|
shell.classList.remove('hidden');
|
||||||
|
if (spinner) spinner.classList.remove('hidden');
|
||||||
|
logo.classList.add('hidden');
|
||||||
|
logo.dataset.brandLogoUrl = expectedUrl;
|
||||||
|
|
||||||
|
logo.onload = () => {
|
||||||
|
if (logo.dataset.brandLogoUrl !== expectedUrl) return;
|
||||||
|
logo.classList.remove('hidden');
|
||||||
|
if (spinner) spinner.classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
logo.onerror = () => {
|
||||||
|
if (logo.dataset.brandLogoUrl !== expectedUrl) return;
|
||||||
|
logo.removeAttribute('src');
|
||||||
|
logo.classList.add('hidden');
|
||||||
|
if (spinner) spinner.classList.add('hidden');
|
||||||
|
shell.classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
logo.src = logoUrl;
|
||||||
|
if (logo.complete && logo.naturalWidth > 0) {
|
||||||
|
logo.classList.remove('hidden');
|
||||||
|
if (spinner) spinner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setBrand() {
|
function setBrand() {
|
||||||
const title = CFG.title || t('page_title');
|
const title = CFG.title || t('page_title');
|
||||||
const logoUrl = CFG.logoUrl || '';
|
const logoUrl = CFG.logoUrl || '';
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
setFavicon(logoUrl);
|
||||||
document.querySelectorAll('[data-brand-title]').forEach(node => {
|
document.querySelectorAll('[data-brand-title]').forEach(node => {
|
||||||
node.textContent = title;
|
node.textContent = title;
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-brand-logo]').forEach(logo => {
|
document.querySelectorAll('[data-brand-logo-shell]').forEach(shell => {
|
||||||
if (!logoUrl) {
|
setBrandLogo(shell, 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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,11 +114,23 @@
|
|||||||
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
@apply rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||||
}
|
}
|
||||||
.plan-card {
|
.plan-card {
|
||||||
@apply flex min-h-16 w-full min-w-0 items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[13px] text-left text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
@apply inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
}
|
}
|
||||||
.plan-card-active {
|
.plan-card-active {
|
||||||
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
|
@apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)];
|
||||||
|
}
|
||||||
|
.payment-method-card {
|
||||||
|
@apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
.payment-method-card--platega {
|
||||||
|
border-color: rgba(0, 254, 122, 0.36);
|
||||||
|
background: rgba(0, 254, 122, 0.08);
|
||||||
|
}
|
||||||
|
.payment-method-card--cryptopay {
|
||||||
|
border-color: rgba(34, 211, 238, 0.36);
|
||||||
|
background: rgba(34, 211, 238, 0.08);
|
||||||
}
|
}
|
||||||
.plan-name {
|
.plan-name {
|
||||||
@apply block text-[15px] font-extrabold leading-tight;
|
@apply block text-[15px] font-extrabold leading-tight;
|
||||||
@@ -195,8 +207,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown {
|
.user-dropdown {
|
||||||
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_24px_48px_rgba(0,0,0,0.45)];
|
@apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[min(calc(100vw-28px),280px)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96];
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown.show {
|
||||||
|
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown-head {
|
.user-dropdown-head {
|
||||||
@@ -286,6 +303,12 @@
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes brand-logo-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sub-countdown {
|
.sub-countdown {
|
||||||
@apply grid gap-1;
|
@apply grid gap-1;
|
||||||
}
|
}
|
||||||
@@ -448,12 +471,33 @@
|
|||||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
|
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 561px) {
|
||||||
|
.app {
|
||||||
|
@apply min-h-screen justify-center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.telegram-login-widget {
|
.telegram-login-widget {
|
||||||
@apply flex min-w-0 items-center;
|
@apply flex min-w-0 items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo-shell {
|
||||||
|
@apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo-shell--lg {
|
||||||
|
@apply h-[96px] w-[96px];
|
||||||
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
@apply h-[38px] w-[38px] flex-none block rounded-[var(--radius-md)] object-contain;
|
@apply absolute inset-0 h-full w-full object-contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo-spinner {
|
||||||
|
@apply h-5 w-5 flex-none rounded-full border-2;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: brand-logo-spin 0.85s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo--lg {
|
.brand-logo--lg {
|
||||||
@@ -521,6 +565,14 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-auth-body {
|
||||||
|
@apply grid w-full min-h-[140px] gap-2.5 content-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-auth-body--telegram {
|
||||||
|
@apply content-center;
|
||||||
|
}
|
||||||
|
|
||||||
.login-text,
|
.login-text,
|
||||||
.promo-status {
|
.promo-status {
|
||||||
@apply text-[var(--text-secondary)];
|
@apply text-[var(--text-secondary)];
|
||||||
@@ -737,6 +789,14 @@
|
|||||||
@apply h-[68px] w-[68px];
|
@apply h-[68px] w-[68px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo-shell--lg {
|
||||||
|
@apply h-[96px] w-[96px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-auth-body {
|
||||||
|
@apply min-h-[128px];
|
||||||
|
}
|
||||||
|
|
||||||
.login-brand-title {
|
.login-brand-title {
|
||||||
@apply max-w-[200px] text-[21px];
|
@apply max-w-[200px] text-[21px];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user