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 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
+42 -56
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="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>
+184 -152
View File
@@ -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];
} }