feat: tune web app visual
This commit is contained in:
@@ -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
@@ -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">
|
||||
<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>
|
||||
<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 class="grid gap-2.5">
|
||||
<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 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 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 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>
|
||||
<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,26 +229,34 @@
|
||||
</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 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 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>
|
||||
<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="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 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 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>
|
||||
|
||||
@@ -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;
|
||||
renderPaymentFlow();
|
||||
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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user