feat: tune web app visual
This commit is contained in:
@@ -6,6 +6,7 @@ bot_database.sqlite3
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
node_modules/
|
||||
|
||||
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
|
||||
.idea/
|
||||
|
||||
@@ -1235,6 +1235,8 @@ def _serialize_subscription(
|
||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes")),
|
||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||
"traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")),
|
||||
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
|
||||
"provider": getattr(local_sub, "provider", None),
|
||||
}
|
||||
@@ -1734,6 +1736,15 @@ def _format_remaining(seconds: int, lang: str) -> str:
|
||||
return f"{max(1, minutes)} мин."
|
||||
|
||||
|
||||
def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_bytes(value: Optional[Any]) -> str:
|
||||
if value is None:
|
||||
return "N/A"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,201 +15,221 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader" class="loader" data-i18n="loading">Загрузка...</div>
|
||||
<div id="loader" class="app-loader" data-i18n="loading">Загрузка...</div>
|
||||
|
||||
<main id="app" class="app hidden">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<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 id="brand-title" class="brand-title" data-brand-title>Моя подписка</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="icon-btn" type="button" data-title-i18n="refresh" aria-label="Обновить" onclick="reloadData()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M544.1 256L552 256C565.3 256 576 245.3 576 232L576 88C576 78.3 570.2 69.5 561.2 65.8C552.2 62.1 541.9 64.2 535 71L483.3 122.8C439 86.1 382 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6C143.2 199.5 223.3 128 320 128C364.4 128 405.2 143 437.7 168.3L391 215C384.1 221.9 382.1 232.2 385.8 241.2C389.5 250.2 398.3 256 408 256L544.1 256zM573.5 356.5C576 339 563.8 322.8 546.4 320.3C529 317.8 512.7 330 510.2 347.4C496.9 440.4 416.8 511.9 320.1 511.9C275.7 511.9 234.9 496.9 202.4 471.6L249 425C255.9 418.1 257.9 407.8 254.2 398.8C250.5 389.8 241.7 384 232 384L88 384C74.7 384 64 394.7 64 408L64 552C64 561.7 69.8 570.5 78.8 574.2C87.8 577.9 98.1 575.8 105 569L156.8 517.2C201 553.9 258 576 320 576C449 576 555.7 480.6 573.4 356.5z"/>
|
||||
<div class="app-header-actions">
|
||||
<div class="user-menu">
|
||||
<button id="user-chip" class="user-chip" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-dropdown" onclick="toggleUserMenu()">
|
||||
<img id="user-chip-avatar" class="user-chip-avatar" alt="" aria-hidden="true">
|
||||
<span id="user-chip-name" class="user-chip-name">...</span>
|
||||
<svg class="user-chip-caret" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn logout-btn" type="button" onclick="logout()">
|
||||
<div id="user-dropdown" class="user-dropdown hidden" role="menu">
|
||||
<div class="user-dropdown-head">
|
||||
<img id="user-dropdown-avatar" class="user-dropdown-avatar" alt="" aria-hidden="true">
|
||||
<div class="user-dropdown-head-text">
|
||||
<div id="user-dropdown-name" class="user-dropdown-name">...</div>
|
||||
<div id="user-dropdown-sub" class="user-dropdown-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-dropdown-rows">
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="email_label">Email</span>
|
||||
<span id="user-dropdown-email-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
<div class="user-dropdown-row" role="menuitem">
|
||||
<span class="user-dropdown-row-label" data-i18n="telegram_label">Telegram</span>
|
||||
<span id="user-dropdown-telegram-status" class="user-dropdown-row-status">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="user-dropdown-logout" type="button" role="menuitem" onclick="logout()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M224 160C241.7 160 256 145.7 256 128C256 110.3 241.7 96 224 96L160 96C107 96 64 139 64 192L64 448C64 501 107 544 160 544L224 544C241.7 544 256 529.7 256 512C256 494.3 241.7 480 224 480L160 480C142.3 480 128 465.7 128 448L128 192C128 174.3 142.3 160 160 160L224 160zM566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L438.6 169.3C426.1 156.8 405.8 156.8 393.3 169.3C380.8 181.8 380.8 202.1 393.3 214.6L466.7 288L256 288C238.3 288 224 302.3 224 320C224 337.7 238.3 352 256 352L466.7 352L393.3 425.4C380.8 437.9 380.8 458.2 393.3 470.7C405.8 483.2 426.1 483.2 438.6 470.7L566.6 342.7z"/>
|
||||
</svg>
|
||||
<span data-i18n="logout">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel subscription-panel">
|
||||
<div class="badge-row">
|
||||
<div class="mono-pill" data-i18n="subscription">Подписка</div>
|
||||
<div id="status-badge" class="badge">...</div>
|
||||
<section class="panel grid gap-[17px] p-[18px]">
|
||||
<div id="sub-status" class="sub-status">
|
||||
<span class="sub-status-dot" aria-hidden="true"></span>
|
||||
<span id="sub-status-text" class="sub-status-text">...</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 id="remaining" class="status-title accent-text">...</h1>
|
||||
<p class="status-subtitle" data-i18n="subscription_subtitle">Ваша ссылка подключения и срок доступа</p>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label" data-i18n="ends_at">Окончание</div>
|
||||
<div id="end-date" class="metric-value">...</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label" data-i18n="traffic">Трафик</div>
|
||||
<div id="traffic" class="metric-value">...</div>
|
||||
<div class="sub-countdown">
|
||||
<div class="sub-countdown-row">
|
||||
<span id="remaining" class="sub-countdown-value">...</span>
|
||||
<span id="end-date-caption" class="sub-countdown-caption"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connect-actions" class="actions">
|
||||
<button id="connect-btn" class="btn primary" type="button" onclick="openConnectLink()" data-i18n="connect">Подключиться</button>
|
||||
<button id="copy-btn" class="btn icon-only copy-icon-btn" type="button" onclick="copyConfigLink()" data-title-i18n="copy_link" aria-label="Скопировать ссылку">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8zM307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="traffic-bar" class="traffic-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100">
|
||||
<div id="traffic-bar-fill" class="traffic-bar-fill"></div>
|
||||
<span id="traffic" class="traffic-bar-value">...</span>
|
||||
</div>
|
||||
|
||||
<div class="extend-row">
|
||||
<button id="extend-btn" class="btn extend-btn full" type="button" onclick="togglePaymentFlow()" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
<div id="connect-actions">
|
||||
<button id="connect-btn" class="btn-primary w-full" type="button" onclick="openConnectLink()" data-i18n="connect">Подключиться</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 border-t border-[var(--border)] pt-3.5">
|
||||
<button id="extend-btn" class="btn-ghost w-full" type="button" onclick="togglePaymentFlow()" data-i18n="extend_subscription">Продлить подписку/Добавить дни</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel account-panel">
|
||||
<section id="link-panel" class="panel hidden grid gap-3.5 p-[17px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div class="section-title" data-i18n="account_title">Аккаунт</div>
|
||||
<div class="flow-caption" data-i18n="account_caption">Способы входа</div>
|
||||
<div id="link-panel-title" class="section-title" data-i18n="link_panel_title">Привяжите способ входа</div>
|
||||
<div id="link-panel-caption" class="section-caption" data-i18n="link_panel_caption">Чтобы не терять доступ к кабинету</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-grid">
|
||||
<div class="account-row">
|
||||
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||
<div id="account-email" class="metric-value">...</div>
|
||||
<div id="email-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_email_title">Привяжите email</span>
|
||||
<span class="link-cta-caption" data-i18n="link_email_caption">Получайте код для входа на почту</span>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<div class="metric-label" data-i18n="telegram_label">Telegram</div>
|
||||
<div id="account-telegram" class="metric-value">...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="email-link-box" class="link-box hidden">
|
||||
<div class="field-row">
|
||||
<input id="email-link-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-input" class="input w-full" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-link-send-btn" class="btn" type="button" onclick="requestEmailLinkCode()" data-i18n="send_code">Отправить код</button>
|
||||
</div>
|
||||
<div id="email-link-code-row" class="field-row hidden">
|
||||
<input id="email-link-code-input" class="input code-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||
<button id="email-link-verify-btn" class="btn primary" type="button" onclick="verifyEmailLinkCode()" data-i18n="confirm">Подтвердить</button>
|
||||
<div id="email-link-code-row" class="hidden grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="email-link-code-input" class="input input-code w-full" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000" data-placeholder-i18n="code_placeholder">
|
||||
<button id="email-link-verify-btn" class="btn-primary" type="button" onclick="verifyEmailLinkCode()" data-i18n="confirm">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="telegram-link-box" class="link-box hidden">
|
||||
<div id="telegram-link-box" class="hidden grid gap-2">
|
||||
<div class="link-cta-head">
|
||||
<span class="link-cta-title" data-i18n="link_telegram_title">Привяжите Telegram</span>
|
||||
<span class="link-cta-caption" data-i18n="link_telegram_caption">Входите одним нажатием через Telegram</span>
|
||||
</div>
|
||||
<div id="telegram-link-widget" class="telegram-login-widget" aria-live="polite"></div>
|
||||
<div id="telegram-link-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||
<div id="telegram-link-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quick-actions">
|
||||
<button class="btn full" type="button" onclick="openReferralModal()" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn full" type="button" onclick="openPromoModal()" data-i18n="promo_title">РџСЂРѕРјРѕРєРѕРґ</button>
|
||||
</section>
|
||||
<button id="tools-modal-backdrop" class="tools-modal-backdrop hidden" type="button" data-title-i18n="close" onclick="closeToolModals()"></button>
|
||||
|
||||
<section class="panel promo-panel hidden">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div class="section-title" data-i18n="promo_title">Промокод</div>
|
||||
<div class="flow-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<input id="promo-code-input" class="input code-input" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn primary" type="button" onclick="applyPromoCode()" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||
<section class="grid min-w-0 grid-cols-2 gap-2">
|
||||
<button class="btn w-full" type="button" onclick="openReferralModal()" data-i18n="referral_title">Пригласить друга</button>
|
||||
<button class="btn w-full" type="button" onclick="openPromoModal()" data-i18n="promo_title">Промокод</button>
|
||||
</section>
|
||||
|
||||
<section class="panel referral-panel hidden"></section>
|
||||
|
||||
<a id="support-link" class="btn support-link hidden" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||
<div id="legal-links-app" class="legal-links hidden">
|
||||
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
|
||||
|
||||
<a id="support-link" class="hidden mt-0.5 btn" href="#" target="_blank" rel="noopener" data-i18n="support">Поддержка</a>
|
||||
<div id="legal-links-app" 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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="payment-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="payment-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" onclick="closePaymentFlow()"></button>
|
||||
<section id="payment-flow" class="panel payment-flow modal-card">
|
||||
<div class="flow-head">
|
||||
<section id="payment-flow" class="panel-modal modal-card grid gap-3.5 p-4">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="payment-title" class="flow-title" data-i18n="payment_title">Оплата подписки</div>
|
||||
<div class="flow-caption" data-i18n="payment_caption">Выберите срок, способ оплаты и создайте платеж.</div>
|
||||
<div id="payment-title" class="section-title" data-i18n="payment_title">Оплата подписки</div>
|
||||
<div class="section-caption" data-i18n="payment_caption">Выберите срок, способ оплаты и создайте платеж.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close_payment" aria-label="Закрыть оплату" onclick="closePaymentFlow()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="stepper" data-aria-i18n="payment_steps" aria-label="Шаги оплаты">
|
||||
<div id="step-plan" class="step">
|
||||
<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">
|
||||
<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">
|
||||
<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="step-pane">
|
||||
<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 full" type="button" onclick="goToPaymentStep('method')" data-i18n="choose_payment_method">Выбрать способ оплаты</button>
|
||||
<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="step-pane hidden">
|
||||
<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="step-actions">
|
||||
<button class="btn ghost" 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 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="step-pane hidden">
|
||||
<div id="result-step" class="hidden grid gap-2.5">
|
||||
<div class="section-label" data-i18n="payment_status">Статус платежа</div>
|
||||
<div class="result-box">
|
||||
<div class="grid gap-2.5">
|
||||
<div id="payment-message" class="notice"></div>
|
||||
<button id="payment-open-btn" class="btn primary full" type="button" onclick="openPaymentUrl()" data-i18n="open_payment">Открыть оплату</button>
|
||||
<button id="payment-check-btn" class="btn full" type="button" onclick="checkPayment()" data-i18n="check_payment">Проверить оплату</button>
|
||||
<button class="btn ghost full" type="button" onclick="goToPaymentStep('method')" data-i18n="choose_other_method">Выбрать другой способ</button>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="promo-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="promo-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closePromoModal()"></button>
|
||||
<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 class="section-caption" data-i18n="promo_caption">Введите код, чтобы начислить бонусные дни.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closePromoModal()">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(120px,auto)] gap-2">
|
||||
<input id="promo-code-input" class="input input-code w-full" type="text" autocomplete="off" inputmode="text" placeholder="PROMO2026" data-placeholder-i18n="promo_placeholder">
|
||||
<button id="promo-apply-btn" class="btn-primary" type="button" onclick="applyPromoCode()" data-i18n="apply_promo">Применить</button>
|
||||
</div>
|
||||
<div id="promo-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="referral-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="referral-title">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeReferralModal()"></button>
|
||||
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
|
||||
</div>
|
||||
|
||||
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
|
||||
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
|
||||
<div class="code-modal-stack">
|
||||
<section class="panel modal-card code-modal-card">
|
||||
<div class="flow-head">
|
||||
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
|
||||
<section class="panel-modal modal-card grid gap-3.5 p-[18px]">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div id="email-code-title" class="flow-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||
<div id="email-code-caption" class="flow-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||
<div id="email-code-title" class="section-title" data-i18n="email_code_title">Подтвердите вход</div>
|
||||
<div id="email-code-caption" class="section-caption" data-i18n="email_code_caption">Введите 6-значный код из письма.</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="code-summary">
|
||||
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.6fr)_minmax(0,1.4fr)]">
|
||||
<div class="metric-label" data-i18n="email_label">Email</div>
|
||||
<div id="email-code-address" class="metric-value">...</div>
|
||||
</div>
|
||||
|
||||
<label class="otp-field" for="email-login-code-input">
|
||||
<label class="relative block min-h-14 cursor-text">
|
||||
<input id="email-login-code-input" class="otp-input" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]*" data-aria-i18n="email_code_aria" aria-label="Код подтверждения">
|
||||
<span class="otp-slots" aria-hidden="true">
|
||||
<span id="email-code-slot-0"></span>
|
||||
@@ -221,40 +241,40 @@
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div id="email-code-status" class="login-text login-status hidden" aria-live="polite"></div>
|
||||
<div id="email-code-status" class="status-text hidden" aria-live="polite"></div>
|
||||
|
||||
<div class="code-modal-actions">
|
||||
<button id="email-login-verify-btn" class="btn primary full" type="button" onclick="verifyEmailLoginCode()" data-i18n="login">Войти</button>
|
||||
<div class="grid gap-2">
|
||||
<button id="email-login-verify-btn" class="btn-primary w-full" type="button" onclick="verifyEmailLoginCode()" data-i18n="login">Войти</button>
|
||||
</div>
|
||||
</section>
|
||||
<button id="email-login-resend-btn" class="code-modal-resend" type="button" onclick="resendEmailLoginCode()" data-i18n="resend_code">Отправить еще раз</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="login" class="login">
|
||||
<div class="panel login-card">
|
||||
<div class="login-brand">
|
||||
<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>
|
||||
</div>
|
||||
<div class="auth-tabs" role="tablist">
|
||||
<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="auth-pane">
|
||||
<div class="field-stack">
|
||||
<input id="email-login-input" class="input" type="email" autocomplete="email" inputmode="email" placeholder="mail@example.com" data-placeholder-i18n="email_placeholder">
|
||||
<button id="email-login-send-btn" class="btn primary full" type="button" onclick="requestEmailLoginCode()" data-i18n="send_code">Отправить код</button>
|
||||
<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>
|
||||
</div>
|
||||
<div id="telegram-login-pane" class="auth-pane hidden">
|
||||
<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="login-text login-status hidden" aria-live="polite"></div>
|
||||
<div id="auth-status" class="status-text hidden" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="legal-links-login" class="legal-links hidden">
|
||||
<a class="legal-link" data-legal-key="privacyPolicyUrl" href="#" target="_blank" rel="noopener" data-i18n="privacy_policy">Политика конфиденциальности</a>
|
||||
<a class="legal-link" data-legal-key="userAgreementUrl" href="#" target="_blank" rel="noopener" data-i18n="user_agreement">Пользовательское соглашение</a>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ window.__WEBAPP_DEV_MOCK__ = {
|
||||
connect_url: 'https://sub.example.com/connect/preview-token',
|
||||
traffic_used: '18.42 GB',
|
||||
traffic_limit: '500.00 GB',
|
||||
traffic_used_bytes: 19778468741,
|
||||
traffic_limit_bytes: 536870912000,
|
||||
auto_renew_enabled: false,
|
||||
provider: null
|
||||
},
|
||||
@@ -105,7 +107,31 @@ const MOCK = (() => {
|
||||
promoStatusText: '',
|
||||
telegramLinkRendered: false,
|
||||
telegramLinkInProgress: false,
|
||||
toastTimer: null
|
||||
toastTimer: null,
|
||||
userMenuOpen: false
|
||||
};
|
||||
|
||||
const TW = {
|
||||
iconBtn: 'icon-btn',
|
||||
btnBase: 'btn',
|
||||
btnIconOnly: 'w-12 min-w-12 p-0',
|
||||
panelHead: 'panel-head',
|
||||
flowCaption: 'section-caption',
|
||||
sectionTitle: 'section-title',
|
||||
metricLabel: 'metric-label',
|
||||
metricValue: 'metric-value',
|
||||
referralLinkRow: 'referral-link-row',
|
||||
referralLinkValue: 'referral-link-value',
|
||||
bonusRow: 'bonus-row',
|
||||
empty: 'empty-state',
|
||||
planCard: 'plan-card',
|
||||
planCardActive: 'plan-card-active',
|
||||
planName: 'plan-name',
|
||||
planMeta: 'plan-meta',
|
||||
planPrice: 'plan-price',
|
||||
notice: 'notice',
|
||||
stepNum: 'step-num',
|
||||
stepName: 'step-name'
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
@@ -175,6 +201,20 @@ const MOCK = (() => {
|
||||
telegram_auth_access_denied: 'Доступ запрещен.',
|
||||
active: 'Активна',
|
||||
inactive: 'Не активна',
|
||||
subscription_active: 'Подписка активна',
|
||||
subscription_inactive: 'Подписка не активна',
|
||||
sub_remaining_template: 'Действует еще {value}',
|
||||
sub_ends_template: 'Закончится {date}',
|
||||
sub_ended_template: 'Закончилась {date}',
|
||||
sub_no_expiry: 'Бессрочно',
|
||||
traffic_unlimited: '∞ бесконечный трафик',
|
||||
link_panel_title: 'Привяжите способ входа',
|
||||
link_panel_caption: 'Чтобы не потерять доступ к кабинету',
|
||||
link_email_title: 'Привяжите email',
|
||||
link_email_caption: 'Получайте код для входа на почту',
|
||||
link_telegram_title: 'Привяжите Telegram',
|
||||
link_telegram_caption: 'Входите одним нажатием через Telegram',
|
||||
guest_user: 'Гость',
|
||||
no_active_subscription: 'Нет активной подписки',
|
||||
not_available: 'N/A',
|
||||
no_plans: 'Тарифы не настроены.',
|
||||
@@ -204,7 +244,7 @@ const MOCK = (() => {
|
||||
promo_empty: 'Введите промокод',
|
||||
promo_invalid: 'Промокод не найден или недействителен.',
|
||||
referral_title: 'Пригласить друга',
|
||||
referral_caption: 'Поделитесь кодом или ссылкой и получайте бонусы.',
|
||||
referral_caption: 'Поделитесь ссылкой и получайте бонусы.',
|
||||
referral_code: 'Ваш код',
|
||||
invited_friends: 'Приглашено',
|
||||
purchased_friends: 'Оплатили',
|
||||
@@ -217,7 +257,8 @@ const MOCK = (() => {
|
||||
referral_telegram_label: 'Пригласить через телеграм',
|
||||
referral_bonus_title: 'Бонусы',
|
||||
referral_bonus_pair: 'Вы: {inviter} дн. / друг: {friend} дн.',
|
||||
referral_no_bonuses: 'Бонусы пока не настроены.'
|
||||
referral_no_bonuses: 'Бонусы пока не настроены.',
|
||||
referral_bonus_explanation: 'Бонусы начисляются один раз за каждого приглашенного пользователя, когда приглашенный пользователь покупает подписку.'
|
||||
},
|
||||
en: {
|
||||
page_title: 'My subscription',
|
||||
@@ -285,6 +326,20 @@ const MOCK = (() => {
|
||||
telegram_auth_access_denied: 'Access denied.',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
subscription_active: 'Subscription is active',
|
||||
subscription_inactive: 'Subscription is inactive',
|
||||
sub_remaining_template: 'Valid for {value} more',
|
||||
sub_ends_template: 'Expires {date}',
|
||||
sub_ended_template: 'Expired {date}',
|
||||
sub_no_expiry: 'No expiration',
|
||||
traffic_unlimited: '∞ unlimited traffic',
|
||||
link_panel_title: 'Link a sign-in method',
|
||||
link_panel_caption: 'So you never lose access',
|
||||
link_email_title: 'Link email',
|
||||
link_email_caption: 'Receive sign-in codes by email',
|
||||
link_telegram_title: 'Link Telegram',
|
||||
link_telegram_caption: 'Sign in with one tap via Telegram',
|
||||
guest_user: 'Guest',
|
||||
no_active_subscription: 'No active subscription',
|
||||
not_available: 'N/A',
|
||||
no_plans: 'Plans are not configured.',
|
||||
@@ -314,7 +369,7 @@ const MOCK = (() => {
|
||||
promo_empty: 'Enter promo code',
|
||||
promo_invalid: 'Promo code was not found or is not valid.',
|
||||
referral_title: 'Invite friend',
|
||||
referral_caption: 'Share your code or link and receive bonuses.',
|
||||
referral_caption: 'Share your link and receive bonuses.',
|
||||
referral_code: 'Your code',
|
||||
invited_friends: 'Invited',
|
||||
purchased_friends: 'Purchased',
|
||||
@@ -327,7 +382,8 @@ const MOCK = (() => {
|
||||
referral_telegram_label: 'Invite via Telegram',
|
||||
referral_bonus_title: 'Bonuses',
|
||||
referral_bonus_pair: 'You: {inviter} d. / friend: {friend} d.',
|
||||
referral_no_bonuses: 'Bonuses are not configured yet.'
|
||||
referral_no_bonuses: 'Bonuses are not configured yet.',
|
||||
referral_bonus_explanation: 'Bonuses are awarded once for each invited user when they purchase a subscription.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -359,6 +415,7 @@ const MOCK = (() => {
|
||||
bindPromoInput();
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
handleDocumentKeyForUserMenu(event);
|
||||
if (event.key !== 'Escape') return;
|
||||
if (state.emailLoginCodeModalOpen) {
|
||||
closeEmailLoginCodeModal();
|
||||
@@ -371,6 +428,8 @@ const MOCK = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', handleDocumentClickForUserMenu);
|
||||
|
||||
boot();
|
||||
|
||||
async function boot() {
|
||||
@@ -597,11 +656,6 @@ const MOCK = (() => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const field = input.closest('.otp-field');
|
||||
if (field) {
|
||||
field.addEventListener('click', () => input.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeCode(value) {
|
||||
@@ -831,40 +885,167 @@ const MOCK = (() => {
|
||||
showApp();
|
||||
}
|
||||
|
||||
async function reloadData() {
|
||||
showToast(t('updating'));
|
||||
await loadData();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderSubscription(state.data.subscription);
|
||||
renderUserMenu(state.data.user || {});
|
||||
renderAccount(state.data.user || {});
|
||||
renderReferral(state.data.referral || {});
|
||||
renderPaymentFlow();
|
||||
}
|
||||
|
||||
function renderSubscription(sub) {
|
||||
const badge = document.getElementById('status-badge');
|
||||
badge.textContent = sub.active ? t('active') : t('inactive');
|
||||
badge.classList.toggle('off', !sub.active);
|
||||
document.getElementById('remaining').textContent = sub.remaining_text || t('no_active_subscription');
|
||||
document.getElementById('end-date').textContent = sub.end_date_text || t('not_available');
|
||||
document.getElementById('traffic').textContent = (sub.traffic_used || t('not_available')) + ' / ' + (sub.traffic_limit || t('not_available'));
|
||||
const wrap = document.getElementById('sub-status');
|
||||
const text = document.getElementById('sub-status-text');
|
||||
const remaining = document.getElementById('remaining');
|
||||
const endCaption = document.getElementById('end-date-caption');
|
||||
|
||||
wrap.classList.toggle('active', Boolean(sub.active));
|
||||
text.textContent = sub.active ? t('subscription_active') : t('subscription_inactive');
|
||||
|
||||
if (sub.active) {
|
||||
const compact = formatCompactRemaining(sub.days_left, getLanguage()) || sub.remaining_text || '';
|
||||
remaining.textContent = compact;
|
||||
} else {
|
||||
remaining.textContent = t('no_active_subscription');
|
||||
}
|
||||
|
||||
if (sub.end_date_text) {
|
||||
const template = sub.active ? 'sub_ends_template' : 'sub_ended_template';
|
||||
endCaption.textContent = t(template, {date: sub.end_date_text});
|
||||
endCaption.classList.remove('hidden');
|
||||
} else if (sub.active) {
|
||||
endCaption.textContent = t('sub_no_expiry');
|
||||
endCaption.classList.remove('hidden');
|
||||
} else {
|
||||
endCaption.textContent = '';
|
||||
endCaption.classList.add('hidden');
|
||||
}
|
||||
|
||||
renderTraffic(sub);
|
||||
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
|
||||
}
|
||||
|
||||
function renderTraffic(sub) {
|
||||
const bar = document.getElementById('traffic-bar');
|
||||
const fill = document.getElementById('traffic-bar-fill');
|
||||
const value = document.getElementById('traffic');
|
||||
if (!bar || !fill || !value) return;
|
||||
|
||||
const limitBytes = Number(sub.traffic_limit_bytes);
|
||||
const usedBytes = Number(sub.traffic_used_bytes);
|
||||
const hasLimit = Number.isFinite(limitBytes) && limitBytes > 0;
|
||||
const hasUsed = Number.isFinite(usedBytes) && usedBytes >= 0;
|
||||
const usedText = sub.traffic_used || t('not_available');
|
||||
const limitText = sub.traffic_limit || t('not_available');
|
||||
|
||||
bar.classList.remove('over', 'unlimited');
|
||||
|
||||
if (!hasLimit) {
|
||||
bar.classList.add('unlimited');
|
||||
fill.style.width = '100%';
|
||||
value.textContent = t('traffic_unlimited');
|
||||
bar.setAttribute('aria-valuenow', '0');
|
||||
return;
|
||||
}
|
||||
|
||||
const ratio = hasUsed ? usedBytes / limitBytes : 0;
|
||||
const pct = Math.max(0, Math.min(100, Math.round(ratio * 100)));
|
||||
fill.style.width = pct + '%';
|
||||
value.textContent = usedText + ' / ' + limitText;
|
||||
bar.setAttribute('aria-valuenow', String(pct));
|
||||
if (hasUsed && usedBytes >= limitBytes) {
|
||||
bar.classList.add('over');
|
||||
}
|
||||
}
|
||||
|
||||
function formatCompactRemaining(daysRaw, lang) {
|
||||
const days = Math.max(0, Math.floor(Number(daysRaw) || 0));
|
||||
if (days <= 0) return null;
|
||||
if (days >= 365) {
|
||||
const years = Math.round((days / 365) * 2) / 2;
|
||||
return formatDecimal(years, lang) + ' ' + pluralizeUnit(years, 'year', lang);
|
||||
}
|
||||
if (days >= 30) {
|
||||
const months = Math.round((days / 30) * 2) / 2;
|
||||
return formatDecimal(months, lang) + ' ' + pluralizeUnit(months, 'month', lang);
|
||||
}
|
||||
return formatDecimal(days, lang) + ' ' + pluralizeUnit(days, 'day', lang);
|
||||
}
|
||||
|
||||
function formatDecimal(value, lang) {
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
const str = value.toFixed(1);
|
||||
return lang === 'ru' ? str.replace('.', ',') : str;
|
||||
}
|
||||
|
||||
function pluralizeUnit(value, kind, lang) {
|
||||
const enMap = {year: ['year', 'years'], month: ['month', 'months'], day: ['day', 'days']};
|
||||
const ruMap = {
|
||||
year: ['год', 'года', 'лет'],
|
||||
month: ['месяц', 'месяца', 'месяцев'],
|
||||
day: ['день', 'дня', 'дней']
|
||||
};
|
||||
if (lang === 'en') {
|
||||
return value === 1 ? enMap[kind][0] : enMap[kind][1];
|
||||
}
|
||||
const forms = ruMap[kind];
|
||||
if (!Number.isInteger(value)) return forms[1];
|
||||
const n = Math.abs(value);
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return forms[0];
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1];
|
||||
return forms[2];
|
||||
}
|
||||
|
||||
function renderUserMenu(user) {
|
||||
const emailLinked = Boolean(user.email && user.email_verified);
|
||||
const telegramLinked = Boolean(user.telegram_linked);
|
||||
const displayName = getUserDisplayName(user);
|
||||
const secondary = getUserSecondaryText(user, emailLinked, telegramLinked);
|
||||
const avatar = getUserAvatarSrc(user, emailLinked);
|
||||
|
||||
const chipName = document.getElementById('user-chip-name');
|
||||
const chipAvatar = document.getElementById('user-chip-avatar');
|
||||
const dropAvatar = document.getElementById('user-dropdown-avatar');
|
||||
const dropName = document.getElementById('user-dropdown-name');
|
||||
const dropSub = document.getElementById('user-dropdown-sub');
|
||||
const emailStatus = document.getElementById('user-dropdown-email-status');
|
||||
const telegramStatus = document.getElementById('user-dropdown-telegram-status');
|
||||
|
||||
chipName.textContent = displayName;
|
||||
chipAvatar.src = avatar;
|
||||
chipAvatar.alt = displayName;
|
||||
dropAvatar.src = avatar;
|
||||
dropAvatar.alt = displayName;
|
||||
dropName.textContent = displayName;
|
||||
dropSub.textContent = secondary;
|
||||
dropSub.classList.toggle('hidden', !secondary);
|
||||
|
||||
emailStatus.textContent = emailLinked ? user.email : t('not_linked');
|
||||
emailStatus.classList.toggle('linked', emailLinked);
|
||||
emailStatus.classList.toggle('unlinked', !emailLinked);
|
||||
|
||||
telegramStatus.textContent = telegramLinked
|
||||
? (user.telegram_id ? String(user.telegram_id) : t('linked'))
|
||||
: t('not_linked');
|
||||
telegramStatus.classList.toggle('linked', telegramLinked);
|
||||
telegramStatus.classList.toggle('unlinked', !telegramLinked);
|
||||
}
|
||||
|
||||
function renderAccount(user) {
|
||||
const emailLinked = Boolean(user.email && user.email_verified);
|
||||
const telegramLinked = Boolean(user.telegram_linked);
|
||||
document.getElementById('account-email').textContent = emailLinked ? user.email : t('not_linked');
|
||||
document.getElementById('account-telegram').textContent = telegramLinked
|
||||
? (user.telegram_id ? String(user.telegram_id) : t('linked'))
|
||||
: t('not_linked');
|
||||
const emailAuthEnabled = Boolean(state.data.settings && state.data.settings.email_auth_enabled);
|
||||
const emailCtaVisible = !emailLinked && emailAuthEnabled;
|
||||
const telegramCtaVisible = !telegramLinked;
|
||||
|
||||
const emailBox = document.getElementById('email-link-box');
|
||||
const telegramBox = document.getElementById('telegram-link-box');
|
||||
emailBox.classList.toggle('hidden', emailLinked || !state.data.settings.email_auth_enabled);
|
||||
telegramBox.classList.toggle('hidden', telegramLinked);
|
||||
const linkPanel = document.getElementById('link-panel');
|
||||
emailBox.classList.toggle('hidden', !emailCtaVisible);
|
||||
telegramBox.classList.toggle('hidden', !telegramCtaVisible);
|
||||
linkPanel.classList.toggle('hidden', !emailCtaVisible && !telegramCtaVisible);
|
||||
|
||||
if (!telegramLinked) {
|
||||
renderTelegramLinkWidget();
|
||||
@@ -873,6 +1054,89 @@ const MOCK = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
function getTelegramInitUser() {
|
||||
if (!tg || !tg.initDataUnsafe || !tg.initDataUnsafe.user) return null;
|
||||
return tg.initDataUnsafe.user;
|
||||
}
|
||||
|
||||
function getUserDisplayName(user) {
|
||||
const tgUser = getTelegramInitUser();
|
||||
if (tgUser && tgUser.first_name) return tgUser.first_name;
|
||||
if (user && user.first_name) return user.first_name;
|
||||
if (user && user.username) return '@' + user.username;
|
||||
if (tgUser && tgUser.username) return '@' + tgUser.username;
|
||||
if (user && user.email) return user.email;
|
||||
return t('guest_user');
|
||||
}
|
||||
|
||||
function getUserSecondaryText(user, emailLinked, telegramLinked) {
|
||||
if (emailLinked && user.email) return user.email;
|
||||
if (telegramLinked && user.telegram_id) return '#' + user.telegram_id;
|
||||
return '';
|
||||
}
|
||||
|
||||
function getUserAvatarSrc(user, emailLinked) {
|
||||
const tgUser = getTelegramInitUser();
|
||||
if (tgUser && tgUser.photo_url) return tgUser.photo_url;
|
||||
const seed = (emailLinked && user.email)
|
||||
? user.email.trim().toLowerCase()
|
||||
: String(user.id || user.telegram_id || tgUser && tgUser.id || 'guest');
|
||||
return buildIdenticon(seed);
|
||||
}
|
||||
|
||||
function buildIdenticon(seed) {
|
||||
const raw = String(seed || 'guest');
|
||||
let hash = 2166136261 >>> 0;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
hash ^= raw.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619) >>> 0;
|
||||
}
|
||||
const hue = hash % 360;
|
||||
const bg = 'hsl(' + hue + ', 60%, 18%)';
|
||||
const fg = 'hsl(' + ((hue + 180) % 360) + ', 78%, 62%)';
|
||||
let rects = '';
|
||||
let bits = hash;
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 3; x++) {
|
||||
const on = bits & 1;
|
||||
bits = (bits >>> 1) ^ ((bits & 1) ? 0xB400 : 0);
|
||||
if (!on) continue;
|
||||
rects += '<rect x="' + x + '" y="' + y + '" width="1" height="1"/>';
|
||||
if (x !== 2) {
|
||||
rects += '<rect x="' + (4 - x) + '" y="' + y + '" width="1" height="1"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" shape-rendering="crispEdges">' +
|
||||
'<rect width="5" height="5" fill="' + bg + '"/>' +
|
||||
'<g fill="' + fg + '">' + rects + '</g>' +
|
||||
'</svg>';
|
||||
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
function toggleUserMenu(force) {
|
||||
const open = typeof force === 'boolean' ? force : !state.userMenuOpen;
|
||||
state.userMenuOpen = open;
|
||||
const chip = document.getElementById('user-chip');
|
||||
const dropdown = document.getElementById('user-dropdown');
|
||||
if (!chip || !dropdown) return;
|
||||
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
dropdown.classList.toggle('hidden', !open);
|
||||
}
|
||||
|
||||
function handleDocumentClickForUserMenu(event) {
|
||||
if (!state.userMenuOpen) return;
|
||||
const menu = document.querySelector('.user-menu');
|
||||
if (menu && menu.contains(event.target)) return;
|
||||
toggleUserMenu(false);
|
||||
}
|
||||
|
||||
function handleDocumentKeyForUserMenu(event) {
|
||||
if (event.key === 'Escape' && state.userMenuOpen) {
|
||||
toggleUserMenu(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPromoModal() {
|
||||
state.promoModalOpen = true;
|
||||
clearPromoStatus();
|
||||
@@ -890,23 +1154,23 @@ const MOCK = (() => {
|
||||
}
|
||||
|
||||
function renderPromoModal() {
|
||||
const panel = document.querySelector('.promo-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hidden', !state.promoModalOpen);
|
||||
panel.classList.toggle('modal-panel', state.promoModalOpen);
|
||||
renderToolsBackdrop();
|
||||
const modal = document.getElementById('promo-modal');
|
||||
if (!modal) return;
|
||||
|
||||
if (!state.promoModalOpen) {
|
||||
modal.classList.remove('show');
|
||||
syncModalLock();
|
||||
window.setTimeout(() => {
|
||||
if (!state.promoModalOpen) modal.classList.add('hidden');
|
||||
}, 180);
|
||||
return;
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
syncModalLock();
|
||||
renderPromoStatus();
|
||||
if (state.promoModalOpen && !panel.querySelector('[data-modal-close="promo"]')) {
|
||||
const head = panel.querySelector('.panel-head');
|
||||
if (head) {
|
||||
head.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<button class="icon-btn" type="button" data-modal-close="promo" data-title-i18n="close" onclick="closePromoModal()">×</button>'
|
||||
);
|
||||
applyI18n(panel);
|
||||
}
|
||||
}
|
||||
applyI18n(modal);
|
||||
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||
}
|
||||
|
||||
function bindPromoInput() {
|
||||
@@ -983,23 +1247,28 @@ const MOCK = (() => {
|
||||
renderReferral(state.data && state.data.referral || {});
|
||||
}
|
||||
|
||||
function renderToolsBackdrop() {
|
||||
const backdrop = document.getElementById('tools-modal-backdrop');
|
||||
if (!backdrop) return;
|
||||
backdrop.classList.toggle('hidden', !(state.promoModalOpen || state.referralModalOpen));
|
||||
}
|
||||
|
||||
|
||||
function renderReferral(referral) {
|
||||
const panel = document.querySelector('.referral-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hidden', !state.referralModalOpen);
|
||||
panel.classList.toggle('modal-panel', state.referralModalOpen);
|
||||
renderToolsBackdrop();
|
||||
const modal = document.getElementById('referral-modal');
|
||||
const panel = document.getElementById('referral-panel');
|
||||
if (!modal || !panel) return;
|
||||
|
||||
if (!state.referralModalOpen) {
|
||||
modal.classList.remove('show');
|
||||
syncModalLock();
|
||||
window.setTimeout(() => {
|
||||
if (!state.referralModalOpen) modal.classList.add('hidden');
|
||||
}, 180);
|
||||
return;
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
syncModalLock();
|
||||
if (!state.referralModalOpen) return;
|
||||
|
||||
const bonusRows = (referral.bonus_details || []).map(item => `
|
||||
<div class="bonus-row">
|
||||
<div class="${TW.bonusRow}">
|
||||
<span>${escapeHtml(item.title || '')}</span>
|
||||
<strong>${escapeHtml(t('referral_bonus_pair', {
|
||||
inviter: item.inviter_days || 0,
|
||||
@@ -1009,34 +1278,47 @@ const MOCK = (() => {
|
||||
`).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="panel-head">
|
||||
<div class="${TW.panelHead}">
|
||||
<div>
|
||||
<div class="section-title">${escapeHtml(t('referral_title'))}</div>
|
||||
<div class="flow-caption">${escapeHtml(t('referral_caption'))}</div>
|
||||
<div class="${TW.sectionTitle} text-[var(--accent)]">${escapeHtml(t('referral_title'))}</div>
|
||||
<div class="${TW.flowCaption}">${escapeHtml(t('referral_caption'))}</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-title-i18n="close" onclick="closeReferralModal()">×</button>
|
||||
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" onclick="closeReferralModal()">×</button>
|
||||
</div>
|
||||
<div class="referral-link-list">
|
||||
|
||||
<div class="grid gap-4 mt-1">
|
||||
<div class="grid gap-2.5">
|
||||
<div class="section-label font-[family-name:var(--font-mono)] text-[12px] font-extrabold text-[var(--accent)] uppercase">ВАШИ ССЫЛКИ</div>
|
||||
<div class="referral-link-list grid gap-2">
|
||||
${renderReferralLinkRow('webapp', t('referral_site_label'), referral.webapp_link)}
|
||||
${renderReferralLinkRow('bot', t('referral_telegram_label'), referral.bot_link)}
|
||||
</div>
|
||||
<div class="referral-bonus-card">
|
||||
<div class="section-label">${escapeHtml(t('referral_bonus_title'))}</div>
|
||||
<div class="bonus-list">${bonusRows || '<div class="empty">' + escapeHtml(t('referral_no_bonuses')) + '</div>'}</div>
|
||||
</div>
|
||||
|
||||
<div class="referral-bonus-card grid gap-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.015)] p-3.5">
|
||||
<div class="grid gap-1">
|
||||
<div class="section-label font-[family-name:var(--font-mono)] text-[12px] font-extrabold text-[var(--accent)] uppercase">${escapeHtml(t('referral_bonus_title'))}</div>
|
||||
<div class="text-[13px] leading-[1.45] text-[var(--text-secondary)]">${escapeHtml(t('referral_bonus_explanation'))}</div>
|
||||
</div>
|
||||
<div class="bonus-list grid gap-2">${bonusRows || '<div class="' + TW.empty + ' border-none bg-[rgba(255,255,255,0.03)]">' + escapeHtml(t('referral_no_bonuses')) + '</div>'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
applyI18n(panel);
|
||||
applyI18n(modal);
|
||||
window.requestAnimationFrame(() => modal.classList.add('show'));
|
||||
}
|
||||
|
||||
function renderReferralLinkRow(kind, label, link) {
|
||||
return `
|
||||
<div class="referral-link-row">
|
||||
<div class="${TW.referralLinkRow}">
|
||||
<div>
|
||||
<div class="metric-label">${escapeHtml(label)}</div>
|
||||
<div class="referral-link-value">${escapeHtml(link || t('not_available'))}</div>
|
||||
<div class="${TW.metricLabel}">${escapeHtml(label)}</div>
|
||||
<div class="${TW.referralLinkValue}">${escapeHtml(link || t('not_available'))}</div>
|
||||
</div>
|
||||
<button class="btn icon-only copy-icon-btn" type="button" onclick="copyReferralLink('${escapeAttr(kind)}')" ${link ? '' : 'disabled'} data-title-i18n="copy_link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true"><path d="M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8zM307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z"/></svg>
|
||||
<button class="${TW.btnBase} w-12 px-0" type="button" onclick="copyReferralLink('${escapeAttr(kind)}')" ${link ? '' : 'disabled'} data-title-i18n="copy_link">
|
||||
<svg class="h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path d="M352 512L128 512L128 288L176 288L176 224L128 224C92.7 224 64 252.7 64 288L64 512C64 547.3 92.7 576 128 576L352 576C387.3 576 416 547.3 416 512L416 464L352 464L352 512zM288 416L512 416C547.3 416 576 387.3 576 352L576 128C576 92.7 547.3 64 512 64L288 64C252.7 64 224 92.7 224 128L224 352C224 387.3 252.7 416 288 416z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -1096,6 +1378,17 @@ const MOCK = (() => {
|
||||
showToast(t('referral_copied'));
|
||||
}
|
||||
|
||||
async function copyReferralCode() {
|
||||
const referral = state.data && state.data.referral;
|
||||
const code = referral && referral.code;
|
||||
if (!code) {
|
||||
showToast(t('no_link'));
|
||||
return;
|
||||
}
|
||||
await copyText(code);
|
||||
showToast(t('code_copied'));
|
||||
}
|
||||
|
||||
async function requestEmailLinkCode() {
|
||||
const input = document.getElementById('email-link-input');
|
||||
const email = normalizeEmail(input.value);
|
||||
@@ -1309,7 +1602,7 @@ const MOCK = (() => {
|
||||
const wrap = document.getElementById('plans');
|
||||
const nextBtn = document.getElementById('to-methods-btn');
|
||||
if (!plans.length) {
|
||||
wrap.innerHTML = '<div class="empty">' + escapeHtml(t('no_plans')) + '</div>';
|
||||
wrap.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_plans')) + '</div>';
|
||||
nextBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
@@ -1319,12 +1612,12 @@ const MOCK = (() => {
|
||||
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
|
||||
const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
|
||||
return `
|
||||
<button class="plan ${isActive ? 'active' : ''}" type="button" onclick="selectPlan(${plan.months})">
|
||||
<span>
|
||||
<span class="plan-name">${escapeHtml(plan.title)}</span>
|
||||
<span class="plan-meta">${escapeHtml(t('access_period'))}</span>
|
||||
<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="plan-price">${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}</span>
|
||||
<span class="${TW.planPrice}">${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1334,7 +1627,7 @@ const MOCK = (() => {
|
||||
const methods = document.getElementById('methods');
|
||||
const createBtn = document.getElementById('create-payment-btn');
|
||||
if (!state.selectedPlan) {
|
||||
methods.innerHTML = '<div class="empty">' + escapeHtml(t('choose_plan_first')) + '</div>';
|
||||
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('choose_plan_first')) + '</div>';
|
||||
createBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
@@ -1346,7 +1639,7 @@ const MOCK = (() => {
|
||||
|
||||
createBtn.disabled = !state.selectedMethod || state.creatingPayment;
|
||||
if (!available.length) {
|
||||
methods.innerHTML = '<div class="empty">' + escapeHtml(t('no_methods')) + '</div>';
|
||||
methods.innerHTML = '<div class="' + TW.empty + '">' + escapeHtml(t('no_methods')) + '</div>';
|
||||
createBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
@@ -1357,12 +1650,12 @@ const MOCK = (() => {
|
||||
? state.selectedPlan.stars_price + ' Stars'
|
||||
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
|
||||
return `
|
||||
<button class="method ${isActive ? 'active' : ''}" type="button" onclick="selectMethod('${escapeAttr(method.id)}')">
|
||||
<span>
|
||||
<span class="method-name">${escapeHtml(method.name)}</span>
|
||||
<span class="method-meta">${escapeHtml(state.selectedPlan.title)}</span>
|
||||
<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="plan-price">${escapeHtml(amount)}</span>
|
||||
<span class="${TW.planPrice}">${escapeHtml(amount)}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1625,6 +1918,7 @@ const MOCK = (() => {
|
||||
}
|
||||
|
||||
function logout() {
|
||||
toggleUserMenu(false);
|
||||
clearToken();
|
||||
closePaymentFlow();
|
||||
closeEmailLoginCodeModal();
|
||||
@@ -1640,12 +1934,14 @@ const MOCK = (() => {
|
||||
function showApp() {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('show');
|
||||
document.getElementById('login').classList.add('hidden');
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
document.getElementById('login').classList.remove('hidden');
|
||||
document.getElementById('login').classList.add('show');
|
||||
}
|
||||
|
||||
@@ -1694,19 +1990,35 @@ const MOCK = (() => {
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
if (tg && tg.showAlert) {
|
||||
tg.showAlert(message);
|
||||
return;
|
||||
}
|
||||
const toast = document.getElementById('toast');
|
||||
if (!toast) return;
|
||||
|
||||
toast.textContent = message;
|
||||
toast.classList.remove('hidden');
|
||||
window.requestAnimationFrame(() => toast.classList.add('show'));
|
||||
|
||||
// Force reflow
|
||||
void toast.offsetWidth;
|
||||
|
||||
toast.classList.add('show');
|
||||
|
||||
// Telegram Haptic Feedback
|
||||
if (tg && tg.HapticFeedback) {
|
||||
try {
|
||||
tg.HapticFeedback.notificationOccurred('success');
|
||||
} catch (e) {
|
||||
console.warn('Haptic feedback failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.toastTimer) window.clearTimeout(state.toastTimer);
|
||||
state.toastTimer = window.setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
window.setTimeout(() => toast.classList.add('hidden'), 180);
|
||||
}, 1800);
|
||||
window.setTimeout(() => {
|
||||
if (!toast.classList.contains('show')) {
|
||||
toast.classList.add('hidden');
|
||||
}
|
||||
}, 250);
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
function setBrand() {
|
||||
@@ -1771,7 +2083,7 @@ const MOCK = (() => {
|
||||
node.setAttribute('aria-hidden', url ? 'false' : 'true');
|
||||
});
|
||||
|
||||
root.querySelectorAll('.legal-links').forEach(container => {
|
||||
root.querySelectorAll('[data-legal-links]').forEach(container => {
|
||||
const visible = Array.from(container.querySelectorAll('[data-legal-key]'))
|
||||
.some(node => !node.classList.contains('hidden'));
|
||||
container.classList.toggle('hidden', !visible);
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "./subscription_webapp.html";
|
||||
@source "./subscription_webapp.js";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #05070a;
|
||||
--bg-card: #0f1521;
|
||||
--bg-card-hover: #161e2e;
|
||||
--border: #1e2940;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #00fe7a;
|
||||
--accent-hover: #5aff9f;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 8px;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--app-safe-top-extra: 10px;
|
||||
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
|
||||
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@apply m-0 min-h-full w-full overflow-x-hidden tracking-[0];
|
||||
}
|
||||
|
||||
body {
|
||||
@apply overflow-x-hidden bg-[var(--bg-primary)] font-[family-name:var(--font-sans)] text-[var(--text-primary)] antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer border-0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@apply cursor-progress opacity-[0.58];
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.btn-sm {
|
||||
@apply inline-flex min-h-[38px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[11px] py-2 text-center font-extrabold text-[13px] leading-none text-[var(--text-secondary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
.metrics {
|
||||
@apply grid gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.018)];
|
||||
}
|
||||
.metric {
|
||||
@apply grid min-h-12 grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] items-center gap-2.5 border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2.5;
|
||||
}
|
||||
.auth-tab {
|
||||
@apply min-h-9 rounded-[var(--radius-sm)] bg-transparent font-[850] text-[var(--text-secondary)] transition-colors;
|
||||
}
|
||||
.login-layout {
|
||||
@apply hidden mx-auto min-h-screen w-[min(100vw,560px)] flex-col items-center justify-center gap-4 px-[14px];
|
||||
padding-top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
|
||||
padding-bottom: max(var(--app-safe-bottom), 18px);
|
||||
}
|
||||
.section-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.app-loader {
|
||||
@apply grid min-h-[78vh] place-items-center font-[family-name:var(--font-mono)] font-extrabold text-[var(--text-muted)];
|
||||
}
|
||||
.auth-tabs-wrapper {
|
||||
@apply grid min-h-[46px] grid-cols-2 gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.38)] p-1;
|
||||
}
|
||||
|
||||
|
||||
.panel-head {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
.section-caption {
|
||||
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
|
||||
}
|
||||
.metric-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
.metric-value {
|
||||
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
|
||||
}
|
||||
.referral-link-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.referral-link-value {
|
||||
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
|
||||
}
|
||||
.bonus-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.empty-state {
|
||||
@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)];
|
||||
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)];
|
||||
}
|
||||
.plan-name {
|
||||
@apply block text-[15px] font-extrabold leading-tight;
|
||||
}
|
||||
.plan-meta {
|
||||
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
.plan-price {
|
||||
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
|
||||
}
|
||||
.notice {
|
||||
@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)];
|
||||
}
|
||||
.step-num {
|
||||
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
|
||||
}
|
||||
.step-name {
|
||||
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
|
||||
}
|
||||
.main-value {
|
||||
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
|
||||
}
|
||||
.main-caption {
|
||||
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.status-text {
|
||||
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.legal-links {
|
||||
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-center;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@apply flex min-h-12 items-center justify-between gap-3;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex min-w-0 items-center gap-2.5;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
.panel-head-sm {
|
||||
@apply flex min-w-0 items-center justify-between gap-2.5;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply inline-flex min-h-[38px] min-w-0 max-w-[200px] cursor-pointer items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] py-1 pl-1 pr-2.5 text-left font-extrabold text-[13px] text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] {
|
||||
@apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.user-chip-avatar {
|
||||
@apply h-[28px] w-[28px] flex-none rounded-[var(--radius-sm)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.user-chip-caret {
|
||||
@apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform;
|
||||
}
|
||||
|
||||
.user-chip[aria-expanded="true"] .user-chip-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.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)];
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.user-dropdown-head {
|
||||
@apply flex items-center gap-2.5 border-b border-[var(--border)] px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-avatar {
|
||||
@apply h-[40px] w-[40px] flex-none rounded-[var(--radius-md)] object-cover;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.user-dropdown-head-text {
|
||||
@apply grid min-w-0 gap-0.5;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm font-extrabold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-sub {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap font-[family-name:var(--font-mono)] text-[11px] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-rows {
|
||||
@apply grid;
|
||||
}
|
||||
|
||||
.user-dropdown-row {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.user-dropdown-row + .user-dropdown-row {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-label {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-right text-[13px] font-bold text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.linked {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
.user-dropdown-row-status.unlinked {
|
||||
@apply text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout {
|
||||
@apply flex min-h-[42px] w-full cursor-pointer items-center justify-center gap-2 rounded-none border-0 border-t border-[var(--border)] bg-transparent px-3 py-2.5 text-center text-[13px] font-extrabold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.user-dropdown-logout svg {
|
||||
@apply h-4 w-4 fill-current;
|
||||
}
|
||||
|
||||
.sub-status {
|
||||
@apply flex min-h-[28px] items-center gap-2.5 text-[var(--danger)];
|
||||
}
|
||||
|
||||
.sub-status.active {
|
||||
@apply text-[var(--success)];
|
||||
}
|
||||
|
||||
.sub-status-dot {
|
||||
@apply relative inline-block h-[10px] w-[10px] flex-none rounded-full;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 12px currentColor;
|
||||
}
|
||||
|
||||
.sub-status-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
animation: sub-status-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.sub-status-text {
|
||||
@apply font-extrabold text-[15px] leading-tight;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.sub-countdown {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.sub-countdown-row {
|
||||
@apply flex flex-wrap items-baseline gap-x-2 gap-y-0.5;
|
||||
}
|
||||
|
||||
.sub-countdown-value {
|
||||
@apply font-extrabold text-[22px] leading-[1.1] text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.sub-countdown-caption {
|
||||
@apply font-[family-name:var(--font-mono)] text-[12px] leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.traffic-bar {
|
||||
@apply relative flex min-h-[24px] w-full items-center justify-center overflow-hidden rounded-full border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.traffic-bar-fill {
|
||||
@apply absolute inset-y-0 left-0;
|
||||
width: 0%;
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease;
|
||||
}
|
||||
|
||||
.traffic-bar.over .traffic-bar-fill {
|
||||
background: color-mix(in srgb, var(--danger) 32%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-fill {
|
||||
width: 100%;
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
}
|
||||
|
||||
.traffic-bar-value {
|
||||
@apply relative z-[1] min-w-0 px-3 font-[family-name:var(--font-mono)] text-[12px] font-bold text-center whitespace-nowrap;
|
||||
color: #ffffff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.traffic-bar.unlimited .traffic-bar-value {
|
||||
animation: traffic-infinity-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes traffic-infinity-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 0 color-mix(in srgb, var(--success) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--success) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.link-cta-head {
|
||||
@apply grid gap-1;
|
||||
}
|
||||
|
||||
.link-cta-title {
|
||||
@apply font-extrabold text-[15px] leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
|
||||
.link-cta-caption {
|
||||
@apply text-[13px] leading-[1.4] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
@keyframes sub-status-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.login-head {
|
||||
@apply flex min-w-0 items-center justify-center gap-3.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-[13px] py-[11px] text-center font-extrabold text-[var(--text-primary)] no-underline transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex min-h-[46px] min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_80%,var(--border))] bg-[var(--accent)] px-[13px] py-[11px] text-center font-extrabold text-[#04110a] no-underline shadow-[0_10px_26px_color-mix(in_srgb,var(--accent)_18%,transparent)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[var(--accent-hover)] hover:shadow-[0_14px_34px_color-mix(in_srgb,var(--accent)_24%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex min-h-[46px] min-w-0 w-full cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[color-mix(in_srgb,var(--accent)_35%,var(--border))] bg-[color-mix(in_srgb,var(--accent)_5%,transparent)] px-[13px] py-[11px] text-center font-extrabold text-[var(--accent)] no-underline shadow-none transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:bg-[color-mix(in_srgb,var(--accent)_10%,transparent)] hover:shadow-[0_10px_24px_color-mix(in_srgb,var(--accent)_10%,transparent)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@apply inline-flex h-[38px] w-[38px] flex-none cursor-pointer items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] text-[var(--text-secondary)] transition-[transform,border-color,background,color,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)];
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply min-h-[46px] w-full rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(5,8,13,0.42)] px-3 py-[11px] font-bold text-[var(--text-primary)] outline-none transition-[border-color,background,box-shadow] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] placeholder:text-[var(--text-muted)] focus:border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] focus:bg-[rgba(7,12,19,0.68)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_12%,transparent),0_12px_28px_rgba(0,0,0,0.2)] aria-[invalid=true]:border-[color-mix(in_srgb,var(--danger)_72%,var(--border))] aria-[invalid=true]:focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--danger)_14%,transparent)];
|
||||
}
|
||||
|
||||
.input-code {
|
||||
@apply text-center font-[family-name:var(--font-mono)] tracking-[0];
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_20px_40px_rgba(0,0,0,0.18)];
|
||||
}
|
||||
|
||||
.panel-modal {
|
||||
@apply relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-card)] shadow-[0_30px_60px_rgba(0,0,0,0.28)];
|
||||
}
|
||||
|
||||
.step-box {
|
||||
@apply min-h-[58px] rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] p-[9px] text-[var(--text-muted)] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)];
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply fixed inset-0 z-20 grid place-items-center opacity-0 transition-opacity pointer-events-none;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
|
||||
.modal-backdrop,
|
||||
.tools-modal-backdrop {
|
||||
@apply absolute inset-0 border-0 p-0;
|
||||
background: rgba(3, 6, 10, 0.72);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.tools-modal-backdrop {
|
||||
@apply fixed z-25;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply relative max-h-[min(86vh,760px)] w-[min(100%,560px)] overflow-auto transition-transform duration-[0.22s] ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
|
||||
.app {
|
||||
@apply mx-auto flex w-[min(100vw,560px)] max-w-[100vw] flex-col gap-4;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply flex min-w-0 items-center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
@apply h-[38px] w-[38px] flex-none block rounded-[var(--radius-md)] object-contain;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[84px] w-[84px];
|
||||
}
|
||||
|
||||
.plans,
|
||||
.methods,
|
||||
.metrics,
|
||||
.referral-link-list,
|
||||
.bonus-list {
|
||||
@apply grid min-w-0;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.otp-slots,
|
||||
.metric {
|
||||
@apply grid min-w-0 gap-2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-template-columns: minmax(0, 1fr) 48px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
@apply gap-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
|
||||
.metric,
|
||||
.notice,
|
||||
.otp-slots span {
|
||||
@apply border border-[var(--border)];
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-[46px] items-center gap-2.5 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply min-h-12;
|
||||
}
|
||||
|
||||
.metric + .metric {
|
||||
@apply border-t border-[var(--border)];
|
||||
}
|
||||
|
||||
.brand-title,
|
||||
.login-brand-title {
|
||||
@apply max-w-[260px] font-[family-name:var(--font-mono)] font-extrabold tracking-[0] text-[var(--accent)];
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply overflow-hidden text-ellipsis whitespace-nowrap text-[17px] leading-[1.15];
|
||||
text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply text-left text-2xl leading-[1.1];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@apply font-extrabold;
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.badge {
|
||||
@apply inline-flex min-h-7 items-center rounded-[var(--radius-md)] px-2.5 font-[family-name:var(--font-mono)] text-[11px] font-bold whitespace-nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mono-pill {
|
||||
@apply text-[var(--accent)];
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis text-[#6ee7b7];
|
||||
border: 1px solid color-mix(in srgb, var(--success) 34%, var(--border));
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.badge.off,
|
||||
.promo-status.error,
|
||||
.login-status.error {
|
||||
@apply text-[#fca5a5];
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.promo-status.success {
|
||||
@apply text-[#6ee7b7];
|
||||
}
|
||||
|
||||
#legal-links-login {
|
||||
@apply w-[min(100vw,560px)];
|
||||
}
|
||||
|
||||
.legal-link,
|
||||
.code-modal-resend {
|
||||
@apply border-0 bg-transparent p-0 text-[13px] font-bold text-[var(--text-secondary)] underline underline-offset-[3px] transition-colors;
|
||||
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
@apply inline flex-initial;
|
||||
}
|
||||
|
||||
|
||||
.otp-input {
|
||||
@apply absolute inset-0 z-2 h-full w-full border-0 bg-transparent text-transparent opacity-[0.01] outline-none;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply grid-cols-6;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply grid aspect-square min-w-0 place-items-center rounded-[var(--radius-md)] font-[family-name:var(--font-mono)] text-xl font-[850] leading-none text-[var(--text-primary)] transition-[border-color,background,box-shadow];
|
||||
background: rgba(5, 8, 13, 0.42);
|
||||
}
|
||||
|
||||
.otp-slots span.filled {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
|
||||
}
|
||||
|
||||
.otp-slots span.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.step-num,
|
||||
.step-name {
|
||||
@apply block text-current;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
@apply font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1];
|
||||
}
|
||||
|
||||
.step-name {
|
||||
@apply mt-1 text-xs font-extrabold leading-[1.15];
|
||||
}
|
||||
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
@apply border-[var(--accent)] text-[var(--accent)];
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
}
|
||||
|
||||
.step.done {
|
||||
@apply text-[var(--text-secondary)];
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
|
||||
.notice {
|
||||
@apply rounded-[var(--radius-md)] p-[13px] text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
|
||||
.login-layout.show {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.telegram-login-widget {
|
||||
@apply min-h-14 justify-center;
|
||||
}
|
||||
|
||||
.login-text,
|
||||
.promo-status {
|
||||
@apply m-0 text-sm leading-[1.45];
|
||||
}
|
||||
|
||||
.promo-status {
|
||||
@apply mt-0.5 min-h-[22px];
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
@apply m-0 min-h-5;
|
||||
}
|
||||
|
||||
.toast {
|
||||
@apply fixed left-1/2 z-40 w-max max-w-[calc(100vw-28px)] rounded-[var(--radius-md)] px-[13px] py-[11px] text-[13px] font-bold text-[var(--text-primary)] opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)];
|
||||
bottom: max(var(--app-safe-bottom), 22px);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: rgba(15, 21, 33, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.45), 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 14px) scale(0.94);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
@apply opacity-100;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mono-pill,
|
||||
.step.active,
|
||||
.auth-tab.active {
|
||||
background: rgba(0, 254, 122, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.legal-link:hover:not(:disabled),
|
||||
.code-modal-resend:hover:not(:disabled) {
|
||||
@apply text-[var(--accent)];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.app {
|
||||
@apply px-2.5;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply max-w-[164px];
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mono-pill {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
.otp-slots {
|
||||
@apply gap-1.5;
|
||||
}
|
||||
|
||||
.otp-slots span {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.brand-logo--lg {
|
||||
@apply h-[68px] w-[68px];
|
||||
}
|
||||
|
||||
.login-brand-title {
|
||||
@apply max-w-[200px] text-[21px];
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand-title {
|
||||
@apply max-w-[150px];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.step-box {
|
||||
@apply min-h-[54px] px-[7px] py-2;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply place-items-center p-0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
|
||||
}
|
||||
|
||||
.modal--page {
|
||||
@apply place-items-stretch p-0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.modal--page .modal-backdrop {
|
||||
@apply bg-[var(--bg-primary)] opacity-100;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
@apply h-auto max-h-[min(92vh,760px)] w-full rounded-[var(--radius-lg)];
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
|
||||
.modal--page .modal-card {
|
||||
@apply h-full w-full max-h-none max-w-none rounded-none border-0 shadow-none;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.modal.show .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
Generated
+1093
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:webapp:css": "tailwindcss -i ./bot/app/web/templates/subscription_webapp.tailwind.css -o ./bot/app/web/templates/subscription_webapp.css --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.2.4",
|
||||
"tailwindcss": "4.2.4"
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import re
|
||||
|
||||
html_path = "bot/app/web/templates/subscription_webapp.html"
|
||||
|
||||
with open(html_path, 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
replacements = {
|
||||
r'class="flex items-start justify-between gap-3"': 'class="panel-head"',
|
||||
r'class="text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]"': 'class="section-title"',
|
||||
r'class="mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]"': 'class="section-caption"',
|
||||
r'class="min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]"': 'class="metric-label"',
|
||||
r'class="min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]"': 'class="metric-value"',
|
||||
r'class="m-0 text-\[32px\] font-extrabold leading-\[1\.08\] text-\[var\(--text-primary\)\]"': 'class="main-value"',
|
||||
r'class="mt-1\.5 mb-0 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="main-caption"',
|
||||
r'class="login-text login-status hidden m-0 min-h-5 text-sm leading-\[1\.45\] text-\[var\(--text-secondary\)\]"': 'class="status-text hidden"',
|
||||
r'class="login-text login-status hidden"': 'class="status-text hidden"',
|
||||
r'class="actions grid min-w-0 grid-cols-\[minmax\(0,1fr\)_48px\] gap-2"': 'class="actions-row"',
|
||||
r'class="hidden flex flex-wrap justify-center gap-x-4 gap-y-1\.5"': 'class="legal-links hidden"',
|
||||
r'class="flex min-h-12 items-center justify-between gap-3"': 'class="app-header"',
|
||||
r'class="flex min-w-0 items-center gap-2\.5"': 'class="app-header-title"',
|
||||
r'class="flex min-w-0 items-center gap-2"': 'class="app-header-actions"',
|
||||
r'class="flex min-w-0 items-center justify-between gap-2\.5"': 'class="panel-head-sm"',
|
||||
r'class="flex min-w-0 items-center justify-center gap-3\.5"': 'class="login-head"',
|
||||
}
|
||||
|
||||
for pattern, repl in replacements.items():
|
||||
html = re.sub(pattern, repl, html)
|
||||
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
|
||||
js_path = "bot/app/web/templates/subscription_webapp.js"
|
||||
with open(js_path, 'r', encoding='utf-8') as f:
|
||||
js = f.read()
|
||||
|
||||
js_replacements = {
|
||||
r"panelHead: 'flex items-start justify-between gap-3',": "panelHead: 'panel-head',",
|
||||
r"flowCaption: 'mt-\[3px\] text-\[13px\] text-\[var\(--text-secondary\)\]',": "flowCaption: 'section-caption',",
|
||||
r"sectionTitle: 'text-lg font-extrabold leading-tight text-\[var\(--text-primary\)\]',": "sectionTitle: 'section-title',",
|
||||
r"metricLabel: 'min-w-0 font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold text-\[var\(--text-muted\)\]',": "metricLabel: 'metric-label',",
|
||||
r"metricValue: 'min-w-0 text-right text-sm font-bold text-\[var\(--text-primary\)\]',": "metricValue: 'metric-value',",
|
||||
r"referralLinkRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,1fr\)_48px\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "referralLinkRow: 'referral-link-row',",
|
||||
r"referralLinkValue: 'mt-1 font-\[family-name:var\(--font-mono\)\] text-xs font-bold leading-\[1\.35\] text-\[var\(--text-primary\)\]',": "referralLinkValue: 'referral-link-value',",
|
||||
r"bonusRow: 'grid min-h-\[58px\] grid-cols-\[minmax\(0,0\.8fr\)_minmax\(0,1\.2fr\)\] items-center gap-2\.5 rounded-\[var\(--radius-md\)\] border border-\[var\(--border\)\] bg-\[rgba\(255,255,255,0\.02\)\] px-3 py-\[11px\]',": "bonusRow: 'bonus-row',",
|
||||
r"empty: '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\)\]',": "empty: 'empty-state',",
|
||||
r"planCard: '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\)\]',": "planCard: 'plan-card',",
|
||||
r"planCardActive: '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\)\]',": "planCardActive: 'plan-card-active',",
|
||||
r"planName: 'block text-\[15px\] font-extrabold leading-tight',": "planName: 'plan-name',",
|
||||
r"planMeta: 'mt-1 block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-bold leading-\[1\.3\] text-\[var\(--text-muted\)\]',": "planMeta: 'plan-meta',",
|
||||
r"planPrice: 'block max-w-\[48%\] flex-none text-right font-\[family-name:var\(--font-mono\)\] text-\[15px\] font-extrabold leading-tight text-\[var\(--accent\)\]',": "planPrice: 'plan-price',",
|
||||
r"notice: '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\)\]',": "notice: 'notice',",
|
||||
r"stepNum: 'block font-\[family-name:var\(--font-mono\)\] text-\[11px\] font-extrabold leading-\[1\.1\] text-current',": "stepNum: 'step-num',",
|
||||
r"stepName: 'mt-1 block text-xs font-extrabold leading-\[1\.15\] text-current'": "stepName: 'step-name'"
|
||||
}
|
||||
|
||||
for pattern, repl in js_replacements.items():
|
||||
js = re.sub(pattern, repl, js)
|
||||
|
||||
with open(js_path, 'w', encoding='utf-8') as f:
|
||||
f.write(js)
|
||||
|
||||
# CSS additions
|
||||
css_path = "bot/app/web/templates/subscription_webapp.tailwind.css"
|
||||
with open(css_path, 'r', encoding='utf-8') as f:
|
||||
css = f.read()
|
||||
|
||||
new_components = """
|
||||
.panel-head {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-lg font-extrabold leading-tight text-[var(--text-primary)];
|
||||
}
|
||||
.section-caption {
|
||||
@apply mt-[3px] text-[13px] text-[var(--text-secondary)];
|
||||
}
|
||||
.metric-label {
|
||||
@apply min-w-0 font-[family-name:var(--font-mono)] text-[11px] font-bold text-[var(--text-muted)];
|
||||
}
|
||||
.metric-value {
|
||||
@apply min-w-0 text-right text-sm font-bold text-[var(--text-primary)];
|
||||
}
|
||||
.referral-link-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,1fr)_48px] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.referral-link-value {
|
||||
@apply mt-1 font-[family-name:var(--font-mono)] text-xs font-bold leading-[1.35] text-[var(--text-primary)];
|
||||
}
|
||||
.bonus-row {
|
||||
@apply grid min-h-[58px] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] items-center gap-2.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-[11px];
|
||||
}
|
||||
.empty-state {
|
||||
@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)];
|
||||
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)];
|
||||
}
|
||||
.plan-name {
|
||||
@apply block text-[15px] font-extrabold leading-tight;
|
||||
}
|
||||
.plan-meta {
|
||||
@apply mt-1 block font-[family-name:var(--font-mono)] text-[11px] font-bold leading-[1.3] text-[var(--text-muted)];
|
||||
}
|
||||
.plan-price {
|
||||
@apply block max-w-[48%] flex-none text-right font-[family-name:var(--font-mono)] text-[15px] font-extrabold leading-tight text-[var(--accent)];
|
||||
}
|
||||
.notice {
|
||||
@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)];
|
||||
}
|
||||
.step-num {
|
||||
@apply block font-[family-name:var(--font-mono)] text-[11px] font-extrabold leading-[1.1] text-current;
|
||||
}
|
||||
.step-name {
|
||||
@apply mt-1 block text-xs font-extrabold leading-[1.15] text-current;
|
||||
}
|
||||
.main-value {
|
||||
@apply m-0 text-[32px] font-extrabold leading-[1.08] text-[var(--text-primary)];
|
||||
}
|
||||
.main-caption {
|
||||
@apply mt-1.5 mb-0 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.status-text {
|
||||
@apply m-0 min-h-5 text-sm leading-[1.45] text-[var(--text-secondary)];
|
||||
}
|
||||
.legal-links {
|
||||
@apply flex flex-wrap justify-center gap-x-4 gap-y-1.5;
|
||||
}
|
||||
.actions-row {
|
||||
@apply grid min-w-0 grid-cols-[minmax(0,1fr)_48px] gap-2;
|
||||
}
|
||||
.app-header {
|
||||
@apply flex min-h-12 items-center justify-between gap-3;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex min-w-0 items-center gap-2.5;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex min-w-0 items-center gap-2;
|
||||
}
|
||||
.panel-head-sm {
|
||||
@apply flex min-w-0 items-center justify-between gap-2.5;
|
||||
}
|
||||
.login-head {
|
||||
@apply flex min-w-0 items-center justify-center gap-3.5;
|
||||
}
|
||||
"""
|
||||
|
||||
css_split = css.split('.btn {')
|
||||
new_css = css_split[0] + new_components + '\n .btn {' + css_split[1]
|
||||
|
||||
with open(css_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_css)
|
||||
|
||||
print("Done")
|
||||
@@ -0,0 +1,8 @@
|
||||
import re
|
||||
|
||||
with open('bot/app/web/templates/subscription_webapp.html', 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
long_classes = set(re.findall(r'class="([^"]{40,})"', html))
|
||||
for cls in long_classes:
|
||||
print(f"--- LONG CLASS ({len(cls)} chars) ---\n{cls}\n")
|
||||
Reference in New Issue
Block a user