feat: tune web app visual

This commit is contained in:
3252a8
2026-04-24 22:35:55 +03:00
parent 651572f15f
commit 778615a97f
5 changed files with 382 additions and 215 deletions
+91 -2
View File
@@ -1,9 +1,10 @@
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
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 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_WIDGET_SDK_URL = "https://telegram.org/js/telegram-widget.js?23"
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"""
_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 -->"
@@ -61,6 +63,8 @@ def create_subscription_webapp_application(
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
app["email_auth_service"] = EmailAuthService(settings)
app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock()
for key in (
"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("/telegram-web-app.js", telegram_web_app_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.js", js_asset_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")
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:
if not TELEGRAM_WEB_APP_SDK_PATH.exists():
await refresh_telegram_web_app_sdk()
@@ -279,7 +368,7 @@ async def index_route(request: web.Request) -> web.Response:
config = {
"title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"logoUrl": settings.WEBAPP_LOGO_URL or "",
"logoUrl": _resolve_webapp_logo_url(settings),
"apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "",
"supportUrl": settings.SUPPORT_LINK or "",
File diff suppressed because one or more lines are too long
+32 -46
View File
@@ -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="robots" content="noindex, nofollow">
<meta name="theme-color" content="#05070a">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>Моя подписка</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -20,7 +21,10 @@
<main id="app" class="app hidden">
<header class="app-header">
<div class="app-header-title">
<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>
<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">
<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>
<div id="payment-title" class="section-title" data-i18n="payment_title">Оплата подписки</div>
<div class="section-caption" data-i18n="payment_caption">Выберите срок, способ оплаты и создайте платеж.</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>
</div>
<div class="grid grid-cols-3 gap-2" data-aria-i18n="payment_steps" aria-label="Шаги оплаты">
<div id="step-plan" class="step step-box">
<span class="step-num">01</span>
<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 id="plan-step" class="grid gap-2.5">
<div class="section-label" data-i18n="select_period">Выберите срок</div>
<div id="plans" class="plans"></div>
<button id="to-methods-btn" class="btn-primary w-full" type="button" onclick="goToPaymentStep('method')" data-i18n="choose_payment_method">Выбрать способ оплаты</button>
</div>
<div id="method-step" class="hidden grid gap-2.5">
<div class="section-label" data-i18n="select_method">Выберите способ оплаты</div>
<div id="methods" class="methods"></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 class="section-label" data-i18n="select_period">Выберите период</div>
<div id="plans" class="grid grid-cols-2 gap-2 sm:grid-cols-4"></div>
</div>
<div class="notice grid gap-1.5">
<div class="section-label" data-i18n="payment_amount_label">Стоимость выбранного периода</div>
<div id="selected-plan-price" class="text-[20px] font-extrabold leading-tight text-[var(--text-primary)]">...</div>
</div>
<div class="grid gap-2.5">
<div class="section-label" data-i18n="choose_payment_method">Способ оплаты</div>
<div id="payment-methods" class="grid gap-2"></div>
</div>
</section>
</div>
@@ -194,7 +172,7 @@
<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" 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>
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closePromoModal()">×</button>
@@ -251,20 +229,26 @@
</div>
</div>
<section id="login" class="login login-layout">
<div class="panel-modal w-full grid gap-[17px] p-[18px] text-center">
<div class="login-head">
<img id="login-brand-logo" class="brand-logo brand-logo--lg hidden" data-brand-logo alt="" aria-hidden="true">
<div id="login-brand-title" class="login-brand-title" data-brand-title>Моя подписка</div>
<section id="login" class="login login-layout gap-5">
<div class="grid justify-items-center gap-3 text-center">
<div class="brand-logo-shell brand-logo-shell--lg hidden" data-brand-logo-shell aria-hidden="true">
<span class="brand-logo-spinner hidden" data-brand-logo-spinner aria-hidden="true"></span>
<img id="login-brand-logo" class="brand-logo hidden" data-brand-logo alt="" aria-hidden="true">
</div>
<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>
</div>
<div class="panel-modal w-full grid gap-5 p-6 text-center">
<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>
</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="send_code">Отправить код</button>
<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">
@@ -272,6 +256,8 @@
</div>
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
</div>
</div>
</div>
<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="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
+183 -151
View File
@@ -84,14 +84,12 @@ const MOCK = (() => {
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
data: null,
selectedPlan: null,
selectedMethod: null,
referralParam: readReferralParam(),
promoApplying: false,
payment: null,
paymentFlowOpen: false,
promoModalOpen: false,
referralModalOpen: false,
paymentStep: 'plan',
creatingPayment: false,
authInProgress: false,
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
@@ -108,7 +106,8 @@ const MOCK = (() => {
telegramLinkRendered: false,
telegramLinkInProgress: false,
toastTimer: null,
userMenuOpen: false
userMenuOpen: false,
userMenuCloseTimer: null
};
const TW = {
@@ -129,6 +128,9 @@ const MOCK = (() => {
planName: 'plan-name',
planMeta: 'plan-meta',
planPrice: 'plan-price',
paymentMethodCard: 'payment-method-card',
paymentMethodCardPlatega: 'payment-method-card--platega',
paymentMethodCardCryptopay: 'payment-method-card--cryptopay',
notice: 'notice',
stepNum: 'step-num',
stepName: 'step-name'
@@ -148,22 +150,25 @@ const MOCK = (() => {
copy_link: 'Скопировать ссылку',
extend_subscription: 'Купить подписку / Добавить дни',
payment_title: 'Оплата подписки',
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
payment_caption: 'Выберите период и нажмите способ оплаты. Сервис оплаты откроется сразу.',
close: 'Закрыть',
close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты',
step_plan: 'Срок',
step_method: 'Метод',
step_result: 'Платеж',
select_period: 'Выберите срок',
select_period: 'Выберите период',
select_method: 'Выберите способ оплаты',
payment_status: 'Статус платежа',
choose_payment_method: 'Выбрать способ оплаты',
payment_amount_label: 'Стоимость выбранного периода',
choose_payment_method: 'Способ оплаты',
back: 'Назад',
create_payment: 'Создать платеж',
open_payment: 'Открыть оплату',
check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ',
pay_with_platega_button: 'Оплатить картой (СБП)',
pay_with_cryptopay_button: 'Оплатить криптой',
support: 'Поддержка',
account_title: 'Аккаунт',
account_caption: 'Способы входа',
@@ -179,6 +184,8 @@ const MOCK = (() => {
resend_code: 'Отправить еще раз',
confirm: 'Подтвердить',
login: 'Войти',
login_title: 'Войдите или зарегистрируйтесь',
login_continue: 'Продолжить',
email_code_title: 'Подтвердите вход',
email_code_caption: 'Введите 6-значный код из письма.',
email_code_aria: 'Код подтверждения',
@@ -273,7 +280,7 @@ const MOCK = (() => {
copy_link: 'Copy link',
extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment',
payment_caption: 'Choose a period, payment method, and create a payment.',
payment_caption: 'Choose a period and tap a payment method. The payment service will open right away.',
close: 'Close',
close_payment: 'Close payment',
payment_steps: 'Payment steps',
@@ -283,12 +290,15 @@ const MOCK = (() => {
select_period: 'Select period',
select_method: 'Select payment method',
payment_status: 'Payment status',
choose_payment_method: 'Choose payment method',
payment_amount_label: 'Selected period cost',
choose_payment_method: 'Payment method',
back: 'Back',
create_payment: 'Create payment',
open_payment: 'Open payment',
check_payment: 'Check payment',
choose_other_method: 'Choose another method',
pay_with_platega_button: 'Pay by card (SBP)',
pay_with_cryptopay_button: 'Pay with crypto',
support: 'Support',
account_title: 'Account',
account_caption: 'Sign-in methods',
@@ -304,6 +314,8 @@ const MOCK = (() => {
resend_code: 'Send again',
confirm: 'Confirm',
login: 'Log in',
login_title: 'Log in or sign up',
login_continue: 'Continue',
email_code_title: 'Confirm login',
email_code_caption: 'Enter the 6-digit code from the email.',
email_code_aria: 'Verification code',
@@ -575,6 +587,7 @@ const MOCK = (() => {
const telegramTab = document.getElementById('telegram-auth-tab');
const emailPane = document.getElementById('email-login-pane');
const telegramPane = document.getElementById('telegram-login-pane');
const authBody = document.querySelector('.login-auth-body');
const emailEnabled = CFG.emailAuthEnabled !== false;
if (!emailEnabled && state.authMode === 'email') {
state.authMode = 'telegram';
@@ -584,6 +597,9 @@ const MOCK = (() => {
telegramTab.classList.toggle('active', state.authMode === 'telegram');
emailPane.classList.toggle('hidden', state.authMode !== 'email');
telegramPane.classList.toggle('hidden', state.authMode !== 'telegram');
if (authBody) {
authBody.classList.toggle('login-auth-body--telegram', state.authMode === 'telegram');
}
emailTab.disabled = !emailEnabled;
if (state.authMode === 'telegram') {
@@ -868,7 +884,6 @@ const MOCK = (() => {
}
async function loadData() {
const previousMonths = state.selectedPlan && state.selectedPlan.months;
const data = await api('/me');
if (!data.ok) throw new Error(data.error || 'load failed');
state.data = data;
@@ -876,10 +891,9 @@ const MOCK = (() => {
applyI18n();
applyLegalLinks();
const plans = data.plans || [];
state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null;
state.selectedMethod = null;
state.selectedPlan = getDefaultPaymentPlan(plans);
state.payment = null;
state.paymentStep = 'plan';
state.creatingPayment = false;
state.telegramLinkRendered = false;
render();
showApp();
@@ -1140,8 +1154,31 @@ const MOCK = (() => {
const chip = document.getElementById('user-chip');
const dropdown = document.getElementById('user-dropdown');
if (!chip || !dropdown) return;
if (state.userMenuCloseTimer) {
window.clearTimeout(state.userMenuCloseTimer);
state.userMenuCloseTimer = null;
}
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) {
@@ -1557,22 +1594,22 @@ const MOCK = (() => {
return;
}
state.paymentFlowOpen = true;
state.paymentStep = 'plan';
state.creatingPayment = false;
state.payment = null;
state.selectedMethod = null;
state.selectedPlan = getDefaultPaymentPlan((state.data && state.data.plans) || []);
renderPaymentFlow();
}
function closePaymentFlow() {
state.paymentFlowOpen = false;
state.paymentStep = 'plan';
state.creatingPayment = false;
state.payment = null;
state.selectedMethod = null;
renderPaymentFlow();
}
function renderPaymentFlow() {
const modal = document.getElementById('payment-modal');
if (!modal) return;
if (!state.paymentFlowOpen) {
modal.classList.remove('show');
syncModalLock();
@@ -1585,11 +1622,10 @@ const MOCK = (() => {
modal.classList.remove('hidden');
syncModalLock();
window.requestAnimationFrame(() => modal.classList.add('show'));
applyI18n(modal);
renderStepState();
renderPlans(state.data.plans || []);
renderPlans((state.data && state.data.plans) || []);
renderPaymentSummary();
renderMethods();
renderPaymentResult();
applyI18n(modal);
}
function syncModalLock() {
@@ -1604,183 +1640,138 @@ const MOCK = (() => {
);
}
function renderStepState() {
const order = ['plan', 'method', 'result'];
order.forEach((step, index) => {
const node = document.getElementById('step-' + step);
const currentIndex = order.indexOf(state.paymentStep);
node.classList.toggle('active', state.paymentStep === step);
node.classList.toggle('done', currentIndex > index);
});
document.getElementById('plan-step').classList.toggle('hidden', state.paymentStep !== 'plan');
document.getElementById('method-step').classList.toggle('hidden', state.paymentStep !== 'method');
document.getElementById('result-step').classList.toggle('hidden', state.paymentStep !== 'result');
function getDefaultPaymentPlan(plans = []) {
if (!plans.length) return null;
return plans.find(plan => Number(plan.months) === 3) || plans[0] || null;
}
function renderPlans(plans) {
const wrap = document.getElementById('plans');
const nextBtn = document.getElementById('to-methods-btn');
if (!wrap) return;
if (!plans.length) {
wrap.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_plans')) + '</div>';
nextBtn.disabled = true;
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 => {
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
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(${plan.months})">
<span class="min-w-0">
<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 class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" onclick="selectPlan(${planMonths})" ${state.creatingPayment ? 'disabled' : ''}>
${escapeHtml(plan.title)}
</button>
`;
}).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() {
const methods = document.getElementById('methods');
const createBtn = document.getElementById('create-payment-btn');
const methods = document.getElementById('payment-methods');
if (!methods) return;
if (!state.selectedPlan) {
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('choose_plan_first')) + '</div>';
createBtn.disabled = true;
return;
}
const available = getAvailableMethods();
if (state.selectedMethod && !available.some(method => method.id === state.selectedMethod)) {
state.selectedMethod = null;
}
createBtn.disabled = !state.selectedMethod || state.creatingPayment;
if (!available.length) {
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_methods')) + '</div>';
createBtn.disabled = true;
return;
}
methods.innerHTML = available.map(method => {
const isActive = state.selectedMethod === method.id;
const amount = method.id === 'stars'
? state.selectedPlan.stars_price + ' Stars'
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
const labelKey = method.id === 'platega'
? 'pay_with_platega_button'
: 'pay_with_cryptopay_button';
const variantClass = method.id === 'platega'
? TW.paymentMethodCardPlatega
: TW.paymentMethodCardCryptopay;
return `
<button class="${TW.planCard} ${isActive ? TW.planCardActive : ''}" type="button" onclick="selectMethod('${escapeAttr(method.id)}')">
<span class="min-w-0">
<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 class="${TW.paymentMethodCard} ${variantClass}" type="button" data-i18n="${labelKey}" onclick="createAndOpenPayment('${escapeAttr(method.id)}')" ${state.creatingPayment ? 'disabled' : ''}>
${escapeHtml(t(labelKey))}
</button>
`;
}).join('');
}
function renderPaymentResult() {
const payment = state.payment;
const msg = document.getElementById('payment-message');
const openBtn = document.getElementById('payment-open-btn');
const checkBtn = document.getElementById('payment-check-btn');
if (!payment) {
msg.textContent = t('payment_not_created');
openBtn.classList.add('hidden');
checkBtn.classList.add('hidden');
return;
}
if (payment.action === 'invoice_sent') {
msg.textContent = t('invoice_sent');
openBtn.classList.add('hidden');
} else {
msg.textContent = t('payment_created');
openBtn.classList.toggle('hidden', !payment.payment_url);
}
checkBtn.classList.toggle('hidden', !payment.payment_id);
}
function selectPlan(months) {
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months) || null;
state.selectedMethod = null;
if (state.creatingPayment) return;
state.selectedPlan = ((state.data && state.data.plans) || []).find(plan => Number(plan.months) === Number(months)) || null;
state.payment = null;
renderPaymentFlow();
}
function selectMethod(method) {
state.selectedMethod = method;
state.payment = null;
renderMethods();
renderPaymentResult();
}
function goToPaymentStep(step) {
if (step === 'method' && !state.selectedPlan) {
showToast(t('choose_plan_toast'));
return;
}
if (step === 'result' && !state.payment) {
showToast(t('create_payment_toast'));
return;
}
state.paymentStep = step;
renderPaymentFlow();
}
async function createPaymentFromSelection() {
async function createAndOpenPayment(method) {
if (state.creatingPayment) return;
if (!state.selectedPlan) {
showToast(t('choose_plan_toast'));
return;
}
if (!state.selectedMethod) {
showToast(t('choose_method_toast'));
const available = getAvailableMethods();
if (!available.some(item => item.id === method)) {
showToast(t('no_methods'));
return;
}
state.creatingPayment = true;
renderMethods();
state.payment = null;
renderPaymentFlow();
try {
const data = await api('/payments', {
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.paymentStep = 'result';
renderPaymentFlow();
const opened = openPaymentUrl(data.payment_url);
if (opened) {
closePaymentFlow();
}
} catch (e) {
showToast(e.message || t('payment_error'));
} finally {
state.creatingPayment = false;
if (state.paymentFlowOpen) {
renderPaymentFlow();
}
}
}
function getAvailableMethods() {
if (!state.data || !state.selectedPlan) return [];
return (state.data.payment_methods || []).filter(method => {
if (method.id === 'stars') return Number(state.selectedPlan.stars_price || 0) > 0;
return true;
});
const methodsById = new Map(
(state.data.payment_methods || []).map(method => [String(method.id || '').toLowerCase(), method])
);
return ['platega', 'cryptopay']
.map(methodId => methodsById.get(methodId))
.filter(Boolean);
}
function openPaymentUrl() {
const payment = state.payment;
if (!payment || !payment.payment_url) return;
if (payment.action === 'open_invoice' && tg && tg.openInvoice) {
tg.openInvoice(payment.payment_url, function(status) {
if (status === 'paid') loadData();
});
return;
}
function openPaymentUrl(url) {
const paymentUrl = String(url || (state.payment && state.payment.payment_url) || '').trim();
if (!paymentUrl) return false;
if (tg && tg.openLink) {
tg.openLink(payment.payment_url);
} else {
window.open(payment.payment_url, '_blank', 'noopener');
tg.openLink(paymentUrl);
return true;
}
const opened = window.open(paymentUrl, '_blank', 'noopener');
if (!opened) {
window.location.assign(paymentUrl);
}
return true;
}
async function checkPayment() {
@@ -2041,33 +2032,74 @@ const MOCK = (() => {
}, 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() {
const title = CFG.title || t('page_title');
const logoUrl = CFG.logoUrl || '';
document.title = title;
setFavicon(logoUrl);
document.querySelectorAll('[data-brand-title]').forEach(node => {
node.textContent = title;
});
document.querySelectorAll('[data-brand-logo]').forEach(logo => {
if (!logoUrl) {
logo.removeAttribute('src');
logo.classList.add('hidden');
return;
}
logo.onload = () => {
logo.classList.remove('hidden');
};
logo.onerror = () => {
logo.removeAttribute('src');
logo.classList.add('hidden');
};
logo.src = logoUrl;
if (logo.complete && logo.naturalWidth > 0) {
logo.classList.remove('hidden');
} else {
logo.classList.add('hidden');
}
document.querySelectorAll('[data-brand-logo-shell]').forEach(shell => {
setBrandLogo(shell, logoUrl);
});
}
@@ -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)];
}
.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;
}
.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 {
@apply block text-[15px] font-extrabold leading-tight;
@@ -195,8 +207,13 @@
}
.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);
will-change: opacity, transform;
}
.user-dropdown.show {
@apply opacity-100 pointer-events-auto translate-y-0 scale-100;
}
.user-dropdown-head {
@@ -286,6 +303,12 @@
letter-spacing: 0.01em;
}
@keyframes brand-logo-spin {
to {
transform: rotate(360deg);
}
}
.sub-countdown {
@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);
}
@media (min-width: 561px) {
.app {
@apply min-h-screen justify-center;
}
}
.telegram-login-widget {
@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 {
@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 {
@@ -521,6 +565,14 @@
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,
.promo-status {
@apply text-[var(--text-secondary)];
@@ -737,6 +789,14 @@
@apply h-[68px] w-[68px];
}
.brand-logo-shell--lg {
@apply h-[96px] w-[96px];
}
.login-auth-body {
@apply min-h-[128px];
}
.login-brand-title {
@apply max-w-[200px] text-[21px];
}