diff --git a/.dockerignore b/.dockerignore index c8a6475..4ce4d8e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,6 +28,7 @@ tmp/ # WebApp build artifacts (regenerated inside Docker) bot/app/web/templates/subscription_webapp.css +bot/app/web/templates/subscription_webapp.js bot/app/web/templates/subscription_webapp.min.*.js # Byte-compiled / optimized / DLL files diff --git a/.env.example b/.env.example index 900d0d4..cb1a2cd 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 # WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color -WEBAPP_LOGO_URL=https://em-content.zobj.net/source/animated-noto-color-emoji/427/dotted-line-face_1fae5.gif # Optional logo URL; shown in the header and login screen, leave empty to hide +WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used +WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) diff --git a/.gitignore b/.gitignore index 4dcd4e4..3dca910 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules/ # WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build) bot/app/web/templates/subscription_webapp.css +bot/app/web/templates/subscription_webapp.js bot/app/web/templates/subscription_webapp.min.*.js tmp .claude @@ -30,3 +31,4 @@ locales/ru_backup.json locales/en_backup.json db/models_old.py config/tariffs.json +docker-compose-dev.yml diff --git a/Dockerfile b/Dockerfile index 84176c4..dd1f6db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY package.json package-lock.json* ./ RUN --mount=type=cache,target=/root/.npm \ if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY bot/app/web/frontend ./bot/app/web/frontend COPY bot/app/web/templates ./bot/app/web/templates COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs @@ -36,9 +37,12 @@ COPY . . # Replace template assets with freshly built ones RUN rm -f bot/app/web/templates/subscription_webapp.css \ + bot/app/web/templates/subscription_webapp.js \ bot/app/web/templates/subscription_webapp.min.*.js COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \ bot/app/web/templates/subscription_webapp.css +COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \ + bot/app/web/templates/subscription_webapp.js COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \ bot/app/web/templates/ diff --git a/README.md b/README.md index ea2bdbc..8492b57 100644 --- a/README.md +++ b/README.md @@ -303,19 +303,6 @@ Web App запускается в том же контейнере, что и б Для email-регистраций пользователь в панели Remnawave создается с анонимным username вида `em_`; email добавляется в описание пользователя панели и, если API панели принимает поле email, передается отдельным полем. Для Telegram-регистраций сохраняется существующая схема `tg_`. -Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов. - -Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям. - -Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды: - -```bash -python scripts/update_telegram_web_app_js.py -python scripts/update_telegram_widget_js.py -``` - -При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник. - ## Подробная инструкция для развертывания на сервере с панелью Remnawave ### 1. Клонирование репозитория diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte new file mode 100644 index 0000000..d11debe --- /dev/null +++ b/bot/app/web/frontend/src/App.svelte @@ -0,0 +1,2463 @@ + + + + {brandTitle} + + + + {#key currentLang} + {#if isPreviewBoard} + + {:else} +
+ {#if mode === "loading"} +
+
+ {#if CFG.logoUrl} + + {:else} + {brandEmoji} + {/if} +
+
{t("wa_loading")}
+
+ {:else if mode === "login"} +
+ {#if screen === "code"} +
+ +
+

{t("wa_email_verification_title")}

+

{t("wa_email_sent_to", { email: pendingEmail })}

+
+ +
+
+ + + {#if authStatus} +
{authStatus}
+ {/if} + +
+ {:else} +
+ + + {#if CFG.emailAuthEnabled !== false} +
+
+
+ + { + loginEmailFieldError = ""; + loginEmailTooltipOpen = false; + }} + /> + {#if loginEmailFieldError} + + + + {/if} + {#if loginEmailFieldError} + + {loginEmailFieldError} + + {/if} + +
+ +
+
+ {/if} + {#if CFG.emailAuthEnabled !== false} +
{t("wa_or")}
+ {/if} +
+ +
+ {#if authStatus} + + {/if} +
+ {#if userAgreementUrl || privacyPolicyUrl} + + {/if} +
+ {/if} +
+ {:else} +
+ {#if screen === "invite" || screen === "devices" || screen === "settings"} +
+
+
+ {#if CFG.logoUrl} + + {:else} + {brandEmoji} + {/if} +
+ {brandTitle} +
+
+ {/if} + + {#if screen === "home"} +
+ + +
+ + {#if subscription.active} +
+ +
+

{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(subscription)}

+

{subscription.end_date_text ? t("wa_until_date", { date: subscription.end_date_text }) : subscription.remaining_text}

+
+
+ {:else} +
+ +

{t("wa_home_subscription_inactive")}

+
+ {/if} +
+ + {#if subscription.active} + +
+ {t("wa_home_traffic_used")} + {trafficLabel(subscription)} +
+
+ +
+
+ {trafficResetLabel(subscription)} + {trafficPercent(subscription)}% +
+
+ {:else if appSettings?.trial_enabled && appSettings?.trial_available} + +
+ + + {t("wa_trial_title")} + {t("wa_trial_details", { days: Number(appSettings?.trial_duration_days || 0), traffic: trialTrafficLabel() })} + +
+
+ {/if} + +
+ + {#if !subscription.active && appSettings?.trial_enabled && appSettings?.trial_available} + + {/if} +
+
+
+ {:else if screen === "invite"} +
+ +
+ +
+ {t("wa_referral_bonus_overview_title")} + {#if referralOneBonusPerReferee} +

{t("wa_referral_bonus_once_note")}

+ {/if} +
+
+
+

{t("wa_referral_link_title")}

+
+ {referral.webapp_link || referral.bot_link || t("wa_link_unavailable")} + +
+
+ {#if referralBonusDetails.length || referralWelcomeBonusDays > 0} +
+ {#if referralWelcomeBonusDays > 0} +
+ {t("wa_referral_bonus_registration_title")} + {t("wa_referral_bonus_friend_days", { days: referralWelcomeBonusDays })} +
+ {/if} + {#if referralBonusDetails.length} +

{t("wa_referral_bonus_paid_intro")}

+ {/if} + {#each referralBonusDetails as bonus, index (bonus.months || index)} +
+ {bonus.title || `${bonus.months || "?"}`} + {t("wa_referral_bonus_you_days", { days: Number(bonus.inviter_days || 0) })} + {t("wa_referral_bonus_friend_days", { days: Number(bonus.friend_days || 0) })} +
+ {/each} +
+ {:else} +

{t("wa_referral_bonus_not_configured")}

+ {/if} +
+ +

+ + {t("wa_activate_promo_title")} +

+
+
+ + (promoFieldError = "")} + /> + {#if promoFieldError} + + + + {/if} + {#if promoFieldError} + + {promoFieldError} + + {/if} + +
+ +
+ {#if promoStatus && !(promoIsError && promoFieldError)} +

{promoStatus}

+ {/if} +
+
+ {:else if screen === "devices"} +
+ +
+ + + {t("wa_devices_title")} + {devicesCountLabel()} + + +
+
+ +
+
+ + {#if devicesBusy && !devicesLoaded} + {t("wa_devices_loading")} + {:else if devicesStatus} + +

{devicesStatus}

+
+ {:else if !devicesData?.devices?.length} + + + {t("wa_devices_empty")} + {t("wa_devices_empty_hint", { max: devicesLimitLabel() })} + + {:else} +
+ {#each devicesData.devices as device (device.token || device.index)} + +
+
+ + {device.display_name || t("wa_device_fallback_name", { index: device.index })} + {device.platform_label || t("wa_devices_platform_unknown")} + +
+
+ {#if device.created_at_text} +
+ {t("wa_devices_connected_at")} + {device.created_at_text} +
+ {/if} + {#if device.hwid_short} +
+ HWID + {device.hwid_short} +
+ {/if} + {#if device.user_agent} +
+ User Agent + {device.user_agent} +
+ {/if} +
+ {#if device.can_disconnect} + + {/if} +
+ {/each} +
+ {/if} +
+ {:else if screen === "settings"} +
+ +
+ {#if profileAvatarUrl} + {t("wa_settings_avatar_alt")} + {:else} + + {/if} +
+
+ {telegramProfileName} + {profileEmail} + {profileTelegramId} +
+
+ +
+
+ + + + + {t("wa_settings_language")} + + + {currentLanguageOption?.label || userLanguage} + + + + + + + {#each languageOptions as option (option.value)} + + + + {option.label} + + + + {/each} + + + +
+ {#if supportUrl} + + {/if} + {#if userAgreementUrl} + + {/if} + {#if privacyPolicyUrl} + + {/if} + +
+
+ {/if} + + {#if screen === "home" || screen === "invite" || screen === "devices" || screen === "settings"} + + {/if} +
+ + +
+
+ {#each plans as plan} + + {/each} +
+ +
+ {#if methods.length} + {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} + {:else} + {t("wa_payment_methods_not_configured")} + {/if} +
+ +
+
+ + +
+ + +
+
+ + +
+ {#if !linkEmailPending} +
+ + (linkEmailFieldError = "")} + /> + {#if linkEmailFieldError} + + + + {/if} + {#if linkEmailFieldError} + + {linkEmailFieldError} + + {/if} + +
+ + {:else} + + {/if} + {#if linkEmailStatus} +

{linkEmailStatus}

+ {/if} +
+
+ {/if} + + {#if toastText} +
{toastText}
+ {/if} +
+ {/if} + {/key} +
+ diff --git a/bot/app/web/frontend/src/PreviewBoard.svelte b/bot/app/web/frontend/src/PreviewBoard.svelte new file mode 100644 index 0000000..feb94e0 --- /dev/null +++ b/bot/app/web/frontend/src/PreviewBoard.svelte @@ -0,0 +1,217 @@ + + +
+ +
+ +
+ +
+ +

Подписка активна

до {sub.end_date_text}

+
+
+ +
Использовано трафика{sub.traffic_used} из {sub.traffic_limit}
+
+
18%
+
+
+ + + +
+
+ +
+
+ + +
{logoEmoji}
{title}
+
+ {#each tariffs as tariff, index} +
+ + {tariff[1]}{tariff[2]}{tariff[3]} + {#if index === 0}{:else}{/if} +
+ {/each} +
+ +
+ + + +
+ {#each plans as plan, index} +
+ {plan.title}{money(plan.price)}{money(Math.round(plan.price / plan.months))}/мес + {#if index === 1}{/if} +
+ {/each} +
+ Итого
К оплате
790 ₽
+ + +
+ + + +
+ {#each traffic as pack, index} +
+ {pack[0]} ГБ{money(pack[1])}{money(Math.round(pack[1] / pack[0]))}/ГБ + {#if index === 2}{/if} +
+ {/each} +
+ Итого
К оплате
990 ₽
+ + +
+ + + +
+
ПодпискаБезлимитный трафикДоплата 190 ₽
+
ТрафикПакеты гигабайтДоплата не требуется
+
ПремиумМаксимальная скоростьДоплата 390 ₽
+
+ +
+ + Сменить тариф без доплаты? +

Остаток 12 дней будет пересчитан по новому тарифу.

+ + +
+
+ + +
{logoEmoji}
{title}
+ +
Ваша реферальная ссылка
+
https://minishop.app/ref/ABCD1234
+
+ +
Ваш бонус+7 дней за каждого друга

Друг получит +3 дня к подписке.

+
+ +
+ + +
{logoEmoji}
{title}
+ +
+ {#if previewAvatar} + Аватар пользователя + {:else} + + {/if} +
+
+ {previewTelegramName} + {previewEmail} + {previewTelegramId} +
+
+ {#each settingsRows as row} +
+ + {row[1]}{row[2]} + +
+ {/each} +
+
+ + + + +
Вход по email
+
Email
+
или
+ +
+
+ + + +
{#each [1, 2, 3, 4, 5, 6] as digit}{digit}{/each}
+ + +
+
+ + + diff --git a/bot/app/web/frontend/src/lib/components/ui/button.svelte b/bot/app/web/frontend/src/lib/components/ui/button.svelte new file mode 100644 index 0000000..aef6d9a --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/ui/button.svelte @@ -0,0 +1,38 @@ + + +{#if href} + + + +{:else} + +{/if} diff --git a/bot/app/web/frontend/src/lib/components/ui/card.svelte b/bot/app/web/frontend/src/lib/components/ui/card.svelte new file mode 100644 index 0000000..3156ce8 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/ui/card.svelte @@ -0,0 +1,12 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte new file mode 100644 index 0000000..c05d31a --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte @@ -0,0 +1,31 @@ + + +{#if open} + +{/if} diff --git a/bot/app/web/frontend/src/lib/components/ui/input.svelte b/bot/app/web/frontend/src/lib/components/ui/input.svelte new file mode 100644 index 0000000..b959758 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/ui/input.svelte @@ -0,0 +1,29 @@ + + + diff --git a/bot/app/web/frontend/src/lib/utils.js b/bot/app/web/frontend/src/lib/utils.js new file mode 100644 index 0000000..378ccef --- /dev/null +++ b/bot/app/web/frontend/src/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/bot/app/web/frontend/src/main.js b/bot/app/web/frontend/src/main.js new file mode 100644 index 0000000..4eb14c2 --- /dev/null +++ b/bot/app/web/frontend/src/main.js @@ -0,0 +1,10 @@ +import { mount } from "svelte"; + +import App from "./App.svelte"; +import "./styles.css"; + +const target = document.getElementById("app"); + +if (target) { + mount(App, { target }); +} diff --git a/bot/app/web/frontend/src/preview/BackTitle.svelte b/bot/app/web/frontend/src/preview/BackTitle.svelte new file mode 100644 index 0000000..b7ade24 --- /dev/null +++ b/bot/app/web/frontend/src/preview/BackTitle.svelte @@ -0,0 +1,17 @@ + + +
+ +
+

{title}

+

{subtitle}

+
+ +
diff --git a/bot/app/web/frontend/src/preview/PhoneFrame.svelte b/bot/app/web/frontend/src/preview/PhoneFrame.svelte new file mode 100644 index 0000000..f6340de --- /dev/null +++ b/bot/app/web/frontend/src/preview/PhoneFrame.svelte @@ -0,0 +1,12 @@ + + +
+

{number}. {label}

+
+ +
+
diff --git a/bot/app/web/frontend/src/preview/PreviewMethods.svelte b/bot/app/web/frontend/src/preview/PreviewMethods.svelte new file mode 100644 index 0000000..b2db0d7 --- /dev/null +++ b/bot/app/web/frontend/src/preview/PreviewMethods.svelte @@ -0,0 +1,26 @@ + + +
+ {#each methods as method, index} +
+ + + {method.name} + {note(index)} + +
+ {/each} +
diff --git a/bot/app/web/frontend/src/preview/PreviewNav.svelte b/bot/app/web/frontend/src/preview/PreviewNav.svelte new file mode 100644 index 0000000..003b881 --- /dev/null +++ b/bot/app/web/frontend/src/preview/PreviewNav.svelte @@ -0,0 +1,11 @@ + + + diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css new file mode 100644 index 0000000..0849b90 --- /dev/null +++ b/bot/app/web/frontend/src/styles.css @@ -0,0 +1,1868 @@ +@font-face { + font-family: "Twemoji Country Flags"; + src: url("https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2") + format("woff2"); + font-display: swap; + unicode-range: + U+1F1E6-1F1FF, + U+1F3F4, + U+E0062-E0063, + U+E0065, + U+E0067, + U+E006C, + U+E006E, + U+E0073-E0074, + U+E0077, + U+E007F; +} + +:root { + --font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + color-scheme: dark; + --accent: #00fe7a; + --bg: #03070b; + --panel: #111820; + --panel-2: #0b1118; + --panel-3: #17212b; + --border: rgba(255, 255, 255, 0.12); + --border-strong: rgba(255, 255, 255, 0.2); + --text: #f2f7f4; + --muted: #a9b4b0; + --dim: #68736f; + --danger: #ff6b6b; + --blue: #2d9cff; + --radius: 8px; + --screen-gutter: 18px; + --safe-inline: max(env(safe-area-inset-left), env(safe-area-inset-right)); + --nav-inline-gutter: max(var(--screen-gutter), var(--safe-inline)); + --bottom-nav-height: 64px; + --bottom-nav-offset: 10px; + --bottom-nav-left: calc(max(0px, (100% - 440px) / 2) + var(--nav-inline-gutter)); + --bottom-nav-width: min( + calc(100% - (var(--nav-inline-gutter) * 2)), + calc(440px - (var(--nav-inline-gutter) * 2)) + ); + font-family: var(--font-sans); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; + margin: 0; + overflow-x: clip; +} + +body { + background: #02070b; + color: var(--text); + -webkit-font-smoothing: antialiased; + letter-spacing: 0; +} + +button, +input { + font: inherit; + letter-spacing: 0; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: progress; + opacity: 0.58; +} + +a { + color: inherit; +} + +.app-shell { + min-height: 100dvh; + background: #02070b !important; +} + +.phone-screen { + position: relative; + width: min(100%, 440px); + min-height: 100dvh; + margin: 0 auto; + overflow-x: hidden; + padding: + max(16px, env(safe-area-inset-top)) + max(var(--screen-gutter), var(--safe-inline)) + max(18px, env(safe-area-inset-bottom)) + max(var(--screen-gutter), var(--safe-inline)); +} + +.content { + display: grid; + gap: 10px; + animation: section-enter 0.22s ease-out both; +} + +.content.with-nav { + padding-bottom: 86px; +} + +.loader { + display: grid; + min-height: 100dvh; + place-items: center; + align-content: center; + gap: 14px; + color: var(--muted); + font-weight: 800; +} + +.app-header, +.preview-header, +.screen-head { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; + min-height: 48px; +} + +.screen-head { + grid-template-columns: 42px minmax(0, 1fr) 42px; + margin-bottom: 6px; +} + +.center-title { + align-items: start; +} + +.center-copy { + min-width: 0; + text-align: center; +} + +.screen-head h1, +.center-copy h1 { + margin: 0; + color: var(--text); + font-size: 18px; + line-height: 1.16; + font-weight: 850; +} + +.screen-head p, +.center-copy p { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.3; +} + +.screen-head strong { + color: var(--accent); +} + +.brand-row { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.brand-row strong { + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 15px; + font-weight: 850; + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.accent-title .brand-row strong { + color: var(--accent); +} + +.brand-mark { + display: grid; + width: 38px; + height: 38px; + flex: 0 0 auto; + place-items: center; + color: var(--accent); + font-size: 26px; + line-height: 1; +} + +.brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.brand-mark-lg { + width: 66px; + height: 66px; + font-size: 46px; +} + +.brand-mark-xl { + width: 96px; + height: 96px; + font-size: 70px; +} + +.card { + position: relative; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + var(--panel); + box-shadow: + 0 18px 48px rgba(0, 0, 0, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + padding: 14px; +} + +.card-active { + border-color: color-mix(in srgb, var(--accent) 76%, var(--border)); + box-shadow: + 0 18px 48px rgba(0, 0, 0, 0.22), + 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.card-compact { + padding: 11px; +} + +.status-card { + padding: 16px; +} + +.status-card-inactive { + border-color: color-mix(in srgb, var(--danger) 58%, var(--border)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--danger) 14%, rgba(255, 255, 255, 0.03)), rgba(255, 255, 255, 0.012)), + var(--panel); +} + +.status-card-inactive .sub-status { + color: var(--danger); +} + +.sub-status-inactive { + min-height: 0; + justify-content: flex-start; +} + +.sub-status { + display: flex; + align-items: center; + gap: 12px; + color: var(--accent); +} + +.sub-status h2 { + margin: 0; + font-size: 15px; + line-height: 1.16; +} + +.sub-status p { + margin: 5px 0 0; + color: var(--text); + font-size: 12px; + opacity: 0.82; +} + +.traffic-top, +.total-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.traffic-top span, +.card-label, +.total-card span, +.settings-row small, +.method-card small { + color: var(--muted); + font-size: 12px; +} + +.traffic-top strong { + font-size: 12px; + font-weight: 850; +} + +.card-heading { + margin: 0 0 10px; + color: var(--text); + font-size: 15px; + font-weight: 850; + line-height: 1.2; +} + +.card-heading-accent { + color: var(--accent); +} + +.promo-heading { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.progress { + position: relative; + height: 8px; + margin-top: 12px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.progress span { + position: absolute; + inset: 0 auto 0 0; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 72%, white)); + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.traffic-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 6px; + color: var(--muted); + font-size: 11px; +} + +.traffic-percent { + margin-top: 0; + text-align: right; + color: var(--muted); + font-size: 11px; +} + +.trial-card { + padding: 13px 14px; +} + +.trial-card-head { + display: flex; + align-items: center; + gap: 11px; +} + +.trial-card-head > svg { + flex: 0 0 auto; + color: var(--accent); +} + +.trial-card-head span { + display: grid; + min-width: 0; + gap: 3px; +} + +.trial-card-head strong { + font-size: 13px; + line-height: 1.18; +} + +.trial-card-head small { + color: var(--muted); + font-size: 11px; + line-height: 1.28; +} + +.devices-summary-card { + display: grid; + gap: 10px; +} + +.devices-summary-head, +.device-card-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 11px; +} + +.devices-summary-head > svg { + color: var(--accent); +} + +.devices-summary-head span, +.device-card-head span { + display: grid; + min-width: 0; + gap: 3px; +} + +.devices-summary-head strong, +.device-card-head strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.devices-summary-head small, +.device-card-head small, +.device-meta span, +.device-meta small, +.devices-empty-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.3; +} + +.devices-progress { + margin-top: 0; +} + +.devices-list { + display: grid; + gap: 8px; +} + +.device-card { + display: grid; + gap: 12px; +} + +.device-card-head { + grid-template-columns: 38px minmax(0, 1fr); +} + +.device-icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03)); + color: var(--accent); +} + +.device-meta { + display: grid; + gap: 7px; +} + +.device-meta div { + display: grid; + gap: 2px; +} + +.device-meta strong, +.device-meta code { + overflow: hidden; + color: var(--text); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-meta code { + font-family: var(--font-mono); +} + +.device-user-agent small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-disconnect-button, +.device-danger-button { + border-color: color-mix(in srgb, var(--danger) 62%, var(--border)); + color: #ffb8b8; +} + +.devices-empty-card { + display: grid; + justify-items: center; + gap: 7px; + text-align: center; +} + +.devices-empty-card svg { + color: var(--accent); +} + +.action-stack { + display: grid; + gap: 8px; +} + +.btn { + display: inline-flex; + min-height: 44px; + min-width: 0; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 10px 14px; + text-align: center; + text-decoration: none; + font-size: 14px; + font-weight: 850; + line-height: 1.1; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + border-color: color-mix(in srgb, var(--accent) 72%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); + color: #031009; + box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.btn-secondary, +.btn-outline { + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.045); + color: var(--text); +} + +.btn-outline { + border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); + color: var(--accent); +} + +.btn-ghost { + background: transparent; + color: var(--muted); +} + +.btn-telegram { + border-color: #2d9cff; + background: linear-gradient(180deg, #2f9ff4, #1786df); + color: white; +} + +.btn-icon { + border-color: var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.btn-square { + width: 42px; + min-width: 42px; + height: 42px; + padding: 0; +} + +.btn-sm { + min-height: 36px; + padding: 8px 10px; + font-size: 12px; +} + +.wide { + width: 100%; +} + +.input { + width: 100%; + min-height: 44px; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(4, 8, 11, 0.48); + color: var(--text); + outline: none; + padding: 11px 12px; + font-size: 13px; + font-weight: 700; +} + +.input:focus { + border-color: color-mix(in srgb, var(--accent) 68%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.input.muted { + display: flex; + align-items: center; + color: var(--dim); +} + +.bottom-action { + margin-top: 4px; +} + +.period-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.period-grid-two-columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.period-card, +.method-card, +.settings-row { + border: 1px solid var(--border); + border-radius: var(--radius); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + color: var(--text); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +.period-card { + position: relative; + display: grid; + min-height: 82px; + place-items: center; + gap: 4px; + padding: 10px 8px; +} + +.period-card strong { + font-size: 12px; + line-height: 1.12; + text-align: center; + overflow-wrap: anywhere; +} + +.period-card span { + font-size: 16px; + font-weight: 850; +} + +.period-card em { + color: var(--muted); + font-size: 10px; + font-style: normal; + font-weight: 750; +} + +.period-card small { + color: var(--muted); + font-size: 10px; +} + +.period-card svg { + position: absolute; + top: 8px; + right: 8px; + color: var(--accent); +} + +.period-card.active, +.method-card.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.total-card { + padding: 12px 14px; +} + +.total-card strong { + font-size: 22px; + font-weight: 900; +} + +.method-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.method-card { + display: flex; + width: 100%; + min-height: 56px; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.035); + padding: 10px; + text-align: center; +} + +.method-card svg { + flex: 0 0 auto; +} + +.method-card-main { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; +} + +.method-card strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.method-card strong { + font-size: 12px; +} + +.settings-list { + display: grid; + gap: 8px; +} + +.copy-row, +.email-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.field-error-wrap { + position: relative; + min-width: 0; +} + +.input.input-error { + border-color: var(--danger); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--danger) 70%, transparent); +} + +.field-error-icon { + color: var(--danger); + pointer-events: none; +} + +.field-error-trigger { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + display: grid; + width: 20px; + height: 20px; + place-items: center; + border: 0; + background: transparent; + padding: 0; + cursor: help; +} + +.field-error-tooltip { + z-index: 120; + max-width: 280px; + border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); + border-radius: 8px; + background: #2a1010; + color: #ffd8d8; + padding: 8px 10px; + font-size: 12px; + line-height: 1.3; + transform-origin: top center; +} + +.field-error-tooltip[data-state="delayed-open"], +.field-error-tooltip[data-state="instant-open"] { + animation: tooltip-in 0.16s ease-out both; +} + +.field-error-tooltip[data-state="closed"] { + animation: tooltip-out 0.12s ease-in both; +} + +@keyframes tooltip-in { + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes tooltip-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(2px) scale(0.98); + } +} + +.copy-row code { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(0, 0, 0, 0.2); + color: var(--text); + padding: 12px; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.referral-copy-row { + align-items: stretch; +} + +.referral-copy-button { + height: 100%; +} + +.bonus-card { + display: grid; + gap: 10px; +} + +.bonus-card-head { + display: flex; + align-items: center; + gap: 14px; +} + +.bonus-card-head > svg { + flex: 0 0 auto; + color: var(--accent); +} + +.bonus-card strong { + display: block; + color: var(--accent); + font-size: 17px; +} + +.bonus-card p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; +} + +.referral-bonus-list { + display: grid; + gap: 8px; +} + +.referral-bonus-intro { + margin: 2px 2px 0; + color: var(--muted); + font-size: 12px; +} + +.referral-bonus-row { + display: grid; + gap: 3px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.02); + padding: 10px 11px; +} + +.referral-bonus-row strong { + font-size: 13px; +} + +.referral-bonus-row small { + color: var(--muted); + font-size: 12px; +} + +.settings-profile { + display: flex; + align-items: center; + gap: 12px; +} + +.settings-avatar { + display: grid; + width: 58px; + height: 58px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); +} + +.settings-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.settings-avatar svg { + color: var(--accent); +} + +.settings-profile-meta { + display: grid; + min-width: 0; + gap: 3px; +} + +.settings-profile-meta strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.settings-profile-meta small { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.settings-links-block { + display: grid; + gap: 8px; +} + +.settings-divider { + height: 1px; + background: var(--border); +} + +.settings-row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 18px; + align-items: center; + gap: 10px; + min-height: 50px; + padding: 9px 10px; + text-align: left; +} + +.settings-row > svg:first-child { + color: var(--text); + opacity: 0.9; +} + +.settings-row > svg:last-child { + color: var(--muted); +} + +.settings-row span { + display: grid; + min-width: 0; + gap: 2px; +} + +.settings-row strong, +.settings-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-row strong { + font-size: 12px; +} + +.settings-row-linked { + grid-template-columns: 28px minmax(0, 1fr); + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03)); +} + +.settings-row-linked > svg:first-child { + color: var(--accent); +} + +.settings-row-linked strong { + color: var(--accent); +} + +.emoji-flag { + font-family: + "Twemoji Country Flags", + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji", + sans-serif; + font-variant-emoji: emoji; + line-height: 1; +} + +.settings-row-language { + grid-template-columns: 28px minmax(0, 1fr); + padding: 7px 10px; +} + +.language-select-trigger { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + min-height: 34px; + border: 0; + background: transparent; + color: var(--text); + text-align: left; + padding: 0; +} + +.language-select-trigger:focus-visible { + outline: 0; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); + border-radius: 6px; +} + +.language-select-trigger > svg { + color: var(--muted); +} + +.language-select-copy { + display: grid; + min-width: 0; + gap: 2px; +} + +.language-select-copy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.language-select-current { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.language-select-current .emoji-flag { + font-size: 14px; +} + +.language-select-content { + z-index: 60; + width: min(210px, calc(100vw - 28px)); + min-width: 170px; + max-width: min(210px, calc(100vw - 28px)); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--panel-3); + box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42); + overflow: hidden; + box-sizing: border-box; + transform-origin: top right; + animation: dropdown-enter 0.16s ease-out both; +} + +.language-select-viewport { + max-height: min(220px, 45dvh); + padding: 6px; +} + +.language-select-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 36px; + border-radius: 6px; + padding: 7px 9px; + color: var(--text); + font-size: 13px; + cursor: pointer; +} + +.language-select-item[data-highlighted] { + background: rgba(255, 255, 255, 0.06); +} + +.language-select-item[data-selected] { + color: var(--text) !important; +} + +.language-select-item[data-selected] .language-select-item-check { + color: var(--text); +} + +.language-select-item-main { + display: inline-flex !important; + align-items: center; + flex-wrap: nowrap !important; + gap: 11px; + min-width: 0; + white-space: nowrap !important; + overflow: hidden; + text-overflow: ellipsis; +} + +.language-select-item-main > span { + display: inline-block !important; +} + +.language-select-item-main > span:last-child { + margin-left: 6px; + white-space: nowrap !important; + overflow: hidden; + text-overflow: ellipsis; +} + +.language-select-item-main .emoji-flag { + font-size: 14px; +} + +.language-select-item-check { + opacity: 0; + color: var(--accent); +} + +.language-select-item[data-selected] .language-select-item-check { + opacity: 1; +} + +.settings-telegram-link-btn { + justify-content: center; +} + +.attention-wrap { + position: relative; +} + +.attention-dot { + position: absolute; + top: 0; + right: 0; + width: 13px; + height: 13px; + transform: translate(50%, -50%); + border-radius: 999px; + background: #ff4b4b; + box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75); + animation: attention-pulse 1.6s ease-out infinite; + pointer-events: none; +} + +.nav-attention-dot { + top: 6px; + right: 10px; + transform: none; +} + +.bottom-nav button.attention-wrap { + padding-right: 6px; +} + +@keyframes attention-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75); + } + 70% { + box-shadow: 0 0 0 8px rgba(255, 75, 75, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 75, 75, 0); + } +} + +.bottom-nav { + position: fixed !important; + left: var(--bottom-nav-left); + right: auto; + width: var(--bottom-nav-width); + max-width: none; + transform: none; + bottom: max(var(--bottom-nav-offset), env(safe-area-inset-bottom)); + z-index: 80; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px; + min-height: 64px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(7, 12, 17, 0.88); + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36); + backdrop-filter: blur(16px); +} + +.bottom-nav-devices { + grid-template-columns: repeat(4, 1fr); +} + +.bottom-nav.static { + position: fixed; +} + +.bottom-nav button { + display: grid; + place-items: center; + align-content: center; + gap: 4px; + border: 0; + background: transparent; + color: var(--muted); + font-size: 10px; + font-weight: 800; +} + +.bottom-nav button.active { + color: var(--accent); +} + +.home-layout { + display: grid; + min-height: calc(100dvh - 34px); + grid-template-rows: minmax(0, 1fr) auto; + gap: 18px; + padding-bottom: 86px; + animation: section-enter 0.24s ease-out both; +} + +.home-brand { + align-self: center; + padding-top: 0; +} + +.home-bottom { + display: grid; + gap: 10px; + align-self: end; +} + +.auth-screen { + display: grid; + align-content: center; +} + +.auth-card-wrap { + display: grid; + gap: 24px; +} + +.login-brand { + display: grid; + justify-items: center; + gap: 10px; + text-align: center; +} + +.login-brand.small { + gap: 5px; +} + +.login-brand-auth .brand-mark.brand-mark-xl { + width: 116px; + height: 116px; + font-size: 84px; +} + +.login-brand h1 { + margin: 0; + overflow-wrap: anywhere; + color: var(--accent); + font-size: 32px; + font-weight: 900; + font-family: var(--font-mono); + line-height: 1.04; +} + +.login-brand p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.auth-card { + display: grid; + gap: 14px; +} + +.auth-pane { + display: grid; + gap: 10px; +} + +.auth-email-stack { + display: grid; + gap: 12px; +} + +.auth-pane .field-label, +.auth-card .field-label { + color: var(--text); + font-size: 13px; + font-weight: 850; +} + +.auth-bottom { + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.telegram-login-button { + justify-content: center; + padding-left: 14px; + padding-right: 14px; +} + +.telegram-login-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; +} + +.auth-legal { + display: grid; + justify-items: center; + gap: 6px; +} + +.auth-legal-intro, +.auth-legal-links { + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.auth-legal-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px; +} + +.auth-legal a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.auth-legal a:hover { + color: var(--accent); +} + +.auth-bottom strong { + color: var(--accent); +} + +.or-line { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 12px; +} + +.or-line span { + height: 1px; + background: var(--border); +} + +.otp-wrap { + display: grid; + align-content: center; + gap: 20px; + min-height: calc(100dvh - 120px); +} + +.otp-input-wrap { + position: relative; + display: block; +} + +.otp-input-wrap input { + position: absolute; + inset: 0; + z-index: 2; + width: 100%; + border: 0; + background: transparent; + color: transparent; + caret-color: transparent; + outline: none; +} + +.otp-slots { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; +} + +.otp-slots span { + display: grid; + aspect-ratio: 1; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border)); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.035); + font-size: 25px; + font-weight: 900; +} + +.otp-slots span.filled { + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.04)); +} + +.link-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + background: transparent; + color: var(--muted); + font-size: 13px; +} + +.status-line { + color: var(--accent); + font-size: 12px; + line-height: 1.4; +} + +.auth-login-status { + justify-self: center; + text-align: center; + width: 100%; +} + +.status-line.error, +.error { + color: var(--danger); +} + +.empty-card { + grid-column: 1 / -1; + color: var(--muted); + font-size: 13px; +} + +.dialog { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + overflow: hidden; + padding: + 16px + var(--screen-gutter) + calc(var(--bottom-nav-height) + var(--bottom-nav-offset) + 16px + env(safe-area-inset-bottom)); +} + +.dialog-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(2, 7, 11, 0.54); + backdrop-filter: blur(10px); + animation: fade-in 0.18s ease-out both; +} + +.dialog-card { + position: relative; + z-index: 1; + display: grid; + width: min(100%, 420px); + gap: 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 28px 70px rgba(0, 0, 0, 0.5); + padding: 14px; + transform-origin: center; + animation: modal-enter 0.2s ease-out both; +} + +.dialog-head { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + align-items: start; + gap: 10px; +} + +.dialog-head h2 { + margin: 0; + color: var(--text); + font-size: 18px; + line-height: 1.16; + font-weight: 850; +} + +.dialog-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.3; +} + +.payment-dialog-card { + max-height: 100%; + overflow-y: auto; +} + +.link-email-dialog-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: min(100%, 560px); + max-height: 100%; + overflow: hidden; +} + +.link-email-dialog-card .dialog-head { + position: relative; + z-index: 2; +} + +.link-email-dialog-card .payment-dialog-body { + min-height: 0; +} + +.link-email-code-layout { + display: grid; + min-height: 0; + height: 100%; + grid-template-rows: minmax(0, 1fr) auto; +} + +.link-email-code-center { + align-self: center; +} + +.link-email-resend { + align-self: end; +} + +.payment-dialog-body { + display: grid; + gap: 10px; +} + +.payment-divider { + height: 1px; + width: 100%; + background: var(--border); +} + +.payment-submit-button { + border-color: color-mix(in srgb, var(--accent) 72%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); + color: #031009; + box-shadow: + 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), + 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), + 0 0 26px color-mix(in srgb, var(--accent) 42%, transparent); +} + +.toast { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 60; + max-width: min(420px, calc(100vw - 32px)); + border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); + border-radius: var(--radius); + background: rgba(15, 21, 27, 0.92); + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42); + color: var(--text); + padding: 12px 14px; + font-size: 13px; + font-weight: 800; + animation: toast-enter 0.22s ease-out both; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes section-enter { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes dropdown-enter { + from { + opacity: 0; + transform: translateY(-6px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modal-enter { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toast-enter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.preview-board { + display: grid; + width: max-content; + min-height: 100vh; + grid-template-columns: repeat(3, auto); + gap: 22px 38px; + align-items: start; + padding: 18px 74px 28px; + background: linear-gradient(180deg, #06100c 0%, #02060b 32%, #010407 100%); +} + +.preview-phone-wrap { + display: grid; + gap: 9px; +} + +.preview-phone-wrap h2 { + margin: 0; + text-align: center; + font-size: 18px; + line-height: 1.1; +} + +.preview-phone-wrap h2 span { + color: var(--accent); +} + +.preview-phone { + position: relative; + display: grid; + width: 430px; + min-height: 318px; + align-content: start; + gap: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), + #071017; + box-shadow: + 0 28px 80px rgba(0, 0, 0, 0.54), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + padding: 15px 16px 16px; +} + +.preview-phone-wrap.wide .preview-phone { + width: 510px; +} + +.preview-phone .bottom-nav { + right: 16px; + bottom: 8px; + left: 16px; + width: auto; + min-height: 50px; + box-shadow: none; +} + +.preview-phone .bottom-nav span { + font-size: 9px; +} + +.preview-phone .card { + padding: 12px; +} + +.preview-phone .status-card { + padding: 14px; +} + +.preview-phone .btn { + min-height: 38px; + font-size: 12px; + padding: 8px 10px; +} + +.preview-phone .btn-square { + width: 36px; + min-width: 36px; + height: 36px; + padding: 0; +} + +.preview-phone .period-card { + min-height: 72px; +} + +.preview-phone .method-card { + min-height: 42px; +} + +.preview-phone .settings-row { + min-height: 39px; +} + +.preview-phone .settings-avatar { + width: 46px; + height: 46px; +} + +.preview-phone .settings-profile-meta strong { + font-size: 12px; +} + +.preview-phone .settings-profile-meta small { + font-size: 10px; +} + +.preview-phone .brand-mark { + width: 34px; + height: 34px; + font-size: 23px; +} + +.preview-phone .brand-mark-lg { + width: 56px; + height: 56px; + font-size: 39px; +} + +.preview-phone .brand-mark-xl { + width: 78px; + height: 78px; + font-size: 56px; +} + +.preview-phone .home-layout { + min-height: 300px; + gap: 12px; + padding-bottom: 58px; +} + +.preview-phone .home-brand { + padding-top: 0; +} + +.preview-phone .login-brand h1 { + font-size: 24px; +} + +.preview-modal { + position: absolute; + right: 18px; + bottom: 18px; + display: grid; + width: 178px; + gap: 8px; + justify-items: center; + border: 1px solid var(--border-strong); + border-radius: 14px; + background: rgba(20, 29, 35, 0.94); + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.46); + padding: 16px; + text-align: center; +} + +.preview-modal svg { + color: var(--accent); +} + +.preview-modal strong { + font-size: 13px; +} + +.preview-modal p { + margin: 0; + color: var(--muted); + font-size: 10px; + line-height: 1.35; +} + +@media (max-width: 460px) { + :root { + --screen-gutter: 10px; + } + + .period-grid, + .method-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .copy-row, + .email-row { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .content, + .home-layout, + .language-select-content, + .dialog-backdrop, + .dialog-card, + .toast { + animation: none !important; + } +} + +@supports not (color: color-mix(in srgb, #000 50%, #fff)) { + .btn-primary { + background: var(--accent); + } +} diff --git a/bot/app/web/frontend/vite.config.mjs b/bot/app/web/frontend/vite.config.mjs new file mode 100644 index 0000000..a3d8952 --- /dev/null +++ b/bot/app/web/frontend/vite.config.mjs @@ -0,0 +1,36 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vite"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const templateDir = path.resolve(__dirname, "../templates"); + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: templateDir, + emptyOutDir: false, + minify: false, + sourcemap: false, + cssCodeSplit: false, + lib: { + entry: path.resolve(__dirname, "src/main.js"), + name: "SubscriptionWebApp", + formats: ["iife"], + fileName: () => "subscription_webapp.js", + cssFileName: "subscription_webapp", + }, + rollupOptions: { + output: { + assetFileNames: (assetInfo) => { + if (assetInfo.name && assetInfo.name.endsWith(".css")) { + return "subscription_webapp.css"; + } + return "subscription_webapp.[name][extname]"; + }, + }, + }, + }, +}); diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index cacbe49..066f129 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -37,6 +37,7 @@ from bot.services.referral_service import ReferralService from bot.services.severpay_service import SeverPayService from bot.services.subscription_service import SubscriptionService from bot.services.yookassa_service import YooKassaService +from bot.utils.config_link import prepare_config_links from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from bot.utils.request_security import request_client_ip from config.settings import Settings @@ -48,13 +49,7 @@ logger = logging.getLogger(__name__) TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html" ASSET_DIR = TEMPLATE_PATH.parent -TELEGRAM_WEB_APP_SDK_URL = "https://telegram.org/js/telegram-web-app.js" -TELEGRAM_WEB_APP_SDK_PATH = ASSET_DIR / "telegram-web-app.js" -TELEGRAM_WIDGET_SDK_URL = "https://telegram.org/js/telegram-widget.js?23" -TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js" WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" -_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n""" -_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n""" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" @@ -105,10 +100,24 @@ class WebAppPaymentCreatePayload(BaseModel): method: str = "" months: Any = None + traffic_gb: Any = None + tariff_key: Optional[constr(max_length=128)] = None description: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None + +class WebAppLanguagePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + language: constr(min_length=2, max_length=16) + + +class WebAppDeviceDisconnectPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: constr(min_length=8, max_length=128) + _SHARED_HTTP_SESSION: Optional[ClientSession] = None _SHARED_HTTP_SESSION_LOCK = asyncio.Lock() @@ -163,9 +172,11 @@ def create_subscription_webapp_application( def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/", index_route) + app.router.add_get("/home", index_route) + app.router.add_get("/invite", index_route) + app.router.add_get("/devices", index_route) + app.router.add_get("/settings", index_route) app.router.add_get("/health", health_route) - app.router.add_get("/telegram-web-app.js", telegram_web_app_asset_route) - app.router.add_get("/telegram-widget.js", telegram_widget_asset_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) app.router.add_get("/subscription_webapp.css", css_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) @@ -176,10 +187,14 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/auth/email/magic", email_auth_magic_route) app.router.add_post("/api/auth/logout", logout_route) app.router.add_get("/api/me", me_route) + app.router.add_post("/api/account/language", account_language_route) app.router.add_post("/api/account/email/request", account_email_request_route) app.router.add_post("/api/account/email/verify", account_email_verify_route) app.router.add_post("/api/account/telegram/link", account_telegram_link_route) app.router.add_post("/api/promo/apply", apply_promo_route) + app.router.add_post("/api/trial/activate", activate_trial_route) + app.router.add_get("/api/devices", devices_route) + app.router.add_post("/api/devices/disconnect", disconnect_device_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) @@ -198,11 +213,23 @@ def _resolve_webapp_logo_url(settings: Settings) -> str: return "" parsed_logo_url = urlsplit(raw_logo_url) - if parsed_logo_url.scheme == "https" and parsed_logo_url.hostname: - return WEBAPP_LOGO_PROXY_PATH + if parsed_logo_url.scheme in {"https", "http", "data"}: + return raw_logo_url + if raw_logo_url.startswith("/"): + return raw_logo_url return "" +def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]: + token_prefix = str(bot_token or "").strip().split(":", 1)[0] + if not token_prefix.isdigit(): + return None + try: + return int(token_prefix) + except ValueError: + return None + + async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() @@ -361,7 +388,7 @@ async def _security_headers_middleware(request: web.Request, handler): "frame-ancestors https://web.telegram.org https://t.me; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " - "img-src 'self' data: https:; " + "img-src 'self' data: https: http:; " "connect-src 'self'; " "object-src 'none'; " "base-uri 'self'; " @@ -416,6 +443,8 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: "logo_url": _resolve_webapp_logo_url(settings), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, + "traffic_packages": settings.traffic_packages, + "stars_traffic_packages": settings.stars_traffic_packages, "support_url": settings.SUPPORT_LINK or "", "terms_url": settings.TERMS_OF_SERVICE_URL or "", "privacy_policy_url": settings.PRIVACY_POLICY_URL or "", @@ -467,144 +496,6 @@ async def _enforce_webapp_rate_limit( return None -async def telegram_web_app_asset_route(request: web.Request) -> web.Response: - if not TELEGRAM_WEB_APP_SDK_PATH.exists(): - await refresh_telegram_web_app_sdk() - - try: - response = await _serve_template_asset( - request, - "telegram-web-app.js", - "application/javascript", - ) - except FileNotFoundError: - logger.exception( - "Telegram Web App SDK is unavailable at %s", - TELEGRAM_WEB_APP_SDK_PATH, - ) - raise web.HTTPServiceUnavailable(text="telegram_web_app_sdk_unavailable") - - response.headers["Cache-Control"] = "no-cache" - return response - - -async def telegram_widget_asset_route(request: web.Request) -> web.Response: - if not TELEGRAM_WIDGET_SDK_PATH.exists(): - await refresh_telegram_login_widget_sdk() - - try: - data = TELEGRAM_WIDGET_SDK_PATH.read_bytes() - except FileNotFoundError: - logger.exception( - "Telegram Login Widget SDK is unavailable at %s", - TELEGRAM_WIDGET_SDK_PATH, - ) - raise web.HTTPServiceUnavailable(text="telegram_widget_sdk_unavailable") - - data = _normalize_telegram_login_widget_sdk(data) - response = web.Response(body=data, content_type="application/javascript") - response.headers["Cache-Control"] = "no-cache" - return response - - -async def refresh_telegram_web_app_sdk() -> bool: - """Best-effort refresh of the vendored Telegram Web App SDK.""" - try: - session = await _get_shared_http_session() - async with session.get(TELEGRAM_WEB_APP_SDK_URL) as response: - if response.status != 200: - logger.warning( - "Telegram Web App SDK refresh returned HTTP %s; keeping the bundled copy.", - response.status, - ) - return False - data = await response.read() - except Exception as exc: - logger.warning("Failed to refresh Telegram Web App SDK: %s", exc) - return False - - try: - TELEGRAM_WEB_APP_SDK_PATH.parent.mkdir(parents=True, exist_ok=True) - existing_data = ( - TELEGRAM_WEB_APP_SDK_PATH.read_bytes() - if TELEGRAM_WEB_APP_SDK_PATH.exists() - else None - ) - if existing_data == data: - logger.info("Telegram Web App SDK is already up to date.") - return True - - temp_path = TELEGRAM_WEB_APP_SDK_PATH.with_name( - f"{TELEGRAM_WEB_APP_SDK_PATH.name}.tmp" - ) - temp_path.write_bytes(data) - temp_path.replace(TELEGRAM_WEB_APP_SDK_PATH) - logger.info( - "Telegram Web App SDK updated at %s (%d bytes).", - TELEGRAM_WEB_APP_SDK_PATH, - len(data), - ) - return True - except Exception as exc: - logger.warning("Failed to store Telegram Web App SDK locally: %s", exc) - return False - - -async def refresh_telegram_login_widget_sdk() -> bool: - """Best-effort refresh of the vendored Telegram Login Widget SDK.""" - try: - session = await _get_shared_http_session() - async with session.get(TELEGRAM_WIDGET_SDK_URL) as response: - if response.status != 200: - logger.warning( - "Telegram Login Widget SDK refresh returned HTTP %s; keeping the bundled copy.", - response.status, - ) - return False - data = await response.read() - except Exception as exc: - logger.warning("Failed to refresh Telegram Login Widget SDK: %s", exc) - return False - - try: - data = _normalize_telegram_login_widget_sdk(data) - TELEGRAM_WIDGET_SDK_PATH.parent.mkdir(parents=True, exist_ok=True) - existing_data = ( - TELEGRAM_WIDGET_SDK_PATH.read_bytes() - if TELEGRAM_WIDGET_SDK_PATH.exists() - else None - ) - if existing_data == data: - logger.info("Telegram Login Widget SDK is already up to date.") - return True - - temp_path = TELEGRAM_WIDGET_SDK_PATH.with_name( - f"{TELEGRAM_WIDGET_SDK_PATH.name}.tmp" - ) - temp_path.write_bytes(data) - temp_path.replace(TELEGRAM_WIDGET_SDK_PATH) - logger.info( - "Telegram Login Widget SDK updated at %s (%d bytes).", - TELEGRAM_WIDGET_SDK_PATH, - len(data), - ) - return True - except Exception as exc: - logger.warning("Failed to store Telegram Login Widget SDK locally: %s", exc) - return False - - -def _normalize_telegram_login_widget_sdk(data: bytes) -> bytes: - # Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin. - text = data.decode("utf-8") - normalized = text.replace( - _UNPATCHED_WIDGET_ORIGIN_SNIPPET, - _PATCHED_WIDGET_ORIGIN_SNIPPET, - 1, - ) - return normalized.encode("utf-8") - - async def js_asset_route(request: web.Request) -> web.Response: asset_hash = request.match_info.get("asset_hash") filename = ( @@ -635,8 +526,10 @@ async def index_route(request: web.Request) -> web.Response: "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, "logoUrl": cached["logo_url"], + "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "apiBase": "/api", "telegramLoginBotUsername": request.app.get("bot_username") or "", + "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, "supportUrl": cached["support_url"], "termsUrl": cached["terms_url"], "privacyPolicyUrl": cached["privacy_policy_url"], @@ -667,7 +560,7 @@ async def index_route(request: web.Request) -> web.Response: ) html = html.replace( WEBAPP_JS_PLACEHOLDER, - f'', + f'', ) return web.Response(text=html, content_type="text/html", charset="utf-8") @@ -1392,6 +1285,29 @@ async def me_route(request: web.Request) -> web.Response: return web.json_response({"ok": True, **data}) +async def account_language_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload) + if validation_error: + return validation_error + + language = _normalize_language(str(language_payload.language or "")) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + await session.rollback() + return _json_error(403, "access_denied", "Access denied") + + if _normalize_language(db_user.language_code or "") != language: + db_user.language_code = language + await session.flush() + await session.commit() + + return web.json_response({"ok": True, "language": language}) + + async def apply_promo_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) payload = await _read_json(request) @@ -1451,19 +1367,111 @@ async def create_payment_route(request: web.Request) -> web.Response: if validation_error: return validation_error method = str(payment_payload.method or "").strip().lower() - try: - months = int(float(payment_payload.months)) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid subscription period") - settings: Settings = request.app["settings"] cached = _get_cached_webapp_settings(request) - price = cached["subscription_options"].get(months) - stars_price = cached["stars_subscription_options"].get(months) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Subscription period is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") + tariffs_config = settings.tariffs_config + traffic_mode = bool(settings.traffic_sale_mode) + sale_mode = "subscription" + traffic_gb_for_payment: Optional[float] = None + + if tariffs_config: + tariff_key = str(payment_payload.tariff_key or "").strip() + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + try: + tariff = tariffs_config.require(tariff_key) + except Exception: + return _json_error(400, "invalid_plan", "Tariff is not available") + + if tariff.billing_model == "traffic": + try: + traffic_gb = float( + payment_payload.traffic_gb + if payment_payload.traffic_gb is not None + else payment_payload.months + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid traffic package") + if traffic_gb <= 0: + return _json_error(400, "invalid_plan", "Invalid traffic package") + rub_packages = { + float(package.gb): float(package.price) + for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + } + stars_packages = { + float(package.gb): int(float(package.price)) + for package in (tariff.traffic_packages.stars if tariff.traffic_packages else []) + } + package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) + stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) + price = rub_packages.get(package_key) if package_key is not None else None + stars_price = ( + stars_packages.get(stars_package_key) + if stars_package_key is not None + else None + ) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Traffic package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb + traffic_gb_for_payment = float(payment_units) + sale_mode = f"traffic_package@{tariff.key}" + else: + try: + months = int(float(payment_payload.months)) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid subscription period") + if months not in tariff.enabled_periods: + return _json_error(400, "invalid_plan", "Subscription period is not available") + price = tariff.period_price(months, "rub") + stars_price_raw = tariff.period_price(months, "stars") + stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Subscription period is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = months + sale_mode = f"subscription@{tariff.key}" + elif traffic_mode: + try: + traffic_gb = float( + payment_payload.traffic_gb + if payment_payload.traffic_gb is not None + else payment_payload.months + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid traffic package") + if traffic_gb <= 0: + return _json_error(400, "invalid_plan", "Invalid traffic package") + package_key = _resolve_numeric_option_key(cached["traffic_packages"], traffic_gb) + stars_package_key = _resolve_numeric_option_key(cached["stars_traffic_packages"], traffic_gb) + price = cached["traffic_packages"].get(package_key) if package_key is not None else None + stars_price = ( + cached["stars_traffic_packages"].get(stars_package_key) + if stars_package_key is not None + else None + ) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Traffic package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb + traffic_gb_for_payment = float(payment_units) + sale_mode = "traffic" + else: + try: + months = int(float(payment_payload.months)) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid subscription period") + price = cached["subscription_options"].get(months) + stars_price = cached["stars_subscription_options"].get(months) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Subscription period is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = months + sale_mode = "subscription" async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: @@ -1476,13 +1484,186 @@ async def create_payment_route(request: web.Request) -> web.Response: session=session, user_id=user_id, method=method, - months=months, + months=payment_units, price=float(price or 0), stars_price=stars_price, lang=lang, + sale_mode=sale_mode, + traffic_gb=traffic_gb_for_payment, ) +async def activate_trial_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + rate_limit_response = await _enforce_webapp_rate_limit( + request, + user_id=user_id, + action="trial_activate", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + if not settings.TRIAL_ENABLED or settings.TRIAL_DURATION_DAYS <= 0: + return _json_error(400, "trial_unavailable", "Trial is not available") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + + activation_result = await subscription_service.activate_trial_subscription(session, user_id) + if not activation_result or not activation_result.get("activated"): + await session.rollback() + message_key = ( + activation_result.get("message_key", "trial_activation_failed") + if activation_result + else "trial_activation_failed" + ) + status = 400 if message_key != "trial_activation_failed_panel_update" else 502 + return _json_error(status, message_key, message_key) + + end_date = activation_result.get("end_date") + config_link, connect_url = await prepare_config_links( + settings, + activation_result.get("subscription_url"), + ) + + i18n_instance = request.app.get("i18n") + if settings.LOG_TRIAL_ACTIVATIONS and i18n_instance: + try: + notification_service = NotificationService(request.app["bot"], settings, i18n_instance) + await notification_service.notify_trial_activation(user_id, end_date) + except Exception: + logger.exception("Failed to send WebApp trial activation notification") + + try: + from db.dal import ad_dal as _ad_dal + + await _ad_dal.mark_trial_activated(session, user_id) + await session.commit() + except Exception: + await session.rollback() + logger.exception("Failed to mark WebApp trial activation for ad attribution") + + return web.json_response( + { + "ok": True, + "activated": True, + "days": activation_result.get("days", settings.TRIAL_DURATION_DAYS), + "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None, + "end_date_text": _format_webapp_datetime(end_date) if isinstance(end_date, datetime) else None, + "traffic_gb": activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB), + "config_link": config_link, + "connect_url": connect_url or config_link, + } + ) + + +async def devices_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + + active = await subscription_service.get_active_subscription_details(session, user_id) + panel_user_uuid = active.get("user_id") if active else None + if not panel_user_uuid: + return _json_error(400, "subscription_not_active", "Subscription is not active") + + panel_service = getattr(subscription_service, "panel_service", None) + if not panel_service: + return _json_error(503, "panel_unavailable", "Panel service unavailable") + + try: + devices_response = await panel_service.get_user_devices(panel_user_uuid) + except Exception: + logger.exception("Failed to load WebApp devices for user %s", user_id) + return _json_error(502, "devices_load_failed", "Failed to load devices") + + devices = _normalize_devices_response(devices_response) + max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None + return web.json_response( + { + "ok": True, + "enabled": True, + "current_devices": len(devices), + "max_devices": max_devices, + "max_devices_label": _format_devices_limit(max_devices), + "devices": [_serialize_device(device, index) for index, device in enumerate(devices, start=1)], + } + ) + + +async def disconnect_device_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + rate_limit_response = await _enforce_webapp_rate_limit( + request, + user_id=user_id, + action="devices_disconnect", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + + payload = await _read_json(request) + disconnect_payload, validation_error = _validate_model_payload(WebAppDeviceDisconnectPayload, payload) + if validation_error: + return validation_error + token = str(disconnect_payload.token or "").strip() + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or db_user.is_banned: + return _json_error(403, "access_denied", "Access denied") + + active = await subscription_service.get_active_subscription_details(session, user_id) + panel_user_uuid = active.get("user_id") if active else None + if not panel_user_uuid: + return _json_error(400, "subscription_not_active", "Subscription is not active") + + panel_service = getattr(subscription_service, "panel_service", None) + if not panel_service: + return _json_error(503, "panel_unavailable", "Panel service unavailable") + + try: + devices_response = await panel_service.get_user_devices(panel_user_uuid) + except Exception: + logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id) + return _json_error(502, "devices_load_failed", "Failed to load devices") + + target_hwid = None + for device in _normalize_devices_response(devices_response): + hwid = str(device.get("hwid") or "").strip() + if hwid and hmac.compare_digest(_device_hwid_token(hwid), token): + target_hwid = hwid + break + + if not target_hwid: + return _json_error(404, "device_not_found", "Device not found") + + success = await panel_service.disconnect_device(panel_user_uuid, target_hwid) + if not success: + return _json_error(502, "device_disconnect_failed", "Failed to disconnect device") + await session.commit() + + return web.json_response({"ok": True}) + + async def payment_status_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) try: @@ -2083,6 +2264,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A user_id, db_user.panel_user_uuid, ) if db_user.panel_user_uuid else None + trial_available = bool( + settings.TRIAL_ENABLED + and settings.TRIAL_DURATION_DAYS > 0 + and not await subscription_service.has_had_any_subscription(session, user_id) + ) try: await session.commit() except Exception: @@ -2108,6 +2294,8 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "webapp_link": webapp_referral_link, "invited_count": referral_stats.get("invited_count", 0), "purchased_count": referral_stats.get("purchased_count", 0), + "welcome_bonus_days": max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)), + "one_bonus_per_referee": bool(getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)), "bonus_details": _serialize_referral_bonus_details(settings, lang), }, "plans": _serialize_plans( @@ -2115,11 +2303,24 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A lang, subscription_options=cached["subscription_options"], stars_subscription_options=cached["stars_subscription_options"], + traffic_packages=cached["traffic_packages"], + stars_traffic_packages=cached["stars_traffic_packages"], ), "payment_methods": _serialize_payment_methods(settings, request.app), "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), + "my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED), + "user_hwid_device_limit": ( + int(settings.USER_HWID_DEVICE_LIMIT) + if settings.USER_HWID_DEVICE_LIMIT is not None + else None + ), + "trial_enabled": bool(settings.TRIAL_ENABLED), + "trial_available": trial_available, + "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), + "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), + "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), "email_auth_enabled": settings.email_auth_configured, }, } @@ -2209,11 +2410,12 @@ def _serialize_subscription( "tariff_name": active.get("tariff_name"), "tariff_description": active.get("tariff_description"), "billing_model": active.get("billing_model"), - "traffic_limit_strategy": active.get("traffic_limit_strategy"), + "traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""), "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")), "topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")), "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None, "is_throttled": bool(active.get("is_throttled")), + "max_devices": _coerce_int_or_none(active.get("max_devices")), "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), "provider": getattr(local_sub, "provider", None), } @@ -2225,33 +2427,92 @@ def _serialize_plans( *, subscription_options: Optional[Dict[int, float]] = None, stars_subscription_options: Optional[Dict[int, int]] = None, + traffic_packages: Optional[Dict[float, float]] = None, + stars_traffic_packages: Optional[Dict[float, int]] = None, ) -> List[Dict[str, Any]]: - if settings.tariffs_config: + tariffs_config = settings.tariffs_config + if tariffs_config: plans: List[Dict[str, Any]] = [] - for tariff in settings.tariffs_config.enabled_tariffs: - item: Dict[str, Any] = { + for tariff in tariffs_config.enabled_tariffs: + common = { "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, - "title": tariff.name(lang), "description": tariff.description(lang), "squad_uuids": tariff.squad_uuids, + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", } if tariff.billing_model == "period": - item["periods"] = [ - { + for months in sorted(tariff.enabled_periods): + price = tariff.period_price(int(months), "rub") + stars_price = tariff.period_price(int(months), "stars") + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + plan = { + **common, + "id": f"{tariff.key}:period:{int(months)}", + "sale_mode": "subscription", "months": int(months), - "price": tariff.period_price(int(months), "rub"), - "stars_price": tariff.period_price(int(months), "stars"), + "price": float(price or 0), + "title": tariff.name(lang), + "subtitle": _format_months_title(int(months), lang), + "monthly_gb": tariff.monthly_gb, } - for months in tariff.enabled_periods - ] - item["monthly_gb"] = tariff.monthly_gb + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) else: - item["traffic_packages"] = [ - {"gb": package.gb, "price": package.price} + rub_packages = { + float(package.gb): float(package.price) for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) - ] - plans.append(item) + } + stars_packages = { + float(package.gb): int(float(package.price)) + for package in (tariff.traffic_packages.stars if tariff.traffic_packages else []) + } + for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): + price = rub_packages.get(traffic_gb) + stars_price = stars_packages.get(traffic_gb) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + traffic_value = float(traffic_gb) + plan = { + **common, + "id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}", + "sale_mode": "traffic_package", + "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, + "traffic_gb": traffic_value, + "price": float(price or 0), + "title": tariff.name(lang), + "subtitle": _format_traffic_title(traffic_value, lang), + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + if getattr(settings, "traffic_sale_mode", False): + active_traffic_packages = traffic_packages or settings.traffic_packages + active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages + traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages)) + plans: List[Dict[str, Any]] = [] + for traffic_gb in traffic_units: + price = active_traffic_packages.get(traffic_gb) + stars_price = active_stars_traffic_packages.get(traffic_gb) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + traffic_value = float(traffic_gb) + plan = { + "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, + "traffic_gb": traffic_value, + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": _format_traffic_title(traffic_value, lang), + "sale_mode": "traffic", + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) return plans active_subscription_options = subscription_options or settings.subscription_options @@ -2263,6 +2524,7 @@ def _serialize_plans( "price": float(price), "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", "title": _format_months_title(int(months), lang), + "sale_mode": "subscription", } stars_price = active_stars_subscription_options.get(months) if stars_price is not None and int(stars_price) > 0: @@ -2309,35 +2571,57 @@ def _service_configured(app: web.Application, key: str) -> bool: return bool(service and getattr(service, "configured", False)) +def _sale_mode_base(sale_mode: str) -> str: + return str(sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0] + + +def _sale_mode_tariff_key(sale_mode: str) -> Optional[str]: + if "@" not in str(sale_mode or ""): + return None + return str(sale_mode).split("@", 1)[1].split("|", 1)[0] or None + + +def _sale_mode_is_traffic(sale_mode: str) -> bool: + return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup"} + + async def _create_subscription_payment( *, request: web.Request, session: AsyncSession, user_id: int, method: str, - months: int, + months: Any, price: float, stars_price: Optional[int], lang: str, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] - description = _payment_description(months, lang) + sale_mode = str(sale_mode or "subscription") + traffic_sale = _sale_mode_is_traffic(sale_mode) + description = ( + _traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang) + if traffic_sale + else _payment_description(int(months), lang) + ) if method == "yookassa": return await _create_yookassa_payment( - request, session, user_id, months, price, description + request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "freekassa": return await _create_freekassa_payment( - request, session, user_id, months, price, description + request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method in ("platega", "platega_sbp", "platega_crypto"): return await _create_platega_payment( - request, session, user_id, months, price, description, variant=method + request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "severpay": return await _create_severpay_payment( - request, session, user_id, months, price, description + request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb ) if method == "cryptopay": service: CryptoPayService = request.app["cryptopay_service"] @@ -2349,7 +2633,7 @@ async def _create_subscription_payment( months=months, amount=price, description=description, - sale_mode="subscription", + sale_mode=sale_mode, url_kind="web", ) if not url: @@ -2361,7 +2645,7 @@ async def _create_subscription_payment( if not settings.STARS_ENABLED or stars_price is None: return _json_error(400, "payment_unavailable", "Payment method unavailable") return await _create_stars_payment( - request, session, user_id, months, int(stars_price), description + request, session, user_id, months, int(stars_price), description, sale_mode=sale_mode, traffic_gb=traffic_gb ) return _json_error(400, "payment_unavailable", "Payment method unavailable") @@ -2377,6 +2661,9 @@ async def _create_base_payment_record( description: str, months: int, provider: str, + sale_mode: Optional[str] = None, + tariff_key: Optional[str] = None, + purchased_gb: Optional[float] = None, ) -> Payment: payment = await payment_dal.create_payment_record( session, @@ -2388,6 +2675,9 @@ async def _create_base_payment_record( "description": description, "subscription_duration_months": months, "provider": provider, + "sale_mode": sale_mode, + "tariff_key": tariff_key, + "purchased_gb": purchased_gb, }, ) await session.commit() @@ -2398,9 +2688,12 @@ async def _create_yookassa_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] service: YooKassaService = request.app["yookassa_service"] @@ -2408,6 +2701,7 @@ async def _create_yookassa_payment( return _json_error(400, "payment_unavailable", "Payment method unavailable") try: + traffic_sale = _sale_mode_is_traffic(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -2415,20 +2709,28 @@ async def _create_yookassa_payment( currency="RUB", status="pending_yookassa", description=description, - months=months, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), provider="yookassa", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, ) + metadata = { + "user_id": str(user_id), + "subscription_months": str(int(float(months)) if not traffic_sale else 0), + "payment_db_id": str(payment.payment_id), + "sale_mode": sale_mode, + "source": "webapp", + } + if traffic_sale: + metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months) + if _sale_mode_tariff_key(sale_mode): + metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode) response = await service.create_payment( amount=price, currency="RUB", description=description, - metadata={ - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(payment.payment_id), - "sale_mode": "subscription", - "source": "webapp", - }, + metadata=metadata, receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, save_payment_method=bool( settings.yookassa_autopayments_active @@ -2468,9 +2770,12 @@ async def _create_freekassa_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] service: FreeKassaService = request.app["freekassa_service"] @@ -2478,6 +2783,7 @@ async def _create_freekassa_payment( return _json_error(400, "payment_unavailable", "Payment method unavailable") try: + traffic_sale = _sale_mode_is_traffic(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -2485,8 +2791,11 @@ async def _create_freekassa_payment( currency=service.default_currency, status="pending_freekassa", description=description, - months=months, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), provider="freekassa", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, ) success, response_data = await service.create_order( payment_db_id=payment.payment_id, @@ -2529,10 +2838,12 @@ async def _create_platega_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, variant: str = "platega_sbp", + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] service: PlategaService = request.app["platega_service"] @@ -2548,6 +2859,7 @@ async def _create_platega_payment( platega_method_id = settings.platega_sbp_method_resolved try: + traffic_sale = _sale_mode_is_traffic(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -2555,15 +2867,20 @@ async def _create_platega_payment( currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", status="pending_platega", description=description, - months=months, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), provider="platega", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, ) + months_for_provider = int(float(months)) if not traffic_sale else int(float(traffic_gb or months)) payload = json.dumps( { "payment_db_id": payment.payment_id, "user_id": user_id, - "months": months, - "sale_mode": "subscription", + "months": months_for_provider if not traffic_sale else 0, + "sale_mode": sale_mode, + "traffic_gb": _format_number_for_payload(traffic_gb or months) if traffic_sale else None, "source": "webapp", "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", } @@ -2571,7 +2888,7 @@ async def _create_platega_payment( success, response_data = await service.create_transaction( payment_db_id=payment.payment_id, user_id=user_id, - months=months, + months=months_for_provider, amount=price, currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", description=description, @@ -2616,9 +2933,12 @@ async def _create_severpay_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, price: float, description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: settings: Settings = request.app["settings"] service: SeverPayService = request.app["severpay_service"] @@ -2626,6 +2946,7 @@ async def _create_severpay_payment( return _json_error(400, "payment_unavailable", "Payment method unavailable") try: + traffic_sale = _sale_mode_is_traffic(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -2633,8 +2954,11 @@ async def _create_severpay_payment( currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", status="pending_severpay", description=description, - months=months, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), provider="severpay", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, ) success, response_data = await service.create_payment( payment_db_id=payment.payment_id, @@ -2679,12 +3003,15 @@ async def _create_stars_payment( request: web.Request, session: AsyncSession, user_id: int, - months: int, + months: Any, stars_price: int, description: str, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> web.Response: bot: Bot = request.app["bot"] try: + traffic_sale = _sale_mode_is_traffic(sale_mode) payment = await _create_base_payment_record( session, user_id=user_id, @@ -2692,10 +3019,14 @@ async def _create_stars_payment( currency="XTR", status="pending_stars", description=description, - months=months, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), provider="telegram_stars", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, ) - payload = f"{payment.payment_id}:{months}:subscription" + payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months + payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}" prices = [LabeledPrice(label=description, amount=stars_price)] create_invoice_link = getattr(bot, "create_invoice_link", None) if callable(create_invoice_link): @@ -2790,6 +3121,67 @@ def _format_bytes(value: Optional[Any]) -> str: return f"{size:.2f} {units[index]}" +def _device_hwid_token(hwid: str) -> str: + return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32] + + +def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str: + value = str(hwid or "").strip() + if len(value) <= max_length: + return value + return f"{value[:8]}...{value[-6:]}" + + +def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]: + if isinstance(devices_response, dict): + devices = devices_response.get("devices") or [] + else: + devices = devices_response or [] + if not isinstance(devices, list): + return [] + return [device for device in devices if isinstance(device, dict)] + + +def _format_devices_limit(max_devices: Optional[int]) -> str: + if max_devices in (None, 0): + return "Unlimited" + return str(max_devices) + + +def _format_device_datetime(value: Any) -> str: + if not value: + return "" + text = str(value) + try: + normalized = datetime.fromisoformat(text.replace("Z", "+00:00")) + return normalized.strftime("%d.%m.%Y %H:%M") + except Exception: + return text + + +def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]: + hwid = str(device.get("hwid") or "").strip() + model = str(device.get("deviceModel") or "").strip() + platform = str(device.get("platform") or "").strip() + os_version = str(device.get("osVersion") or "").strip() + user_agent = str(device.get("userAgent") or "").strip() + display_name = model or platform or f"Device {index}" + platform_label = " ".join(part for part in (platform, os_version) if part).strip() + return { + "index": index, + "display_name": display_name, + "platform": platform, + "os_version": os_version, + "platform_label": platform_label, + "user_agent": user_agent, + "created_at": device.get("createdAt"), + "created_at_text": _format_device_datetime(device.get("createdAt")), + "hwid_short": _shorten_hwid_for_display(hwid), + "token": _device_hwid_token(hwid) if hwid else "", + "can_disconnect": bool(hwid), + } + + def _format_months_title(months: int, lang: str) -> str: if lang == "en": if months == 1: @@ -2802,6 +3194,31 @@ def _format_months_title(months: int, lang: str) -> str: return f"{months} месяцев" +def _format_number_for_payload(value: Any) -> str: + numeric = float(value or 0) + return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}" + + +def _format_traffic_title(traffic_gb: float, lang: str) -> str: + return f"{_format_number_for_payload(traffic_gb)} GB" + + +def _traffic_payment_description(traffic_gb: float, lang: str) -> str: + if lang == "en": + return f"Traffic package {_format_traffic_title(traffic_gb, lang)}" + return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}" + + +def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]: + for key in options: + try: + if abs(float(key) - float(target)) < 0.000001: + return key + except (TypeError, ValueError): + continue + return None + + def _payment_description(months: int, lang: str) -> str: if lang == "en": return f"Subscription for {_format_months_title(months, lang)}" diff --git a/bot/app/web/templates/subscription_webapp.css b/bot/app/web/templates/subscription_webapp.css deleted file mode 100644 index 656217f..0000000 --- a/bot/app/web/templates/subscription_webapp.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial;--tw-duration:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-bold:700;--font-weight-extrabold:800;--tracking-wide:.025em;--leading-tight:1.25;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body{margin:calc(var(--spacing) * 0);--tw-tracking:0;letter-spacing:0;width:100%;min-height:100%;overflow-x:hidden}body{background-color:var(--bg-primary);font-family:var(--font-sans);color:var(--text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-tap-highlight-color:transparent;overflow-x:hidden}body.modal-open{overflow:hidden}button,a{font:inherit}button{cursor:pointer;border-style:var(--tw-border-style);border-width:0}button:disabled{cursor:progress;opacity:.58}}@layer components{.btn-sm{min-height:38px;min-width:calc(var(--spacing) * 0);cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);padding-inline:11px;padding-block:calc(var(--spacing) * 2);text-align:center;--tw-leading:1;--tw-font-weight:var(--font-weight-extrabold);font-size:13px;line-height:1;font-weight:var(--font-weight-extrabold);color:var(--text-secondary);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);background-color:#ffffff08;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-flex}@media (hover:hover){.btn-sm:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.btn-sm:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.btn-sm:hover{background-color:var(--bg-card-hover)}}.metrics{gap:calc(var(--spacing) * 0);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background-color:#ffffff05;display:grid;overflow:hidden}.metric{min-height:calc(var(--spacing) * 12);align-items:center;gap:calc(var(--spacing) * 2.5);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);background-color:#ffffff05;grid-template-columns:minmax(0,.88fr) minmax(0,1.12fr);display:grid}.auth-tab{min-height:calc(var(--spacing) * 9);border-radius:var(--radius-sm);--tw-font-weight:850;color:var(--text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;font-weight:850}.login-layout{justify-content:center;align-items:center;gap:calc(var(--spacing) * 4);width:min(100vw,560px);min-height:100vh;padding-inline:14px;padding-top:calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));padding-bottom:max(var(--app-safe-bottom), 18px);flex-direction:column;margin-inline:auto;display:none}.section-label{font-family:var(--font-mono);--tw-font-weight:var(--font-weight-extrabold);font-size:11px;font-weight:var(--font-weight-extrabold);color:var(--text-muted)}.app-loader{min-height:78vh;font-family:var(--font-mono);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--text-muted);place-items:center;display:grid}.auth-tabs-wrapper{gap:calc(var(--spacing) * 1);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);min-height:46px;padding:calc(var(--spacing) * 1);background-color:#05080d61;grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.panel-head{justify-content:space-between;align-items:flex-start;gap:calc(var(--spacing) * 3);display:flex}.section-title{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-leading:var(--leading-tight);line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--text-primary)}.section-caption{color:var(--text-secondary);margin-top:3px;font-size:13px}.metric-label{min-width:calc(var(--spacing) * 0);font-family:var(--font-mono);--tw-font-weight:var(--font-weight-bold);font-size:11px;font-weight:var(--font-weight-bold);color:var(--text-muted)}.metric-value{min-width:calc(var(--spacing) * 0);text-align:right;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--text-primary)}.referral-link-row{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);min-height:58px;padding-inline:calc(var(--spacing) * 3);background-color:#ffffff05;grid-template-columns:minmax(0,1fr) 48px;padding-block:11px;display:grid}.referral-link-value{margin-top:calc(var(--spacing) * 1);font-family:var(--font-mono);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:1.35;--tw-font-weight:var(--font-weight-bold);line-height:1.35;font-weight:var(--font-weight-bold);color:var(--text-primary)}.bonus-row{align-items:center;gap:calc(var(--spacing) * 2.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);min-height:58px;padding-inline:calc(var(--spacing) * 3);background-color:#ffffff05;grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);padding-block:11px;display:grid}.empty-state{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1.45;color:var(--text-secondary);background-color:#ffffff05;padding:13px;line-height:1.45}.plan-card{width:100%;min-height:44px;min-width:calc(var(--spacing) * 0);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);text-align:center;--tw-leading:var(--leading-tight);font-size:14px;line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--text-primary);transition-property:transform,border-color,background,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#ffffff05;justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.plan-card:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.plan-card:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.plan-card:hover{background-color:var(--bg-card-hover)}}.plan-card{transition-duration:.2s}.plan-card-active{border-color:var(--accent);background-color:color-mix(in srgb,var(--accent) 8%,transparent);color:var(--accent);--tw-shadow:0 12px 30px var(--tw-shadow-color,#0003);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:color-mix(in srgb,var(--accent) 58%,transparent)}.payment-method-card{width:100%;min-height:52px;min-width:calc(var(--spacing) * 0);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);padding-inline:calc(var(--spacing) * 4);text-align:center;--tw-leading:var(--leading-tight);font-size:15px;line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--text-primary);transition-property:transform,border-color,background,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#ffffff08;justify-content:center;align-items:center;padding-block:12px;display:inline-flex}@media (hover:hover){.payment-method-card:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.payment-method-card:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.payment-method-card:hover{background-color:var(--bg-card-hover)}}.payment-method-card{transition-duration:.2s}.payment-method-card--platega{background:#00fe7a14;border-color:#00fe7a5c}.payment-method-card--cryptopay{background:#22d3ee14;border-color:#22d3ee5c}.plan-name{--tw-leading:var(--leading-tight);font-size:15px;line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);display:block}.plan-meta{margin-top:calc(var(--spacing) * 1);font-family:var(--font-mono);--tw-leading:1.3;--tw-font-weight:var(--font-weight-bold);font-size:11px;line-height:1.3;font-weight:var(--font-weight-bold);color:var(--text-muted);display:block}.plan-price{text-align:right;max-width:48%;font-family:var(--font-mono);--tw-leading:var(--leading-tight);font-size:15px;line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--accent);flex:none;display:block}.notice{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1.45;color:var(--text-secondary);background-color:#ffffff05;padding:13px;line-height:1.45}.step-num{font-family:var(--font-mono);--tw-leading:1.1;--tw-font-weight:var(--font-weight-extrabold);font-size:11px;line-height:1.1;font-weight:var(--font-weight-extrabold);color:currentColor;display:block}.step-name{margin-top:calc(var(--spacing) * 1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:1.15;--tw-font-weight:var(--font-weight-extrabold);line-height:1.15;font-weight:var(--font-weight-extrabold);color:currentColor;display:block}.main-value{margin:calc(var(--spacing) * 0);--tw-leading:1.08;--tw-font-weight:var(--font-weight-extrabold);font-size:32px;line-height:1.08;font-weight:var(--font-weight-extrabold);color:var(--text-primary)}.main-caption{margin-top:calc(var(--spacing) * 1.5);margin-bottom:calc(var(--spacing) * 0);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1.45;color:var(--text-secondary);line-height:1.45}.status-text{margin:calc(var(--spacing) * 0);min-height:calc(var(--spacing) * 5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1.45;color:var(--text-secondary);line-height:1.45}.legal-links{justify-content:center;column-gap:calc(var(--spacing) * 4);row-gap:calc(var(--spacing) * 1.5);text-align:center;flex-wrap:wrap;display:flex}.app-header{min-height:calc(var(--spacing) * 12);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);display:flex}.app-header-title{min-width:calc(var(--spacing) * 0);align-items:center;gap:calc(var(--spacing) * 2.5);display:flex}.app-header-actions{min-width:calc(var(--spacing) * 0);align-items:center;gap:calc(var(--spacing) * 2);display:flex}.panel-head-sm{min-width:calc(var(--spacing) * 0);justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 2.5);display:flex}.user-menu,.lang-menu{display:inline-flex;position:relative}.lang-chip{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);min-height:38px;padding-inline:calc(var(--spacing) * 2);text-align:left;--tw-font-weight:var(--font-weight-extrabold);font-size:12px;font-weight:var(--font-weight-extrabold);color:var(--text-primary);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);background-color:#ffffff08;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-flex}@media (hover:hover){.lang-chip:hover{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.lang-chip:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.lang-chip:hover{background-color:var(--bg-card-hover)}}.lang-chip[aria-expanded=true]{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.lang-chip[aria-expanded=true]{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}}.lang-chip[aria-expanded=true]{background-color:var(--bg-card-hover)}.lang-chip-flag{--tw-leading:1;font-family:Twemoji Country Flags,Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif;font-size:14px;line-height:1}.lang-chip-label{font-family:var(--font-mono);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);font-size:11px}.lang-chip-caret{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);fill:var(--text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:none}.lang-chip[aria-expanded=true] .lang-chip-caret{transform:rotate(180deg)}.lang-dropdown{pointer-events:none;top:calc(100% + 8px);right:calc(var(--spacing) * 0);z-index:30;transform-origin:100% 0;--tw-translate-y:calc(var(--spacing) * -2);width:160px;translate:var(--tw-translate-x) var(--tw-translate-y);gap:calc(var(--spacing) * 0);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background-color:var(--bg-card);opacity:0;--tw-shadow:0 24px 48px var(--tw-shadow-color,#00000073);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.2,.8,.2,1);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);will-change:opacity, transform;transition-duration:.2s;transition-timing-function:cubic-bezier(.2,.8,.2,1);display:grid;position:absolute;scale:.96}.lang-dropdown.show{pointer-events:auto;--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.lang-dropdown-item{cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2.5);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);text-align:left;--tw-font-weight:var(--font-weight-bold);font-size:13px;font-weight:var(--font-weight-bold);color:var(--text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-width:0;display:flex}@media (hover:hover){.lang-dropdown-item:hover{background-color:var(--bg-card-hover);color:var(--text-primary)}}.lang-dropdown-item+.lang-dropdown-item{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--border)}.lang-dropdown-item.is-active{background-color:var(--bg-card-hover);color:var(--text-primary)}.lang-dropdown-flag{--tw-leading:1;font-family:Twemoji Country Flags,Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif;font-size:16px;line-height:1}.lang-dropdown-label{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.user-chip{max-width:200px;min-height:38px;min-width:calc(var(--spacing) * 0);cursor:pointer;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);padding-block:calc(var(--spacing) * 1);padding-right:calc(var(--spacing) * 2.5);padding-left:calc(var(--spacing) * 1);text-align:left;--tw-font-weight:var(--font-weight-extrabold);font-size:13px;font-weight:var(--font-weight-extrabold);color:var(--text-primary);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);background-color:#ffffff08;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-flex}@media (hover:hover){.user-chip:hover{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.user-chip:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.user-chip:hover{background-color:var(--bg-card-hover)}}.user-chip[aria-expanded=true]{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.user-chip[aria-expanded=true]{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}}.user-chip[aria-expanded=true]{background-color:var(--bg-card-hover)}.user-chip-avatar{border-radius:var(--radius-sm);object-fit:cover;background:var(--accent);flex:none;width:28px;height:28px}@supports (color:color-mix(in lab, red, red)){.user-chip-avatar{background:color-mix(in srgb, var(--accent) 10%, var(--bg-card))}}.user-chip-avatar{image-rendering:pixelated}.user-chip-name{min-width:calc(var(--spacing) * 0);text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.user-chip-caret{height:calc(var(--spacing) * 3);width:calc(var(--spacing) * 3);fill:var(--text-secondary);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex:none}.user-chip[aria-expanded=true] .user-chip-caret{transform:rotate(180deg)}.user-dropdown{pointer-events:none;top:calc(100% + 8px);right:calc(var(--spacing) * 0);z-index:30;transform-origin:100% 0;--tw-translate-y:calc(var(--spacing) * -2);width:min(100vw - 28px,280px);translate:var(--tw-translate-x) var(--tw-translate-y);gap:calc(var(--spacing) * 0);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background-color:var(--bg-card);opacity:0;--tw-shadow:0 24px 48px var(--tw-shadow-color,#00000073);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.2,.8,.2,1);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);will-change:opacity, transform;transition-duration:.2s;transition-timing-function:cubic-bezier(.2,.8,.2,1);display:grid;position:absolute;scale:.96}.user-dropdown.show{pointer-events:auto;--tw-translate-y:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y);--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.user-dropdown-head{align-items:center;gap:calc(var(--spacing) * 2.5);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--border);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);display:flex}.user-dropdown-avatar{border-radius:var(--radius-md);object-fit:cover;background:var(--accent);flex:none;width:40px;height:40px}@supports (color:color-mix(in lab, red, red)){.user-dropdown-avatar{background:color-mix(in srgb, var(--accent) 10%, var(--bg-card))}}.user-dropdown-avatar{image-rendering:pixelated}.user-dropdown-head-text{min-width:calc(var(--spacing) * 0);gap:calc(var(--spacing) * .5);display:grid}.user-dropdown-name{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);text-overflow:ellipsis;white-space:nowrap;color:var(--text-primary);overflow:hidden}.user-dropdown-sub{font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:11px;overflow:hidden}.user-dropdown-rows{display:grid}.user-dropdown-row{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);display:flex}.user-dropdown-row+.user-dropdown-row{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--border)}.user-dropdown-row-label{font-family:var(--font-mono);--tw-font-weight:var(--font-weight-bold);font-size:11px;font-weight:var(--font-weight-bold);color:var(--text-muted)}.user-dropdown-row-status{text-align:right;--tw-font-weight:var(--font-weight-bold);font-size:13px;font-weight:var(--font-weight-bold);text-overflow:ellipsis;white-space:nowrap;color:var(--text-primary);overflow:hidden}.user-dropdown-row-status.linked{color:#6ee7b7}.user-dropdown-row-status.unlinked{color:var(--text-muted)}.user-dropdown-logout{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-style:var(--tw-border-style);border-width:0;border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--border);width:100%;min-height:42px;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);text-align:center;--tw-font-weight:var(--font-weight-extrabold);font-size:13px;font-weight:var(--font-weight-extrabold);color:var(--text-secondary);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background-color:#0000;border-radius:0;display:flex}@media (hover:hover){.user-dropdown-logout:hover{background-color:var(--bg-card-hover);color:var(--text-primary)}}.user-dropdown-logout svg{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);fill:currentColor}.sub-status{align-items:center;gap:calc(var(--spacing) * 2.5);min-height:28px;color:var(--danger);display:flex}.sub-status.active{color:var(--success)}.sub-status-dot{background:currentColor;border-radius:3.40282e38px;flex:none;width:10px;height:10px;display:inline-block;position:relative;box-shadow:0 0 12px}.sub-status-dot:after{content:"";opacity:.55;background:currentColor;border-radius:9999px;animation:1.8s cubic-bezier(.4,0,.6,1) infinite sub-status-pulse;position:absolute;inset:0}.sub-status-text{--tw-leading:var(--leading-tight);font-size:15px;line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);letter-spacing:.01em}@keyframes brand-logo-spin{to{transform:rotate(360deg)}}.sub-countdown{gap:calc(var(--spacing) * 1);display:grid}.sub-countdown-row{align-items:baseline;column-gap:calc(var(--spacing) * 2);row-gap:calc(var(--spacing) * .5);flex-wrap:wrap;display:flex}.sub-countdown-value{--tw-leading:1.1;--tw-font-weight:var(--font-weight-extrabold);font-size:22px;line-height:1.1;font-weight:var(--font-weight-extrabold);color:var(--text-primary)}.sub-countdown-caption{font-family:var(--font-mono);--tw-leading:1.3;color:var(--text-muted);font-size:12px;line-height:1.3}.traffic-bar{border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background:#ffffff05;border-radius:3.40282e38px;justify-content:center;align-items:center;width:100%;min-height:24px;display:flex;position:relative;overflow:hidden}.traffic-bar-fill{inset-block:calc(var(--spacing) * 0);left:calc(var(--spacing) * 0);background:var(--accent);width:0%;position:absolute}@supports (color:color-mix(in lab, red, red)){.traffic-bar-fill{background:color-mix(in srgb, var(--accent) 28%, transparent)}}.traffic-bar-fill{transition:width .3s cubic-bezier(.4,0,.2,1),background .2s}.traffic-bar.over .traffic-bar-fill{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.traffic-bar.over .traffic-bar-fill{background:color-mix(in srgb, var(--danger) 32%, transparent)}}.traffic-bar.unlimited .traffic-bar-fill{background:var(--success);width:100%}@supports (color:color-mix(in lab, red, red)){.traffic-bar.unlimited .traffic-bar-fill{background:color-mix(in srgb, var(--success) 12%, transparent)}}.traffic-bar.inactive{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.traffic-bar.inactive{border-color:color-mix(in srgb, var(--danger) 60%, transparent)}}.traffic-bar.inactive .traffic-bar-fill{background:var(--danger);width:100%}@supports (color:color-mix(in lab, red, red)){.traffic-bar.inactive .traffic-bar-fill{background:color-mix(in srgb, var(--danger) 55%, transparent)}}.traffic-bar-value{z-index:1;min-width:calc(var(--spacing) * 0);padding-inline:calc(var(--spacing) * 3);text-align:center;font-family:var(--font-mono);--tw-font-weight:var(--font-weight-bold);font-size:12px;font-weight:var(--font-weight-bold);white-space:nowrap;color:#fff;font-variant-numeric:tabular-nums;position:relative}.traffic-bar.unlimited .traffic-bar-value{animation:1.8s ease-in-out infinite traffic-infinity-pulse}@keyframes traffic-infinity-pulse{0%,to{opacity:1;text-shadow:0 0 0 color-mix(in srgb, var(--success) 0%, transparent)}50%{opacity:.7;text-shadow:0 0 10px color-mix(in srgb, var(--success) 70%, transparent)}}.link-cta-head{gap:calc(var(--spacing) * 1);display:grid}.link-cta-title{--tw-leading:var(--leading-tight);font-size:15px;line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--text-primary)}.link-cta-caption{--tw-leading:1.4;color:var(--text-secondary);font-size:13px;line-height:1.4}@keyframes sub-status-pulse{0%{opacity:.55;transform:scale(1)}70%{opacity:0;transform:scale(2.2)}to{opacity:0;transform:scale(2.2)}}.login-head{min-width:calc(var(--spacing) * 0);justify-content:center;align-items:center;gap:calc(var(--spacing) * 3.5);display:flex}.btn{min-height:46px;min-width:calc(var(--spacing) * 0);cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);text-align:center;--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--text-primary);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);background-color:#ffffff08;padding-block:11px;padding-inline:13px;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-flex}@media (hover:hover){.btn:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.btn:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.btn:hover{background-color:var(--bg-card-hover)}}.btn{overflow-wrap:anywhere}.btn-primary{min-height:46px;min-width:calc(var(--spacing) * 0);cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--accent);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn-primary{border-color:color-mix(in srgb,var(--accent) 80%,var(--border))}}.btn-primary{background-color:var(--accent);text-align:center;--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:#04110a;--tw-shadow:0 10px 26px var(--tw-shadow-color,var(--accent));padding-block:11px;padding-inline:13px;text-decoration-line:none}@supports (color:color-mix(in lab, red, red)){.btn-primary{--tw-shadow:0 10px 26px var(--tw-shadow-color,color-mix(in srgb,var(--accent) 18%,transparent))}}.btn-primary{box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}@media (hover:hover){.btn-primary:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);background-color:var(--accent-hover);--tw-shadow:0 14px 34px var(--tw-shadow-color,var(--accent))}@supports (color:color-mix(in lab, red, red)){.btn-primary:hover{--tw-shadow:0 14px 34px var(--tw-shadow-color,color-mix(in srgb,var(--accent) 24%,transparent))}}.btn-primary:hover{box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary{overflow-wrap:anywhere}.btn-ghost{width:100%;min-height:46px;min-width:calc(var(--spacing) * 0);cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--accent);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn-ghost{border-color:color-mix(in srgb,var(--accent) 35%,var(--border))}}.btn-ghost{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.btn-ghost{background-color:color-mix(in srgb,var(--accent) 5%,transparent)}}.btn-ghost{text-align:center;--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);color:var(--accent);--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);padding-block:11px;padding-inline:13px;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}@media (hover:hover){.btn-ghost:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.btn-ghost:hover{background-color:color-mix(in srgb,var(--accent) 10%,transparent)}}.btn-ghost:hover{--tw-shadow:0 10px 24px var(--tw-shadow-color,var(--accent))}@supports (color:color-mix(in lab, red, red)){.btn-ghost:hover{--tw-shadow:0 10px 24px var(--tw-shadow-color,color-mix(in srgb,var(--accent) 10%,transparent))}}.btn-ghost:hover{box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-ghost{overflow-wrap:anywhere}.icon-btn{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);width:38px;height:38px;color:var(--text-secondary);transition-property:transform,border-color,background,color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);background-color:#ffffff08;flex:none;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-flex}@media (hover:hover){.icon-btn:hover{--tw-translate-y:calc(var(--spacing) * -.5);translate:var(--tw-translate-x) var(--tw-translate-y);border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.icon-btn:hover{border-color:color-mix(in srgb,var(--accent) 42%,var(--border))}}.icon-btn:hover{background-color:var(--bg-card-hover)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);width:100%;min-height:46px;padding-inline:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold);color:var(--text-primary);transition-property:border-color,background,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:cubic-bezier(.4,0,.2,1);--tw-outline-style:none;background-color:#05080d6b;outline-style:none;padding-block:11px;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.input::placeholder{color:var(--text-muted)}.input:focus{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.input:focus{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}}.input:focus{--tw-shadow:0 0 0 3px var(--tw-shadow-color,var(--accent)), 0 12px 28px var(--tw-shadow-color,#0003);background-color:#070c13ad}@supports (color:color-mix(in lab, red, red)){.input:focus{--tw-shadow:0 0 0 3px var(--tw-shadow-color,color-mix(in srgb,var(--accent) 12%,transparent)), 0 12px 28px var(--tw-shadow-color,#0003)}}.input:focus{box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.input[aria-invalid=true]{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.input[aria-invalid=true]{border-color:color-mix(in srgb,var(--danger) 72%,var(--border))}}.input[aria-invalid=true]:focus{--tw-shadow:0 0 0 3px var(--tw-shadow-color,var(--danger))}@supports (color:color-mix(in lab, red, red)){.input[aria-invalid=true]:focus{--tw-shadow:0 0 0 3px var(--tw-shadow-color,color-mix(in srgb,var(--danger) 14%,transparent))}}.input[aria-invalid=true]:focus{box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.input-code{text-align:center;font-family:var(--font-mono);--tw-tracking:0;letter-spacing:0}.panel{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background-color:var(--bg-card);--tw-shadow:0 20px 40px var(--tw-shadow-color,#0000002e);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);position:relative;overflow:hidden}.panel-modal{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background-color:var(--bg-card);--tw-shadow:0 30px 60px var(--tw-shadow-color,#00000047);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);position:relative;overflow:hidden}.step-box{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);min-height:58px;color:var(--text-muted);--tw-shadow:inset 0 1px 0 var(--tw-shadow-color,#ffffff0b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);background-color:#ffffff05;padding:9px}.modal{pointer-events:none;inset:calc(var(--spacing) * 0);z-index:20;opacity:0;transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));padding:calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);place-items:center;display:grid;position:fixed}.modal.show{pointer-events:auto;opacity:1}.modal-backdrop,.tools-modal-backdrop{inset:calc(var(--spacing) * 0);border-style:var(--tw-border-style);padding:calc(var(--spacing) * 0);-webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px);background:#03060ab8;border-width:0;position:absolute}.tools-modal-backdrop{z-index:25;position:fixed}.modal-card{width:min(100%,560px);max-height:min(86vh,760px);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.22s;--tw-ease:cubic-bezier(.2,.8,.2,1);transition-duration:.22s;transition-timing-function:cubic-bezier(.2,.8,.2,1);position:relative;overflow:auto;transform:translateY(18px)scale(.98)}.modal.show .modal-card{transform:translateY(0)scale(1)}.app{gap:calc(var(--spacing) * 4);width:min(100vw,560px);max-width:100vw;padding:calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 22px);flex-direction:column;margin-inline:auto;display:flex}@media (min-width:561px){.app{justify-content:center;min-height:100vh}}.telegram-login-widget{min-width:calc(var(--spacing) * 0);align-items:center;display:flex}.brand-logo-shell{flex:none;place-items:center;width:38px;height:38px;display:grid;position:relative;overflow:hidden}.brand-logo-shell--lg{width:96px;height:96px}.brand-logo{inset:calc(var(--spacing) * 0);object-fit:contain;width:100%;height:100%;position:absolute}.brand-logo-spinner{height:calc(var(--spacing) * 5);width:calc(var(--spacing) * 5);border-style:var(--tw-border-style);border-width:2px;border-color:var(--accent);border-radius:3.40282e38px;flex:none}@supports (color:color-mix(in lab, red, red)){.brand-logo-spinner{border-color:color-mix(in srgb, var(--accent) 18%, var(--border))}}.brand-logo-spinner{border-top-color:var(--accent);animation:.85s linear infinite brand-logo-spin}.brand-logo--lg{width:84px;height:84px}.plans,.methods,.metrics,.referral-link-list,.bonus-list{min-width:calc(var(--spacing) * 0);display:grid}.actions,.otp-slots,.metric{min-width:calc(var(--spacing) * 0);gap:calc(var(--spacing) * 2);display:grid}.actions{grid-template-columns:minmax(0,1fr) 48px}.metric{grid-template-columns:minmax(0,.88fr) minmax(0,1.12fr)}.metrics{gap:calc(var(--spacing) * 0);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background:#ffffff05;overflow:hidden}.metric,.notice,.otp-slots span{border-style:var(--tw-border-style);border-width:1px;border-color:var(--border);background:#ffffff05}.metric{align-items:center;gap:calc(var(--spacing) * 2.5);min-height:46px;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2.5);min-height:calc(var(--spacing) * 12)}.metric+.metric{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--border)}.brand-title,.login-brand-title{max-width:260px;font-family:var(--font-mono);--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold);--tw-tracking:0;letter-spacing:0;color:var(--accent)}.brand-title{--tw-leading:1.15;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 0 20px var(--accent);font-size:17px;line-height:1.15;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.brand-title{text-shadow:0 0 20px color-mix(in srgb, var(--accent) 18%, transparent)}}.login-brand-title{text-align:left;font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-leading:1.1;overflow-wrap:anywhere;line-height:1.1}.login-auth-body{align-content:flex-start;gap:calc(var(--spacing) * 2.5);width:100%;min-height:140px;display:grid}.login-auth-body--telegram{align-content:center}.login-text,.promo-status{color:var(--text-secondary)}.section-label{min-width:calc(var(--spacing) * 0);font-family:var(--font-mono);--tw-font-weight:var(--font-weight-extrabold);font-size:11px;font-weight:var(--font-weight-bold);color:var(--text-muted);overflow-wrap:anywhere;font-weight:var(--font-weight-extrabold)}.mono-pill,.badge{min-height:calc(var(--spacing) * 7);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 2.5);font-family:var(--font-mono);--tw-font-weight:var(--font-weight-bold);font-size:11px;font-weight:var(--font-weight-bold);white-space:nowrap;align-items:center;display:inline-flex;box-shadow:inset 0 1px #ffffff0f}.mono-pill{color:var(--accent);border:1px solid var(--accent)}@supports (color:color-mix(in lab, red, red)){.mono-pill{border:1px solid color-mix(in srgb, var(--accent) 45%, var(--border))}}.mono-pill{background:var(--accent)}@supports (color:color-mix(in lab, red, red)){.mono-pill{background:color-mix(in srgb, var(--accent) 9%, transparent)}}.badge{min-width:calc(var(--spacing) * 0);text-overflow:ellipsis;color:#6ee7b7;border:1px solid var(--success);overflow:hidden}@supports (color:color-mix(in lab, red, red)){.badge{border:1px solid color-mix(in srgb, var(--success) 34%, var(--border))}}.badge{background:#10b9811f}.badge.off,.promo-status.error,.login-status.error{color:#fca5a5}.badge.off{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.badge.off{border-color:color-mix(in srgb, var(--danger) 34%, var(--border))}}.badge.off{background:#ef44441f}.promo-status.success{color:#6ee7b7}#legal-links-login{width:min(100vw,560px)}.legal-link,.code-modal-resend{border-style:var(--tw-border-style);padding:calc(var(--spacing) * 0);--tw-font-weight:var(--font-weight-bold);font-size:13px;font-weight:var(--font-weight-bold);color:var(--text-secondary);text-underline-offset:3px;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));text-decoration-line:underline;-webkit-text-decoration-color:var(--accent);-webkit-text-decoration-color:var(--accent);text-decoration-color:var(--accent);background-color:#0000;border-width:0}@supports (color:color-mix(in lab, red, red)){.legal-link,.code-modal-resend{-webkit-text-decoration-color:color-mix(in srgb, var(--accent) 42%, currentColor);-webkit-text-decoration-color:color-mix(in srgb, var(--accent) 42%, currentColor);text-decoration-color:color-mix(in srgb, var(--accent) 42%, currentColor)}}.legal-link{flex:0 auto;display:inline}.otp-input{inset:calc(var(--spacing) * 0);z-index:2;border-style:var(--tw-border-style);color:#0000;opacity:.01;--tw-outline-style:none;caret-color:#0000;background-color:#0000;border-width:0;outline-style:none;width:100%;height:100%;position:absolute}.otp-slots{grid-template-columns:repeat(6,minmax(0,1fr))}.otp-slots span{aspect-ratio:1;min-width:calc(var(--spacing) * 0);border-radius:var(--radius-md);font-family:var(--font-mono);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-leading:1;--tw-font-weight:850;color:var(--text-primary);transition-property:border-color,background,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));background:#05080d6b;place-items:center;font-weight:850;line-height:1;display:grid}.otp-slots span.filled{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.otp-slots span.filled{border-color:color-mix(in srgb, var(--accent) 35%, var(--border))}}.otp-slots span.filled{background:var(--accent)}@supports (color:color-mix(in lab, red, red)){.otp-slots span.filled{background:color-mix(in srgb, var(--accent) 7%, #ffffff09)}}.otp-slots span.active{border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.otp-slots span.active{border-color:color-mix(in srgb, var(--accent) 72%, var(--border))}}.otp-slots span.active{box-shadow:0 0 0 3px var(--accent)}@supports (color:color-mix(in lab, red, red)){.otp-slots span.active{box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent)}}.step-num,.step-name{color:currentColor;display:block}.step-num{font-family:var(--font-mono);--tw-leading:1.1;--tw-font-weight:var(--font-weight-extrabold);font-size:11px;line-height:1.1;font-weight:var(--font-weight-extrabold)}.step-name{margin-top:calc(var(--spacing) * 1);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:1.15;--tw-font-weight:var(--font-weight-extrabold);line-height:1.15;font-weight:var(--font-weight-extrabold)}.step.active,.auth-tab.active{border-color:var(--accent);color:var(--accent);background:var(--accent)}@supports (color:color-mix(in lab, red, red)){.step.active,.auth-tab.active{background:color-mix(in srgb, var(--accent) 9%, transparent)}}.step.done{color:var(--text-secondary);border-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.step.done{border-color:color-mix(in srgb, var(--accent) 45%, var(--border))}}.notice{border-radius:var(--radius-md);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1.45;color:var(--text-secondary);padding:13px;line-height:1.45}.login-layout.show{display:flex}.telegram-login-widget{min-height:calc(var(--spacing) * 14);justify-content:center}.login-text,.promo-status{margin:calc(var(--spacing) * 0);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:1.45;line-height:1.45}.promo-status{margin-top:calc(var(--spacing) * .5);overflow-wrap:anywhere;min-height:22px}.login-status{margin:calc(var(--spacing) * 0);min-height:calc(var(--spacing) * 5)}.toast{z-index:40;border-radius:var(--radius-md);--tw-font-weight:var(--font-weight-bold);width:max-content;max-width:calc(100vw - 28px);font-size:13px;font-weight:var(--font-weight-bold);color:var(--text-primary);opacity:0;transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;--tw-ease:cubic-bezier(.2,.8,.2,1);left:50%;bottom:max(var(--app-safe-bottom), 22px);border:1px solid var(--accent);padding-block:11px;padding-inline:13px;transition-duration:.3s;transition-timing-function:cubic-bezier(.2,.8,.2,1);position:fixed}@supports (color:color-mix(in lab, red, red)){.toast{border:1px solid color-mix(in srgb, var(--accent) 45%, var(--border))}}.toast{-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:0 16px 38px #00000073, 0 0 15px var(--accent);background:#0f1521d1}@supports (color:color-mix(in lab, red, red)){.toast{box-shadow:0 16px 38px #00000073, 0 0 15px color-mix(in srgb, var(--accent) 15%, transparent)}}.toast{pointer-events:none;transform:translate(-50%,14px)scale(.94)}.toast.show{opacity:1;transform:translate(-50%)scale(1)}}@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.z-\[1\]{z-index:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-5{height:calc(var(--spacing) * 5)}.min-h-14{min-height:calc(var(--spacing) * 14)}.w-5{width:calc(var(--spacing) * 5)}.w-12{width:calc(var(--spacing) * 12)}.w-\[min\(100\%\,420px\)\]{width:min(100%,420px)}.w-full{width:100%}.max-w-\[320px\]{max-width:320px}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-12{min-width:calc(var(--spacing) * 12)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-text{cursor:text}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[minmax\(0\,0\.6fr\)_minmax\(0\,1\.4fr\)\]{grid-template-columns:minmax(0,.6fr) minmax(0,1.4fr)}.grid-cols-\[minmax\(0\,0\.8fr\)_minmax\(0\,1\.2fr\)\]{grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr)}.grid-cols-\[minmax\(0\,1fr\)_minmax\(120px\,auto\)\]{grid-template-columns:minmax(0,1fr) minmax(120px,auto)}.justify-items-center{justify-items:center}.justify-items-stretch{justify-items:stretch}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-\[17px\]{gap:17px}.rounded{border-radius:.25rem}.rounded-\[var\(--radius-md\)\]{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-none{--tw-border-style:none;border-style:none}.border-\[var\(--border\)\]{border-color:var(--border)}.bg-\[rgba\(255\,255\,255\,0\.03\)\]{background-color:#ffffff08}.bg-\[rgba\(255\,255\,255\,0\.015\)\]{background-color:#ffffff04}.fill-current{fill:currentColor}.p-0{padding:calc(var(--spacing) * 0)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[17px\]{padding:17px}.p-\[18px\]{padding:18px}.px-0{padding-inline:calc(var(--spacing) * 0)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.text-center{text-align:center}.font-\[family-name\:var\(--font-mono\)\]{font-family:var(--font-mono)}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[28px\]{font-size:28px}.leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.leading-\[1\.05\]{--tw-leading:1.05;line-height:1.05}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.45\]{--tw-leading:1.45;line-height:1.45}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.tracking-\[0\]{--tw-tracking:0;letter-spacing:0}.break-words{overflow-wrap:break-word}.text-\[var\(--accent\)\]{color:var(--accent)}.text-\[var\(--text-primary\)\]{color:var(--text-primary)}.text-\[var\(--text-secondary\)\]{color:var(--text-secondary)}.uppercase{text-transform:uppercase}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}@media not all and (min-width:560px){.max-\[560px\]\:flex-\[1_1_100\%\]{flex:100%}}@media not all and (min-width:460px){.max-\[460px\]\:max-w-\[240px\]{max-width:240px}.max-\[460px\]\:text-\[24px\]{font-size:24px}}@media (min-width:40rem){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}: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:.2s cubic-bezier(.4, 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))}.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);background:#00fe7a14}}@media (hover:hover) and (pointer:fine){.legal-link:hover:not(:disabled),.code-modal-resend:hover:not(:disabled){color:var(--accent)}}@media (max-width:460px){.app{padding-inline:calc(var(--spacing) * 2.5)}.user-chip{max-width:150px}.user-chip-name{display:none}.brand-title{max-width:164px}.metric{grid-template-columns:repeat(1,minmax(0,1fr))}.badge,.mono-pill{max-width:100%}.otp-slots{gap:calc(var(--spacing) * 1.5)}.otp-slots span{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.brand-logo--lg{width:68px;height:68px}.brand-logo-shell--lg{width:96px;height:96px}.login-auth-body{min-height:128px}.login-brand-title{max-width:200px;font-size:21px}}@media (max-width:380px){.brand-title{max-width:150px}}@media (max-width:560px){.step-box{min-height:54px;padding-inline:7px;padding-block:calc(var(--spacing) * 2)}.modal{padding:calc(var(--spacing) * 0);padding:calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);place-items:center;top:0;bottom:0}.modal--page{padding:calc(var(--spacing) * 0);place-items:stretch stretch;padding:0!important}.modal--page .modal-backdrop{background-color:var(--bg-primary);opacity:1;-webkit-backdrop-filter:none;backdrop-filter:none}.modal-card{border-radius:var(--radius-lg);width:100%;height:auto;max-height:min(92vh,760px);transform:translateY(12px)scale(.98)}.modal--page .modal-card{border-style:var(--tw-border-style);--tw-shadow:0 0 #0000;width:100%;max-width:none;height:100%;max-height:none;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-width:0;border-radius:0;transform:none!important}.modal.show .modal-card{transform:translateY(0)scale(1)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1} \ No newline at end of file diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index 7d151ff..cec2f84 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -5,292 +5,25 @@ - + - Моя подписка + /minishop - - - - + + + -
Загрузка...
- -
-
-
- -
Моя подписка
-
-
-
- - -
-
- - -
-
-
- -
-
- - ... -
- -
-
- ... - -
-
- -
-
- ... -
- -
- -
- -
- -
-
- - - -
- - -
- - - - - - -
- - - - - - - - - - - - - - +
+ + + + diff --git a/bot/app/web/templates/subscription_webapp.js b/bot/app/web/templates/subscription_webapp.js deleted file mode 100644 index 2549c5f..0000000 --- a/bot/app/web/templates/subscription_webapp.js +++ /dev/null @@ -1,2636 +0,0 @@ -/* WEBAPP_DEV_MOCK_START */ -window.__WEBAPP_DEV_MOCK__ = { - config: { - title: 'Моя подписка', - primaryColor: '#00fe7a', - logoUrl: '', - apiBase: '/api', - supportUrl: 'https://t.me/support', - privacyPolicyUrl: 'https://example.com/privacy', - userAgreementUrl: 'https://example.com/agreement', - currency: 'RUB', - language: 'ru', - emailAuthEnabled: true, - telegramLoginBotUsername: 'preview_bot' - }, - data: { - ok: true, - user: { - id: 100200300, - username: 'preview', - email: '', - email_verified: false, - telegram_id: 100200300, - telegram_linked: true, - first_name: 'Preview', - language_code: 'ru' - }, - subscription: { - active: true, - status: 'ACTIVE', - remaining_text: '27 д. 5 ч.', - end_date_text: '19.05.2026 22:40', - days_left: 27, - config_link: 'https://sub.example.com/sub/preview-token', - 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 - }, - plans: [ - {months: 1, price: 190, currency: 'RUB', title: '1 месяц'}, - {months: 3, price: 550, currency: 'RUB', title: '3 месяца'}, - {months: 6, price: 1100, currency: 'RUB', title: '6 месяцев'}, - {months: 12, price: 2000, currency: 'RUB', title: '12 месяцев'} - ], - payment_methods: [ - {id: 'cryptopay', name: 'CryptoPay'}, - {id: 'platega_sbp', name: 'Platega · СБП'}, - {id: 'platega_crypto', name: 'Platega · Crypto'}, - {id: 'freekassa', name: 'FreeKassa / СБП'} - ], - referral: { - code: 'AB12CD34E', - bot_link: 'https://t.me/preview_bot?start=ref_uAB12CD34E', - webapp_link: 'https://app.example.com/?ref=uAB12CD34E', - invited_count: 3, - purchased_count: 1, - bonus_details: [ - {months: 1, title: '1 месяц', inviter_days: 3, friend_days: 3}, - {months: 3, title: '3 месяца', inviter_days: 10, friend_days: 7} - ] - }, - settings: { - support_url: 'https://t.me/support', - traffic_mode: false, - email_auth_enabled: true - } - } - }; -/* WEBAPP_DEV_MOCK_END */ - -const MOCK = (() => { - const mock = window.__WEBAPP_DEV_MOCK__; - const host = window.location.hostname; - const isLocal = window.location.protocol === 'file:' || host === 'localhost' || host === '127.0.0.1' || host === ''; - return mock && isLocal ? mock : null; - })(); - function readJsonScript(id) { - const node = document.getElementById(id); - if (!node || !node.textContent) return null; - try { - return JSON.parse(node.textContent); - } catch (error) { - console.warn('Failed to parse JSON config from #' + id, error); - return null; - } - } - - const CFG = readJsonScript('webapp-config') || (MOCK && MOCK.config) || {}; - const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; - const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js'; - const MANUAL_LOGOUT_FLAG_KEY = 'rw_webapp_manual_logout'; - const state = { - token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''), - csrfToken: MOCK ? '' : (readCookie('rw_webapp_csrf') || ''), - data: null, - selectedPlan: null, - referralParam: readReferralParam(), - promoApplying: false, - payment: null, - paymentFlowOpen: false, - promoModalOpen: false, - referralModalOpen: false, - accountMergeModalOpen: false, - accountMergeNotice: null, - creatingPayment: false, - authInProgress: false, - authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'), - emailLoginPending: false, - emailLoginEmail: '', - emailLoginCodeModalOpen: false, - emailLoginVerifying: false, - emailLoginResending: false, - emailLinkPending: false, - emailLinkEmail: '', - promoStatusTimer: null, - promoStatusType: '', - promoStatusText: '', - telegramLinkRendered: false, - telegramLinkInProgress: false, - toastTimer: null, - userMenuOpen: false, - userMenuCloseTimer: null, - langOverride: (function() { - try { return localStorage.getItem('rw_webapp_lang') || ''; } catch (e) { return ''; } - })(), - langMenuOpen: false, - langMenuCloseTimer: null - }; - - const LANG_OPTIONS = [ - { code: 'ru', label: 'Русский', short: 'RU', flag: '🇷🇺' }, - { code: 'en', label: 'English', short: 'EN', flag: '🇬🇧' } - ]; - - 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', - paymentMethodCard: 'payment-method-card', - paymentMethodCardPlatega: 'payment-method-card--platega', - paymentMethodCardPlategaSbp: 'payment-method-card--platega payment-method-card--platega-sbp', - paymentMethodCardPlategaCrypto: 'payment-method-card--platega payment-method-card--platega-crypto', - paymentMethodCardCryptopay: 'payment-method-card--cryptopay', - notice: 'notice', - stepNum: 'step-num', - stepName: 'step-name' - }; - - const FALLBACK_I18N = { - ru: { - page_title: 'Моя подписка', - loading: 'Загрузка...', - refresh: 'Обновить', - logout: 'Выйти', - subscription: 'Подписка', - subscription_subtitle: 'Ваша ссылка подключения и срок доступа', - ends_at: 'Окончание', - traffic: 'Трафик', - connect: 'Подключиться', - copy_link: 'Скопировать ссылку', - extend_subscription: 'Купить подписку / Добавить дни', - payment_title: 'Оплата подписки', - payment_caption: 'Выберите период и нажмите способ оплаты. Сервис оплаты откроется сразу.', - close: 'Закрыть', - close_payment: 'Закрыть оплату', - payment_steps: 'Шаги оплаты', - step_plan: 'Срок', - step_method: 'Метод', - step_result: 'Платеж', - select_period: 'Выберите период', - select_method: 'Выберите способ оплаты', - payment_status: 'Статус платежа', - payment_amount_label: 'Стоимость выбранного периода', - choose_payment_method: 'Способ оплаты', - back: 'Назад', - create_payment: 'Создать платеж', - open_payment: 'Открыть оплату', - check_payment: 'Проверить оплату', - choose_other_method: 'Выбрать другой способ', - pay_with_platega_button: 'Оплатить картой (СБП)', - pay_with_platega_sbp_button: 'Оплата через СБП', - pay_with_platega_crypto_button: 'Оплата криптой', - pay_with_cryptopay_button: 'Оплатить криптой', - support: 'Поддержка', - account_title: 'Аккаунт', - account_caption: 'Способы входа', - email_label: 'Email', - telegram_label: 'Telegram', - linked: 'Привязан', - not_linked: 'Не привязан', - email_login_tab: 'Email', - telegram_login_tab: 'Telegram', - email_placeholder: 'mail@example.com', - code_placeholder: '000000', - send_code: 'Отправить код', - resend_code: 'Отправить еще раз', - confirm: 'Подтвердить', - login: 'Войти', - login_title: 'Войдите или зарегистрируйтесь', - login_continue: 'Продолжить', - email_code_title: 'Подтвердите вход', - email_code_caption: 'Введите 6-значный код из письма.', - email_code_aria: 'Код подтверждения', - email_auth_disabled: 'Вход по email пока не настроен.', - email_required: 'Введите email', - email_invalid: 'Введите корректный email', - email_code_sending: 'Отправляю код...', - email_code_sent: 'Код отправлен на почту', - email_code_send_failed: 'Не удалось отправить код', - email_code_invalid: 'Неверный код', - email_code_expired: 'Код устарел', - email_rate_limited: 'Повторная отправка доступна через {seconds} сек.', - email_linked: 'Email привязан', - telegram_linked: 'Telegram привязан', - account_merge_toast: 'Аккаунты объединены', - account_merge_title: 'Аккаунты объединены', - account_merge_caption: 'Оплаченные периоды сложились, и теперь у вас один аккаунт.', - account_merge_body: 'Мы объединили ваш Telegram и email в один профиль. Оплаченные периоды сложились, ссылка на подписку осталась прежней, а более поздний аккаунт был удалён автоматически.', - account_merge_primary_account_label: 'Оставлен аккаунт', - account_merge_removed_account_label: 'Удалён аккаунт', - account_merge_end_date_label: 'Новая дата окончания', - account_merge_conflict: 'Этот аккаунт уже связан с другими данными.', - telegram_auth: 'Telegram auth', - telegram_auth_verifying: 'Проверяю вход...', - telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.', - telegram_auth_unavailable: 'Telegram Login Widget недоступен. Проверьте username бота и доступ к telegram.org.', - 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: '∞ бесконечный трафик', - traffic_unavailable: 'Сервис недоступен', - 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: 'Тарифы не настроены.', - access_period: 'Срок доступа', - choose_plan_first: 'Сначала выберите срок подписки.', - no_methods: 'Нет доступных способов оплаты.', - payment_not_created: 'Платеж еще не создан.', - invoice_sent: 'Счет отправлен в чат с ботом. После оплаты вернитесь сюда и проверьте статус.', - payment_created: 'Платеж создан. Откройте оплату, завершите ее у провайдера и затем проверьте статус.', - updating: 'Обновляю данные', - choose_plan_toast: 'Выберите срок подписки', - choose_method_toast: 'Выберите способ оплаты', - create_payment_toast: 'Создайте платеж', - payment_confirmed: 'Оплата подтверждена', - payment_pending: 'Платеж пока не подтвержден', - link_copied: 'Ссылка скопирована', - no_link: 'Ссылка пока недоступна', - payment_error: 'Ошибка оплаты', - privacy_policy: 'Политика конфиденциальности', - user_agreement: 'Пользовательское соглашение', - promo_title: 'Промокод', - promo_caption: 'Введите код, чтобы начислить бонусные дни.', - promo_placeholder: 'PROMO2026', - apply_promo: 'Применить', - promo_applied: 'Промокод применен', - promo_applied_ok: 'Промокод применен, всё ок.', - promo_empty: 'Введите промокод', - promo_invalid: 'Промокод не найден или недействителен.', - referral_title: 'Пригласить друга', - referral_caption: 'Поделитесь ссылкой и получайте бонусы.', - referral_code: 'Ваш код', - invited_friends: 'Приглашено', - purchased_friends: 'Оплатили', - copy_code: 'Скопировать код', - copy_webapp_link: 'Скопировать ссылку Web App', - copy_bot_link: 'Скопировать ссылку бота', - referral_copied: 'Ссылка скопирована', - code_copied: 'Код скопирован', - referral_site_label: 'Пригласить через сайт', - referral_telegram_label: 'Пригласить через телеграм', - referral_bonus_title: 'Бонусы', - referral_bonus_pair: 'Вы: {inviter} дн. / друг: {friend} дн.', - referral_no_bonuses: 'Бонусы пока не настроены.', - referral_bonus_explanation: 'Бонусы начисляются один раз за каждого приглашенного пользователя, когда приглашенный пользователь покупает подписку.' - }, - en: { - page_title: 'My subscription', - loading: 'Loading...', - refresh: 'Refresh', - logout: 'Log out', - subscription: 'Subscription', - subscription_subtitle: 'Your connection link and access time', - ends_at: 'Ends at', - traffic: 'Traffic', - connect: 'Connect', - copy_link: 'Copy link', - extend_subscription: 'Renew subscription/Add days', - payment_title: 'Subscription payment', - payment_caption: 'Choose a period and tap a payment method. The payment service will open right away.', - close: 'Close', - close_payment: 'Close payment', - payment_steps: 'Payment steps', - step_plan: 'Period', - step_method: 'Method', - step_result: 'Payment', - select_period: 'Select period', - select_method: 'Select payment method', - payment_status: 'Payment status', - payment_amount_label: 'Selected period cost', - choose_payment_method: 'Payment method', - back: 'Back', - create_payment: 'Create payment', - open_payment: 'Open payment', - check_payment: 'Check payment', - choose_other_method: 'Choose another method', - pay_with_platega_button: 'Pay by card (SBP)', - pay_with_platega_sbp_button: 'Pay via SBP', - pay_with_platega_crypto_button: 'Pay with crypto', - pay_with_cryptopay_button: 'Pay with crypto', - support: 'Support', - account_title: 'Account', - account_caption: 'Sign-in methods', - email_label: 'Email', - telegram_label: 'Telegram', - linked: 'Linked', - not_linked: 'Not linked', - email_login_tab: 'Email', - telegram_login_tab: 'Telegram', - email_placeholder: 'mail@example.com', - code_placeholder: '000000', - send_code: 'Send code', - resend_code: 'Send again', - confirm: 'Confirm', - login: 'Log in', - login_title: 'Log in or sign up', - login_continue: 'Continue', - email_code_title: 'Confirm login', - email_code_caption: 'Enter the 6-digit code from the email.', - email_code_aria: 'Verification code', - email_auth_disabled: 'Email sign-in is not configured yet.', - email_required: 'Enter your email', - email_invalid: 'Enter a valid email address', - email_code_sending: 'Sending code...', - email_code_sent: 'Code sent to email', - email_code_send_failed: 'Could not send code', - email_code_invalid: 'Invalid code', - email_code_expired: 'Code expired', - email_rate_limited: 'Try again in {seconds} sec.', - email_linked: 'Email linked', - telegram_linked: 'Telegram linked', - account_merge_toast: 'Accounts merged', - account_merge_title: 'Accounts merged', - account_merge_caption: 'Your paid periods were combined and you now have one account.', - account_merge_body: 'We merged your Telegram and email accounts into one profile. Your paid periods were combined, your subscription link stayed the same, and the later account was removed automatically.', - account_merge_primary_account_label: 'Kept account', - account_merge_removed_account_label: 'Removed account', - account_merge_end_date_label: 'New end date', - account_merge_conflict: 'This account is already linked to different data.', - telegram_auth: 'Telegram auth', - telegram_auth_verifying: 'Verifying login...', - telegram_auth_failed: 'Could not verify Telegram login. Try again.', - telegram_auth_unavailable: 'Telegram Login Widget is unavailable. Check the bot username and access to telegram.org.', - 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', - traffic_unavailable: 'Service unavailable', - 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.', - access_period: 'Access period', - choose_plan_first: 'Choose a subscription period first.', - no_methods: 'No payment methods available.', - payment_not_created: 'Payment has not been created yet.', - invoice_sent: 'The invoice was sent to the bot chat. Pay it, then return here and check the status.', - payment_created: 'Payment was created. Open it, complete payment with the provider, then check the status.', - updating: 'Refreshing data', - choose_plan_toast: 'Choose a subscription period', - choose_method_toast: 'Choose a payment method', - create_payment_toast: 'Create a payment', - payment_confirmed: 'Payment confirmed', - payment_pending: 'Payment is not confirmed yet', - link_copied: 'Link copied', - no_link: 'Link is not available yet', - payment_error: 'Payment error', - privacy_policy: 'Privacy policy', - user_agreement: 'User agreement', - promo_title: 'Promo code', - promo_caption: 'Enter a code to add bonus days.', - promo_placeholder: 'PROMO2026', - apply_promo: 'Apply', - promo_applied: 'Promo code applied', - promo_applied_ok: 'Promo code applied, all good.', - promo_empty: 'Enter promo code', - promo_invalid: 'Promo code was not found or is not valid.', - referral_title: 'Invite friend', - referral_caption: 'Share your link and receive bonuses.', - referral_code: 'Your code', - invited_friends: 'Invited', - purchased_friends: 'Purchased', - copy_code: 'Copy code', - copy_webapp_link: 'Copy Web App link', - copy_bot_link: 'Copy bot link', - referral_copied: 'Link copied', - code_copied: 'Code copied', - referral_site_label: 'Invite via site', - 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_bonus_explanation: 'Bonuses are awarded once for each invited user when they purchase a subscription.' - } - }; - const I18N = (function () { - const server = readJsonScript('i18n') || (MOCK && MOCK.i18n) || {}; - const merged = {}; - const langs = new Set(Object.keys(FALLBACK_I18N).concat(Object.keys(server))); - langs.forEach(function (lang) { - merged[lang] = Object.assign({}, FALLBACK_I18N[lang] || {}, server[lang] || {}); - }); - return merged; - })(); - - const accent = CFG.primaryColor || '#00fe7a'; - document.documentElement.style.setProperty('--accent', accent); - setBrand(); - applyI18n(); - applyLegalLinks(); - if (CFG.supportUrl) { - const support = document.getElementById('support-link'); - support.href = CFG.supportUrl; - support.classList.remove('hidden'); - } - - if (tg) { - try { - tg.ready(); - tg.expand(); - const canSetColors = typeof tg.isVersionAtLeast === 'function' && tg.isVersionAtLeast('6.1'); - if (canSetColors) { - tg.setHeaderColor('#05070a'); - tg.setBackgroundColor('#05070a'); - } - } catch (e) { } - } - - bindEmailLoginInput(); - bindEmailCodeInput(); - bindPromoInput(); - - document.addEventListener('keydown', event => { - handleDocumentKeyForUserMenu(event); - if (event.key !== 'Escape') return; - if (state.emailLoginCodeModalOpen) { - closeEmailLoginCodeModal(); - } else if (state.accountMergeModalOpen) { - closeAccountMergeModal(); - } else if (state.promoModalOpen) { - closePromoModal(); - } else if (state.referralModalOpen) { - closeReferralModal(); - } else if (state.paymentFlowOpen) { - closePaymentFlow(); - } - }); - - document.addEventListener('click', handleDocumentActionClick); - document.addEventListener('click', handleDocumentClickForUserMenu); - document.addEventListener('click', handleDocumentClickForLangMenu); - renderLangMenu(); - - boot(); - - async function boot() { - showLoader(); - if (MOCK) { - await loadData(); - return; - } - - const magicToken = readMagicLoginToken(); - if (magicToken) { - const authenticated = await finalizeMagicLogin(magicToken); - if (authenticated) { - return; - } - } - - // Explicit logout should survive refresh, even if the old cookie session is still valid. - if (isManuallyLoggedOut()) { - await startExternalAuth(); - return; - } - - const widgetAuthData = readTelegramLoginWidgetAuthData(); - if (widgetAuthData) { - const authenticated = await finalizeTelegramAuth(widgetAuthData); - if (authenticated) { - return; - } - clearToken(); - startExternalAuth({resetStatus: false}); - return; - } - - if (tg && tg.initData) { - try { - const authenticated = await finalizeTelegramAuth(tg.initData, 'init_data'); - if (authenticated) { - return; - } - } catch (e) { } - } - - if (state.token || readCookie('rw_webapp_csrf')) { - try { - await loadData(); - return; - } catch (e) { - clearToken(); - } - } - - await startExternalAuth(); - } - - function readMagicLoginToken() { - const query = new URLSearchParams(window.location.search); - const token = (query.get('login_token') || '').trim(); - return token || null; - } - - function clearMagicLoginQuery() { - const url = new URL(window.location.href); - ['login_token', 'login_purpose'].forEach(key => url.searchParams.delete(key)); - if (window.history && window.history.replaceState) { - window.history.replaceState({}, document.title, url.pathname + url.search + url.hash); - } - } - - async function finalizeMagicLogin(token) { - if (state.authInProgress) return false; - state.authInProgress = true; - setAuthStatus(t('telegram_auth_verifying')); - try { - const payload = {token}; - if (state.referralParam) payload.referral_code = state.referralParam; - const data = await publicApi('/auth/email/magic', payload); - if (data && data.ok && data.token) { - setToken(data.token, data.csrf_token); - clearManualLogoutFlag(); - clearMagicLoginQuery(); - try { - setAuthStatus(''); - await loadData(); - return true; - } catch (e) { - clearToken(); - setAuthStatus(t('telegram_auth_failed'), true); - return false; - } - } - clearMagicLoginQuery(); - setAuthStatus(emailErrorMessage(data, 'email_code_invalid'), true); - return false; - } catch (e) { - clearMagicLoginQuery(); - setAuthStatus(t('telegram_auth_failed'), true); - return false; - } finally { - state.authInProgress = false; - } - } - - function readTelegramLoginWidgetAuthData() { - const query = new URLSearchParams(window.location.search); - const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash']; - const authData = {}; - let hasAuthValue = false; - - keys.forEach(key => { - if (!query.has(key)) return; - authData[key] = query.get(key) || ''; - hasAuthValue = true; - }); - - if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) { - return null; - } - - return authData; - } - - function readReferralParam() { - const query = new URLSearchParams(window.location.search); - const fromQuery = query.get('ref') || query.get('start') || query.get('start_param') || ''; - const fromTelegram = tg && tg.initDataUnsafe && tg.initDataUnsafe.start_param - ? tg.initDataUnsafe.start_param - : ''; - const value = String(fromTelegram || fromQuery || '').trim(); - if (value) { - localStorage.setItem('rw_webapp_referral', value); - return value; - } - return localStorage.getItem('rw_webapp_referral') || ''; - } - - function clearTelegramLoginWidgetQuery() { - const url = new URL(window.location.href); - const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash']; - keys.forEach(key => url.searchParams.delete(key)); - if (window.history && window.history.replaceState) { - window.history.replaceState({}, document.title, url.pathname + url.search + url.hash); - } - } - - async function finalizeTelegramAuth(authData, source = 'auth_data') { - if (state.authInProgress) return false; - state.authInProgress = true; - setAuthStatus(t('telegram_auth_verifying')); - try { - const payload = source === 'init_data' - ? {init_data: authData} - : {auth_data: authData}; - if (state.referralParam) payload.referral_code = state.referralParam; - const response = await fetch(CFG.apiBase + '/auth/token', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - const data = await response.json(); - if (response.ok && data.ok && data.token) { - setToken(data.token, data.csrf_token); - clearTelegramLoginWidgetQuery(); - try { - setAuthStatus(''); - await loadData(); - return true; - } catch (e) { - clearToken(); - setAuthStatus(t('telegram_auth_failed'), true); - return false; - } - } - - const errorKey = data.error === 'banned' || data.error === 'access_denied' - ? 'telegram_auth_access_denied' - : 'telegram_auth_failed'; - setAuthStatus(t(errorKey), true); - } catch (e) { - setAuthStatus(t('telegram_auth_failed'), true); - } finally { - state.authInProgress = false; - } - - clearTelegramLoginWidgetQuery(); - return false; - } - - async function handleTelegramLoginWidgetAuth(user) { - if (!user) { - setAuthStatus(t('telegram_auth_failed'), true); - return; - } - - await finalizeTelegramAuth(user, 'auth_data'); - } - - function setAuthMode(mode) { - state.authMode = mode === 'telegram' ? 'telegram' : 'email'; - renderAuthMode(); - } - - function renderAuthMode() { - const emailTab = document.getElementById('email-auth-tab'); - const telegramTab = document.getElementById('telegram-auth-tab'); - const emailPane = document.getElementById('email-login-pane'); - const telegramPane = document.getElementById('telegram-login-pane'); - const authBody = document.querySelector('.login-auth-body'); - const emailEnabled = CFG.emailAuthEnabled !== false; - if (!emailEnabled && state.authMode === 'email') { - state.authMode = 'telegram'; - } - - emailTab.classList.toggle('active', state.authMode === 'email'); - telegramTab.classList.toggle('active', state.authMode === 'telegram'); - emailPane.classList.toggle('hidden', state.authMode !== 'email'); - telegramPane.classList.toggle('hidden', state.authMode !== 'telegram'); - if (authBody) { - authBody.classList.toggle('login-auth-body--telegram', state.authMode === 'telegram'); - } - - emailTab.disabled = !emailEnabled; - if (state.authMode === 'telegram') { - renderTelegramLoginWidget(); - } else if (!emailEnabled) { - setAuthStatus(t('email_auth_disabled'), true); - } - } - - function renderTelegramLoginWidget() { - const container = document.getElementById('telegram-login-widget'); - if (!container) return; - - container.innerHTML = ''; - const botUsername = String(CFG.telegramLoginBotUsername || '').trim(); - if (!botUsername) { - setAuthStatus(t('telegram_auth_unavailable'), true); - return; - } - - if (typeof window.onTelegramAuth !== 'function') { - window.onTelegramAuth = async function(user) { - await handleTelegramLoginWidgetAuth(user); - }; - } - - const script = document.createElement('script'); - script.async = true; - script.src = TELEGRAM_LOGIN_WIDGET_URL; - script.setAttribute('data-telegram-login', botUsername); - script.setAttribute('data-size', 'large'); - script.setAttribute('data-userpic', 'true'); - script.setAttribute('data-request-access', 'write'); - script.setAttribute('data-onauth', 'onTelegramAuth(user)'); - script.onerror = () => setAuthStatus(t('telegram_auth_unavailable'), true); - container.appendChild(script); - } - - function bindEmailLoginInput() { - const input = document.getElementById('email-login-input'); - if (!input) return; - - input.addEventListener('keydown', event => { - if (event.key !== 'Enter') return; - event.preventDefault(); - requestEmailLoginCode(); - }); - input.addEventListener('input', () => { - if (input.getAttribute('aria-invalid') !== 'true') return; - input.removeAttribute('aria-invalid'); - setAuthStatus(''); - }); - } - - function bindEmailCodeInput() { - const input = document.getElementById('email-login-code-input'); - if (!input) return; - - input.addEventListener('input', () => { - input.value = sanitizeCode(input.value); - updateEmailLoginCodeSlots(); - }); - input.addEventListener('focus', updateEmailLoginCodeSlots); - input.addEventListener('blur', updateEmailLoginCodeSlots); - input.addEventListener('keydown', event => { - if (event.key === 'Enter') { - event.preventDefault(); - if (sanitizeCode(input.value).length === 6 && !state.emailLoginVerifying) { - verifyEmailLoginCode(); - } - } - }); - } - - function sanitizeCode(value) { - return String(value || '').replace(/\D/g, '').slice(0, 6); - } - - function isValidEmail(value) { - const email = normalizeEmail(value); - return Boolean(email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)); - } - - function readValidEmailLoginInput() { - const input = document.getElementById('email-login-input'); - const email = normalizeEmail(input && input.value); - if (!email || !isValidEmail(email)) { - if (input) { - input.setAttribute('aria-invalid', 'true'); - input.focus(); - } - setAuthStatus(t(email ? 'email_invalid' : 'email_required'), true); - return ''; - } - - if (input) { - input.value = email; - input.removeAttribute('aria-invalid'); - } - return email; - } - - function openEmailLoginCodeModal(email, message, isError = false) { - state.emailLoginEmail = email; - state.emailLoginCodeModalOpen = true; - const input = document.getElementById('email-login-code-input'); - if (input) input.value = ''; - renderEmailLoginCodeModal(); - setEmailCodeStatus(message || t('email_code_sent'), isError); - window.setTimeout(() => { - const codeInput = document.getElementById('email-login-code-input'); - if (state.emailLoginCodeModalOpen && codeInput) codeInput.focus(); - }, 80); - } - - function closeEmailLoginCodeModal() { - state.emailLoginCodeModalOpen = false; - const input = document.getElementById('email-login-code-input'); - if (input) input.value = ''; - setEmailCodeStatus(''); - renderEmailLoginCodeModal(); - } - - function renderEmailLoginCodeModal() { - const modal = document.getElementById('email-code-modal'); - if (!modal) return; - - if (!state.emailLoginCodeModalOpen) { - modal.classList.remove('show'); - syncModalLock(); - window.setTimeout(() => { - if (!state.emailLoginCodeModalOpen) modal.classList.add('hidden'); - }, 180); - return; - } - - const address = document.getElementById('email-code-address'); - if (address) address.textContent = state.emailLoginEmail || '...'; - modal.classList.remove('hidden'); - syncModalLock(); - applyI18n(modal); - updateEmailLoginCodeSlots(); - window.requestAnimationFrame(() => modal.classList.add('show')); - } - - function updateEmailLoginCodeSlots() { - const input = document.getElementById('email-login-code-input'); - const value = sanitizeCode(input && input.value); - if (input && input.value !== value) input.value = value; - - for (let index = 0; index < 6; index += 1) { - const slot = document.getElementById('email-code-slot-' + index); - if (!slot) continue; - const character = value[index] || ''; - slot.textContent = character; - slot.classList.toggle('filled', Boolean(character)); - slot.classList.toggle( - 'active', - state.emailLoginCodeModalOpen - && document.activeElement === input - && index === Math.min(value.length, 5) - ); - } - - const verifyButton = document.getElementById('email-login-verify-btn'); - if (verifyButton) { - verifyButton.disabled = value.length !== 6 || state.emailLoginVerifying || state.emailLoginPending; - } - - const resendButton = document.getElementById('email-login-resend-btn'); - if (resendButton) { - resendButton.disabled = state.emailLoginPending || state.emailLoginResending; - } - } - - async function requestEmailLoginCode() { - if (state.emailLoginPending) return; - if (CFG.emailAuthEnabled === false) { - setAuthStatus(t('email_auth_disabled'), true); - return; - } - const email = readValidEmailLoginInput(); - if (!email) { - return; - } - state.emailLoginPending = true; - setButtonBusy('email-login-send-btn', true); - openEmailLoginCodeModal(email, t('email_code_sending')); - setAuthStatus(''); - try { - const data = await publicApi('/auth/email/request', { - email, - language: getLanguage() - }); - if (!data.ok) throw data; - setEmailCodeStatus(t('email_code_sent')); - } catch (e) { - const message = emailErrorMessage(e, 'email_code_send_failed'); - if (e && e.error === 'invalid_email') { - closeEmailLoginCodeModal(); - const input = document.getElementById('email-login-input'); - if (input) input.setAttribute('aria-invalid', 'true'); - setAuthStatus(message, true); - } else { - setEmailCodeStatus(message, true); - } - } finally { - state.emailLoginPending = false; - setButtonBusy('email-login-send-btn', false); - updateEmailLoginCodeSlots(); - } - } - - async function resendEmailLoginCode() { - if (state.emailLoginPending || state.emailLoginResending) return; - const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value); - if (!email || !isValidEmail(email)) { - setEmailCodeStatus(t(email ? 'email_invalid' : 'email_required'), true); - return; - } - - state.emailLoginResending = true; - updateEmailLoginCodeSlots(); - setEmailCodeStatus(t('email_code_sending')); - try { - const data = await publicApi('/auth/email/request', { - email, - language: getLanguage() - }); - if (!data.ok) throw data; - state.emailLoginEmail = email; - const input = document.getElementById('email-login-code-input'); - if (input) input.value = ''; - setEmailCodeStatus(t('email_code_sent')); - } catch (e) { - setEmailCodeStatus(emailErrorMessage(e, 'email_code_send_failed'), true); - } finally { - state.emailLoginResending = false; - updateEmailLoginCodeSlots(); - } - } - - async function verifyEmailLoginCode() { - const email = state.emailLoginEmail || normalizeEmail(document.getElementById('email-login-input').value); - const input = document.getElementById('email-login-code-input'); - const code = sanitizeCode(input && input.value); - if (!email || code.length !== 6) { - setEmailCodeStatus(t('email_code_invalid'), true); - updateEmailLoginCodeSlots(); - return; - } - - state.emailLoginVerifying = true; - updateEmailLoginCodeSlots(); - setEmailCodeStatus(t('telegram_auth_verifying')); - try { - const data = await publicApi('/auth/email/verify', { - email, - code, - referral_code: state.referralParam || '' - }); - if (!data.ok || !data.token) throw data; - setToken(data.token, data.csrf_token); - closeEmailLoginCodeModal(); - await loadData(); - } catch (e) { - clearToken(); - setEmailCodeStatus(emailErrorMessage(e, 'email_code_invalid'), true); - } finally { - state.emailLoginVerifying = false; - updateEmailLoginCodeSlots(); - } - } - - function startExternalAuth(options = {}) { - const resetStatus = options.resetStatus !== false; - showLogin(); - if (resetStatus) { - setAuthStatus(''); - } - renderAuthMode(); - } - - async function loadData() { - const data = await api('/me'); - if (!data.ok) throw new Error(data.error || 'load failed'); - state.data = data; - setBrand(); - applyI18n(); - applyLegalLinks(); - const plans = data.plans || []; - state.selectedPlan = getDefaultPaymentPlan(plans); - state.payment = null; - state.creatingPayment = false; - state.telegramLinkRendered = false; - render(); - showApp(); - } - - function render() { - renderSubscription(state.data.subscription); - renderUserMenu(state.data.user || {}); - renderAccount(state.data.user || {}); - renderReferral(state.data.referral || {}); - renderAccountMergeModal(); - renderPaymentFlow(); - } - - function renderSubscription(sub) { - 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', 'inactive'); - - if (!sub.active) { - bar.classList.add('inactive'); - fill.style.width = '100%'; - value.textContent = t('traffic_unavailable'); - bar.setAttribute('aria-valuenow', '0'); - return; - } - - 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 avatarSrc = getUserAvatarSrc(user); - const avatarFallback = buildIdenticon(getUserAvatarSeed(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; - setAvatarWithFallback(chipAvatar, avatarSrc, avatarFallback, displayName); - setAvatarWithFallback(dropAvatar, avatarSrc, avatarFallback, 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); - 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'); - 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(); - } else { - state.telegramLinkRendered = false; - } - } - - function getTelegramInitUser() { - if (!tg || !tg.initDataUnsafe || !tg.initDataUnsafe.user) return null; - return tg.initDataUnsafe.user; - } - - function getUserAvatarSeed(user, emailLinked) { - const tgUser = getTelegramInitUser(); - if (emailLinked && user && user.email) return user.email.trim().toLowerCase(); - return String((user && user.id) || (user && user.telegram_id) || (tgUser && tgUser.id) || 'guest'); - } - - function getUserAvatarSrc(user) { - const tgUser = getTelegramInitUser(); - if (tgUser && tgUser.photo_url) return tgUser.photo_url; - if (user && user.telegram_photo_url) return user.telegram_photo_url; - return ''; - } - - 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 setAvatarWithFallback(img, avatarSrc, avatarFallback, altText) { - if (!img) return; - - img.alt = altText || ''; - if (!avatarSrc) { - img.onerror = null; - img.src = avatarFallback; - return; - } - - img.onerror = () => { - img.onerror = null; - img.src = avatarFallback; - }; - img.src = avatarSrc; - } - - 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 += ''; - if (x !== 2) { - rects += ''; - } - } - } - const svg = '' + - '' + - '' + rects + '' + - ''; - 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; - - if (state.userMenuCloseTimer) { - window.clearTimeout(state.userMenuCloseTimer); - state.userMenuCloseTimer = null; - } - - chip.setAttribute('aria-expanded', open ? 'true' : 'false'); - if (open) { - dropdown.classList.remove('hidden'); - dropdown.classList.remove('show'); - window.requestAnimationFrame(() => { - if (state.userMenuOpen) { - dropdown.classList.add('show'); - } - }); - return; - } - - dropdown.classList.remove('show'); - state.userMenuCloseTimer = window.setTimeout(() => { - if (!state.userMenuOpen) { - dropdown.classList.add('hidden'); - } - state.userMenuCloseTimer = null; - }, 180); - } - - function handleDocumentClickForUserMenu(event) { - 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); - } - if (event.key === 'Escape' && state.langMenuOpen) { - toggleLangMenu(false); - } - } - - function renderLangMenu() { - const current = getLanguage(); - const chipLabel = document.getElementById('lang-chip-label'); - const chipFlag = document.getElementById('lang-chip-flag'); - const opt = LANG_OPTIONS.find(o => o.code === current) || LANG_OPTIONS[0]; - if (chipLabel) chipLabel.textContent = opt.short; - if (chipFlag) chipFlag.textContent = opt.flag; - const list = document.getElementById('lang-dropdown'); - if (!list) return; - list.innerHTML = LANG_OPTIONS.map(o => ( - '' - )).join(''); - list.querySelectorAll('[data-lang]').forEach(btn => { - btn.addEventListener('click', () => setLanguage(btn.dataset.lang)); - }); - } - - function toggleLangMenu(force) { - const open = typeof force === 'boolean' ? force : !state.langMenuOpen; - state.langMenuOpen = open; - const chip = document.getElementById('lang-chip'); - const dropdown = document.getElementById('lang-dropdown'); - if (!chip || !dropdown) return; - if (state.langMenuCloseTimer) { - window.clearTimeout(state.langMenuCloseTimer); - state.langMenuCloseTimer = null; - } - chip.setAttribute('aria-expanded', open ? 'true' : 'false'); - if (open) { - dropdown.classList.remove('hidden'); - dropdown.classList.remove('show'); - window.requestAnimationFrame(() => { - if (state.langMenuOpen) dropdown.classList.add('show'); - }); - return; - } - dropdown.classList.remove('show'); - state.langMenuCloseTimer = window.setTimeout(() => { - if (!state.langMenuOpen) dropdown.classList.add('hidden'); - state.langMenuCloseTimer = null; - }, 180); - } - - function handleDocumentClickForLangMenu(event) { - if (!state.langMenuOpen) return; - const menu = document.querySelector('.lang-menu'); - if (menu && menu.contains(event.target)) return; - toggleLangMenu(false); - } - - function handleDocumentActionClick(event) { - const target = event.target instanceof Element ? event.target.closest('[data-action]') : null; - if (!target || !document.contains(target) || target.disabled) return; - - const action = String(target.dataset.action || ''); - if (!action) return; - - switch (action) { - case 'toggle-user-menu': - toggleUserMenu(); - break; - case 'toggle-lang-menu': - toggleLangMenu(); - break; - case 'logout': - logout(); - break; - case 'open-connect-link': - openConnectLink(); - break; - case 'toggle-payment-flow': - togglePaymentFlow(); - break; - case 'open-referral-modal': - openReferralModal(); - break; - case 'open-promo-modal': - openPromoModal(); - break; - case 'close-payment-flow': - closePaymentFlow(); - break; - case 'close-promo-modal': - closePromoModal(); - break; - case 'close-referral-modal': - closeReferralModal(); - break; - case 'close-account-merge-modal': - closeAccountMergeModal(); - break; - case 'close-email-login-code-modal': - closeEmailLoginCodeModal(); - break; - case 'request-email-link-code': - requestEmailLinkCode(); - break; - case 'verify-email-link-code': - verifyEmailLinkCode(); - break; - case 'request-email-login-code': - requestEmailLoginCode(); - break; - case 'verify-email-login-code': - verifyEmailLoginCode(); - break; - case 'resend-email-login-code': - resendEmailLoginCode(); - break; - case 'set-auth-mode': - setAuthMode(target.dataset.mode || 'email'); - break; - case 'apply-promo-code': - applyPromoCode(); - break; - case 'select-plan': - selectPlan(target.dataset.months || ''); - break; - case 'create-payment': - createAndOpenPayment(target.dataset.method || ''); - break; - case 'copy-referral-link': - copyReferralLink(target.dataset.kind || 'webapp'); - break; - case 'copy-config-link': - copyConfigLink(); - break; - case 'check-payment': - checkPayment(); - break; - default: - return; - } - - event.preventDefault(); - } - - function openPromoModal() { - state.promoModalOpen = true; - clearPromoStatus(); - renderPromoModal(); - window.setTimeout(() => { - const input = document.getElementById('promo-code-input'); - if (input) input.focus(); - }, 80); - } - - function closePromoModal() { - state.promoModalOpen = false; - clearPromoStatus(); - renderPromoModal(); - } - - function renderPromoModal() { - 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(); - applyI18n(modal); - window.requestAnimationFrame(() => modal.classList.add('show')); - } - - function bindPromoInput() { - const input = document.getElementById('promo-code-input'); - if (!input) return; - - input.addEventListener('input', () => { - if (input.getAttribute('aria-invalid') === 'true') { - input.removeAttribute('aria-invalid'); - } - if (state.promoStatusText) { - clearPromoStatus(); - } - }); - - input.addEventListener('keydown', event => { - if (event.key !== 'Enter') return; - event.preventDefault(); - applyPromoCode(); - }); - } - - function setPromoStatus(message, type = 'error') { - state.promoStatusType = type === 'success' ? 'success' : 'error'; - state.promoStatusText = normalizeStatusText(message || ''); - renderPromoStatus(); - } - - function clearPromoStatus() { - state.promoStatusType = ''; - state.promoStatusText = ''; - renderPromoStatus(); - } - - function renderPromoStatus() { - const status = document.getElementById('promo-status'); - if (!status) return; - - if (!state.promoStatusText) { - status.textContent = ''; - status.classList.add('hidden'); - status.classList.remove('error', 'success'); - return; - } - - status.textContent = state.promoStatusText; - status.classList.remove('hidden'); - status.classList.toggle('error', state.promoStatusType === 'error'); - status.classList.toggle('success', state.promoStatusType === 'success'); - } - - function normalizeStatusText(value) { - const text = String(value || ''); - const wrapper = document.createElement('div'); - wrapper.innerHTML = text; - return (wrapper.textContent || wrapper.innerText || '').trim(); - } - - function openReferralModal() { - state.referralModalOpen = true; - renderReferral(state.data && state.data.referral || {}); - } - - function closeReferralModal() { - state.referralModalOpen = false; - renderReferral(state.data && state.data.referral || {}); - } - - function closeToolModals() { - state.promoModalOpen = false; - state.referralModalOpen = false; - state.accountMergeModalOpen = false; - clearPromoStatus(); - renderPromoModal(); - renderReferral(state.data && state.data.referral || {}); - renderAccountMergeModal(); - } - - - - function openAccountMergeModal(notice = null) { - if (notice) { - state.accountMergeNotice = notice; - } - state.accountMergeModalOpen = true; - renderAccountMergeModal(); - } - - function closeAccountMergeModal() { - state.accountMergeModalOpen = false; - renderAccountMergeModal(); - } - - function renderAccountMergeModal() { - const modal = document.getElementById('account-merge-modal'); - const panel = document.getElementById('account-merge-panel'); - if (!modal || !panel) return; - - if (!state.accountMergeModalOpen) { - modal.classList.remove('show'); - syncModalLock(); - window.setTimeout(() => { - if (!state.accountMergeModalOpen) modal.classList.add('hidden'); - }, 180); - return; - } - - modal.classList.remove('hidden'); - syncModalLock(); - - const notice = state.accountMergeNotice || {}; - const primaryUserId = notice.primary_user_id != null ? String(notice.primary_user_id) : t('not_available'); - const removedUserId = notice.removed_user_id != null ? String(notice.removed_user_id) : t('not_available'); - const finalEndDateText = notice.final_end_date_text || t('not_available'); - - panel.innerHTML = ` -
-
-
${escapeHtml(t('account_merge_title'))}
-
${escapeHtml(t('account_merge_caption'))}
-
- -
- -
-
${escapeHtml(t('account_merge_body'))}
-
- -
-
-
${escapeHtml(t('account_merge_primary_account_label'))}
-
#${escapeHtml(primaryUserId)}
-
-
-
${escapeHtml(t('account_merge_removed_account_label'))}
-
#${escapeHtml(removedUserId)}
-
-
-
${escapeHtml(t('account_merge_end_date_label'))}
-
${escapeHtml(finalEndDateText)}
-
-
- `; - applyI18n(modal); - window.requestAnimationFrame(() => modal.classList.add('show')); - } - - function renderReferral(referral) { - 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 => ` -
- ${escapeHtml(item.title || '')} - ${escapeHtml(t('referral_bonus_pair', { - inviter: item.inviter_days || 0, - friend: item.friend_days || 0 - }))} -
- `).join(''); - - panel.innerHTML = ` -
-
-
${escapeHtml(t('referral_title'))}
-
${escapeHtml(t('referral_caption'))}
-
- -
- -
-
- - -
- -
-
- -
${escapeHtml(t('referral_bonus_explanation'))}
-
-
${bonusRows || '
' + escapeHtml(t('referral_no_bonuses')) + '
'}
-
-
- `; - applyI18n(modal); - window.requestAnimationFrame(() => modal.classList.add('show')); - } - - function renderReferralLinkRow(kind, label, link) { - return ` -
-
-
${escapeHtml(label)}
-
${escapeHtml(link || t('not_available'))}
-
- -
- `; - } - - async function applyPromoCode() { - if (state.promoApplying) return; - const input = document.getElementById('promo-code-input'); - const code = String(input && input.value || '').trim(); - if (!code) { - setPromoStatus(t('promo_empty'), 'error'); - if (input) input.setAttribute('aria-invalid', 'true'); - if (input) input.focus(); - return; - } - - state.promoApplying = true; - setButtonBusy('promo-apply-btn', true); - clearPromoStatus(); - if (input) input.removeAttribute('aria-invalid'); - try { - const data = await api('/promo/apply', { - method: 'POST', - body: JSON.stringify({code}) - }); - if (!data.ok) throw data; - if (input) input.value = ''; - setPromoStatus(t('promo_applied_ok'), 'success'); - await loadData(); - } catch (e) { - const errorCode = e && e.error; - const serverMessage = e && (e.message || e.detail); - if (input) input.setAttribute('aria-invalid', 'true'); - if (errorCode === 'empty_code') { - setPromoStatus(t('promo_empty'), 'error'); - } else if (serverMessage) { - setPromoStatus(serverMessage, 'error'); - } else if (errorCode === 'promo_apply_failed') { - setPromoStatus(t('promo_invalid'), 'error'); - } else { - setPromoStatus(t('payment_error'), 'error'); - } - } finally { - state.promoApplying = false; - setButtonBusy('promo-apply-btn', false); - } - } - - async function copyReferralLink(kind) { - const referral = state.data && state.data.referral; - const link = referral && (kind === 'bot' ? referral.bot_link : referral.webapp_link); - if (!link) { - showToast(t('no_link')); - return; - } - await copyText(link); - 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); - if (!email) { - showToast(t('email_code_send_failed')); - return; - } - state.emailLinkPending = true; - setButtonBusy('email-link-send-btn', true); - try { - const data = await api('/account/email/request', { - method: 'POST', - body: JSON.stringify({email}) - }); - if (!data.ok) throw data; - if (data.already_linked) { - showToast(t('email_linked')); - await loadData(); - return; - } - state.emailLinkEmail = email; - document.getElementById('email-link-code-row').classList.remove('hidden'); - showToast(t('email_code_sent')); - } catch (e) { - showToast(emailErrorMessage(e, 'email_code_send_failed')); - } finally { - state.emailLinkPending = false; - setButtonBusy('email-link-send-btn', false); - } - } - - async function verifyEmailLinkCode() { - const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value); - const code = document.getElementById('email-link-code-input').value; - setButtonBusy('email-link-verify-btn', true); - let mergeNotice = null; - try { - const data = await api('/account/email/verify', { - method: 'POST', - body: JSON.stringify({email, code}) - }); - if (!data.ok) throw data; - if (data.token) setToken(data.token, data.csrf_token); - mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null; - state.accountMergeNotice = mergeNotice; - showToast(mergeNotice ? t('account_merge_toast') : t('email_linked')); - state.emailLinkEmail = ''; - try { - await loadData(); - if (mergeNotice) { - openAccountMergeModal(mergeNotice); - } - } catch (refreshError) { - console.warn('Email account linked, but data refresh failed', refreshError); - if (mergeNotice) { - openAccountMergeModal(mergeNotice); - } - } - } catch (e) { - showToast(emailErrorMessage(e, 'email_code_invalid')); - } finally { - setButtonBusy('email-link-verify-btn', false); - } - } - - function renderTelegramLinkWidget() { - const container = document.getElementById('telegram-link-widget'); - if (!container || state.telegramLinkRendered) return; - container.innerHTML = ''; - const botUsername = String(CFG.telegramLoginBotUsername || '').trim(); - if (!botUsername) { - setTelegramLinkStatus(t('telegram_auth_unavailable'), true); - return; - } - - window.onTelegramLinkAuth = async function(user) { - await linkTelegramAccount(user); - }; - - const script = document.createElement('script'); - script.async = true; - script.src = TELEGRAM_LOGIN_WIDGET_URL; - script.setAttribute('data-telegram-login', botUsername); - script.setAttribute('data-size', 'large'); - script.setAttribute('data-userpic', 'true'); - script.setAttribute('data-request-access', 'write'); - script.setAttribute('data-onauth', 'onTelegramLinkAuth(user)'); - script.onerror = () => setTelegramLinkStatus(t('telegram_auth_unavailable'), true); - container.appendChild(script); - state.telegramLinkRendered = true; - } - - async function linkTelegramAccount(user) { - if (!user || state.telegramLinkInProgress) return; - state.telegramLinkInProgress = true; - setTelegramLinkStatus(t('telegram_auth_verifying')); - try { - const data = await api('/account/telegram/link', { - method: 'POST', - body: JSON.stringify({auth_data: user}) - }); - if (!data.ok) throw data; - await finishTelegramLink(data, user.id); - } catch (e) { - if (await refreshLinkedTelegramAfterError()) { - setTelegramLinkStatus(''); - try { - showToast(t('telegram_linked')); - } catch (toastError) { - console.warn('Telegram link success toast failed', toastError); - } - return; - } - setTelegramLinkStatus(emailErrorMessage(e, 'telegram_auth_failed'), true); - } finally { - state.telegramLinkInProgress = false; - } - } - - async function finishTelegramLink(data, fallbackTelegramId) { - if (data.token) setToken(data.token, data.csrf_token); - markTelegramLinked(data.telegram_id || fallbackTelegramId); - setTelegramLinkStatus(''); - state.telegramLinkRendered = false; - const mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null; - state.accountMergeNotice = mergeNotice; - try { - showToast(mergeNotice ? t('account_merge_toast') : t('telegram_linked')); - } catch (toastError) { - console.warn('Telegram link success toast failed', toastError); - } - try { - await loadData(); - if (mergeNotice) { - openAccountMergeModal(mergeNotice); - } - } catch (refreshError) { - console.warn('Telegram account linked, but data refresh failed', refreshError); - if (mergeNotice) { - openAccountMergeModal(mergeNotice); - } - } - } - - function markTelegramLinked(telegramId) { - if (!state.data || !state.data.user) return; - state.data.user.telegram_linked = true; - if (telegramId) { - state.data.user.telegram_id = Number(telegramId); - } - renderAccount(state.data.user); - } - - async function refreshLinkedTelegramAfterError() { - try { - await loadData(); - return Boolean(state.data && state.data.user && state.data.user.telegram_linked); - } catch (refreshError) { - console.warn('Telegram link status refresh failed', refreshError); - return false; - } - } - - function togglePaymentFlow() { - if (state.paymentFlowOpen) { - closePaymentFlow(); - return; - } - state.paymentFlowOpen = true; - state.creatingPayment = false; - state.payment = null; - state.selectedPlan = getDefaultPaymentPlan((state.data && state.data.plans) || []); - renderPaymentFlow(); - } - - function closePaymentFlow() { - state.paymentFlowOpen = false; - state.creatingPayment = false; - state.payment = null; - renderPaymentFlow(); - } - - function renderPaymentFlow() { - const modal = document.getElementById('payment-modal'); - if (!modal) return; - if (!state.paymentFlowOpen) { - modal.classList.remove('show'); - syncModalLock(); - window.setTimeout(() => { - if (!state.paymentFlowOpen) modal.classList.add('hidden'); - }, 180); - return; - } - - modal.classList.remove('hidden'); - syncModalLock(); - window.requestAnimationFrame(() => modal.classList.add('show')); - renderPlans((state.data && state.data.plans) || []); - renderPaymentSummary(); - renderMethods(); - applyI18n(modal); - } - - function syncModalLock() { - document.body.classList.toggle( - 'modal-open', - Boolean( - state.paymentFlowOpen - || state.emailLoginCodeModalOpen - || state.promoModalOpen - || state.referralModalOpen - || state.accountMergeModalOpen - ) - ); - } - - function getDefaultPaymentPlan(plans = []) { - if (!plans.length) return null; - return plans.find(plan => Number(plan.months) === 3) || plans[0] || null; - } - - function renderPlans(plans) { - const wrap = document.getElementById('plans'); - if (!wrap) return; - if (!plans.length) { - wrap.innerHTML = '
' + escapeHtml(t('no_plans')) + '
'; - return; - } - - if (!state.selectedPlan || !plans.some(plan => Number(plan.months) === Number(state.selectedPlan.months))) { - state.selectedPlan = getDefaultPaymentPlan(plans); - } - - wrap.innerHTML = plans.map(plan => { - const planMonths = Number(plan.months); - const isActive = state.selectedPlan && Number(state.selectedPlan.months) === planMonths; - return ` - - `; - }).join(''); - } - - function renderPaymentSummary() { - const price = document.getElementById('selected-plan-price'); - if (!price) return; - if (!state.selectedPlan) { - price.textContent = t('no_plans'); - return; - } - price.textContent = formatMoney(state.selectedPlan.price, state.selectedPlan.currency); - } - - function renderMethods() { - const methods = document.getElementById('payment-methods'); - if (!methods) return; - if (!state.selectedPlan) { - methods.innerHTML = '
' + escapeHtml(t('choose_plan_first')) + '
'; - return; - } - - const available = getAvailableMethods(); - if (!available.length) { - methods.innerHTML = '
' + escapeHtml(t('no_methods')) + '
'; - return; - } - - methods.innerHTML = available.map(method => { - let labelKey; - let variantClass; - if (method.id === 'platega_sbp') { - labelKey = 'pay_with_platega_sbp_button'; - variantClass = TW.paymentMethodCardPlategaSbp; - } else if (method.id === 'platega_crypto') { - labelKey = 'pay_with_platega_crypto_button'; - variantClass = TW.paymentMethodCardPlategaCrypto; - } else if (method.id === 'platega') { - labelKey = 'pay_with_platega_button'; - variantClass = TW.paymentMethodCardPlatega; - } else { - labelKey = 'pay_with_cryptopay_button'; - variantClass = TW.paymentMethodCardCryptopay; - } - return ` - - `; - }).join(''); - } - - function selectPlan(months) { - if (state.creatingPayment) return; - state.selectedPlan = ((state.data && state.data.plans) || []).find(plan => Number(plan.months) === Number(months)) || null; - state.payment = null; - renderPaymentFlow(); - } - - async function createAndOpenPayment(method) { - if (state.creatingPayment) return; - if (!state.selectedPlan) { - showToast(t('choose_plan_toast')); - return; - } - const available = getAvailableMethods(); - if (!available.some(item => item.id === method)) { - showToast(t('no_methods')); - return; - } - - state.creatingPayment = true; - state.payment = null; - renderPaymentFlow(); - try { - const data = await api('/payments', { - method: 'POST', - body: JSON.stringify({months: state.selectedPlan.months, method}) - }); - if (!data.ok || !data.payment_url) throw new Error(data.message || t('payment_error')); - state.payment = data; - const opened = openPaymentUrl(data.payment_url); - if (opened) { - closePaymentFlow(); - } - } catch (e) { - showToast(e.message || t('payment_error')); - } finally { - state.creatingPayment = false; - if (state.paymentFlowOpen) { - renderPaymentFlow(); - } - } - } - - function getAvailableMethods() { - if (!state.data || !state.selectedPlan) return []; - const methodsById = new Map( - (state.data.payment_methods || []).map(method => [String(method.id || '').toLowerCase(), method]) - ); - return ['platega_sbp', 'platega_crypto', 'platega', 'cryptopay'] - .map(methodId => methodsById.get(methodId)) - .filter(Boolean); - } - - function openPaymentUrl(url) { - const paymentUrl = String(url || (state.payment && state.payment.payment_url) || '').trim(); - if (!paymentUrl) return false; - if (tg && tg.openLink) { - tg.openLink(paymentUrl); - return true; - } - const opened = window.open(paymentUrl, '_blank', 'noopener'); - if (!opened) { - window.location.assign(paymentUrl); - } - return true; - } - - async function checkPayment() { - if (!state.payment || !state.payment.payment_id) return; - const data = await api('/payments/' + state.payment.payment_id); - if (data.paid) { - showToast(t('payment_confirmed')); - state.paymentFlowOpen = false; - await loadData(); - } else { - showToast(t('payment_pending')); - } - } - - function openConnectLink() { - const sub = state.data && state.data.subscription; - const url = sub && (sub.connect_url || sub.config_link); - if (!url) { - showToast(t('no_link')); - return; - } - if (tg && tg.openLink) tg.openLink(url); - else window.open(url, '_blank', 'noopener'); - } - - async function copyConfigLink() { - const sub = state.data && state.data.subscription; - const link = sub && sub.config_link; - if (!link) { - showToast(t('no_link')); - return; - } - await copyText(link); - showToast(t('link_copied')); - } - - async function copyText(value) { - try { - await navigator.clipboard.writeText(value); - } catch (e) { - const area = document.createElement('textarea'); - area.value = value; - document.body.appendChild(area); - area.select(); - document.execCommand('copy'); - area.remove(); - } - } - - async function api(path, options = {}) { - if (MOCK) { - return mockApi(path, options); - } - - const method = String(options.method || 'GET').toUpperCase(); - const headers = Object.assign({}, options.headers || {}); - if (state.token) { - headers.Authorization = 'Bearer ' + state.token; - } - const csrfToken = state.csrfToken || readCookie('rw_webapp_csrf') || ''; - if (csrfToken && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { - headers['X-CSRF-Token'] = csrfToken; - } - if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; - const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers})); - if (response.status === 401) { - clearToken(); - await startExternalAuth(); - throw new Error('Unauthorized'); - } - return response.json(); - } - - async function publicApi(path, payload = {}) { - if (MOCK) { - return mockApi(path, { - method: 'POST', - body: JSON.stringify(payload) - }); - } - const response = await fetch(CFG.apiBase + path, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - return response.json(); - } - - async function mockApi(path, options = {}) { - await new Promise(resolve => window.setTimeout(resolve, 120)); - if (path === '/me') { - return JSON.parse(JSON.stringify(MOCK.data)); - } - if (path === '/auth/email/request' || path === '/account/email/request') { - return {ok: true}; - } - if (path === '/auth/email/verify') { - return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; - } - if (path === '/auth/email/magic') { - return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; - } - if (path === '/account/email/verify') { - MOCK.data.user.email = 'preview@example.com'; - MOCK.data.user.email_verified = true; - return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; - } - if (path === '/account/telegram/link') { - MOCK.data.user.telegram_linked = true; - MOCK.data.user.telegram_id = 100200300; - return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'}; - } - if (path === '/auth/logout') { - return {ok: true}; - } - if (path === '/promo/apply') { - return {ok: true, end_date_text: '20.05.2026 12:00'}; - } - if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') { - return { - ok: true, - action: 'open_link', - payment_url: 'https://example.com/payment-preview', - payment_id: 10001 - }; - } - if (path.startsWith('/payments/')) { - return { - ok: true, - payment_id: 10001, - status: 'pending', - paid: false - }; - } - return {ok: false, error: 'not_found'}; - } - - function setToken(token, csrfToken = '') { - clearManualLogoutFlag(); - state.token = token; - state.csrfToken = csrfToken || readCookie('rw_webapp_csrf') || state.csrfToken || ''; - } - - function clearToken() { - state.token = ''; - state.csrfToken = ''; - localStorage.removeItem('rw_webapp_token'); - } - - function clearReadableAuthCookie() { - document.cookie = 'rw_webapp_csrf=; Max-Age=0; path=/; SameSite=None; Secure'; - } - - function isManuallyLoggedOut() { - try { - return localStorage.getItem(MANUAL_LOGOUT_FLAG_KEY) === '1'; - } catch (e) { - return false; - } - } - - function markManualLogout() { - try { - localStorage.setItem(MANUAL_LOGOUT_FLAG_KEY, '1'); - } catch (e) { } - } - - function clearManualLogoutFlag() { - try { - localStorage.removeItem(MANUAL_LOGOUT_FLAG_KEY); - } catch (e) { } - } - - function readCookie(name) { - const prefix = name + '='; - const cookie = document.cookie.split('; ').find(part => part.startsWith(prefix)); - return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ''; - } - - function normalizeEmail(value) { - return String(value || '').trim().toLowerCase(); - } - - function emailErrorMessage(error, fallbackKey) { - const code = error && error.error; - if (code === 'rate_limited') { - return t('email_rate_limited', {seconds: error.retry_after || 60}); - } - if (code === 'invalid_email') return t('email_invalid'); - if (code === 'expired_code') return t('email_code_expired'); - if (code === 'invalid_code' || code === 'too_many_attempts') return t('email_code_invalid'); - if (code === 'email_auth_not_configured') return t('email_auth_disabled'); - if (code === 'account_merge_conflict') return t('account_merge_conflict'); - if (code === 'invalid_auth') return t('telegram_auth_failed'); - return t(fallbackKey); - } - - function setButtonBusy(id, busy) { - const button = document.getElementById(id); - if (button) button.disabled = Boolean(busy); - } - - async function logout() { - toggleUserMenu(false); - markManualLogout(); - clearReadableAuthCookie(); - clearToken(); - closePaymentFlow(); - closeEmailLoginCodeModal(); - startExternalAuth(); - try { - await publicApi('/auth/logout', {keepalive: true}); - } catch (e) { } - } - - function showLoader() { - document.getElementById('loader').classList.remove('hidden'); - document.getElementById('app').classList.add('hidden'); - document.getElementById('login').classList.remove('show'); - } - - 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'); - } - - function setAuthStatus(message, isError = false) { - const status = document.getElementById('auth-status'); - if (!status) return; - if (!message) { - status.textContent = ''; - status.classList.add('hidden'); - status.classList.remove('error'); - return; - } - - status.textContent = message; - status.classList.remove('hidden'); - status.classList.toggle('error', Boolean(isError)); - } - - function setEmailCodeStatus(message, isError = false) { - const status = document.getElementById('email-code-status'); - if (!status) return; - if (!message) { - status.textContent = ''; - status.classList.add('hidden'); - status.classList.remove('error'); - return; - } - - status.textContent = message; - status.classList.remove('hidden'); - status.classList.toggle('error', Boolean(isError)); - } - - function setTelegramLinkStatus(message, isError = false) { - const status = document.getElementById('telegram-link-status'); - if (!status) return; - if (!message) { - status.textContent = ''; - status.classList.add('hidden'); - status.classList.remove('error'); - return; - } - status.textContent = message; - status.classList.remove('hidden'); - status.classList.toggle('error', Boolean(isError)); - } - - function showToast(message) { - const toast = document.getElementById('toast'); - if (!toast) return; - - toast.textContent = message; - toast.classList.remove('hidden'); - - // 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(() => { - if (!toast.classList.contains('show')) { - toast.classList.add('hidden'); - } - }, 250); - }, 2200); - } - - function setFavicon(href) { - let favicon = document.getElementById('app-favicon'); - if (!favicon) { - favicon = document.createElement('link'); - favicon.id = 'app-favicon'; - favicon.rel = 'icon'; - favicon.setAttribute('sizes', 'any'); - document.head.appendChild(favicon); - } - favicon.href = href || 'data:,'; - } - - function setBrandLogo(shell, logoUrl) { - if (!shell) return; - - const logo = shell.querySelector('[data-brand-logo]'); - const spinner = shell.querySelector('[data-brand-logo-spinner]'); - if (!logo) return; - - logo.onload = null; - logo.onerror = null; - logo.removeAttribute('data-brand-logo-url'); - - if (!logoUrl) { - logo.removeAttribute('src'); - logo.classList.add('hidden'); - if (spinner) spinner.classList.add('hidden'); - shell.classList.add('hidden'); - return; - } - - const expectedUrl = logoUrl; - shell.classList.remove('hidden'); - if (spinner) spinner.classList.remove('hidden'); - logo.classList.add('hidden'); - logo.dataset.brandLogoUrl = expectedUrl; - - logo.onload = () => { - if (logo.dataset.brandLogoUrl !== expectedUrl) return; - logo.classList.remove('hidden'); - if (spinner) spinner.classList.add('hidden'); - }; - - logo.onerror = () => { - if (logo.dataset.brandLogoUrl !== expectedUrl) return; - logo.removeAttribute('src'); - logo.classList.add('hidden'); - if (spinner) spinner.classList.add('hidden'); - shell.classList.add('hidden'); - }; - - logo.src = logoUrl; - if (logo.complete && logo.naturalWidth > 0) { - logo.classList.remove('hidden'); - if (spinner) spinner.classList.add('hidden'); - } - } - - function setBrand() { - const title = CFG.title || document.title || t('page_title'); - const logoUrl = CFG.logoUrl || ''; - document.title = title; - setFavicon(logoUrl); - document.querySelectorAll('[data-brand-title]').forEach(node => { - node.textContent = title; - }); - document.querySelectorAll('[data-brand-logo-shell]').forEach(shell => { - setBrandLogo(shell, logoUrl); - }); - } - - function applyI18n(root = document) { - document.documentElement.lang = getLanguage(); - root.querySelectorAll('[data-i18n]').forEach(node => { - node.textContent = t(node.dataset.i18n); - }); - root.querySelectorAll('[data-title-i18n]').forEach(node => { - const value = t(node.dataset.titleI18n); - node.setAttribute('title', value); - node.setAttribute('aria-label', value); - }); - root.querySelectorAll('[data-aria-i18n]').forEach(node => { - node.setAttribute('aria-label', t(node.dataset.ariaI18n)); - }); - root.querySelectorAll('[data-placeholder-i18n]').forEach(node => { - node.setAttribute('placeholder', t(node.dataset.placeholderI18n)); - }); - } - - function applyLegalLinks(root = document) { - const urls = { - privacyPolicyUrl: CFG.privacyPolicyUrl || '', - userAgreementUrl: CFG.userAgreementUrl || '' - }; - - root.querySelectorAll('[data-legal-key]').forEach(node => { - const key = node.dataset.legalKey; - const url = urls[key] || ''; - node.href = url || '#'; - node.classList.toggle('hidden', !url); - node.setAttribute('aria-hidden', url ? 'false' : 'true'); - }); - - 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); - }); - } - - function getLanguage() { - const candidates = []; - if (state.langOverride) candidates.push(state.langOverride); - if (state.data && state.data.user && state.data.user.language_code) { - candidates.push(state.data.user.language_code); - } - const tgUser = tg && tg.initDataUnsafe && tg.initDataUnsafe.user; - if (tgUser && tgUser.language_code) candidates.push(tgUser.language_code); - if (typeof navigator !== 'undefined') { - if (Array.isArray(navigator.languages)) { - navigator.languages.forEach(l => l && candidates.push(l)); - } - if (navigator.language) candidates.push(navigator.language); - } - if (CFG.language) candidates.push(CFG.language); - if (document.documentElement.lang) candidates.push(document.documentElement.lang); - for (const raw of candidates) { - const short = String(raw).toLowerCase().split('-')[0]; - if (I18N && I18N[short]) return short; - } - return 'ru'; - } - - function setLanguage(lang) { - const short = String(lang || '').toLowerCase().split('-')[0]; - if (I18N && Object.keys(I18N).length && !I18N[short]) return; - state.langOverride = short; - try { localStorage.setItem('rw_webapp_lang', short); } catch (e) { } - applyI18n(); - if (state.data) { - try { render(); } catch (e) { } - } - renderLangMenu(); - toggleLangMenu(false); - } - - function t(key, params = {}) { - const table = (I18N && I18N[getLanguage()]) || (I18N && I18N.ru) || {}; - const fallback = (I18N && I18N.ru && I18N.ru[key]) || key; - const template = table[key] || fallback; - return template.replace(/\{(\w+)\}/g, (_, name) => ( - Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : '' - )); - } - - function formatMoney(value, currency) { - const numeric = Number(value || 0); - const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2); - return formatted + ' ' + (currency || CFG.currency || 'RUB'); - } - - function escapeHtml(value) { - return String(value == null ? '' : value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - } - - function escapeAttr(value) { - return escapeHtml(value).replaceAll('`', '`'); - } diff --git a/bot/app/web/templates/subscription_webapp.tailwind.css b/bot/app/web/templates/subscription_webapp.tailwind.css deleted file mode 100644 index fb95da0..0000000 --- a/bot/app/web/templates/subscription_webapp.tailwind.css +++ /dev/null @@ -1,916 +0,0 @@ -@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 inline-flex min-h-[44px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-center font-extrabold text-[14px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)]; - transition-duration: 200ms; - } - .plan-card-active { - @apply border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_8%,transparent)] text-[var(--accent)] ring-1 ring-[color-mix(in_srgb,var(--accent)_58%,transparent)] shadow-[0_12px_30px_rgba(0,0,0,0.2)]; - } - .payment-method-card { - @apply inline-flex min-h-[52px] w-full min-w-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-4 py-[12px] text-center font-extrabold text-[15px] leading-tight text-[var(--text-primary)] transition-[transform,border-color,background,box-shadow] hover:-translate-y-0.5 hover:border-[color-mix(in_srgb,var(--accent)_42%,var(--border))] hover:bg-[var(--bg-card-hover)]; - transition-duration: 200ms; - } - .payment-method-card--platega { - border-color: rgba(0, 254, 122, 0.36); - background: rgba(0, 254, 122, 0.08); - } - .payment-method-card--cryptopay { - border-color: rgba(34, 211, 238, 0.36); - background: rgba(34, 211, 238, 0.08); - } - .plan-name { - @apply block text-[15px] font-extrabold leading-tight; - } - .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; - } - - .lang-menu { - @apply relative inline-flex; - } - - .lang-chip { - @apply inline-flex min-h-[38px] cursor-pointer items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border)] bg-[rgba(255,255,255,0.03)] px-2 text-left font-extrabold text-[12px] 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)]; - } - - .lang-chip[aria-expanded="true"] { - @apply border-[color-mix(in_srgb,var(--accent)_55%,var(--border))] bg-[var(--bg-card-hover)]; - } - - .lang-chip-flag { - @apply text-[14px] leading-none; - font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; - } - - .lang-chip-label { - @apply font-[family-name:var(--font-mono)] text-[11px] tracking-wide; - } - - .lang-chip-caret { - @apply h-3 w-3 flex-none fill-[var(--text-secondary)] transition-transform; - } - - .lang-chip[aria-expanded="true"] .lang-chip-caret { - transform: rotate(180deg); - } - - .lang-dropdown { - @apply absolute right-0 top-[calc(100%+8px)] z-30 grid w-[160px] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96]; - backdrop-filter: blur(14px); - will-change: opacity, transform; - } - - .lang-dropdown.show { - @apply opacity-100 pointer-events-auto translate-y-0 scale-100; - } - - .lang-dropdown-item { - @apply flex cursor-pointer items-center gap-2.5 border-0 bg-transparent px-3 py-2.5 text-left text-[13px] font-bold text-[var(--text-secondary)] transition-colors hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-primary)]; - } - - .lang-dropdown-item + .lang-dropdown-item { - @apply border-t border-[var(--border)]; - } - - .lang-dropdown-item.is-active { - @apply bg-[var(--bg-card-hover)] text-[var(--text-primary)]; - } - - .lang-dropdown-flag { - @apply text-[16px] leading-none; - font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; - } - - .lang-dropdown-label { - @apply flex-1 overflow-hidden text-ellipsis whitespace-nowrap; - } - - .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)] origin-top-right gap-0 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--bg-card)] opacity-0 shadow-[0_24px_48px_rgba(0,0,0,0.45)] transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.2,0.8,0.2,1)] pointer-events-none -translate-y-2 scale-[0.96]; - backdrop-filter: blur(14px); - will-change: opacity, transform; - } - - .user-dropdown.show { - @apply opacity-100 pointer-events-auto translate-y-0 scale-100; - } - - .user-dropdown-head { - @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; - } - - @keyframes brand-logo-spin { - to { - transform: rotate(360deg); - } - } - - .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.inactive { - border-color: color-mix(in srgb, var(--danger) 60%, transparent); - } - - .traffic-bar.inactive .traffic-bar-fill { - width: 100%; - background: color-mix(in srgb, var(--danger) 55%, 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); - } - - @media (min-width: 561px) { - .app { - @apply min-h-screen justify-center; - } - } - - .telegram-login-widget { - @apply flex min-w-0 items-center; - } - - .brand-logo-shell { - @apply relative grid h-[38px] w-[38px] flex-none place-items-center overflow-hidden; - } - - .brand-logo-shell--lg { - @apply h-[96px] w-[96px]; - } - - .brand-logo { - @apply absolute inset-0 h-full w-full object-contain; - } - - .brand-logo-spinner { - @apply h-5 w-5 flex-none rounded-full border-2; - border-color: color-mix(in srgb, var(--accent) 18%, var(--border)); - border-top-color: var(--accent); - animation: brand-logo-spin 0.85s linear infinite; - } - - .brand-logo--lg { - @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-auth-body { - @apply grid w-full min-h-[140px] gap-2.5 content-start; - } - - .login-auth-body--telegram { - @apply content-center; - } - - .login-text, - .promo-status { - @apply text-[var(--text-secondary)]; - } - - .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]; - } - - .brand-logo-shell--lg { - @apply h-[96px] w-[96px]; - } - - .login-auth-body { - @apply min-h-[128px]; - } - - .login-brand-title { - @apply max-w-[200px] text-[21px]; - } -} - -@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); - } -} diff --git a/bot/app/web/templates/telegram-web-app.js b/bot/app/web/templates/telegram-web-app.js deleted file mode 100644 index 6c8051c..0000000 --- a/bot/app/web/templates/telegram-web-app.js +++ /dev/null @@ -1,3392 +0,0 @@ -// WebView -(function () { - var eventHandlers = {}; - - var locationHash = ''; - try { - locationHash = location.hash.toString(); - } catch (e) {} - - var initParams = urlParseHashParams(locationHash); - var storedParams = sessionStorageGet('initParams'); - if (storedParams) { - for (var key in storedParams) { - if (typeof initParams[key] === 'undefined') { - initParams[key] = storedParams[key]; - } - } - } - sessionStorageSet('initParams', initParams); - - var isIframe = false, iFrameStyle; - try { - isIframe = (window.parent != null && window != window.parent); - if (isIframe) { - window.addEventListener('message', function (event) { - if (event.source !== window.parent) return; - try { - var dataParsed = JSON.parse(event.data); - } catch (e) { - return; - } - if (!dataParsed || !dataParsed.eventType) { - return; - } - if (dataParsed.eventType == 'set_custom_style') { - if (event.origin === 'https://web.telegram.org') { - iFrameStyle.innerHTML = dataParsed.eventData; - } - } else if (dataParsed.eventType == 'reload_iframe') { - try { - window.parent.postMessage(JSON.stringify({eventType: 'iframe_will_reload'}), '*'); - } catch (e) {} - location.reload(); - } else { - receiveEvent(dataParsed.eventType, dataParsed.eventData); - } - }); - iFrameStyle = document.createElement('style'); - document.head.appendChild(iFrameStyle); - try { - window.parent.postMessage(JSON.stringify({eventType: 'iframe_ready', eventData: {reload_supported: true}}), '*'); - } catch (e) {} - } - } catch (e) {} - - function urlSafeDecode(urlencoded) { - try { - urlencoded = urlencoded.replace(/\+/g, '%20'); - return decodeURIComponent(urlencoded); - } catch (e) { - return urlencoded; - } - } - - function urlParseHashParams(locationHash) { - locationHash = locationHash.replace(/^#/, ''); - var params = {}; - if (!locationHash.length) { - return params; - } - if (locationHash.indexOf('=') < 0 && locationHash.indexOf('?') < 0) { - params._path = urlSafeDecode(locationHash); - return params; - } - var qIndex = locationHash.indexOf('?'); - if (qIndex >= 0) { - var pathParam = locationHash.substr(0, qIndex); - params._path = urlSafeDecode(pathParam); - locationHash = locationHash.substr(qIndex + 1); - } - var query_params = urlParseQueryString(locationHash); - for (var k in query_params) { - params[k] = query_params[k]; - } - return params; - } - - function urlParseQueryString(queryString) { - var params = {}; - if (!queryString.length) { - return params; - } - var queryStringParams = queryString.split('&'); - var i, param, paramName, paramValue; - for (i = 0; i < queryStringParams.length; i++) { - param = queryStringParams[i].split('='); - paramName = urlSafeDecode(param[0]); - paramValue = param[1] == null ? null : urlSafeDecode(param[1]); - params[paramName] = paramValue; - } - return params; - } - - // Telegram apps will implement this logic to add service params (e.g. tgShareScoreUrl) to game URL - function urlAppendHashParams(url, addHash) { - // url looks like 'https://game.com/path?query=1#hash' - // addHash looks like 'tgShareScoreUrl=' + encodeURIComponent('tgb://share_game_score?hash=very_long_hash123') - - var ind = url.indexOf('#'); - if (ind < 0) { - // https://game.com/path -> https://game.com/path#tgShareScoreUrl=etc - return url + '#' + addHash; - } - var curHash = url.substr(ind + 1); - if (curHash.indexOf('=') >= 0 || curHash.indexOf('?') >= 0) { - // https://game.com/#hash=1 -> https://game.com/#hash=1&tgShareScoreUrl=etc - // https://game.com/#path?query -> https://game.com/#path?query&tgShareScoreUrl=etc - return url + '&' + addHash; - } - // https://game.com/#hash -> https://game.com/#hash?tgShareScoreUrl=etc - if (curHash.length > 0) { - return url + '?' + addHash; - } - // https://game.com/# -> https://game.com/#tgShareScoreUrl=etc - return url + addHash; - } - - function postEvent(eventType, callback, eventData) { - if (!callback) { - callback = function () {}; - } - if (eventData === undefined) { - eventData = ''; - } - console.log('[Telegram.WebView] > postEvent', eventType, eventData); - - if (window.TelegramWebviewProxy !== undefined) { - TelegramWebviewProxy.postEvent(eventType, JSON.stringify(eventData)); - callback(); - } - else if (window.external && 'notify' in window.external) { - window.external.notify(JSON.stringify({eventType: eventType, eventData: eventData})); - callback(); - } - else if (isIframe) { - try { - var trustedTarget = 'https://web.telegram.org'; - // For now we don't restrict target, for testing purposes - trustedTarget = '*'; - window.parent.postMessage(JSON.stringify({eventType: eventType, eventData: eventData}), trustedTarget); - callback(); - } catch (e) { - callback(e); - } - } - else { - callback({notAvailable: true}); - } - }; - - function receiveEvent(eventType, eventData) { - console.log('[Telegram.WebView] < receiveEvent', eventType, eventData); - callEventCallbacks(eventType, function(callback) { - callback(eventType, eventData); - }); - } - - function callEventCallbacks(eventType, func) { - var curEventHandlers = eventHandlers[eventType]; - if (curEventHandlers === undefined || - !curEventHandlers.length) { - return; - } - for (var i = 0; i < curEventHandlers.length; i++) { - try { - func(curEventHandlers[i]); - } catch (e) {} - } - } - - function onEvent(eventType, callback) { - if (eventHandlers[eventType] === undefined) { - eventHandlers[eventType] = []; - } - var index = eventHandlers[eventType].indexOf(callback); - if (index === -1) { - eventHandlers[eventType].push(callback); - } - }; - - function offEvent(eventType, callback) { - if (eventHandlers[eventType] === undefined) { - return; - } - var index = eventHandlers[eventType].indexOf(callback); - if (index === -1) { - return; - } - eventHandlers[eventType].splice(index, 1); - }; - - function openProtoUrl(url) { - if (!url.match(/^(web\+)?tgb?:\/\/./)) { - return false; - } - var useIframe = navigator.userAgent.match(/iOS|iPhone OS|iPhone|iPod|iPad/i) ? true : false; - if (useIframe) { - var iframeContEl = document.getElementById('tgme_frame_cont') || document.body; - var iframeEl = document.createElement('iframe'); - iframeContEl.appendChild(iframeEl); - var pageHidden = false; - var enableHidden = function () { - pageHidden = true; - }; - window.addEventListener('pagehide', enableHidden, false); - window.addEventListener('blur', enableHidden, false); - if (iframeEl !== null) { - iframeEl.src = url; - } - setTimeout(function() { - if (!pageHidden) { - window.location = url; - } - window.removeEventListener('pagehide', enableHidden, false); - window.removeEventListener('blur', enableHidden, false); - }, 2000); - } - else { - window.location = url; - } - return true; - } - - function sessionStorageSet(key, value) { - try { - window.sessionStorage.setItem('__telegram__' + key, JSON.stringify(value)); - return true; - } catch(e) {} - return false; - } - function sessionStorageGet(key) { - try { - return JSON.parse(window.sessionStorage.getItem('__telegram__' + key)); - } catch(e) {} - return null; - } - - if (!window.Telegram) { - window.Telegram = {}; - } - window.Telegram.WebView = { - initParams: initParams, - isIframe: isIframe, - onEvent: onEvent, - offEvent: offEvent, - postEvent: postEvent, - receiveEvent: receiveEvent, - callEventCallbacks: callEventCallbacks - }; - - window.Telegram.Utils = { - urlSafeDecode: urlSafeDecode, - urlParseQueryString: urlParseQueryString, - urlParseHashParams: urlParseHashParams, - urlAppendHashParams: urlAppendHashParams, - sessionStorageSet: sessionStorageSet, - sessionStorageGet: sessionStorageGet - }; - - // For Windows Phone app - window.TelegramGameProxy_receiveEvent = receiveEvent; - - // App backward compatibility - window.TelegramGameProxy = { - receiveEvent: receiveEvent - }; -})(); - -// WebApp -(function () { - var Utils = window.Telegram.Utils; - var WebView = window.Telegram.WebView; - var initParams = WebView.initParams; - var isIframe = WebView.isIframe; - - var WebApp = {}; - var webAppInitData = '', webAppInitDataUnsafe = {}; - var themeParams = {}, colorScheme = 'light'; - var webAppVersion = '6.0'; - var webAppPlatform = 'unknown'; - var webAppIsActive = true; - var webAppIsFullscreen = false; - var webAppIsOrientationLocked = false; - var webAppBackgroundColor = 'bg_color'; - var webAppHeaderColorKey = 'bg_color'; - var webAppHeaderColor = null; - - if (initParams.tgWebAppData && initParams.tgWebAppData.length) { - webAppInitData = initParams.tgWebAppData; - webAppInitDataUnsafe = Utils.urlParseQueryString(webAppInitData); - for (var key in webAppInitDataUnsafe) { - var val = webAppInitDataUnsafe[key]; - try { - if (val.substr(0, 1) == '{' && val.substr(-1) == '}' || - val.substr(0, 1) == '[' && val.substr(-1) == ']') { - webAppInitDataUnsafe[key] = JSON.parse(val); - } - } catch (e) {} - } - } - var stored_theme_params = Utils.sessionStorageGet('themeParams'); - if (initParams.tgWebAppThemeParams && initParams.tgWebAppThemeParams.length) { - var themeParamsRaw = initParams.tgWebAppThemeParams; - try { - var theme_params = JSON.parse(themeParamsRaw); - if (theme_params) { - setThemeParams(theme_params); - } - } catch (e) {} - } - if (stored_theme_params) { - setThemeParams(stored_theme_params); - } - var stored_def_colors = Utils.sessionStorageGet('defaultColors'); - if (initParams.tgWebAppDefaultColors && initParams.tgWebAppDefaultColors.length) { - var defColorsRaw = initParams.tgWebAppDefaultColors; - try { - var def_colors = JSON.parse(defColorsRaw); - if (def_colors) { - setDefaultColors(def_colors); - } - } catch (e) {} - } - if (stored_def_colors) { - setDefaultColors(stored_def_colors); - } - if (initParams.tgWebAppVersion) { - webAppVersion = initParams.tgWebAppVersion; - } - if (initParams.tgWebAppPlatform) { - webAppPlatform = initParams.tgWebAppPlatform; - } - - var stored_fullscreen = Utils.sessionStorageGet('isFullscreen'); - if (initParams.tgWebAppFullscreen) { - setFullscreen(true); - } - if (stored_fullscreen) { - setFullscreen(stored_fullscreen == 'yes'); - } - - var stored_orientation_lock = Utils.sessionStorageGet('isOrientationLocked'); - if (stored_orientation_lock) { - setOrientationLock(stored_orientation_lock == 'yes'); - } - - function onThemeChanged(eventType, eventData) { - if (eventData.theme_params) { - setThemeParams(eventData.theme_params); - window.Telegram.WebApp.MainButton.setParams({}); - window.Telegram.WebApp.SecondaryButton.setParams({}); - updateHeaderColor(); - updateBackgroundColor(); - updateBottomBarColor(); - receiveWebViewEvent('themeChanged'); - } - } - - var lastWindowHeight = window.innerHeight; - function onViewportChanged(eventType, eventData) { - if (eventData.height) { - window.removeEventListener('resize', onWindowResize); - setViewportHeight(eventData); - } - } - - function onWindowResize(e) { - if (lastWindowHeight != window.innerHeight) { - lastWindowHeight = window.innerHeight; - receiveWebViewEvent('viewportChanged', { - isStateStable: true - }); - } - } - - function onSafeAreaChanged(eventType, eventData) { - if (eventData) { - setSafeAreaInset(eventData); - } - } - function onContentSafeAreaChanged(eventType, eventData) { - if (eventData) { - setContentSafeAreaInset(eventData); - } - } - - function onVisibilityChanged(eventType, eventData) { - if (eventData.is_visible) { - webAppIsActive = true; - receiveWebViewEvent('activated'); - } else { - webAppIsActive = false; - receiveWebViewEvent('deactivated'); - } - } - - function linkHandler(e) { - if (e.metaKey || e.ctrlKey) return; - var el = e.target; - while (el.tagName != 'A' && el.parentNode) { - el = el.parentNode; - } - if (el.tagName == 'A' && - el.target != '_blank' && - (el.protocol == 'http:' || el.protocol == 'https:') && - el.hostname == 't.me') { - WebApp.openTelegramLink(el.href); - e.preventDefault(); - } - } - - function strTrim(str) { - return str.toString().replace(/^\s+|\s+$/g, ''); - } - - function receiveWebViewEvent(eventType) { - var args = Array.prototype.slice.call(arguments); - eventType = args.shift(); - WebView.callEventCallbacks('webview:' + eventType, function(callback) { - callback.apply(WebApp, args); - }); - } - - function onWebViewEvent(eventType, callback) { - WebView.onEvent('webview:' + eventType, callback); - }; - - function offWebViewEvent(eventType, callback) { - WebView.offEvent('webview:' + eventType, callback); - }; - - function setCssProperty(name, value) { - var root = document.documentElement; - if (root && root.style && root.style.setProperty) { - root.style.setProperty('--tg-' + name, value); - } - } - - function setFullscreen(is_fullscreen) { - webAppIsFullscreen = !!is_fullscreen; - Utils.sessionStorageSet('isFullscreen', webAppIsFullscreen ? 'yes' : 'no'); - } - - function setOrientationLock(is_locked) { - webAppIsOrientationLocked = !!is_locked; - Utils.sessionStorageSet('isOrientationLocked', webAppIsOrientationLocked ? 'yes' : 'no'); - } - - function setThemeParams(theme_params) { - // temp iOS fix - if (theme_params.bg_color == '#1c1c1d' && - theme_params.bg_color == theme_params.secondary_bg_color) { - theme_params.secondary_bg_color = '#2c2c2e'; - } - var color; - for (var key in theme_params) { - if (color = parseColorToHex(theme_params[key])) { - themeParams[key] = color; - if (key == 'bg_color') { - colorScheme = isColorDark(color) ? 'dark' : 'light' - setCssProperty('color-scheme', colorScheme); - } - key = 'theme-' + key.split('_').join('-'); - setCssProperty(key, color); - } - } - Utils.sessionStorageSet('themeParams', themeParams); - } - - function setDefaultColors(def_colors) { - if (colorScheme == 'dark') { - if (def_colors.bg_dark_color) { - webAppBackgroundColor = def_colors.bg_dark_color; - } - if (def_colors.header_dark_color) { - webAppHeaderColorKey = null; - webAppHeaderColor = def_colors.header_dark_color; - } - } else { - if (def_colors.bg_color) { - webAppBackgroundColor = def_colors.bg_color; - } - if (def_colors.header_color) { - webAppHeaderColorKey = null; - webAppHeaderColor = def_colors.header_color; - } - } - Utils.sessionStorageSet('defaultColors', def_colors); - } - - var webAppCallbacks = {}; - function generateCallbackId(len) { - var tries = 100; - while (--tries) { - var id = '', chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', chars_len = chars.length; - for (var i = 0; i < len; i++) { - id += chars[Math.floor(Math.random() * chars_len)]; - } - if (!webAppCallbacks[id]) { - webAppCallbacks[id] = {}; - return id; - } - } - throw Error('WebAppCallbackIdGenerateFailed'); - } - - var viewportHeight = false, viewportStableHeight = false, isExpanded = true; - function setViewportHeight(data) { - if (typeof data !== 'undefined') { - isExpanded = !!data.is_expanded; - viewportHeight = data.height; - if (data.is_state_stable) { - viewportStableHeight = data.height; - } - receiveWebViewEvent('viewportChanged', { - isStateStable: !!data.is_state_stable - }); - } - var height, stable_height; - if (viewportHeight !== false) { - height = (viewportHeight - bottomBarHeight) + 'px'; - } else { - height = bottomBarHeight ? 'calc(100vh - ' + bottomBarHeight + 'px)' : '100vh'; - } - if (viewportStableHeight !== false) { - stable_height = (viewportStableHeight - bottomBarHeight) + 'px'; - } else { - stable_height = bottomBarHeight ? 'calc(100vh - ' + bottomBarHeight + 'px)' : '100vh'; - } - setCssProperty('viewport-height', height); - setCssProperty('viewport-stable-height', stable_height); - } - - var safeAreaInset = {top: 0, bottom: 0, left: 0, right: 0}; - function setSafeAreaInset(data) { - if (typeof data !== 'undefined') { - if (typeof data.top !== 'undefined') { - safeAreaInset.top = data.top; - } - if (typeof data.bottom !== 'undefined') { - safeAreaInset.bottom = data.bottom; - } - if (typeof data.left !== 'undefined') { - safeAreaInset.left = data.left; - } - if (typeof data.right !== 'undefined') { - safeAreaInset.right = data.right; - } - receiveWebViewEvent('safeAreaChanged'); - } - setCssProperty('safe-area-inset-top', safeAreaInset.top + 'px'); - setCssProperty('safe-area-inset-bottom', safeAreaInset.bottom + 'px'); - setCssProperty('safe-area-inset-left', safeAreaInset.left + 'px'); - setCssProperty('safe-area-inset-right', safeAreaInset.right + 'px'); - } - - var contentSafeAreaInset = {top: 0, bottom: 0, left: 0, right: 0}; - function setContentSafeAreaInset(data) { - if (typeof data !== 'undefined') { - if (typeof data.top !== 'undefined') { - contentSafeAreaInset.top = data.top; - } - if (typeof data.bottom !== 'undefined') { - contentSafeAreaInset.bottom = data.bottom; - } - if (typeof data.left !== 'undefined') { - contentSafeAreaInset.left = data.left; - } - if (typeof data.right !== 'undefined') { - contentSafeAreaInset.right = data.right; - } - receiveWebViewEvent('contentSafeAreaChanged'); - } - setCssProperty('content-safe-area-inset-top', contentSafeAreaInset.top + 'px'); - setCssProperty('content-safe-area-inset-bottom', contentSafeAreaInset.bottom + 'px'); - setCssProperty('content-safe-area-inset-left', contentSafeAreaInset.left + 'px'); - setCssProperty('content-safe-area-inset-right', contentSafeAreaInset.right + 'px'); - } - - var isClosingConfirmationEnabled = false; - function setClosingConfirmation(need_confirmation) { - if (!versionAtLeast('6.2')) { - console.warn('[Telegram.WebApp] Closing confirmation is not supported in version ' + webAppVersion); - return; - } - isClosingConfirmationEnabled = !!need_confirmation; - WebView.postEvent('web_app_setup_closing_behavior', false, {need_confirmation: isClosingConfirmationEnabled}); - } - - var isVerticalSwipesEnabled = true; - function toggleVerticalSwipes(enable_swipes) { - if (!versionAtLeast('7.7')) { - console.warn('[Telegram.WebApp] Changing swipes behavior is not supported in version ' + webAppVersion); - return; - } - isVerticalSwipesEnabled = !!enable_swipes; - WebView.postEvent('web_app_setup_swipe_behavior', false, {allow_vertical_swipe: isVerticalSwipesEnabled}); - } - - function onFullscreenChanged(eventType, eventData) { - setFullscreen(eventData.is_fullscreen); - receiveWebViewEvent('fullscreenChanged'); - } - function onFullscreenFailed(eventType, eventData) { - if (eventData.error == 'ALREADY_FULLSCREEN' && !webAppIsFullscreen) { - setFullscreen(true); - } - receiveWebViewEvent('fullscreenFailed', { - error: eventData.error - }); - } - - function toggleOrientationLock(locked) { - if (!versionAtLeast('8.0')) { - console.warn('[Telegram.WebApp] Orientation locking is not supported in version ' + webAppVersion); - return; - } - setOrientationLock(locked); - WebView.postEvent('web_app_toggle_orientation_lock', false, {locked: webAppIsOrientationLocked}); - } - - var homeScreenCallbacks = []; - function onHomeScreenAdded(eventType, eventData) { - receiveWebViewEvent('homeScreenAdded'); - } - function onHomeScreenChecked(eventType, eventData) { - var status = eventData.status || 'unknown'; - if (homeScreenCallbacks.length > 0) { - for (var i = 0; i < homeScreenCallbacks.length; i++) { - var callback = homeScreenCallbacks[i]; - callback(status); - } - homeScreenCallbacks = []; - } - receiveWebViewEvent('homeScreenChecked', { - status: status - }); - } - - var WebAppShareMessageOpened = false; - function onPreparedMessageSent(eventType, eventData) { - if (WebAppShareMessageOpened) { - var requestData = WebAppShareMessageOpened; - WebAppShareMessageOpened = false; - if (requestData.callback) { - requestData.callback(true); - } - receiveWebViewEvent('shareMessageSent'); - } - } - function onPreparedMessageFailed(eventType, eventData) { - if (WebAppShareMessageOpened) { - var requestData = WebAppShareMessageOpened; - WebAppShareMessageOpened = false; - if (requestData.callback) { - requestData.callback(false); - } - receiveWebViewEvent('shareMessageFailed', { - error: eventData.error - }); - } - } - - var WebAppRequestChatOpened = false; - function onRequestedChatSent(eventType, eventData) { - if (WebAppRequestChatOpened) { - var requestData = WebAppRequestChatOpened; - WebAppRequestChatOpened = false; - if (requestData.callback) { - requestData.callback(true); - } - receiveWebViewEvent('requestedChatSent'); - } - } - function onRequestedChatFailed(eventType, eventData) { - if (WebAppRequestChatOpened) { - var requestData = WebAppRequestChatOpened; - WebAppRequestChatOpened = false; - if (requestData.callback) { - requestData.callback(false); - } - receiveWebViewEvent('requestedChatFailed', { - error: eventData.error - }); - } - } - - var WebAppEmojiStatusRequested = false; - function onEmojiStatusSet(eventType, eventData) { - if (WebAppEmojiStatusRequested) { - var requestData = WebAppEmojiStatusRequested; - WebAppEmojiStatusRequested = false; - if (requestData.callback) { - requestData.callback(true); - } - receiveWebViewEvent('emojiStatusSet'); - } - } - function onEmojiStatusFailed(eventType, eventData) { - if (WebAppEmojiStatusRequested) { - var requestData = WebAppEmojiStatusRequested; - WebAppEmojiStatusRequested = false; - if (requestData.callback) { - requestData.callback(false); - } - receiveWebViewEvent('emojiStatusFailed', { - error: eventData.error - }); - } - } - var WebAppEmojiStatusAccessRequested = false; - function onEmojiStatusAccessRequested(eventType, eventData) { - if (WebAppEmojiStatusAccessRequested) { - var requestData = WebAppEmojiStatusAccessRequested; - WebAppEmojiStatusAccessRequested = false; - if (requestData.callback) { - requestData.callback(eventData.status == 'allowed'); - } - receiveWebViewEvent('emojiStatusAccessRequested', { - status: eventData.status - }); - } - } - - var webAppPopupOpened = false; - function onPopupClosed(eventType, eventData) { - if (webAppPopupOpened) { - var popupData = webAppPopupOpened; - webAppPopupOpened = false; - var button_id = null; - if (typeof eventData.button_id !== 'undefined') { - button_id = eventData.button_id; - } - if (popupData.callback) { - popupData.callback(button_id); - } - receiveWebViewEvent('popupClosed', { - button_id: button_id - }); - } - } - - - function getHeaderColor() { - if (webAppHeaderColorKey == 'secondary_bg_color') { - return themeParams.secondary_bg_color; - } else if (webAppHeaderColorKey == 'bg_color') { - return themeParams.bg_color; - } - return webAppHeaderColor; - } - function setHeaderColor(color) { - if (!versionAtLeast('6.1')) { - console.warn('[Telegram.WebApp] Header color is not supported in version ' + webAppVersion); - return; - } - if (!versionAtLeast('6.9')) { - if (themeParams.bg_color && - themeParams.bg_color == color) { - color = 'bg_color'; - } else if (themeParams.secondary_bg_color && - themeParams.secondary_bg_color == color) { - color = 'secondary_bg_color'; - } - } - var head_color = null, color_key = null; - if (color == 'bg_color' || color == 'secondary_bg_color') { - color_key = color; - } else if (versionAtLeast('6.9')) { - head_color = parseColorToHex(color); - if (!head_color) { - console.error('[Telegram.WebApp] Header color format is invalid', color); - throw Error('WebAppHeaderColorInvalid'); - } - } - if (!versionAtLeast('6.9') && - color_key != 'bg_color' && - color_key != 'secondary_bg_color') { - console.error('[Telegram.WebApp] Header color key should be one of Telegram.WebApp.themeParams.bg_color, Telegram.WebApp.themeParams.secondary_bg_color, \'bg_color\', \'secondary_bg_color\'', color); - throw Error('WebAppHeaderColorKeyInvalid'); - } - webAppHeaderColorKey = color_key; - webAppHeaderColor = head_color; - updateHeaderColor(); - } - var appHeaderColorKey = null, appHeaderColor = null; - function updateHeaderColor() { - if (appHeaderColorKey != webAppHeaderColorKey || - appHeaderColor != webAppHeaderColor) { - appHeaderColorKey = webAppHeaderColorKey; - appHeaderColor = webAppHeaderColor; - if (appHeaderColor) { - WebView.postEvent('web_app_set_header_color', false, {color: webAppHeaderColor}); - } else { - WebView.postEvent('web_app_set_header_color', false, {color_key: webAppHeaderColorKey}); - } - } - } - - function getBackgroundColor() { - if (webAppBackgroundColor == 'secondary_bg_color') { - return themeParams.secondary_bg_color; - } else if (webAppBackgroundColor == 'bg_color') { - return themeParams.bg_color; - } - return webAppBackgroundColor; - } - function setBackgroundColor(color) { - if (!versionAtLeast('6.1')) { - console.warn('[Telegram.WebApp] Background color is not supported in version ' + webAppVersion); - return; - } - var bg_color; - if (color == 'bg_color' || color == 'secondary_bg_color') { - bg_color = color; - } else { - bg_color = parseColorToHex(color); - if (!bg_color) { - console.error('[Telegram.WebApp] Background color format is invalid', color); - throw Error('WebAppBackgroundColorInvalid'); - } - } - webAppBackgroundColor = bg_color; - updateBackgroundColor(); - } - var appBackgroundColor = null; - function updateBackgroundColor() { - var color = getBackgroundColor(); - if (appBackgroundColor != color) { - appBackgroundColor = color; - WebView.postEvent('web_app_set_background_color', false, {color: color}); - } - } - - var bottomBarColor = 'bottom_bar_bg_color'; - function getBottomBarColor() { - if (bottomBarColor == 'bottom_bar_bg_color') { - return themeParams.bottom_bar_bg_color || themeParams.secondary_bg_color || '#ffffff'; - } else if (bottomBarColor == 'secondary_bg_color') { - return themeParams.secondary_bg_color; - } else if (bottomBarColor == 'bg_color') { - return themeParams.bg_color; - } - return bottomBarColor; - } - function setBottomBarColor(color) { - if (!versionAtLeast('7.10')) { - console.warn('[Telegram.WebApp] Bottom bar color is not supported in version ' + webAppVersion); - return; - } - var bg_color; - if (color == 'bg_color' || color == 'secondary_bg_color' || color == 'bottom_bar_bg_color') { - bg_color = color; - } else { - bg_color = parseColorToHex(color); - if (!bg_color) { - console.error('[Telegram.WebApp] Bottom bar color format is invalid', color); - throw Error('WebAppBottomBarColorInvalid'); - } - } - bottomBarColor = bg_color; - updateBottomBarColor(); - window.Telegram.WebApp.SecondaryButton.setParams({}); - } - var appBottomBarColor = null; - function updateBottomBarColor() { - var color = getBottomBarColor(); - if (appBottomBarColor != color) { - appBottomBarColor = color; - WebView.postEvent('web_app_set_bottom_bar_color', false, {color: color}); - } - if (initParams.tgWebAppDebug) { - updateDebugBottomBar(); - } - } - - - function parseColorToHex(color) { - color += ''; - var match; - if (match = /^\s*#([0-9a-f]{6})\s*$/i.exec(color)) { - return '#' + match[1].toLowerCase(); - } - else if (match = /^\s*#([0-9a-f])([0-9a-f])([0-9a-f])\s*$/i.exec(color)) { - return ('#' + match[1] + match[1] + match[2] + match[2] + match[3] + match[3]).toLowerCase(); - } - else if (match = /^\s*rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.{0,1}\d*))?\)\s*$/.exec(color)) { - var r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]); - r = (r < 16 ? '0' : '') + r.toString(16); - g = (g < 16 ? '0' : '') + g.toString(16); - b = (b < 16 ? '0' : '') + b.toString(16); - return '#' + r + g + b; - } - return false; - } - - function isColorDark(rgb) { - rgb = rgb.replace(/[\s#]/g, ''); - if (rgb.length == 3) { - rgb = rgb[0] + rgb[0] + rgb[1] + rgb[1] + rgb[2] + rgb[2]; - } - var r = parseInt(rgb.substr(0, 2), 16); - var g = parseInt(rgb.substr(2, 2), 16); - var b = parseInt(rgb.substr(4, 2), 16); - var hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); - return hsp < 120; - } - - function versionCompare(v1, v2) { - if (typeof v1 !== 'string') v1 = ''; - if (typeof v2 !== 'string') v2 = ''; - v1 = v1.replace(/^\s+|\s+$/g, '').split('.'); - v2 = v2.replace(/^\s+|\s+$/g, '').split('.'); - var a = Math.max(v1.length, v2.length), i, p1, p2; - for (i = 0; i < a; i++) { - p1 = parseInt(v1[i]) || 0; - p2 = parseInt(v2[i]) || 0; - if (p1 == p2) continue; - if (p1 > p2) return 1; - return -1; - } - return 0; - } - - function versionAtLeast(ver) { - return versionCompare(webAppVersion, ver) >= 0; - } - - function byteLength(str) { - if (window.Blob) { - try { return new Blob([str]).size; } catch (e) {} - } - var s = str.length; - for (var i=str.length-1; i>=0; i--) { - var code = str.charCodeAt(i); - if (code > 0x7f && code <= 0x7ff) s++; - else if (code > 0x7ff && code <= 0xffff) s+=2; - if (code >= 0xdc00 && code <= 0xdfff) i--; - } - return s; - } - - var BackButton = (function() { - var isVisible = false; - - var backButton = {}; - Object.defineProperty(backButton, 'isVisible', { - set: function(val){ setParams({is_visible: val}); }, - get: function(){ return isVisible; }, - enumerable: true - }); - - var curButtonState = null; - - WebView.onEvent('back_button_pressed', onBackButtonPressed); - - function onBackButtonPressed() { - receiveWebViewEvent('backButtonClicked'); - } - - function buttonParams() { - return {is_visible: isVisible}; - } - - function buttonState(btn_params) { - if (typeof btn_params === 'undefined') { - btn_params = buttonParams(); - } - return JSON.stringify(btn_params); - } - - function buttonCheckVersion() { - if (!versionAtLeast('6.1')) { - console.warn('[Telegram.WebApp] BackButton is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - function updateButton() { - var btn_params = buttonParams(); - var btn_state = buttonState(btn_params); - if (curButtonState === btn_state) { - return; - } - curButtonState = btn_state; - WebView.postEvent('web_app_setup_back_button', false, btn_params); - } - - function setParams(params) { - if (!buttonCheckVersion()) { - return backButton; - } - if (typeof params.is_visible !== 'undefined') { - isVisible = !!params.is_visible; - } - updateButton(); - return backButton; - } - - backButton.onClick = function(callback) { - if (buttonCheckVersion()) { - onWebViewEvent('backButtonClicked', callback); - } - return backButton; - }; - backButton.offClick = function(callback) { - if (buttonCheckVersion()) { - offWebViewEvent('backButtonClicked', callback); - } - return backButton; - }; - backButton.show = function() { - return setParams({is_visible: true}); - }; - backButton.hide = function() { - return setParams({is_visible: false}); - }; - return backButton; - })(); - - var debugBottomBar = null, debugBottomBarBtns = {}, bottomBarHeight = 0; - if (initParams.tgWebAppDebug) { - debugBottomBar = document.createElement('tg-bottom-bar'); - var debugBottomBarStyle = { - display: 'flex', - gap: '7px', - font: '600 14px/18px sans-serif', - width: '100%', - background: getBottomBarColor(), - position: 'fixed', - left: '0', - right: '0', - bottom: '0', - margin: '0', - padding: '7px', - textAlign: 'center', - boxSizing: 'border-box', - zIndex: '10000' - }; - for (var k in debugBottomBarStyle) { - debugBottomBar.style[k] = debugBottomBarStyle[k]; - } - document.addEventListener('DOMContentLoaded', function onDomLoaded(event) { - document.removeEventListener('DOMContentLoaded', onDomLoaded); - document.body.appendChild(debugBottomBar); - }); - var animStyle = document.createElement('style'); - animStyle.innerHTML = 'tg-bottom-button.shine { position: relative; overflow: hidden; } tg-bottom-button.shine:before { content:""; position: absolute; top: 0; width: 100%; height: 100%; background: linear-gradient(120deg, transparent, rgba(255, 255, 255, .2), transparent); animation: tg-bottom-button-shine 5s ease-in-out infinite; } @-webkit-keyframes tg-bottom-button-shine { 0% {left: -100%;} 12%,100% {left: 100%}} @keyframes tg-bottom-button-shine { 0% {left: -100%;} 12%,100% {left: 100%}}'; - debugBottomBar.appendChild(animStyle); - } - function updateDebugBottomBar() { - var mainBtn = debugBottomBarBtns.main._bottomButton; - var secondaryBtn = debugBottomBarBtns.secondary._bottomButton; - if (mainBtn.isVisible || secondaryBtn.isVisible) { - debugBottomBar.style.display = 'flex'; - bottomBarHeight = 58; - if (mainBtn.isVisible && secondaryBtn.isVisible) { - if (secondaryBtn.position == 'top') { - debugBottomBar.style.flexDirection = 'column-reverse'; - bottomBarHeight += 51; - } else if (secondaryBtn.position == 'bottom') { - debugBottomBar.style.flexDirection = 'column'; - bottomBarHeight += 51; - } else if (secondaryBtn.position == 'left') { - debugBottomBar.style.flexDirection = 'row-reverse'; - } else if (secondaryBtn.position == 'right') { - debugBottomBar.style.flexDirection = 'row'; - } - } - } else { - debugBottomBar.style.display = 'none'; - bottomBarHeight = 0; - } - debugBottomBar.style.background = getBottomBarColor(); - if (document.documentElement) { - document.documentElement.style.boxSizing = 'border-box'; - document.documentElement.style.paddingBottom = bottomBarHeight + 'px'; - } - setViewportHeight(); - } - - - var BottomButtonConstructor = function(type) { - var isMainButton = (type == 'main'); - if (isMainButton) { - var setupFnName = 'web_app_setup_main_button'; - var tgEventName = 'main_button_pressed'; - var webViewEventName = 'mainButtonClicked'; - var buttonTextDefault = 'Continue'; - var buttonColorDefault = function(){ return themeParams.button_color || '#2481cc'; }; - var buttonTextColorDefault = function(){ return themeParams.button_text_color || '#ffffff'; }; - } else { - var setupFnName = 'web_app_setup_secondary_button'; - var tgEventName = 'secondary_button_pressed'; - var webViewEventName = 'secondaryButtonClicked'; - var buttonTextDefault = 'Cancel'; - var buttonColorDefault = function(){ return getBottomBarColor(); }; - var buttonTextColorDefault = function(){ return themeParams.button_color || '#2481cc'; }; - } - - var isVisible = false; - var isActive = true; - var hasShineEffect = false; - var isProgressVisible = false; - var iconCustomEmojiId = false; - var buttonType = type; - var buttonText = buttonTextDefault; - var buttonColor = false; - var buttonTextColor = false; - var buttonPosition = 'left'; - - var bottomButton = {}; - Object.defineProperty(bottomButton, 'type', { - get: function(){ return buttonType; }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'iconCustomEmojiId', { - set: function(val){ bottomButton.setParams({icon_custom_emoji_id: val}); }, - get: function(){ return iconCustomEmojiId; }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'text', { - set: function(val){ bottomButton.setParams({text: val}); }, - get: function(){ return buttonText; }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'color', { - set: function(val){ bottomButton.setParams({color: val}); }, - get: function(){ return buttonColor || buttonColorDefault(); }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'textColor', { - set: function(val){ bottomButton.setParams({text_color: val}); }, - get: function(){ return buttonTextColor || buttonTextColorDefault(); }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'isVisible', { - set: function(val){ bottomButton.setParams({is_visible: val}); }, - get: function(){ return isVisible; }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'isProgressVisible', { - get: function(){ return isProgressVisible; }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'isActive', { - set: function(val){ bottomButton.setParams({is_active: val}); }, - get: function(){ return isActive; }, - enumerable: true - }); - Object.defineProperty(bottomButton, 'hasShineEffect', { - set: function(val){ bottomButton.setParams({has_shine_effect: val}); }, - get: function(){ return hasShineEffect; }, - enumerable: true - }); - if (!isMainButton) { - Object.defineProperty(bottomButton, 'position', { - set: function(val){ bottomButton.setParams({position: val}); }, - get: function(){ return buttonPosition; }, - enumerable: true - }); - } - - var curButtonState = null; - - WebView.onEvent(tgEventName, onBottomButtonPressed); - - var debugBtn = null; - if (initParams.tgWebAppDebug) { - debugBtn = document.createElement('tg-bottom-button'); - var debugBtnStyle = { - display: 'none', - width: '100%', - height: '44px', - borderRadius: '0', - background: 'no-repeat right center', - padding: '13px 15px', - textAlign: 'center', - boxSizing: 'border-box' - }; - for (var k in debugBtnStyle) { - debugBtn.style[k] = debugBtnStyle[k]; - } - debugBottomBar.appendChild(debugBtn); - debugBtn.addEventListener('click', onBottomButtonPressed, false); - debugBtn._bottomButton = bottomButton; - debugBottomBarBtns[type] = debugBtn; - } - - function onBottomButtonPressed() { - if (isActive) { - receiveWebViewEvent(webViewEventName); - } - } - - function buttonParams() { - var color = bottomButton.color; - var text_color = bottomButton.textColor; - if (isVisible) { - var params = { - is_visible: true, - is_active: isActive, - is_progress_visible: isProgressVisible, - icon_custom_emoji_id: iconCustomEmojiId, - text: buttonText, - color: color, - text_color: text_color, - has_shine_effect: hasShineEffect && isActive && !isProgressVisible - }; - if (!isMainButton) { - params.position = buttonPosition; - } - } else { - var params = { - is_visible: false - }; - } - return params; - } - - function buttonState(btn_params) { - if (typeof btn_params === 'undefined') { - btn_params = buttonParams(); - } - return JSON.stringify(btn_params); - } - - function updateButton() { - var btn_params = buttonParams(); - var btn_state = buttonState(btn_params); - if (curButtonState === btn_state) { - return; - } - curButtonState = btn_state; - WebView.postEvent(setupFnName, false, btn_params); - if (initParams.tgWebAppDebug) { - updateDebugButton(btn_params); - } - } - - function updateDebugButton(btn_params) { - if (btn_params.is_visible) { - debugBtn.style.display = 'block'; - - debugBtn.style.opacity = btn_params.is_active ? '1' : '0.8'; - debugBtn.style.cursor = btn_params.is_active ? 'pointer' : 'auto'; - debugBtn.disabled = !btn_params.is_active; - debugBtn.innerText = btn_params.text; - debugBtn.className = btn_params.has_shine_effect ? 'shine' : ''; - debugBtn.style.backgroundImage = btn_params.is_progress_visible ? "url('data:image/svg+xml," + encodeURIComponent('') + "')" : 'none'; - debugBtn.style.backgroundColor = btn_params.color; - debugBtn.style.color = btn_params.text_color; - } else { - debugBtn.style.display = 'none'; - } - updateDebugBottomBar(); - } - - function setParams(params) { - if (typeof params.icon_custom_emoji_id !== 'undefined') { - var emoji_id = params.icon_custom_emoji_id; - if (emoji_id === false || emoji_id === null) { - emoji_id = ''; - } - if (emoji_id !== '' && !/^[0-9]{10,20}$/.test(emoji_id)) { - console.error('[Telegram.WebApp] Bottom button icon custom emoji is invalid', params.icon_custom_emoji_id); - throw Error('WebAppBottomButtonParamInvalid'); - } - iconCustomEmojiId = emoji_id; - } - if (typeof params.text !== 'undefined') { - var text = strTrim(params.text); - if (!text.length && !iconCustomEmojiId) { - console.error('[Telegram.WebApp] Bottom button text is required', params.text); - throw Error('WebAppBottomButtonParamInvalid'); - } - if (text.length > 64) { - console.error('[Telegram.WebApp] Bottom button text is too long', text); - throw Error('WebAppBottomButtonParamInvalid'); - } - buttonText = text; - } - if (typeof params.color !== 'undefined') { - if (params.color === false || - params.color === null) { - buttonColor = false; - } else { - var color = parseColorToHex(params.color); - if (!color) { - console.error('[Telegram.WebApp] Bottom button color format is invalid', params.color); - throw Error('WebAppBottomButtonParamInvalid'); - } - buttonColor = color; - } - } - if (typeof params.text_color !== 'undefined') { - if (params.text_color === false || - params.text_color === null) { - buttonTextColor = false; - } else { - var text_color = parseColorToHex(params.text_color); - if (!text_color) { - console.error('[Telegram.WebApp] Bottom button text color format is invalid', params.text_color); - throw Error('WebAppBottomButtonParamInvalid'); - } - buttonTextColor = text_color; - } - } - if (typeof params.is_visible !== 'undefined') { - if (params.is_visible && - !bottomButton.text.length) { - console.error('[Telegram.WebApp] Bottom button text is required'); - throw Error('WebAppBottomButtonParamInvalid'); - } - isVisible = !!params.is_visible; - } - if (typeof params.has_shine_effect !== 'undefined') { - hasShineEffect = !!params.has_shine_effect; - } - if (!isMainButton && typeof params.position !== 'undefined') { - if (params.position != 'left' && params.position != 'right' && - params.position != 'top' && params.position != 'bottom') { - console.error('[Telegram.WebApp] Bottom button posiition is invalid', params.position); - throw Error('WebAppBottomButtonParamInvalid'); - } - buttonPosition = params.position; - } - if (typeof params.is_active !== 'undefined') { - isActive = !!params.is_active; - } - updateButton(); - return bottomButton; - } - - bottomButton.setText = function(text) { - return bottomButton.setParams({text: text}); - }; - bottomButton.onClick = function(callback) { - onWebViewEvent(webViewEventName, callback); - return bottomButton; - }; - bottomButton.offClick = function(callback) { - offWebViewEvent(webViewEventName, callback); - return bottomButton; - }; - bottomButton.show = function() { - return bottomButton.setParams({is_visible: true}); - }; - bottomButton.hide = function() { - return bottomButton.setParams({is_visible: false}); - }; - bottomButton.enable = function() { - return bottomButton.setParams({is_active: true}); - }; - bottomButton.disable = function() { - return bottomButton.setParams({is_active: false}); - }; - bottomButton.showProgress = function(leaveActive) { - isActive = !!leaveActive; - isProgressVisible = true; - updateButton(); - return bottomButton; - }; - bottomButton.hideProgress = function() { - if (!bottomButton.isActive) { - isActive = true; - } - isProgressVisible = false; - updateButton(); - return bottomButton; - } - bottomButton.setParams = setParams; - return bottomButton; - }; - var MainButton = BottomButtonConstructor('main'); - var SecondaryButton = BottomButtonConstructor('secondary'); - - var SettingsButton = (function() { - var isVisible = false; - - var settingsButton = {}; - Object.defineProperty(settingsButton, 'isVisible', { - set: function(val){ setParams({is_visible: val}); }, - get: function(){ return isVisible; }, - enumerable: true - }); - - var curButtonState = null; - - WebView.onEvent('settings_button_pressed', onSettingsButtonPressed); - - function onSettingsButtonPressed() { - receiveWebViewEvent('settingsButtonClicked'); - } - - function buttonParams() { - return {is_visible: isVisible}; - } - - function buttonState(btn_params) { - if (typeof btn_params === 'undefined') { - btn_params = buttonParams(); - } - return JSON.stringify(btn_params); - } - - function buttonCheckVersion() { - if (!versionAtLeast('6.10')) { - console.warn('[Telegram.WebApp] SettingsButton is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - function updateButton() { - var btn_params = buttonParams(); - var btn_state = buttonState(btn_params); - if (curButtonState === btn_state) { - return; - } - curButtonState = btn_state; - WebView.postEvent('web_app_setup_settings_button', false, btn_params); - } - - function setParams(params) { - if (!buttonCheckVersion()) { - return settingsButton; - } - if (typeof params.is_visible !== 'undefined') { - isVisible = !!params.is_visible; - } - updateButton(); - return settingsButton; - } - - settingsButton.onClick = function(callback) { - if (buttonCheckVersion()) { - onWebViewEvent('settingsButtonClicked', callback); - } - return settingsButton; - }; - settingsButton.offClick = function(callback) { - if (buttonCheckVersion()) { - offWebViewEvent('settingsButtonClicked', callback); - } - return settingsButton; - }; - settingsButton.show = function() { - return setParams({is_visible: true}); - }; - settingsButton.hide = function() { - return setParams({is_visible: false}); - }; - return settingsButton; - })(); - - var HapticFeedback = (function() { - var hapticFeedback = {}; - - function triggerFeedback(params) { - if (!versionAtLeast('6.1')) { - console.warn('[Telegram.WebApp] HapticFeedback is not supported in version ' + webAppVersion); - return hapticFeedback; - } - if (params.type == 'impact') { - if (params.impact_style != 'light' && - params.impact_style != 'medium' && - params.impact_style != 'heavy' && - params.impact_style != 'rigid' && - params.impact_style != 'soft') { - console.error('[Telegram.WebApp] Haptic impact style is invalid', params.impact_style); - throw Error('WebAppHapticImpactStyleInvalid'); - } - } else if (params.type == 'notification') { - if (params.notification_type != 'error' && - params.notification_type != 'success' && - params.notification_type != 'warning') { - console.error('[Telegram.WebApp] Haptic notification type is invalid', params.notification_type); - throw Error('WebAppHapticNotificationTypeInvalid'); - } - } else if (params.type == 'selection_change') { - // no params needed - } else { - console.error('[Telegram.WebApp] Haptic feedback type is invalid', params.type); - throw Error('WebAppHapticFeedbackTypeInvalid'); - } - WebView.postEvent('web_app_trigger_haptic_feedback', false, params); - return hapticFeedback; - } - - hapticFeedback.impactOccurred = function(style) { - return triggerFeedback({type: 'impact', impact_style: style}); - }; - hapticFeedback.notificationOccurred = function(type) { - return triggerFeedback({type: 'notification', notification_type: type}); - }; - hapticFeedback.selectionChanged = function() { - return triggerFeedback({type: 'selection_change'}); - }; - return hapticFeedback; - })(); - - var CloudStorage = (function() { - var cloudStorage = {}; - - function invokeStorageMethod(method, params, callback) { - if (!versionAtLeast('6.9')) { - console.error('[Telegram.WebApp] CloudStorage is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - invokeCustomMethod(method, params, callback); - return cloudStorage; - } - - cloudStorage.setItem = function(key, value, callback) { - return invokeStorageMethod('saveStorageValue', {key: key, value: value}, callback); - }; - cloudStorage.getItem = function(key, callback) { - return cloudStorage.getItems([key], callback ? function(err, res) { - if (err) callback(err); - else callback(null, res[key]); - } : null); - }; - cloudStorage.getItems = function(keys, callback) { - return invokeStorageMethod('getStorageValues', {keys: keys}, callback); - }; - cloudStorage.removeItem = function(key, callback) { - return cloudStorage.removeItems([key], callback); - }; - cloudStorage.removeItems = function(keys, callback) { - return invokeStorageMethod('deleteStorageValues', {keys: keys}, callback); - }; - cloudStorage.getKeys = function(callback) { - return invokeStorageMethod('getStorageKeys', {}, callback); - }; - return cloudStorage; - })(); - - var DeviceStorage = (function() { - var deviceStorage = {}; - - WebView.onEvent('device_storage_key_saved', onDeviceStorageEvent); - WebView.onEvent('device_storage_key_received', onDeviceStorageEvent); - WebView.onEvent('device_storage_cleared', onDeviceStorageEvent); - WebView.onEvent('device_storage_failed', onDeviceStorageEvent); - - function onDeviceStorageEvent(eventType, eventData) { - if (eventData.req_id && webAppCallbacks[eventData.req_id]) { - var requestData = webAppCallbacks[eventData.req_id]; - delete webAppCallbacks[eventData.req_id]; - var res = null, err = null; - if (eventType == 'device_storage_failed') { - err = eventData.error || 'UNKNOWN_ERROR'; - } else if (eventType == 'device_storage_key_received') { - res = eventData.value; - } else { - res = true; - } - if (requestData.callback) { - requestData.callback(err, res); - } - } - } - - function invokeStorageMethod(method, params, callback) { - if (!versionAtLeast('9.0')) { - console.error('[Telegram.WebApp] DeviceStorage is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - var req_id = generateCallbackId(16); - var req_params = {req_id: req_id}; - for (var k in params) { - req_params[k] = params[k]; - } - webAppCallbacks[req_id] = { - callback: callback - }; - WebView.postEvent(method, false, req_params); - return deviceStorage; - } - - deviceStorage.setItem = function(key, value, callback) { - return invokeStorageMethod('web_app_device_storage_save_key', {key: key, value: value}, callback); - }; - deviceStorage.getItem = function(key, callback) { - return invokeStorageMethod('web_app_device_storage_get_key', {key: key}, callback); - }; - deviceStorage.removeItem = function(key, callback) { - return invokeStorageMethod('web_app_device_storage_save_key', {key: key, value: null}, callback); - }; - deviceStorage.clear = function(callback) { - return invokeStorageMethod('web_app_device_storage_clear', {}, callback); - }; - return deviceStorage; - })(); - - var SecureStorage = (function() { - var secureStorage = {}; - - WebView.onEvent('secure_storage_key_saved', onSecureStorageEvent); - WebView.onEvent('secure_storage_key_received', onSecureStorageEvent); - WebView.onEvent('secure_storage_key_restored', onSecureStorageEvent); - WebView.onEvent('secure_storage_cleared', onSecureStorageEvent); - WebView.onEvent('secure_storage_failed', onSecureStorageEvent); - - function onSecureStorageEvent(eventType, eventData) { - if (eventData.req_id && webAppCallbacks[eventData.req_id]) { - var requestData = webAppCallbacks[eventData.req_id]; - delete webAppCallbacks[eventData.req_id]; - var res = null, err = null, can_restore = null; - if (eventType == 'secure_storage_failed') { - err = eventData.error || 'UNKNOWN_ERROR'; - } else if (eventType == 'secure_storage_key_received') { - res = eventData.value; - if (eventData.can_restore) { - can_restore = true; - } - } else if (eventType == 'secure_storage_key_restored') { - res = eventData.value; - } else { - res = true; - } - if (requestData.callback) { - requestData.callback(err, res, can_restore); - } - } - } - - function invokeStorageMethod(method, params, callback) { - if (!versionAtLeast('9.0')) { - console.error('[Telegram.WebApp] SecureStorage is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - var req_id = generateCallbackId(16); - var req_params = {req_id: req_id}; - for (var k in params) { - req_params[k] = params[k]; - } - webAppCallbacks[req_id] = { - callback: callback - }; - WebView.postEvent(method, false, req_params); - return secureStorage; - } - - secureStorage.setItem = function(key, value, callback) { - return invokeStorageMethod('web_app_secure_storage_save_key', {key: key, value: value}, callback); - }; - secureStorage.getItem = function(key, callback) { - return invokeStorageMethod('web_app_secure_storage_get_key', {key: key}, callback); - }; - secureStorage.restoreItem = function(key, callback) { - return invokeStorageMethod('web_app_secure_storage_restore_key', {key: key}, callback); - }; - secureStorage.removeItem = function(key, callback) { - return invokeStorageMethod('web_app_secure_storage_save_key', {key: key, value: null}, callback); - }; - secureStorage.clear = function(callback) { - return invokeStorageMethod('web_app_secure_storage_clear', {}, callback); - }; - return secureStorage; - })(); - - var BiometricManager = (function() { - var isInited = false; - var isBiometricAvailable = false; - var biometricType = 'unknown'; - var isAccessRequested = false; - var isAccessGranted = false; - var isBiometricTokenSaved = false; - var deviceId = ''; - - var biometricManager = {}; - Object.defineProperty(biometricManager, 'isInited', { - get: function(){ return isInited; }, - enumerable: true - }); - Object.defineProperty(biometricManager, 'isBiometricAvailable', { - get: function(){ return isInited && isBiometricAvailable; }, - enumerable: true - }); - Object.defineProperty(biometricManager, 'biometricType', { - get: function(){ return biometricType || 'unknown'; }, - enumerable: true - }); - Object.defineProperty(biometricManager, 'isAccessRequested', { - get: function(){ return isAccessRequested; }, - enumerable: true - }); - Object.defineProperty(biometricManager, 'isAccessGranted', { - get: function(){ return isAccessRequested && isAccessGranted; }, - enumerable: true - }); - Object.defineProperty(biometricManager, 'isBiometricTokenSaved', { - get: function(){ return isBiometricTokenSaved; }, - enumerable: true - }); - Object.defineProperty(biometricManager, 'deviceId', { - get: function(){ return deviceId || ''; }, - enumerable: true - }); - - var initRequestState = {callbacks: []}; - var accessRequestState = false; - var authRequestState = false; - var tokenRequestState = false; - - WebView.onEvent('biometry_info_received', onBiometryInfoReceived); - WebView.onEvent('biometry_auth_requested', onBiometryAuthRequested); - WebView.onEvent('biometry_token_updated', onBiometryTokenUpdated); - - function onBiometryInfoReceived(eventType, eventData) { - isInited = true; - if (eventData.available) { - isBiometricAvailable = true; - biometricType = eventData.type || 'unknown'; - if (eventData.access_requested) { - isAccessRequested = true; - isAccessGranted = !!eventData.access_granted; - isBiometricTokenSaved = !!eventData.token_saved; - } else { - isAccessRequested = false; - isAccessGranted = false; - isBiometricTokenSaved = false; - } - } else { - isBiometricAvailable = false; - biometricType = 'unknown'; - isAccessRequested = false; - isAccessGranted = false; - isBiometricTokenSaved = false; - } - deviceId = eventData.device_id || ''; - - if (initRequestState.callbacks.length > 0) { - for (var i = 0; i < initRequestState.callbacks.length; i++) { - var callback = initRequestState.callbacks[i]; - callback(); - } - initRequestState.callbacks = []; - } - if (accessRequestState) { - var state = accessRequestState; - accessRequestState = false; - if (state.callback) { - state.callback(isAccessGranted); - } - } - receiveWebViewEvent('biometricManagerUpdated'); - } - function onBiometryAuthRequested(eventType, eventData) { - var isAuthenticated = (eventData.status == 'authorized'), - biometricToken = eventData.token || ''; - if (authRequestState) { - var state = authRequestState; - authRequestState = false; - if (state.callback) { - state.callback(isAuthenticated, isAuthenticated ? biometricToken : null); - } - } - receiveWebViewEvent('biometricAuthRequested', isAuthenticated ? { - isAuthenticated: true, - biometricToken: biometricToken - } : { - isAuthenticated: false - }); - } - function onBiometryTokenUpdated(eventType, eventData) { - var applied = false; - if (isBiometricAvailable && - isAccessRequested) { - if (eventData.status == 'updated') { - isBiometricTokenSaved = true; - applied = true; - } - else if (eventData.status == 'removed') { - isBiometricTokenSaved = false; - applied = true; - } - } - if (tokenRequestState) { - var state = tokenRequestState; - tokenRequestState = false; - if (state.callback) { - state.callback(applied); - } - } - receiveWebViewEvent('biometricTokenUpdated', { - isUpdated: applied - }); - } - - function checkVersion() { - if (!versionAtLeast('7.2')) { - console.warn('[Telegram.WebApp] BiometricManager is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - function checkInit() { - if (!isInited) { - console.error('[Telegram.WebApp] BiometricManager should be inited before using.'); - throw Error('WebAppBiometricManagerNotInited'); - } - return true; - } - - biometricManager.init = function(callback) { - if (!checkVersion()) { - return biometricManager; - } - if (isInited) { - return biometricManager; - } - if (callback) { - initRequestState.callbacks.push(callback); - } - WebView.postEvent('web_app_biometry_get_info', false); - return biometricManager; - }; - biometricManager.requestAccess = function(params, callback) { - if (!checkVersion()) { - return biometricManager; - } - checkInit(); - if (!isBiometricAvailable) { - console.error('[Telegram.WebApp] Biometrics is not available on this device.'); - throw Error('WebAppBiometricManagerBiometricsNotAvailable'); - } - if (accessRequestState) { - console.error('[Telegram.WebApp] Access is already requested'); - throw Error('WebAppBiometricManagerAccessRequested'); - } - var popup_params = {}; - if (typeof params.reason !== 'undefined') { - var reason = strTrim(params.reason); - if (reason.length > 128) { - console.error('[Telegram.WebApp] Biometric reason is too long', reason); - throw Error('WebAppBiometricRequestAccessParamInvalid'); - } - if (reason.length > 0) { - popup_params.reason = reason; - } - } - - accessRequestState = { - callback: callback - }; - WebView.postEvent('web_app_biometry_request_access', false, popup_params); - return biometricManager; - }; - biometricManager.authenticate = function(params, callback) { - if (!checkVersion()) { - return biometricManager; - } - checkInit(); - if (!isBiometricAvailable) { - console.error('[Telegram.WebApp] Biometrics is not available on this device.'); - throw Error('WebAppBiometricManagerBiometricsNotAvailable'); - } - if (!isAccessGranted) { - console.error('[Telegram.WebApp] Biometric access was not granted by the user.'); - throw Error('WebAppBiometricManagerBiometricAccessNotGranted'); - } - if (authRequestState) { - console.error('[Telegram.WebApp] Authentication request is already in progress.'); - throw Error('WebAppBiometricManagerAuthenticationRequested'); - } - var popup_params = {}; - if (typeof params.reason !== 'undefined') { - var reason = strTrim(params.reason); - if (reason.length > 128) { - console.error('[Telegram.WebApp] Biometric reason is too long', reason); - throw Error('WebAppBiometricRequestAccessParamInvalid'); - } - if (reason.length > 0) { - popup_params.reason = reason; - } - } - - authRequestState = { - callback: callback - }; - WebView.postEvent('web_app_biometry_request_auth', false, popup_params); - return biometricManager; - }; - biometricManager.updateBiometricToken = function(token, callback) { - if (!checkVersion()) { - return biometricManager; - } - token = token || ''; - if (token.length > 1024) { - console.error('[Telegram.WebApp] Token is too long', token); - throw Error('WebAppBiometricManagerTokenInvalid'); - } - checkInit(); - if (!isBiometricAvailable) { - console.error('[Telegram.WebApp] Biometrics is not available on this device.'); - throw Error('WebAppBiometricManagerBiometricsNotAvailable'); - } - if (!isAccessGranted) { - console.error('[Telegram.WebApp] Biometric access was not granted by the user.'); - throw Error('WebAppBiometricManagerBiometricAccessNotGranted'); - } - if (tokenRequestState) { - console.error('[Telegram.WebApp] Token request is already in progress.'); - throw Error('WebAppBiometricManagerTokenUpdateRequested'); - } - tokenRequestState = { - callback: callback - }; - WebView.postEvent('web_app_biometry_update_token', false, {token: token}); - return biometricManager; - }; - biometricManager.openSettings = function() { - if (!checkVersion()) { - return biometricManager; - } - checkInit(); - if (!isBiometricAvailable) { - console.error('[Telegram.WebApp] Biometrics is not available on this device.'); - throw Error('WebAppBiometricManagerBiometricsNotAvailable'); - } - if (!isAccessRequested) { - console.error('[Telegram.WebApp] Biometric access was not requested yet.'); - throw Error('WebAppBiometricManagerBiometricsAccessNotRequested'); - } - if (isAccessGranted) { - console.warn('[Telegram.WebApp] Biometric access was granted by the user, no need to go to settings.'); - return biometricManager; - } - WebView.postEvent('web_app_biometry_open_settings', false); - return biometricManager; - }; - return biometricManager; - })(); - - var LocationManager = (function() { - var isInited = false; - var isLocationAvailable = false; - var isAccessRequested = false; - var isAccessGranted = false; - - var locationManager = {}; - Object.defineProperty(locationManager, 'isInited', { - get: function(){ return isInited; }, - enumerable: true - }); - Object.defineProperty(locationManager, 'isLocationAvailable', { - get: function(){ return isInited && isLocationAvailable; }, - enumerable: true - }); - Object.defineProperty(locationManager, 'isAccessRequested', { - get: function(){ return isAccessRequested; }, - enumerable: true - }); - Object.defineProperty(locationManager, 'isAccessGranted', { - get: function(){ return isAccessRequested && isAccessGranted; }, - enumerable: true - }); - - var initRequestState = {callbacks: []}; - var getRequestState = {callbacks: []}; - - WebView.onEvent('location_checked', onLocationChecked); - WebView.onEvent('location_requested', onLocationRequested); - - function onLocationChecked(eventType, eventData) { - isInited = true; - if (eventData.available) { - isLocationAvailable = true; - if (eventData.access_requested) { - isAccessRequested = true; - isAccessGranted = !!eventData.access_granted; - } else { - isAccessRequested = false; - isAccessGranted = false; - } - } else { - isLocationAvailable = false; - isAccessRequested = false; - isAccessGranted = false; - } - - if (initRequestState.callbacks.length > 0) { - for (var i = 0; i < initRequestState.callbacks.length; i++) { - var callback = initRequestState.callbacks[i]; - callback(); - } - initRequestState.callbacks = []; - } - receiveWebViewEvent('locationManagerUpdated'); - } - function onLocationRequested(eventType, eventData) { - if (!eventData.available) { - locationData = null; - } else { - var locationData = { - latitude: eventData.latitude, - longitude: eventData.longitude, - altitude: null, - course: null, - speed: null, - horizontal_accuracy: null, - vertical_accuracy: null, - course_accuracy: null, - speed_accuracy: null, - }; - if (typeof eventData.altitude !== 'undefined' && eventData.altitude !== null) { - locationData.altitude = eventData.altitude; - } - if (typeof eventData.course !== 'undefined' && eventData.course !== null) { - locationData.course = eventData.course % 360; - } - if (typeof eventData.speed !== 'undefined' && eventData.speed !== null) { - locationData.speed = eventData.speed; - } - if (typeof eventData.horizontal_accuracy !== 'undefined' && eventData.horizontal_accuracy !== null) { - locationData.horizontal_accuracy = eventData.horizontal_accuracy; - } - if (typeof eventData.vertical_accuracy !== 'undefined' && eventData.vertical_accuracy !== null) { - locationData.vertical_accuracy = eventData.vertical_accuracy; - } - if (typeof eventData.course_accuracy !== 'undefined' && eventData.course_accuracy !== null) { - locationData.course_accuracy = eventData.course_accuracy; - } - if (typeof eventData.speed_accuracy !== 'undefined' && eventData.speed_accuracy !== null) { - locationData.speed_accuracy = eventData.speed_accuracy; - } - } - if (!eventData.available || - !isLocationAvailable || - !isAccessRequested || - !isAccessGranted) { - initRequestState.callbacks.push(function() { - locationResponse(locationData); - }); - WebView.postEvent('web_app_check_location', false); - } else { - locationResponse(locationData); - } - } - function locationResponse(response) { - if (getRequestState.callbacks.length > 0) { - for (var i = 0; i < getRequestState.callbacks.length; i++) { - var callback = getRequestState.callbacks[i]; - callback(response); - } - getRequestState.callbacks = []; - } - if (response !== null) { - receiveWebViewEvent('locationRequested', { - locationData: response - }); - } - } - - function checkVersion() { - if (!versionAtLeast('8.0')) { - console.warn('[Telegram.WebApp] LocationManager is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - function checkInit() { - if (!isInited) { - console.error('[Telegram.WebApp] LocationManager should be inited before using.'); - throw Error('WebAppLocationManagerNotInited'); - } - return true; - } - - locationManager.init = function(callback) { - if (!checkVersion()) { - return locationManager; - } - if (isInited) { - return locationManager; - } - if (callback) { - initRequestState.callbacks.push(callback); - } - WebView.postEvent('web_app_check_location', false); - return locationManager; - }; - locationManager.getLocation = function(callback) { - if (!checkVersion()) { - return locationManager; - } - checkInit(); - if (!isLocationAvailable) { - console.error('[Telegram.WebApp] Location is not available on this device.'); - throw Error('WebAppLocationManagerLocationNotAvailable'); - } - - getRequestState.callbacks.push(callback); - WebView.postEvent('web_app_request_location'); - return locationManager; - }; - locationManager.openSettings = function() { - if (!checkVersion()) { - return locationManager; - } - checkInit(); - if (!isLocationAvailable) { - console.error('[Telegram.WebApp] Location is not available on this device.'); - throw Error('WebAppLocationManagerLocationNotAvailable'); - } - if (!isAccessRequested) { - console.error('[Telegram.WebApp] Location access was not requested yet.'); - throw Error('WebAppLocationManagerLocationAccessNotRequested'); - } - if (isAccessGranted) { - console.warn('[Telegram.WebApp] Location access was granted by the user, no need to go to settings.'); - return locationManager; - } - WebView.postEvent('web_app_open_location_settings', false); - return locationManager; - }; - return locationManager; - })(); - - var Accelerometer = (function() { - var isStarted = false; - var valueX = null, valueY = null, valueZ = null; - var startCallbacks = [], stopCallbacks = []; - - var accelerometer = {}; - Object.defineProperty(accelerometer, 'isStarted', { - get: function(){ return isStarted; }, - enumerable: true - }); - Object.defineProperty(accelerometer, 'x', { - get: function(){ return valueX; }, - enumerable: true - }); - Object.defineProperty(accelerometer, 'y', { - get: function(){ return valueY; }, - enumerable: true - }); - Object.defineProperty(accelerometer, 'z', { - get: function(){ return valueZ; }, - enumerable: true - }); - - WebView.onEvent('accelerometer_started', onAccelerometerStarted); - WebView.onEvent('accelerometer_stopped', onAccelerometerStopped); - WebView.onEvent('accelerometer_changed', onAccelerometerChanged); - WebView.onEvent('accelerometer_failed', onAccelerometerFailed); - - function onAccelerometerStarted(eventType, eventData) { - isStarted = true; - if (startCallbacks.length > 0) { - for (var i = 0; i < startCallbacks.length; i++) { - var callback = startCallbacks[i]; - callback(true); - } - startCallbacks = []; - } - receiveWebViewEvent('accelerometerStarted'); - } - function onAccelerometerStopped(eventType, eventData) { - isStarted = false; - if (stopCallbacks.length > 0) { - for (var i = 0; i < stopCallbacks.length; i++) { - var callback = stopCallbacks[i]; - callback(true); - } - stopCallbacks = []; - } - receiveWebViewEvent('accelerometerStopped'); - } - function onAccelerometerChanged(eventType, eventData) { - valueX = eventData.x; - valueY = eventData.y; - valueZ = eventData.z; - receiveWebViewEvent('accelerometerChanged'); - } - function onAccelerometerFailed(eventType, eventData) { - if (startCallbacks.length > 0) { - for (var i = 0; i < startCallbacks.length; i++) { - var callback = startCallbacks[i]; - callback(false); - } - startCallbacks = []; - } - receiveWebViewEvent('accelerometerFailed', { - error: eventData.error - }); - } - - function checkVersion() { - if (!versionAtLeast('8.0')) { - console.warn('[Telegram.WebApp] Accelerometer is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - accelerometer.start = function(params, callback) { - params = params || {}; - if (!checkVersion()) { - return accelerometer; - } - var req_params = {}; - var refresh_rate = parseInt(params.refresh_rate || 1000); - if (isNaN(refresh_rate) || refresh_rate < 20 || refresh_rate > 1000) { - console.warn('[Telegram.WebApp] Accelerometer refresh_rate is invalid', refresh_rate); - } else { - req_params.refresh_rate = refresh_rate; - } - - if (callback) { - startCallbacks.push(callback); - } - WebView.postEvent('web_app_start_accelerometer', false, req_params); - return accelerometer; - }; - accelerometer.stop = function(callback) { - if (!checkVersion()) { - return accelerometer; - } - if (callback) { - stopCallbacks.push(callback); - } - WebView.postEvent('web_app_stop_accelerometer'); - return accelerometer; - }; - return accelerometer; - })(); - - var DeviceOrientation = (function() { - var isStarted = false; - var valueAlpha = null, valueBeta = null, valueGamma = null, valueAbsolute = false; - var startCallbacks = [], stopCallbacks = []; - - var deviceOrientation = {}; - Object.defineProperty(deviceOrientation, 'isStarted', { - get: function(){ return isStarted; }, - enumerable: true - }); - Object.defineProperty(deviceOrientation, 'absolute', { - get: function(){ return valueAbsolute; }, - enumerable: true - }); - Object.defineProperty(deviceOrientation, 'alpha', { - get: function(){ return valueAlpha; }, - enumerable: true - }); - Object.defineProperty(deviceOrientation, 'beta', { - get: function(){ return valueBeta; }, - enumerable: true - }); - Object.defineProperty(deviceOrientation, 'gamma', { - get: function(){ return valueGamma; }, - enumerable: true - }); - - WebView.onEvent('device_orientation_started', onDeviceOrientationStarted); - WebView.onEvent('device_orientation_stopped', onDeviceOrientationStopped); - WebView.onEvent('device_orientation_changed', onDeviceOrientationChanged); - WebView.onEvent('device_orientation_failed', onDeviceOrientationFailed); - - function onDeviceOrientationStarted(eventType, eventData) { - isStarted = true; - if (startCallbacks.length > 0) { - for (var i = 0; i < startCallbacks.length; i++) { - var callback = startCallbacks[i]; - callback(true); - } - startCallbacks = []; - } - receiveWebViewEvent('deviceOrientationStarted'); - } - function onDeviceOrientationStopped(eventType, eventData) { - isStarted = false; - if (stopCallbacks.length > 0) { - for (var i = 0; i < stopCallbacks.length; i++) { - var callback = stopCallbacks[i]; - callback(true); - } - stopCallbacks = []; - } - receiveWebViewEvent('deviceOrientationStopped'); - } - function onDeviceOrientationChanged(eventType, eventData) { - valueAbsolute = !!eventData.absolute; - valueAlpha = eventData.alpha; - valueBeta = eventData.beta; - valueGamma = eventData.gamma; - receiveWebViewEvent('deviceOrientationChanged'); - } - function onDeviceOrientationFailed(eventType, eventData) { - if (startCallbacks.length > 0) { - for (var i = 0; i < startCallbacks.length; i++) { - var callback = startCallbacks[i]; - callback(false); - } - startCallbacks = []; - } - receiveWebViewEvent('deviceOrientationFailed', { - error: eventData.error - }); - } - - function checkVersion() { - if (!versionAtLeast('8.0')) { - console.warn('[Telegram.WebApp] DeviceOrientation is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - deviceOrientation.start = function(params, callback) { - params = params || {}; - if (!checkVersion()) { - return deviceOrientation; - } - var req_params = {}; - var refresh_rate = parseInt(params.refresh_rate || 1000); - if (isNaN(refresh_rate) || refresh_rate < 20 || refresh_rate > 1000) { - console.warn('[Telegram.WebApp] DeviceOrientation refresh_rate is invalid', refresh_rate); - } else { - req_params.refresh_rate = refresh_rate; - } - req_params.need_absolute = !!params.need_absolute; - - if (callback) { - startCallbacks.push(callback); - } - WebView.postEvent('web_app_start_device_orientation', false, req_params); - return deviceOrientation; - }; - deviceOrientation.stop = function(callback) { - if (!checkVersion()) { - return deviceOrientation; - } - if (callback) { - stopCallbacks.push(callback); - } - WebView.postEvent('web_app_stop_device_orientation'); - return deviceOrientation; - }; - return deviceOrientation; - })(); - - var Gyroscope = (function() { - var isStarted = false; - var valueX = null, valueY = null, valueZ = null; - var startCallbacks = [], stopCallbacks = []; - - var gyroscope = {}; - Object.defineProperty(gyroscope, 'isStarted', { - get: function(){ return isStarted; }, - enumerable: true - }); - Object.defineProperty(gyroscope, 'x', { - get: function(){ return valueX; }, - enumerable: true - }); - Object.defineProperty(gyroscope, 'y', { - get: function(){ return valueY; }, - enumerable: true - }); - Object.defineProperty(gyroscope, 'z', { - get: function(){ return valueZ; }, - enumerable: true - }); - - WebView.onEvent('gyroscope_started', onGyroscopeStarted); - WebView.onEvent('gyroscope_stopped', onGyroscopeStopped); - WebView.onEvent('gyroscope_changed', onGyroscopeChanged); - WebView.onEvent('gyroscope_failed', onGyroscopeFailed); - - function onGyroscopeStarted(eventType, eventData) { - isStarted = true; - if (startCallbacks.length > 0) { - for (var i = 0; i < startCallbacks.length; i++) { - var callback = startCallbacks[i]; - callback(true); - } - startCallbacks = []; - } - receiveWebViewEvent('gyroscopeStarted'); - } - function onGyroscopeStopped(eventType, eventData) { - isStarted = false; - if (stopCallbacks.length > 0) { - for (var i = 0; i < stopCallbacks.length; i++) { - var callback = stopCallbacks[i]; - callback(true); - } - stopCallbacks = []; - } - receiveWebViewEvent('gyroscopeStopped'); - } - function onGyroscopeChanged(eventType, eventData) { - valueX = eventData.x; - valueY = eventData.y; - valueZ = eventData.z; - receiveWebViewEvent('gyroscopeChanged'); - } - function onGyroscopeFailed(eventType, eventData) { - if (startCallbacks.length > 0) { - for (var i = 0; i < startCallbacks.length; i++) { - var callback = startCallbacks[i]; - callback(false); - } - startCallbacks = []; - } - receiveWebViewEvent('gyroscopeFailed', { - error: eventData.error - }); - } - - function checkVersion() { - if (!versionAtLeast('8.0')) { - console.warn('[Telegram.WebApp] Gyroscope is not supported in version ' + webAppVersion); - return false; - } - return true; - } - - gyroscope.start = function(params, callback) { - params = params || {}; - if (!checkVersion()) { - return gyroscope; - } - var req_params = {}; - var refresh_rate = parseInt(params.refresh_rate || 1000); - if (isNaN(refresh_rate) || refresh_rate < 20 || refresh_rate > 1000) { - console.warn('[Telegram.WebApp] Gyroscope refresh_rate is invalid', refresh_rate); - } else { - req_params.refresh_rate = refresh_rate; - } - - if (callback) { - startCallbacks.push(callback); - } - WebView.postEvent('web_app_start_gyroscope', false, req_params); - return gyroscope; - }; - gyroscope.stop = function(callback) { - if (!checkVersion()) { - return gyroscope; - } - if (callback) { - stopCallbacks.push(callback); - } - WebView.postEvent('web_app_stop_gyroscope'); - return gyroscope; - }; - return gyroscope; - })(); - - var webAppInvoices = {}; - function onInvoiceClosed(eventType, eventData) { - if (eventData.slug && webAppInvoices[eventData.slug]) { - var invoiceData = webAppInvoices[eventData.slug]; - delete webAppInvoices[eventData.slug]; - if (invoiceData.callback) { - invoiceData.callback(eventData.status); - } - receiveWebViewEvent('invoiceClosed', { - url: invoiceData.url, - status: eventData.status - }); - } - } - - var webAppPopupOpened = false; - function onPopupClosed(eventType, eventData) { - if (webAppPopupOpened) { - var popupData = webAppPopupOpened; - webAppPopupOpened = false; - var button_id = null; - if (typeof eventData.button_id !== 'undefined') { - button_id = eventData.button_id; - } - if (popupData.callback) { - popupData.callback(button_id); - } - receiveWebViewEvent('popupClosed', { - button_id: button_id - }); - } - } - - var webAppScanQrPopupOpened = false; - function onQrTextReceived(eventType, eventData) { - if (webAppScanQrPopupOpened) { - var popupData = webAppScanQrPopupOpened; - var data = null; - if (typeof eventData.data !== 'undefined') { - data = eventData.data; - } - if (popupData.callback) { - if (popupData.callback(data)) { - webAppScanQrPopupOpened = false; - WebView.postEvent('web_app_close_scan_qr_popup', false); - } - } - receiveWebViewEvent('qrTextReceived', { - data: data - }); - } - } - function onScanQrPopupClosed(eventType, eventData) { - webAppScanQrPopupOpened = false; - receiveWebViewEvent('scanQrPopupClosed'); - } - - function onClipboardTextReceived(eventType, eventData) { - if (eventData.req_id && webAppCallbacks[eventData.req_id]) { - var requestData = webAppCallbacks[eventData.req_id]; - delete webAppCallbacks[eventData.req_id]; - var data = null; - if (typeof eventData.data !== 'undefined') { - data = eventData.data; - } - if (requestData.callback) { - requestData.callback(data); - } - receiveWebViewEvent('clipboardTextReceived', { - data: data - }); - } - } - - var WebAppWriteAccessRequested = false; - function onWriteAccessRequested(eventType, eventData) { - if (WebAppWriteAccessRequested) { - var requestData = WebAppWriteAccessRequested; - WebAppWriteAccessRequested = false; - if (requestData.callback) { - requestData.callback(eventData.status == 'allowed'); - } - receiveWebViewEvent('writeAccessRequested', { - status: eventData.status - }); - } - } - - function getRequestedContact(callback, timeout) { - var reqTo, fallbackTo, reqDelay = 0; - var reqInvoke = function() { - invokeCustomMethod('getRequestedContact', {}, function(err, res) { - if (res.substr(0, 1) == '"' && res.substr(-1) == '"') { // macos fix - res = JSON.parse(res); - } - if (res && res.length) { - clearTimeout(fallbackTo); - callback(res); - } else { - reqDelay += 50; - reqTo = setTimeout(reqInvoke, reqDelay); - } - }); - }; - var fallbackInvoke = function() { - clearTimeout(reqTo); - callback(''); - }; - fallbackTo = setTimeout(fallbackInvoke, timeout); - reqInvoke(); - } - - var WebAppContactRequested = false; - function onPhoneRequested(eventType, eventData) { - if (WebAppContactRequested) { - var requestData = WebAppContactRequested; - WebAppContactRequested = false; - var requestSent = eventData.status == 'sent'; - var webViewEvent = { - status: eventData.status - }; - if (requestSent) { - getRequestedContact(function(res) { - if (res && res.length) { - webViewEvent.response = res; - webViewEvent.responseUnsafe = Utils.urlParseQueryString(res); - for (var key in webViewEvent.responseUnsafe) { - var val = webViewEvent.responseUnsafe[key]; - try { - if (val.substr(0, 1) == '{' && val.substr(-1) == '}' || - val.substr(0, 1) == '[' && val.substr(-1) == ']') { - webViewEvent.responseUnsafe[key] = JSON.parse(val); - } - } catch (e) {} - } - } - if (requestData.callback) { - requestData.callback(requestSent, webViewEvent); - } - receiveWebViewEvent('contactRequested', webViewEvent); - }, 3000); - } else { - if (requestData.callback) { - requestData.callback(requestSent, webViewEvent); - } - receiveWebViewEvent('contactRequested', webViewEvent); - } - } - } - - var webAppDownloadFileRequested = false; - function onFileDownloadRequested(eventType, eventData) { - if (webAppDownloadFileRequested) { - var requestData = webAppDownloadFileRequested; - webAppDownloadFileRequested = false; - var isDownloading = eventData.status == 'downloading'; - if (requestData.callback) { - requestData.callback(isDownloading); - } - receiveWebViewEvent('fileDownloadRequested', { - status: isDownloading ? 'downloading' : 'cancelled' - }); - } - } - - function onCustomMethodInvoked(eventType, eventData) { - if (eventData.req_id && webAppCallbacks[eventData.req_id]) { - var requestData = webAppCallbacks[eventData.req_id]; - delete webAppCallbacks[eventData.req_id]; - var res = null, err = null; - if (typeof eventData.result !== 'undefined') { - res = eventData.result; - } - if (typeof eventData.error !== 'undefined') { - err = eventData.error; - } - if (requestData.callback) { - requestData.callback(err, res); - } - } - } - - function invokeCustomMethod(method, params, callback) { - if (!versionAtLeast('6.9')) { - console.error('[Telegram.WebApp] Method invokeCustomMethod is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - var req_id = generateCallbackId(16); - var req_params = {req_id: req_id, method: method, params: params || {}}; - webAppCallbacks[req_id] = { - callback: callback - }; - WebView.postEvent('web_app_invoke_custom_method', false, req_params); - }; - - if (!window.Telegram) { - window.Telegram = {}; - } - - Object.defineProperty(WebApp, 'initData', { - get: function(){ return webAppInitData; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'initDataUnsafe', { - get: function(){ return webAppInitDataUnsafe; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'version', { - get: function(){ return webAppVersion; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'platform', { - get: function(){ return webAppPlatform; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'colorScheme', { - get: function(){ return colorScheme; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'themeParams', { - get: function(){ return themeParams; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'isExpanded', { - get: function(){ return isExpanded; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'viewportHeight', { - get: function(){ return (viewportHeight === false ? window.innerHeight : viewportHeight) - bottomBarHeight; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'viewportStableHeight', { - get: function(){ return (viewportStableHeight === false ? window.innerHeight : viewportStableHeight) - bottomBarHeight; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'safeAreaInset', { - get: function(){ return safeAreaInset; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'contentSafeAreaInset', { - get: function(){ return contentSafeAreaInset; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'isClosingConfirmationEnabled', { - set: function(val){ setClosingConfirmation(val); }, - get: function(){ return isClosingConfirmationEnabled; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'isVerticalSwipesEnabled', { - set: function(val){ toggleVerticalSwipes(val); }, - get: function(){ return isVerticalSwipesEnabled; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'isFullscreen', { - get: function(){ return webAppIsFullscreen; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'isOrientationLocked', { - set: function(val){ toggleOrientationLock(val); }, - get: function(){ return webAppIsOrientationLocked; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'isActive', { - get: function(){ return webAppIsActive; }, - enumerable: true - }); - Object.defineProperty(WebApp, 'headerColor', { - set: function(val){ setHeaderColor(val); }, - get: function(){ return getHeaderColor(); }, - enumerable: true - }); - Object.defineProperty(WebApp, 'backgroundColor', { - set: function(val){ setBackgroundColor(val); }, - get: function(){ return getBackgroundColor(); }, - enumerable: true - }); - Object.defineProperty(WebApp, 'bottomBarColor', { - set: function(val){ setBottomBarColor(val); }, - get: function(){ return getBottomBarColor(); }, - enumerable: true - }); - Object.defineProperty(WebApp, 'BackButton', { - value: BackButton, - enumerable: true - }); - Object.defineProperty(WebApp, 'MainButton', { - value: MainButton, - enumerable: true - }); - Object.defineProperty(WebApp, 'SecondaryButton', { - value: SecondaryButton, - enumerable: true - }); - Object.defineProperty(WebApp, 'SettingsButton', { - value: SettingsButton, - enumerable: true - }); - Object.defineProperty(WebApp, 'HapticFeedback', { - value: HapticFeedback, - enumerable: true - }); - Object.defineProperty(WebApp, 'CloudStorage', { - value: CloudStorage, - enumerable: true - }); - Object.defineProperty(WebApp, 'DeviceStorage', { - value: DeviceStorage, - enumerable: true - }); - Object.defineProperty(WebApp, 'SecureStorage', { - value: SecureStorage, - enumerable: true - }); - Object.defineProperty(WebApp, 'BiometricManager', { - value: BiometricManager, - enumerable: true - }); - Object.defineProperty(WebApp, 'Accelerometer', { - value: Accelerometer, - enumerable: true - }); - Object.defineProperty(WebApp, 'DeviceOrientation', { - value: DeviceOrientation, - enumerable: true - }); - Object.defineProperty(WebApp, 'Gyroscope', { - value: Gyroscope, - enumerable: true - }); - Object.defineProperty(WebApp, 'LocationManager', { - value: LocationManager, - enumerable: true - }); - WebApp.isVersionAtLeast = function(ver) { - return versionAtLeast(ver); - }; - WebApp.setHeaderColor = function(color_key) { - WebApp.headerColor = color_key; - }; - WebApp.setBackgroundColor = function(color) { - WebApp.backgroundColor = color; - }; - WebApp.setBottomBarColor = function(color) { - WebApp.bottomBarColor = color; - }; - WebApp.enableClosingConfirmation = function() { - WebApp.isClosingConfirmationEnabled = true; - }; - WebApp.disableClosingConfirmation = function() { - WebApp.isClosingConfirmationEnabled = false; - }; - WebApp.enableVerticalSwipes = function() { - WebApp.isVerticalSwipesEnabled = true; - }; - WebApp.disableVerticalSwipes = function() { - WebApp.isVerticalSwipesEnabled = false; - }; - WebApp.lockOrientation = function() { - WebApp.isOrientationLocked = true; - }; - WebApp.unlockOrientation = function() { - WebApp.isOrientationLocked = false; - }; - WebApp.requestFullscreen = function() { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method requestFullscreen is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - WebView.postEvent('web_app_request_fullscreen'); - }; - WebApp.exitFullscreen = function() { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method exitFullscreen is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - WebView.postEvent('web_app_exit_fullscreen'); - }; - WebApp.addToHomeScreen = function() { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method addToHomeScreen is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - WebView.postEvent('web_app_add_to_home_screen'); - }; - WebApp.checkHomeScreenStatus = function(callback) { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method checkHomeScreenStatus is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (callback) { - homeScreenCallbacks.push(callback); - } - WebView.postEvent('web_app_check_home_screen'); - }; - WebApp.onEvent = function(eventType, callback) { - onWebViewEvent(eventType, callback); - }; - WebApp.offEvent = function(eventType, callback) {offWebViewEvent(eventType, callback); - }; - WebApp.sendData = function (data) { - if (!data || !data.length) { - console.error('[Telegram.WebApp] Data is required', data); - throw Error('WebAppDataInvalid'); - } - if (byteLength(data) > 4096) { - console.error('[Telegram.WebApp] Data is too long', data); - throw Error('WebAppDataInvalid'); - } - WebView.postEvent('web_app_data_send', false, {data: data}); - }; - WebApp.switchInlineQuery = function (query, choose_chat_types) { - if (!versionAtLeast('6.6')) { - console.error('[Telegram.WebApp] Method switchInlineQuery is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (!initParams.tgWebAppBotInline) { - console.error('[Telegram.WebApp] Inline mode is disabled for this bot. Read more about inline mode: https://core.telegram.org/bots/inline'); - throw Error('WebAppInlineModeDisabled'); - } - query = query || ''; - if (query.length > 256) { - console.error('[Telegram.WebApp] Inline query is too long', query); - throw Error('WebAppInlineQueryInvalid'); - } - var chat_types = []; - if (choose_chat_types) { - if (!Array.isArray(choose_chat_types)) { - console.error('[Telegram.WebApp] Choose chat types should be an array', choose_chat_types); - throw Error('WebAppInlineChooseChatTypesInvalid'); - } - var good_types = {users: 1, bots: 1, groups: 1, channels: 1}; - for (var i = 0; i < choose_chat_types.length; i++) { - var chat_type = choose_chat_types[i]; - if (!good_types[chat_type]) { - console.error('[Telegram.WebApp] Choose chat type is invalid', chat_type); - throw Error('WebAppInlineChooseChatTypeInvalid'); - } - if (good_types[chat_type] != 2) { - good_types[chat_type] = 2; - chat_types.push(chat_type); - } - } - } - WebView.postEvent('web_app_switch_inline_query', false, {query: query, chat_types: chat_types}); - }; - WebApp.openLink = function (url, options) { - var a = document.createElement('A'); - a.href = url; - if (a.protocol != 'http:' && - a.protocol != 'https:') { - console.error('[Telegram.WebApp] Url protocol is not supported', url); - throw Error('WebAppTgUrlInvalid'); - } - var url = a.href; - options = options || {}; - if (versionAtLeast('6.1')) { - var req_params = {url: url}; - if (versionAtLeast('6.4') && options.try_instant_view) { - req_params.try_instant_view = true; - } - if (versionAtLeast('7.6') && options.try_browser) { - req_params.try_browser = options.try_browser; - } - WebView.postEvent('web_app_open_link', false, req_params); - } else { - window.open(url, '_blank'); - } - }; - WebApp.openTelegramLink = function (url, options) { - var a = document.createElement('A'); - a.href = url; - if (a.protocol != 'http:' && - a.protocol != 'https:') { - console.error('[Telegram.WebApp] Url protocol is not supported', url); - throw Error('WebAppTgUrlInvalid'); - } - if (a.hostname != 't.me') { - console.error('[Telegram.WebApp] Url host is not supported', url); - throw Error('WebAppTgUrlInvalid'); - } - var path_full = a.pathname + a.search; - options = options || {}; - if (isIframe || versionAtLeast('6.1')) { - var req_params = {path_full: path_full}; - if (options.force_request) { - req_params.force_request = true; - } - WebView.postEvent('web_app_open_tg_link', false, req_params); - } else { - location.href = 'https://t.me' + path_full; - } - }; - WebApp.openInvoice = function (url, callback) { - var a = document.createElement('A'), match, slug; - a.href = url; - if (a.protocol != 'http:' && - a.protocol != 'https:' || - a.hostname != 't.me' || - !(match = a.pathname.match(/^\/(\$|invoice\/)([A-Za-z0-9\-_=]+)$/)) || - !(slug = match[2])) { - console.error('[Telegram.WebApp] Invoice url is invalid', url); - throw Error('WebAppInvoiceUrlInvalid'); - } - if (!versionAtLeast('6.1')) { - console.error('[Telegram.WebApp] Method openInvoice is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (webAppInvoices[slug]) { - console.error('[Telegram.WebApp] Invoice is already opened'); - throw Error('WebAppInvoiceOpened'); - } - webAppInvoices[slug] = { - url: url, - callback: callback - }; - WebView.postEvent('web_app_open_invoice', false, {slug: slug}); - }; - WebApp.showPopup = function (params, callback) { - if (!versionAtLeast('6.2')) { - console.error('[Telegram.WebApp] Method showPopup is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (webAppPopupOpened) { - console.error('[Telegram.WebApp] Popup is already opened'); - throw Error('WebAppPopupOpened'); - } - var title = ''; - var message = ''; - var buttons = []; - var popup_buttons = {}; - var popup_params = {}; - if (typeof params.title !== 'undefined') { - title = strTrim(params.title); - if (title.length > 64) { - console.error('[Telegram.WebApp] Popup title is too long', title); - throw Error('WebAppPopupParamInvalid'); - } - if (title.length > 0) { - popup_params.title = title; - } - } - if (typeof params.message !== 'undefined') { - message = strTrim(params.message); - } - if (!message.length) { - console.error('[Telegram.WebApp] Popup message is required', params.message); - throw Error('WebAppPopupParamInvalid'); - } - if (message.length > 256) { - console.error('[Telegram.WebApp] Popup message is too long', message); - throw Error('WebAppPopupParamInvalid'); - } - popup_params.message = message; - if (typeof params.buttons !== 'undefined') { - if (!Array.isArray(params.buttons)) { - console.error('[Telegram.WebApp] Popup buttons should be an array', params.buttons); - throw Error('WebAppPopupParamInvalid'); - } - for (var i = 0; i < params.buttons.length; i++) { - var button = params.buttons[i]; - var btn = {}; - var id = ''; - if (typeof button.id !== 'undefined') { - id = button.id.toString(); - if (id.length > 64) { - console.error('[Telegram.WebApp] Popup button id is too long', id); - throw Error('WebAppPopupParamInvalid'); - } - } - btn.id = id; - var button_type = button.type; - if (typeof button_type === 'undefined') { - button_type = 'default'; - } - btn.type = button_type; - if (button_type == 'ok' || - button_type == 'close' || - button_type == 'cancel') { - // no params needed - } else if (button_type == 'default' || - button_type == 'destructive') { - var text = ''; - if (typeof button.text !== 'undefined') { - text = strTrim(button.text); - } - if (!text.length) { - console.error('[Telegram.WebApp] Popup button text is required for type ' + button_type, button.text); - throw Error('WebAppPopupParamInvalid'); - } - if (text.length > 64) { - console.error('[Telegram.WebApp] Popup button text is too long', text); - throw Error('WebAppPopupParamInvalid'); - } - btn.text = text; - } else { - console.error('[Telegram.WebApp] Popup button type is invalid', button_type); - throw Error('WebAppPopupParamInvalid'); - } - buttons.push(btn); - } - } else { - buttons.push({id: '', type: 'close'}); - } - if (buttons.length < 1) { - console.error('[Telegram.WebApp] Popup should have at least one button'); - throw Error('WebAppPopupParamInvalid'); - } - if (buttons.length > 3) { - console.error('[Telegram.WebApp] Popup should not have more than 3 buttons'); - throw Error('WebAppPopupParamInvalid'); - } - popup_params.buttons = buttons; - - webAppPopupOpened = { - callback: callback - }; - WebView.postEvent('web_app_open_popup', false, popup_params); - }; - WebApp.showAlert = function (message, callback) { - WebApp.showPopup({ - message: message - }, callback ? function(){ callback(); } : null); - }; - WebApp.showConfirm = function (message, callback) { - WebApp.showPopup({ - message: message, - buttons: [ - {type: 'ok', id: 'ok'}, - {type: 'cancel'} - ] - }, callback ? function (button_id) { - callback(button_id == 'ok'); - } : null); - }; - WebApp.showScanQrPopup = function (params, callback) { - if (!versionAtLeast('6.4')) { - console.error('[Telegram.WebApp] Method showScanQrPopup is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (webAppScanQrPopupOpened) { - console.error('[Telegram.WebApp] Popup is already opened'); - throw Error('WebAppScanQrPopupOpened'); - } - var text = ''; - var popup_params = {}; - if (typeof params.text !== 'undefined') { - text = strTrim(params.text); - if (text.length > 64) { - console.error('[Telegram.WebApp] Scan QR popup text is too long', text); - throw Error('WebAppScanQrPopupParamInvalid'); - } - if (text.length > 0) { - popup_params.text = text; - } - } - - webAppScanQrPopupOpened = { - callback: callback - }; - WebView.postEvent('web_app_open_scan_qr_popup', false, popup_params); - }; - WebApp.closeScanQrPopup = function () { - if (!versionAtLeast('6.4')) { - console.error('[Telegram.WebApp] Method closeScanQrPopup is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - - webAppScanQrPopupOpened = false; - WebView.postEvent('web_app_close_scan_qr_popup', false); - }; - WebApp.readTextFromClipboard = function (callback) { - if (!versionAtLeast('6.4')) { - console.error('[Telegram.WebApp] Method readTextFromClipboard is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - var req_id = generateCallbackId(16); - var req_params = {req_id: req_id}; - webAppCallbacks[req_id] = { - callback: callback - }; - WebView.postEvent('web_app_read_text_from_clipboard', false, req_params); - }; - WebApp.requestWriteAccess = function (callback) { - if (!versionAtLeast('6.9')) { - console.error('[Telegram.WebApp] Method requestWriteAccess is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (WebAppWriteAccessRequested) { - console.error('[Telegram.WebApp] Write access is already requested'); - throw Error('WebAppWriteAccessRequested'); - } - WebAppWriteAccessRequested = { - callback: callback - }; - WebView.postEvent('web_app_request_write_access'); - }; - WebApp.requestContact = function (callback) { - if (!versionAtLeast('6.9')) { - console.error('[Telegram.WebApp] Method requestContact is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (WebAppContactRequested) { - console.error('[Telegram.WebApp] Contact is already requested'); - throw Error('WebAppContactRequested'); - } - WebAppContactRequested = { - callback: callback - }; - WebView.postEvent('web_app_request_phone'); - }; - WebApp.downloadFile = function (params, callback) { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method downloadFile is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (webAppDownloadFileRequested) { - console.error('[Telegram.WebApp] Popup is already opened'); - throw Error('WebAppDownloadFilePopupOpened'); - } - var a = document.createElement('A'); - - var dl_params = {}; - if (!params || !params.url || !params.url.length) { - console.error('[Telegram.WebApp] Url is required'); - throw Error('WebAppDownloadFileParamInvalid'); - } - a.href = params.url; - if (a.protocol != 'https:') { - console.error('[Telegram.WebApp] Url protocol is not supported', url); - throw Error('WebAppDownloadFileParamInvalid'); - } - dl_params.url = a.href; - - if (!params || !params.file_name || !params.file_name.length) { - console.error('[Telegram.WebApp] File name is required'); - throw Error('WebAppDownloadFileParamInvalid'); - } - dl_params.file_name = params.file_name; - - webAppDownloadFileRequested = { - callback: callback - }; - WebView.postEvent('web_app_request_file_download', false, dl_params); - }; - WebApp.shareToStory = function (media_url, params) { - params = params || {}; - if (!versionAtLeast('7.8')) { - console.error('[Telegram.WebApp] Method shareToStory is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - var a = document.createElement('A'); - a.href = media_url; - if (a.protocol != 'http:' && - a.protocol != 'https:') { - console.error('[Telegram.WebApp] Media url protocol is not supported', url); - throw Error('WebAppMediaUrlInvalid'); - } - var share_params = {}; - share_params.media_url = a.href; - if (typeof params.text !== 'undefined') { - var text = strTrim(params.text); - if (text.length > 2048) { - console.error('[Telegram.WebApp] Text is too long', text); - throw Error('WebAppShareToStoryParamInvalid'); - } - if (text.length > 0) { - share_params.text = text; - } - } - if (typeof params.widget_link !== 'undefined') { - params.widget_link = params.widget_link || {}; - a.href = params.widget_link.url; - if (a.protocol != 'http:' && - a.protocol != 'https:') { - console.error('[Telegram.WebApp] Link protocol is not supported', url); - throw Error('WebAppShareToStoryParamInvalid'); - } - var widget_link = { - url: a.href - }; - if (typeof params.widget_link.name !== 'undefined') { - var link_name = strTrim(params.widget_link.name); - if (link_name.length > 48) { - console.error('[Telegram.WebApp] Link name is too long', link_name); - throw Error('WebAppShareToStoryParamInvalid'); - } - if (link_name.length > 0) { - widget_link.name = link_name; - } - } - share_params.widget_link = widget_link; - } - - WebView.postEvent('web_app_share_to_story', false, share_params); - }; - WebApp.shareMessage = function (msg_id, callback) { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method shareMessage is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (WebAppShareMessageOpened) { - console.error('[Telegram.WebApp] Share message is already opened'); - throw Error('WebAppShareMessageOpened'); - } - WebAppShareMessageOpened = { - callback: callback - }; - WebView.postEvent('web_app_send_prepared_message', false, {id: msg_id}); - }; - WebApp.requestChat = function (req_id, callback) { - if (!versionAtLeast('9.6')) { - console.error('[Telegram.WebApp] Method requestChat is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (WebAppRequestChatOpened) { - console.error('[Telegram.WebApp] Request chat is already opened'); - throw Error('WebAppRequestChatOpened'); - } - WebAppRequestChatOpened = { - callback: callback - }; - WebView.postEvent('web_app_request_chat', false, {req_id: req_id}); - }; - WebApp.setEmojiStatus = function (custom_emoji_id, params, callback) { - params = params || {}; - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method setEmojiStatus is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - var status_params = {}; - status_params.custom_emoji_id = custom_emoji_id; - if (typeof params.duration !== 'undefined') { - status_params.duration = params.duration; - } - if (WebAppEmojiStatusRequested) { - console.error('[Telegram.WebApp] Emoji status is already requested'); - throw Error('WebAppEmojiStatusRequested'); - } - WebAppEmojiStatusRequested = { - callback: callback - }; - WebView.postEvent('web_app_set_emoji_status', false, status_params); - }; - WebApp.requestEmojiStatusAccess = function (callback) { - if (!versionAtLeast('8.0')) { - console.error('[Telegram.WebApp] Method requestEmojiStatusAccess is not supported in version ' + webAppVersion); - throw Error('WebAppMethodUnsupported'); - } - if (WebAppEmojiStatusAccessRequested) { - console.error('[Telegram.WebApp] Emoji status permission is already requested'); - throw Error('WebAppEmojiStatusAccessRequested'); - } - WebAppEmojiStatusAccessRequested = { - callback: callback - }; - WebView.postEvent('web_app_request_emoji_status_access'); - }; - WebApp.invokeCustomMethod = function (method, params, callback) { - invokeCustomMethod(method, params, callback); - }; - WebApp.hideKeyboard = function () { - WebView.postEvent('web_app_hide_keyboard'); - }; - WebApp.ready = function () { - WebView.postEvent('web_app_ready'); - }; - WebApp.expand = function () { - WebView.postEvent('web_app_expand'); - }; - WebApp.close = function (options) { - options = options || {}; - var req_params = {}; - if (versionAtLeast('7.6') && options.return_back) { - req_params.return_back = true; - } - WebView.postEvent('web_app_close', false, req_params); - }; - - window.Telegram.WebApp = WebApp; - - updateHeaderColor(); - updateBackgroundColor(); - updateBottomBarColor(); - setViewportHeight(); - if (initParams.tgWebAppShowSettings) { - SettingsButton.show(); - } - - window.addEventListener('resize', onWindowResize); - if (isIframe) { - document.addEventListener('click', linkHandler); - } - - WebView.onEvent('theme_changed', onThemeChanged); - WebView.onEvent('viewport_changed', onViewportChanged); - WebView.onEvent('safe_area_changed', onSafeAreaChanged); - WebView.onEvent('content_safe_area_changed', onContentSafeAreaChanged); - WebView.onEvent('visibility_changed', onVisibilityChanged); - WebView.onEvent('invoice_closed', onInvoiceClosed); - WebView.onEvent('popup_closed', onPopupClosed); - WebView.onEvent('qr_text_received', onQrTextReceived); - WebView.onEvent('scan_qr_popup_closed', onScanQrPopupClosed); - WebView.onEvent('clipboard_text_received', onClipboardTextReceived); - WebView.onEvent('write_access_requested', onWriteAccessRequested); - WebView.onEvent('phone_requested', onPhoneRequested); - WebView.onEvent('file_download_requested', onFileDownloadRequested); - WebView.onEvent('custom_method_invoked', onCustomMethodInvoked); - WebView.onEvent('fullscreen_changed', onFullscreenChanged); - WebView.onEvent('fullscreen_failed', onFullscreenFailed); - WebView.onEvent('home_screen_added', onHomeScreenAdded); - WebView.onEvent('home_screen_checked', onHomeScreenChecked); - WebView.onEvent('prepared_message_sent', onPreparedMessageSent); - WebView.onEvent('prepared_message_failed', onPreparedMessageFailed); - WebView.onEvent('requested_chat_sent', onRequestedChatSent); - WebView.onEvent('requested_chat_failed', onRequestedChatFailed); - WebView.onEvent('emoji_status_set', onEmojiStatusSet); - WebView.onEvent('emoji_status_failed', onEmojiStatusFailed); - WebView.onEvent('emoji_status_access_requested', onEmojiStatusAccessRequested); - WebView.postEvent('web_app_request_theme'); - WebView.postEvent('web_app_request_viewport'); - WebView.postEvent('web_app_request_safe_area'); - WebView.postEvent('web_app_request_content_safe_area'); - -})(); diff --git a/bot/app/web/templates/telegram-widget.js b/bot/app/web/templates/telegram-widget.js deleted file mode 100644 index 43b8ed9..0000000 --- a/bot/app/web/templates/telegram-widget.js +++ /dev/null @@ -1,575 +0,0 @@ -(function(window) { - (function(window){ - window.__parseFunction = function(__func, __attrs) { - __attrs = __attrs || []; - __func = '(function(' + __attrs.join(',') + '){' + __func + '})'; - return window.execScript ? window.execScript(__func) : eval(__func); - } - }(window)); - (function(window){ - - function addEvent(el, event, handler) { - var events = event.split(/\s+/); - for (var i = 0; i < events.length; i++) { - if (el.addEventListener) { - el.addEventListener(events[i], handler); - } else { - el.attachEvent('on' + events[i], handler); - } - } - } - function removeEvent(el, event, handler) { - var events = event.split(/\s+/); - for (var i = 0; i < events.length; i++) { - if (el.removeEventListener) { - el.removeEventListener(events[i], handler); - } else { - el.detachEvent('on' + events[i], handler); - } - } - } - function getCssProperty(el, prop) { - if (window.getComputedStyle) { - return window.getComputedStyle(el, '').getPropertyValue(prop) || null; - } else if (el.currentStyle) { - return el.currentStyle[prop] || null; - } - return null; - } - function geById(el_or_id) { - if (typeof el_or_id == 'string' || el_or_id instanceof String) { - return document.getElementById(el_or_id); - } else if (el_or_id instanceof HTMLElement) { - return el_or_id; - } - return null; - } - - var getWidgetsOrigin = function(default_origin, dev_origin) { - var link = document.createElement('A'), origin; - link.href = document.currentScript && document.currentScript.src || default_origin; - origin = link.origin || link.protocol + '//' + link.hostname; - if (origin == 'https://telegram.org') { - origin = default_origin; - } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') { - origin = dev_origin; - } else { - origin = default_origin; - } - return origin; - }; - - var getPageCanonical = function() { - var a = document.createElement('A'), link, href; - if (document.querySelector) { - link = document.querySelector('link[rel="canonical"]'); - if (link && (href = link.getAttribute('href'))) { - a.href = href; - return a.href; - } - } else { - var links = document.getElementsByTagName('LINK'); - for (var i = 0; i < links.length; i++) { - if ((link = links[i]) && - (link.getAttribute('rel') == 'canonical') && - (href = link.getAttribute('href'))) { - a.href = href; - return a.href; - } - } - } - return false; - }; - - function haveTgAuthResult() { - var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match; - try { - locationHash = location.hash.toString(); - if (match = locationHash.match(re)) { - location.hash = locationHash.replace(re, ''); - var data = match[1] || ''; - data = data.replace(/-/g, '+').replace(/_/g, '/'); - var pad = data.length % 4; - if (pad > 1) { - data += new Array(5 - pad).join('='); - } - return JSON.parse(window.atob(data)); - } - } catch (e) {} - return false; - } - - function getXHR() { - if (navigator.appName == "Microsoft Internet Explorer"){ - return new ActiveXObject("Microsoft.XMLHTTP"); - } else { - return new XMLHttpRequest(); - } - } - - if (!window.Telegram) { - window.Telegram = {}; - } - if (!window.Telegram.__WidgetUuid) { - window.Telegram.__WidgetUuid = 0; - } - if (!window.Telegram.__WidgetLastId) { - window.Telegram.__WidgetLastId = 0; - } - if (!window.Telegram.__WidgetCallbacks) { - window.Telegram.__WidgetCallbacks = {}; - } - - function postMessageToIframe(iframe, event, data, callback) { - if (!iframe._ready) { - if (!iframe._readyQueue) iframe._readyQueue = []; - iframe._readyQueue.push([event, data, callback]); - return; - } - try { - data = data || {}; - data.event = event; - if (callback) { - data._cb = ++window.Telegram.__WidgetLastId; - window.Telegram.__WidgetCallbacks[data._cb] = { - iframe: iframe, - callback: callback - }; - } - iframe.contentWindow.postMessage(JSON.stringify(data), '*'); - } catch(e) {} - } - - function initWidget(widgetEl) { - var widgetId, widgetElId, widgetsOrigin, existsEl, - src, styles = {}, allowedAttrs = [], - defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth; - if (!widgetEl.tagName || - !(widgetEl.tagName.toUpperCase() == 'SCRIPT' || - widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' && - widgetEl.classList.contains('telegram-post'))) { - return null; - } - if (widgetEl._iframe) { - return widgetEl._iframe; - } - if (widgetId = widgetEl.getAttribute('data-telegram-post')) { - var comment = widgetEl.getAttribute('data-comment') || ''; - widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev'); - widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : ''); - src = widgetsOrigin + '/' + widgetId + '?embed=1'; - allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color']; - defWidth = widgetEl.getAttribute('data-width') || '100%'; - defHeight = ''; - styles.minWidth = '320px'; - } - else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) { - widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev'); - widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid); - var websitePageUrl = widgetEl.getAttribute('data-page-url'); - if (!websitePageUrl) { - websitePageUrl = getPageCanonical(); - } - src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : ''); - allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height']; - defWidth = widgetEl.getAttribute('data-width') || '100%'; - defHeight = widgetEl.getAttribute('data-height') || 0; - styles.minWidth = '320px'; - if (defHeight > 0) { - scrollable = true; - } - } - else if (widgetEl.hasAttribute('data-telegram-login')) { - widgetId = widgetEl.getAttribute('data-telegram-login'); - widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev'); - widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-'); - src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href); - allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang']; - defWidth = 186; - defHeight = 28; - if (widgetEl.hasAttribute('data-size')) { - var size = widgetEl.getAttribute('data-size'); - if (size == 'small') defWidth = 148, defHeight = 20; - else if (size == 'large') defWidth = 238, defHeight = 40; - } - if (widgetEl.hasAttribute('data-onauth')) { - onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']); - } - else if (widgetEl.hasAttribute('data-auth-url')) { - var a = document.createElement('A'); - a.href = widgetEl.getAttribute('data-auth-url'); - onAuthUser = function(user) { - var authUrl = a.href; - authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?'; - var params = []; - for (var key in user) { - params.push(key + '=' + encodeURIComponent(user[key])); - } - authUrl += params.join('&'); - location.href = authUrl; - }; - } - if (widgetEl.hasAttribute('data-onunauth')) { - onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth')); - } - var auth_result = haveTgAuthResult(); - if (auth_result && onAuthUser) { - onAuthUser(auth_result); - } - } - else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) { - widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev'); - widgetElId = 'telegram-share-' + window.btoa(widgetId); - src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname); - allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text']; - defWidth = 60; - defHeight = 20; - if (widgetEl.getAttribute('data-size') == 'large') { - defWidth = 76; - defHeight = 28; - } - } - else { - return null; - } - existsEl = document.getElementById(widgetElId); - if (existsEl) { - return existsEl; - } - for (var i = 0; i < allowedAttrs.length; i++) { - var attr = allowedAttrs[i]; - var novalue = attr.substr(-1) == '?'; - if (novalue) { - attr = attr.slice(0, -1); - } - var data_attr = 'data-' + attr.replace(/_/g, '-'); - if (widgetEl.hasAttribute(data_attr)) { - var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr)); - src += '&' + attr + '=' + attr_value; - } - } - function getCurCoords(iframe) { - var docEl = document.documentElement; - var frect = iframe.getBoundingClientRect(); - return { - frameTop: frect.top, - frameBottom: frect.bottom, - frameLeft: frect.left, - frameRight: frect.right, - frameWidth: frect.width, - frameHeight: frect.height, - scrollTop: window.pageYOffset, - scrollLeft: window.pageXOffset, - clientWidth: docEl.clientWidth, - clientHeight: docEl.clientHeight - }; - } - function visibilityHandler() { - if (isVisible(iframe, 50)) { - postMessageToIframe(iframe, 'visible', {frame: widgetElId}); - } - } - function focusHandler() { - postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()}); - } - function postMessageHandler(event) { - if (event.source !== iframe.contentWindow || - event.origin != widgetsOrigin) { - return; - } - try { - var data = JSON.parse(event.data); - } catch(e) { - var data = {}; - } - if (data.event == 'resize') { - if (data.height) { - iframe.style.height = data.height + 'px'; - } - if (data.width) { - iframe.style.width = data.width + 'px'; - } - } - else if (data.event == 'ready') { - iframe._ready = true; - focusHandler(); - for (var i = 0; i < iframe._readyQueue.length; i++) { - var queue_item = iframe._readyQueue[i]; - postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]); - } - iframe._readyQueue = []; - } - else if (data.event == 'visible_off') { - removeEvent(window, 'scroll', visibilityHandler); - removeEvent(window, 'resize', visibilityHandler); - } - else if (data.event == 'get_coords') { - postMessageToIframe(iframe, 'callback', { - _cb: data._cb, - value: getCurCoords(iframe) - }); - } - else if (data.event == 'scroll_to') { - try { - window.scrollTo(data.x || 0, data.y || 0); - } catch(e) {} - } - else if (data.event == 'auth_user') { - if (data.init) { - onInitAuthUser && onInitAuthUser(data.auth_data); - } else { - onAuthUser && onAuthUser(data.auth_data); - } - } - else if (data.event == 'unauthorized') { - onUnauth && onUnauth(); - } - else if (data.event == 'callback') { - var cb_data = null; - if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) { - if (cb_data.iframe === iframe) { - cb_data.callback(data.value); - delete window.Telegram.__WidgetCallbacks[data._cb]; - } - } else { - console.warn('Callback #' + data._cb + ' not found'); - } - } - } - var iframe = document.createElement('iframe'); - iframe.id = widgetElId; - iframe.src = src; - iframe.width = defWidth; - iframe.height = defHeight; - iframe.setAttribute('frameborder', '0'); - if (!scrollable) { - iframe.setAttribute('scrolling', 'no'); - iframe.style.overflow = 'hidden'; - } - iframe.style.colorScheme = 'light dark'; - iframe.style.border = 'none'; - for (var prop in styles) { - iframe.style[prop] = styles[prop]; - } - if (widgetEl.parentNode) { - widgetEl.parentNode.insertBefore(iframe, widgetEl); - if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') { - widgetEl.parentNode.removeChild(widgetEl); - } - } - iframe._ready = false; - iframe._readyQueue = []; - widgetEl._iframe = iframe; - addEvent(iframe, 'load', function() { - removeEvent(iframe, 'load', visibilityHandler); - addEvent(window, 'scroll', visibilityHandler); - addEvent(window, 'resize', visibilityHandler); - visibilityHandler(); - }); - addEvent(window, 'focus blur', focusHandler); - addEvent(window, 'message', postMessageHandler); - return iframe; - } - function isVisible(el, padding) { - var node = el, val; - var visibility = getCssProperty(node, 'visibility'); - if (visibility == 'hidden') return false; - while (node) { - if (node === document.documentElement) break; - var display = getCssProperty(node, 'display'); - if (display == 'none') return false; - var opacity = getCssProperty(node, 'opacity'); - if (opacity !== null && opacity < 0.1) return false; - node = node.parentNode; - } - if (el.getBoundingClientRect) { - padding = +padding || 0; - var rect = el.getBoundingClientRect(); - var html = document.documentElement; - if (rect.bottom < padding || - rect.right < padding || - rect.top > (window.innerHeight || html.clientHeight) - padding || - rect.left > (window.innerWidth || html.clientWidth) - padding) { - return false; - } - } - return true; - } - - function getAllWidgets() { - var widgets = []; - if (document.querySelectorAll) { - widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]'); - } else { - widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT')); - widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE'))); - } - return widgets; - } - - function getWidgetInfo(el_or_id, callback) { - var e = null, iframe = null; - if (el = geById(el_or_id)) { - if (el.tagName && - el.tagName.toUpperCase() == 'IFRAME') { - iframe = el; - } else if (el._iframe) { - iframe = el._iframe; - } - if (iframe && callback) { - postMessageToIframe(iframe, 'get_info', {}, callback); - } - } - } - - function setWidgetOptions(options, el_or_id) { - var e = null, iframe = null; - if (typeof el_or_id === 'undefined') { - var widgets = getAllWidgets(); - for (var i = 0; i < widgets.length; i++) { - if (iframe = widgets[i]._iframe) { - postMessageToIframe(iframe, 'set_options', {options: options}); - } - } - } else { - if (el = geById(el_or_id)) { - if (el.tagName && - el.tagName.toUpperCase() == 'IFRAME') { - iframe = el; - } else if (el._iframe) { - iframe = el._iframe; - } - if (iframe) { - postMessageToIframe(iframe, 'set_options', {options: options}); - } - } - } - } - - if (!document.currentScript || - !initWidget(document.currentScript)) { - var widgets = getAllWidgets(); - for (var i = 0; i < widgets.length; i++) { - initWidget(widgets[i]); - } - } - - var TelegramLogin = { - popups: {}, - options: null, - auth_callback: null, - _init: function(options, auth_callback) { - TelegramLogin.options = options; - TelegramLogin.auth_callback = auth_callback; - var auth_result = haveTgAuthResult(); - if (auth_result && auth_callback) { - auth_callback(auth_result); - } - }, - _open: function(callback) { - TelegramLogin._auth(TelegramLogin.options, function(authData) { - if (TelegramLogin.auth_callback) { - TelegramLogin.auth_callback(authData); - } - if (callback) { - callback(authData); - } - }); - }, - _auth: function(options, callback) { - var bot_id = parseInt(options.bot_id); - if (!bot_id) { - throw new Error('Bot id required'); - } - var width = 550; - var height = 470; - var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0), - top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0); - var onMessage = function (event) { - try { - var data = JSON.parse(event.data); - } catch(e) { - var data = {}; - } - if (!TelegramLogin.popups[bot_id]) return; - if (event.source !== TelegramLogin.popups[bot_id].window) return; - if (data.event == 'auth_result') { - onAuthDone(data.result); - } - }; - var onAuthDone = function (authData) { - if (!TelegramLogin.popups[bot_id]) return; - if (TelegramLogin.popups[bot_id].authFinished) return; - callback && callback(authData); - TelegramLogin.popups[bot_id].authFinished = true; - removeEvent(window, 'message', onMessage); - }; - var checkClose = function(bot_id) { - if (!TelegramLogin.popups[bot_id]) return; - if (!TelegramLogin.popups[bot_id].window || - TelegramLogin.popups[bot_id].window.closed) { - return TelegramLogin.getAuthData(options, function(origin, authData) { - onAuthDone(authData); - }); - } - setTimeout(checkClose, 100, bot_id); - } - var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href); - var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0'); - TelegramLogin.popups[bot_id] = { - window: popup, - authFinished: false - }; - if (popup) { - addEvent(window, 'message', onMessage); - popup.focus(); - checkClose(bot_id); - } - }, - getAuthData: function(options, callback) { - var bot_id = parseInt(options.bot_id); - if (!bot_id) { - throw new Error('Bot id required'); - } - var xhr = getXHR(); - var url = Telegram.Login.widgetsOrigin + '/auth/get'; - xhr.open('POST', url); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); - xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); - xhr.onreadystatechange = function() { - if (xhr.readyState == 4) { - if (typeof xhr.responseBody == 'undefined' && xhr.responseText) { - try { - var result = JSON.parse(xhr.responseText); - } catch(e) { - var result = {}; - } - if (result.user) { - callback(result.origin, result.user); - } else { - callback(result.origin, false); - } - } else { - callback('*', false); - } - } - }; - xhr.onerror = function() { - callback('*', false); - }; - xhr.withCredentials = true; - xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '')); - } - }; - - window.Telegram.getWidgetInfo = getWidgetInfo; - window.Telegram.setWidgetOptions = setWidgetOptions; - window.Telegram.Login = { - init: TelegramLogin._init, - open: TelegramLogin._open, - auth: TelegramLogin._auth, - widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev') - }; - - }(window)); -})(window); diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index 0c10052..5317168 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -1,7 +1,6 @@ import hmac import asyncio import logging -from contextlib import suppress from aiohttp import web from aiogram import Bot, Dispatcher @@ -17,8 +16,6 @@ class SecureSimpleRequestHandler(SimpleRequestHandler): return False return hmac.compare_digest(telegram_secret_token, self.secret_token) -TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 - def _inject_shared_instances( app: web.Application, @@ -132,13 +129,8 @@ async def build_and_start_web_app( f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}" ) - telegram_web_app_sdk_refresh_task = None if settings.WEBAPP_ENABLED: - from bot.app.web.subscription_webapp import ( - create_subscription_webapp_application, - refresh_telegram_login_widget_sdk, - refresh_telegram_web_app_sdk, - ) + from bot.app.web.subscription_webapp import create_subscription_webapp_application subscription_app = create_subscription_webapp_application( dp, @@ -161,25 +153,9 @@ async def build_and_start_web_app( settings.WEBAPP_SERVER_PORT, ) - async def _refresh_telegram_web_assets_forever() -> None: - while True: - await refresh_telegram_web_app_sdk() - await refresh_telegram_login_widget_sdk() - await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS) - - telegram_web_app_sdk_refresh_task = asyncio.create_task( - _refresh_telegram_web_assets_forever(), - name="TelegramWebAssetsRefreshTask", - ) - try: await asyncio.Event().wait() finally: - if telegram_web_app_sdk_refresh_task is not None: - telegram_web_app_sdk_refresh_task.cancel() - with suppress(asyncio.CancelledError): - await telegram_web_app_sdk_refresh_task - for runner in reversed(runners): try: await runner.cleanup() diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index d976cf2..1a064c8 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -99,7 +99,7 @@ def _layout( logo_block = ( f'' + f'border-radius:16px;">' ) return f""" diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 7f4f239..f8cc004 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -495,6 +495,7 @@ class SubscriptionService: "is_active": True, "status_from_panel": "TRIAL", "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, + "traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY, "auto_renew_enabled": False, } try: @@ -516,6 +517,7 @@ class SubscriptionService: expire_at=end_date, status="ACTIVE", traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, + traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY, ) panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) diff --git a/config/settings.py b/config/settings.py index a14a5b1..246048f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -87,6 +87,7 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] + logo_emoji: str session_ttl_seconds: int session_secret: str webhook_secret_token: str @@ -315,6 +316,7 @@ class Settings(BaseSettings): TRIAL_ENABLED: bool = Field(default=True) TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) + TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET") CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs") CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled") @@ -331,6 +333,7 @@ class Settings(BaseSettings): WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a") WEBAPP_LOGO_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) @@ -475,6 +478,7 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, + logo_emoji=self.WEBAPP_LOGO_EMOJI, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, session_secret=self.WEBAPP_SESSION_SECRET, webhook_secret_token=self.WEBHOOK_SECRET_TOKEN, diff --git a/docker-compose.yml b/docker-compose.yml index 9829ff6..4c81c24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: ports: - 127.0.0.1:8080:8080 - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} + # Optional Tariffs 2.0 config. Put tariffs.json near this compose file, + # then uncomment the mount below. If no JSON is mounted, legacy .env + # pricing is used. + # volumes: + # - ./tariffs.json:/app/config/tariffs.json:ro healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"] interval: 30s diff --git a/locales/en.json b/locales/en.json index 9a5adb2..745f43f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -57,7 +57,6 @@ "pay_with_stars_button": "🌟 Telegram Stars", "connect_button": "🔗 Connect", "cancel_button": "❌ Cancel", - "devices_button": "📱 My Devices ({current_devices}/{max_devices})", "my_devices_details": "📱 My Devices ({current_devices}/{max_devices})\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n
Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.
", "no_devices_details_found_message": "📱 My Devices\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.", @@ -67,7 +66,6 @@ "no_devices_found": "Error loading device list.", "devices_unlimited_label": "Unlimited", "my_devices_feature_disabled": "The My Devices section is currently unavailable.", - "payment_description_subscription": "Subscription payment for {months} mo.", "payment_description_traffic": "Traffic package {traffic_gb} GB", "payment_link_message": "To pay for {months} mo. subscription, click the button below:", @@ -522,9 +520,7 @@ "admin_ads_deleted_success": "Campaign deleted.", "admin_ads_not_found": "Campaign not found.", "free_kassa_order_full": "Order #{order_id} from {date}\n\n", - "email_footer_auto": "Sent automatically by {brand}. Please don't reply.", - "email_login_code_subject": "{code} — your sign-in code", "email_login_code_preheader": "Your one-time code expires in {minutes} min.", "email_login_code_heading": "Confirm your sign-in", @@ -537,7 +533,6 @@ "email_login_code_magic_or": "or", "email_login_code_text": "Your verification code: {code}\n\nThe code is valid for {minutes} min.\nIf you didn't request this code, ignore this message.", "email_login_code_text_magic": "Or sign in with this single-use link: {url}", - "email_account_merged_subject": "Accounts merged", "email_account_merged_preheader": "Your accounts were combined into one profile.", "email_account_merged_heading": "Accounts merged", @@ -547,7 +542,6 @@ "email_account_merged_row_end_date": "New end date", "email_account_merged_note": "Your subscription link is unchanged. The duplicate panel user was removed automatically.", "email_account_merged_text": "We merged your accounts into one profile.\n\nKept account: {primary}\nRemoved account: {removed}\nPaid periods were combined. New subscription end date: {end_date}.\nYour subscription link stayed the same, and the later account was removed from Remnawave automatically.", - "email_payment_success_subject": "Payment received", "email_payment_success_preheader": "Thanks — your subscription has been activated.", "email_payment_success_heading": "Payment received", @@ -565,7 +559,6 @@ "email_payment_success_text_subscription": "Payment received: {amount}.\nSubscription extended by {months} month(s).\nActive until: {end_date}.", "email_payment_success_text_traffic": "Payment received: {amount}.\nTraffic added: {traffic_gb} GB.\nActive until: {end_date}.", "email_payment_success_text_dashboard": "Dashboard: {url}", - "email_subscription_expiring_subject_today": "Your subscription ends today", "email_subscription_expiring_subject_tomorrow": "1 day left on your subscription", "email_subscription_expiring_subject_days": "{days} days left on your subscription", @@ -583,5 +576,149 @@ "email_subscription_expiring_cta": "Renew subscription", "email_subscription_expiring_note": "If you've already renewed or use auto-renewal, you can ignore this email.", "email_subscription_expiring_text": "{heading}.\nActive until: {end_date}.", - "email_subscription_expiring_text_renew": "Renew: {url}" + "email_subscription_expiring_text_renew": "Renew: {url}", + "wa_loading": "Loading...", + "wa_back": "Back", + "wa_close": "Close", + "wa_auth_checking_login": "Checking sign-in...", + "wa_auth_login_confirm_failed": "Could not confirm sign-in", + "wa_auth_checking_telegram": "Checking Telegram...", + "wa_auth_access_denied": "Access denied", + "wa_auth_telegram_not_confirmed": "Telegram sign-in not confirmed", + "wa_auth_telegram_unavailable": "Telegram sign-in is currently unavailable", + "wa_auth_telegram_not_configured": "Telegram sign-in is not configured", + "wa_auth_telegram_cancelled": "Telegram sign-in was cancelled", + "wa_auth_invalid_email": "Enter a valid email", + "wa_auth_sending_code": "Sending code...", + "wa_auth_send_code_failed": "Failed to send code", + "wa_auth_enter_code_6digits": "Enter the 6-digit code from the email", + "wa_auth_checking_code": "Checking code...", + "wa_auth_invalid_code": "Invalid code", + "wa_auth_code_expired": "Code expired", + "wa_auth_resend_wait": "You can resend in {seconds} sec.", + "wa_email_verification_title": "Email confirmation", + "wa_email_sent_to": "We sent a code to {email}", + "wa_email_code_aria": "Verification code", + "wa_email_placeholder": "Email", + "wa_confirm": "Confirm", + "wa_resend_code": "Resend code", + "wa_send_code_email": "Send code to email", + "wa_or": "or", + "wa_login_telegram_button": "Sign in with Telegram", + "wa_auth_legal_intro": "By creating an account, you agree to the", + "wa_auth_legal_privacy": "privacy policy", + "wa_auth_legal_and": "and", + "wa_auth_legal_agreement": "user agreement", + "wa_home_subscription_active": "Subscription active", + "wa_home_access_active": "Access active", + "wa_sub_term_forever": "Forever", + "wa_sub_term_value_unit": "{value} {unit}", + "wa_sub_term_day_one": "day", + "wa_sub_term_day_few": "days", + "wa_sub_term_day_many": "days", + "wa_sub_term_month_one": "month", + "wa_sub_term_month_few": "months", + "wa_sub_term_month_many": "months", + "wa_sub_term_year_one": "year", + "wa_sub_term_year_few": "years", + "wa_sub_term_year_many": "years", + "wa_home_subscription_inactive": "Subscription inactive", + "wa_until_date": "until {date}", + "wa_home_traffic_used": "Traffic used", + "wa_renew": "Renew", + "wa_pay_subscription": "Pay subscription", + "wa_subscription_title": "Subscription", + "wa_subscription_choose_period": "Choose subscription period", + "wa_tariffs_title": "Tariffs", + "wa_tariffs_choose": "Choose a tariff and payment option", + "wa_per_month_short": "/mo", + "wa_traffic_packages_title": "Traffic", + "wa_traffic_packages_choose": "Choose traffic package", + "wa_per_gb_short": "/GB", + "wa_buy_traffic": "Buy traffic", + "wa_activate_trial": "Activate trial", + "wa_trial_title": "Trial period", + "wa_trial_details": "{days} days, {traffic}", + "wa_trial_activated": "Trial activated", + "wa_trial_activation_failed": "Failed to activate trial", + "wa_payment_methods_not_configured": "Payment methods are not configured yet", + "wa_pay": "Pay", + "wa_referral_link_title": "Your referral link", + "wa_link_unavailable": "Link is not available yet", + "wa_link_copied": "Link copied", + "wa_copy": "Copy", + "wa_referral_bonus_overview_title": "Bonus system", + "wa_referral_bonus_once_note": "Bonus is granted only once per invited user after their first payment.", + "wa_referral_bonus_registration_title": "For registration via referral link", + "wa_referral_bonus_paid_intro": "If a friend pays for a subscription:", + "wa_referral_bonus_you_days": "You: +{days} days", + "wa_referral_bonus_friend_days": "Friend: +{days} days", + "wa_referral_bonus_not_configured": "Referral bonuses are not configured", + "wa_promo_enter": "Enter promo code", + "wa_promo_activated_until": "Promo code activated. Subscription until {date}", + "wa_promo_activated": "Promo code activated", + "wa_promo_activation_failed": "Failed to activate promo code", + "wa_activate": "Activate", + "wa_settings_avatar_alt": "User avatar", + "wa_settings_language": "Language", + "wa_settings_link_telegram": "Telegram linked", + "wa_settings_not_linked": "Not linked", + "wa_settings_link_email": "Email linked", + "wa_settings_email_not_linked": "Not linked", + "wa_settings_linked": "Linked", + "wa_settings_language_update_failed": "Failed to update language", + "wa_settings_user_agreement": "User agreement", + "wa_settings_privacy_policy": "Privacy policy", + "wa_logout": "Log out", + "wa_end_session": "End session", + "wa_navigation": "Navigation", + "wa_nav_home": "Home", + "wa_nav_settings": "Settings", + "wa_nav_devices": "Devices", + "wa_devices_title": "My devices", + "wa_devices_count": "{current} of {max}", + "wa_devices_unlimited": "Unlimited", + "wa_devices_refresh": "Refresh devices", + "wa_devices_loading": "Loading devices...", + "wa_devices_load_failed": "Failed to load devices", + "wa_devices_empty": "No devices yet", + "wa_devices_empty_hint": "You can connect up to {max} devices from your subscription app.", + "wa_device_fallback_name": "Device {index}", + "wa_devices_platform_unknown": "Platform unknown", + "wa_devices_connected_at": "Connected", + "wa_devices_disconnect": "Disconnect device", + "wa_devices_disconnect_title": "Disconnect device", + "wa_devices_disconnect_desc": "{device} will be removed from your device list. It may connect again next time you use the subscription on that device.", + "wa_devices_disconnect_confirm": "Disconnect", + "wa_device_disconnected": "Device disconnected", + "wa_device_disconnect_failed": "Failed to disconnect device", + "wa_cancel": "Cancel", + "wa_payment_created": "Payment created", + "wa_payment_create_failed": "Failed to create payment", + "wa_connect_link_unavailable": "Connection link is not available yet", + "wa_copied": "Copied", + "wa_unavailable": "Not available yet", + "wa_method_other_title": "Other methods", + "wa_unlimited_traffic": "Unlimited traffic", + "wa_traffic_of": "{used} of {limit}", + "wa_language_default": "English", + "wa_telegram_not_linked": "Telegram not linked", + "wa_tg_id_not_linked": "TG ID not linked", + "wa_nav_bonuses": "Bonuses", + "wa_method_platega_sbp_card": "SBP/Card", + "wa_method_platega_crypto": "Crypto", + "wa_settings_link_telegram_action": "Link Telegram", + "wa_settings_telegram_linked_title": "Telegram linked", + "wa_settings_link_email_action": "Link email", + "wa_settings_email_linked_title": "Email linked", + "wa_activate_promo_title": "Activate promo code", + "wa_link_email_modal_title": "Link email", + "wa_traffic_reset_none": "No reset", + "wa_traffic_reset_monthly": "Monthly reset", + "wa_traffic_reset_weekly": "Weekly reset", + "wa_traffic_reset_daily": "Daily reset", + "wa_traffic_reset_yearly": "Yearly reset", + "wa_traffic_reset_policy": "Traffic reset policy", + "wa_plan_one_year": "1 year", + "wa_link_email_modal_desc": "Enter your email and get a verification code" } diff --git a/locales/ru.json b/locales/ru.json index e2b1318..afba3e7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -56,7 +56,6 @@ "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_stars_button": "🌟 Звезды Telegram", "connect_button": "🔗 Подключиться", - "devices_button": "📱 Мои устройства ({current_devices}/{max_devices})", "my_devices_details": "📱 Список ваших устройств ({current_devices}/{max_devices})\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n
Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.
", "no_devices_details_found_message": "📱 Список ваших устройств\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.", @@ -66,7 +65,6 @@ "no_devices_found": "Ошибка загрузки списка устройств.", "devices_unlimited_label": "Без ограничений", "my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.", - "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", "payment_description_traffic": "Пакет трафика {traffic_gb} ГБ", @@ -522,9 +520,7 @@ "admin_ads_deleted_success": "Кампания удалена.", "admin_ads_not_found": "Кампания не найдена.", "free_kassa_order_full": "Заказ №{order_id} от {date}\n\n", - "email_footer_auto": "Это автоматическое письмо от {brand}. Отвечать на него не нужно.", - "email_login_code_subject": "{code} — код для входа", "email_login_code_preheader": "Одноразовый код действует {minutes} мин.", "email_login_code_heading": "Подтвердите вход", @@ -537,7 +533,6 @@ "email_login_code_magic_or": "или", "email_login_code_text": "Ваш код подтверждения: {code}\n\nКод действует {minutes} мин.\nЕсли вы не запрашивали код, проигнорируйте это письмо.", "email_login_code_text_magic": "Или войдите по ссылке (одноразовая): {url}", - "email_account_merged_subject": "Аккаунты объединены", "email_account_merged_preheader": "Мы объединили ваши аккаунты в один профиль.", "email_account_merged_heading": "Аккаунты объединены", @@ -547,7 +542,6 @@ "email_account_merged_row_end_date": "Новая дата окончания", "email_account_merged_note": "Ссылка на подписку осталась прежней. Дублирующий пользователь панели был удалён автоматически.", "email_account_merged_text": "Мы объединили ваши аккаунты в один профиль.\n\nОставлен аккаунт: {primary}\nУдалён аккаунт: {removed}\nОплаченные периоды сложились. Новая дата окончания подписки: {end_date}.\nСсылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически.", - "email_payment_success_subject": "Платёж получен", "email_payment_success_preheader": "Спасибо — подписка активирована.", "email_payment_success_heading": "Платёж получен", @@ -565,7 +559,6 @@ "email_payment_success_text_subscription": "Платёж получен: {amount}.\nПодписка продлена на {months} мес.\nДействует до: {end_date}.", "email_payment_success_text_traffic": "Платёж получен: {amount}.\nЗачислено трафика: {traffic_gb} ГБ.\nДействует до: {end_date}.", "email_payment_success_text_dashboard": "Кабинет: {url}", - "email_subscription_expiring_subject_today": "Подписка заканчивается сегодня", "email_subscription_expiring_subject_tomorrow": "Подписка заканчивается завтра", "email_subscription_expiring_subject_days": "До конца подписки осталось {days} дн.", @@ -583,5 +576,149 @@ "email_subscription_expiring_cta": "Продлить подписку", "email_subscription_expiring_note": "Если вы уже продлили или включили автопродление — просто проигнорируйте это письмо.", "email_subscription_expiring_text": "{heading}.\nДействует до: {end_date}.", - "email_subscription_expiring_text_renew": "Продлить: {url}" + "email_subscription_expiring_text_renew": "Продлить: {url}", + "wa_loading": "Загрузка...", + "wa_back": "Назад", + "wa_close": "Закрыть", + "wa_auth_checking_login": "Проверяем вход...", + "wa_auth_login_confirm_failed": "Не удалось подтвердить вход", + "wa_auth_checking_telegram": "Проверяем Telegram...", + "wa_auth_access_denied": "Доступ запрещен", + "wa_auth_telegram_not_confirmed": "Telegram-вход не подтвержден", + "wa_auth_telegram_unavailable": "Telegram-вход сейчас недоступен", + "wa_auth_telegram_not_configured": "Telegram-вход не настроен", + "wa_auth_telegram_cancelled": "Вход через Telegram отменён", + "wa_auth_invalid_email": "Введите корректный email", + "wa_auth_sending_code": "Отправляем код...", + "wa_auth_send_code_failed": "Не удалось отправить код", + "wa_auth_enter_code_6digits": "Введите 6 цифр из письма", + "wa_auth_checking_code": "Проверяем код...", + "wa_auth_invalid_code": "Неверный код", + "wa_auth_code_expired": "Код устарел", + "wa_auth_resend_wait": "Повторная отправка через {seconds} сек.", + "wa_email_verification_title": "Подтверждение по email", + "wa_email_sent_to": "Мы отправили код на {email}", + "wa_email_code_aria": "Код подтверждения", + "wa_email_placeholder": "Email", + "wa_confirm": "Подтвердить", + "wa_resend_code": "Отправить код повторно", + "wa_send_code_email": "Отправить код на почту", + "wa_or": "или", + "wa_login_telegram_button": "Войти через телеграм", + "wa_auth_legal_intro": "Создавая аккаунт, вы соглашаетесь с", + "wa_auth_legal_privacy": "политикой конфиденциальности", + "wa_auth_legal_and": "и", + "wa_auth_legal_agreement": "пользовательским соглашением", + "wa_home_subscription_active": "Подписка активна", + "wa_home_access_active": "Доступ активен", + "wa_sub_term_forever": "Навсегда", + "wa_sub_term_value_unit": "{value} {unit}", + "wa_sub_term_day_one": "день", + "wa_sub_term_day_few": "дня", + "wa_sub_term_day_many": "дней", + "wa_sub_term_month_one": "месяц", + "wa_sub_term_month_few": "месяца", + "wa_sub_term_month_many": "месяцев", + "wa_sub_term_year_one": "год", + "wa_sub_term_year_few": "года", + "wa_sub_term_year_many": "лет", + "wa_home_subscription_inactive": "Подписка не активна", + "wa_until_date": "до {date}", + "wa_home_traffic_used": "Использовано трафика", + "wa_renew": "Продлить", + "wa_pay_subscription": "Оплатить подписку", + "wa_subscription_title": "Подписка", + "wa_subscription_choose_period": "Выберите срок подписки", + "wa_tariffs_title": "Тарифы", + "wa_tariffs_choose": "Выберите тариф и вариант оплаты", + "wa_per_month_short": "/мес", + "wa_traffic_packages_title": "Трафик", + "wa_traffic_packages_choose": "Выберите пакет трафика", + "wa_per_gb_short": "/ГБ", + "wa_buy_traffic": "Купить трафик", + "wa_activate_trial": "Активировать триал", + "wa_trial_title": "Пробный период", + "wa_trial_details": "{days} дн., {traffic}", + "wa_trial_activated": "Пробный период активирован", + "wa_trial_activation_failed": "Не удалось активировать пробный период", + "wa_payment_methods_not_configured": "Способы оплаты пока не настроены", + "wa_pay": "Оплатить", + "wa_referral_link_title": "Ваша реферальная ссылка", + "wa_link_unavailable": "Ссылка пока недоступна", + "wa_link_copied": "Ссылка скопирована", + "wa_copy": "Копировать", + "wa_referral_bonus_overview_title": "Система бонусов", + "wa_referral_bonus_once_note": "Бонус за приглашённого начисляется только один раз, после его первой оплаты.", + "wa_referral_bonus_registration_title": "За регистрацию по реферальной ссылке", + "wa_referral_bonus_paid_intro": "Если друг оплатит подписку:", + "wa_referral_bonus_you_days": "Вам: +{days} дней", + "wa_referral_bonus_friend_days": "Другу: +{days} дней", + "wa_referral_bonus_not_configured": "Реферальные бонусы не настроены", + "wa_promo_enter": "Введите промокод", + "wa_promo_activated_until": "Промокод активирован. Подписка до {date}", + "wa_promo_activated": "Промокод активирован", + "wa_promo_activation_failed": "Не удалось активировать промокод", + "wa_activate": "Активировать", + "wa_settings_avatar_alt": "Аватар пользователя", + "wa_settings_language": "Выбор языка", + "wa_settings_link_telegram": "Привязка Telegram", + "wa_settings_not_linked": "Не привязан", + "wa_settings_link_email": "Привязка почты", + "wa_settings_email_not_linked": "Не привязана", + "wa_settings_linked": "Привязано", + "wa_settings_language_update_failed": "Не удалось обновить язык", + "wa_settings_user_agreement": "Пользовательское соглашение", + "wa_settings_privacy_policy": "Политика конфиденциальности", + "wa_logout": "Выйти", + "wa_end_session": "Завершить сессию", + "wa_navigation": "Навигация", + "wa_nav_home": "Главная", + "wa_nav_settings": "Настройки", + "wa_nav_devices": "Устройства", + "wa_devices_title": "Мои устройства", + "wa_devices_count": "{current} из {max}", + "wa_devices_unlimited": "Без ограничений", + "wa_devices_refresh": "Обновить устройства", + "wa_devices_loading": "Загружаем устройства...", + "wa_devices_load_failed": "Не удалось загрузить устройства", + "wa_devices_empty": "Устройств пока нет", + "wa_devices_empty_hint": "Можно подключить до {max} устройств через приложение с подпиской.", + "wa_device_fallback_name": "Устройство {index}", + "wa_devices_platform_unknown": "Платформа неизвестна", + "wa_devices_connected_at": "Подключено", + "wa_devices_disconnect": "Отключить устройство", + "wa_devices_disconnect_title": "Отключить устройство", + "wa_devices_disconnect_desc": "{device} будет удалено из списка устройств. Оно может подключиться снова при следующем использовании подписки на этом устройстве.", + "wa_devices_disconnect_confirm": "Отключить", + "wa_device_disconnected": "Устройство отключено", + "wa_device_disconnect_failed": "Не удалось отключить устройство", + "wa_cancel": "Отмена", + "wa_payment_created": "Платеж создан", + "wa_payment_create_failed": "Не удалось создать платеж", + "wa_connect_link_unavailable": "Ссылка для подключения пока недоступна", + "wa_copied": "Скопировано", + "wa_unavailable": "Пока недоступно", + "wa_method_other_title": "Другие способы", + "wa_unlimited_traffic": "Безлимитный трафик", + "wa_traffic_of": "{used} из {limit}", + "wa_language_default": "Русский", + "wa_telegram_not_linked": "Telegram не привязан", + "wa_tg_id_not_linked": "TG ID не привязан", + "wa_nav_bonuses": "Бонусы", + "wa_method_platega_sbp_card": "СБП/Карта", + "wa_method_platega_crypto": "Крипта", + "wa_settings_link_telegram_action": "Привязать телеграм", + "wa_settings_telegram_linked_title": "Телеграм привязан", + "wa_settings_link_email_action": "Привязать почту", + "wa_settings_email_linked_title": "Почта привязана", + "wa_activate_promo_title": "Активировать промокод", + "wa_link_email_modal_title": "Привязать почту", + "wa_traffic_reset_none": "Без сброса", + "wa_traffic_reset_monthly": "Сброс ежемесячно", + "wa_traffic_reset_weekly": "Сброс еженедельно", + "wa_traffic_reset_daily": "Сброс ежедневно", + "wa_traffic_reset_yearly": "Сброс ежегодно", + "wa_traffic_reset_policy": "Стратегия сброса трафика", + "wa_plan_one_year": "1 год", + "wa_link_email_modal_desc": "Введите email и получите код подтверждения" } diff --git a/package-lock.json b/package-lock.json index 66ea61d..183d183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,51 @@ "packages": { "": { "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/cli": "4.2.4", + "bits-ui": "^2.18.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "esbuild": "^0.28.0", - "tailwindcss": "4.2.4" + "lucide-svelte": "^1.0.1", + "svelte": "^5.55.5", + "tailwind-merge": "^3.5.0", + "tailwindcss": "4.2.4", + "vite": "^8.0.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -452,6 +494,45 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -502,6 +583,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -829,6 +939,329 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/cli": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.4.tgz", @@ -1117,6 +1550,132 @@ "node": ">= 20" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.0.tgz", + "integrity": "sha512-GLOBZRVy3hxNHIQ2MpD/+5aK9KcBFZRhUJtZ1UDABXdlVR4K6zFpgt4T+Rwuhf2sQzlc6yK1q/DprHPjwT4Pjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1127,6 +1686,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -1183,6 +1749,64 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1190,6 +1814,13 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1213,6 +1844,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1496,6 +2137,33 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-svelte": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1516,6 +2184,25 @@ "node": ">=4" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1523,6 +2210,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1543,6 +2241,94 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1553,6 +2339,83 @@ "node": ">=0.10.0" } }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svelte": { + "version": "5.55.5", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", + "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -1573,6 +2436,135 @@ "type": "opencollective", "url": "https://opencollective.com/webpack" } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 2ec6f43..2c5dbc7 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,22 @@ { "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", + "build:webapp:svelte": "vite build --config ./bot/app/web/frontend/vite.config.mjs", + "build:webapp:css": "npm run build:webapp:svelte", "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", - "build:webapp": "npm run build:webapp:css && npm run build:webapp:js" + "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/cli": "4.2.4", + "bits-ui": "^2.18.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "esbuild": "^0.28.0", - "tailwindcss": "4.2.4" + "lucide-svelte": "^1.0.1", + "svelte": "^5.55.5", + "tailwind-merge": "^3.5.0", + "tailwindcss": "4.2.4", + "vite": "^8.0.10" } } diff --git a/scripts/update_telegram_web_app_js.py b/scripts/update_telegram_web_app_js.py deleted file mode 100644 index d984e7f..0000000 --- a/scripts/update_telegram_web_app_js.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from urllib.request import Request, urlopen - - -SOURCE_URL = "https://telegram.org/js/telegram-web-app.js" -TARGET_PATH = ( - Path(__file__).resolve().parents[1] - / "bot" - / "app" - / "web" - / "templates" - / "telegram-web-app.js" -) - - -def _download(source_url: str) -> bytes: - request = Request( - source_url, - headers={ - "User-Agent": "Mozilla/5.0", - "Accept": "application/javascript,text/javascript,*/*;q=0.8", - }, - ) - with urlopen(request, timeout=30) as response: - return response.read() - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Download the latest Telegram Web App SDK into the local templates directory." - ) - parser.add_argument( - "--source-url", - default=SOURCE_URL, - help="Telegram Web App SDK URL to download from.", - ) - parser.add_argument( - "--target", - type=Path, - default=TARGET_PATH, - help="Output path for the vendored SDK.", - ) - args = parser.parse_args() - - data = _download(args.source_url) - args.target.parent.mkdir(parents=True, exist_ok=True) - args.target.write_bytes(data) - print(f"Wrote {len(data)} bytes to {args.target}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/update_telegram_widget_js.py b/scripts/update_telegram_widget_js.py deleted file mode 100644 index 1e6b85c..0000000 --- a/scripts/update_telegram_widget_js.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from urllib.request import Request, urlopen - - -SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23" -TARGET_PATH = ( - Path(__file__).resolve().parents[1] - / "bot" - / "app" - / "web" - / "templates" - / "telegram-widget.js" -) -_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n""" -_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n""" - - -def _download(source_url: str) -> bytes: - request = Request( - source_url, - headers={ - "User-Agent": "Mozilla/5.0", - "Accept": "application/javascript,text/javascript,*/*;q=0.8", - }, - ) - with urlopen(request, timeout=30) as response: - return response.read() - - -def _normalize_widget_sdk(data: bytes) -> bytes: - # Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin. - text = data.decode("utf-8") - normalized = text.replace( - _UNPATCHED_WIDGET_ORIGIN_SNIPPET, - _PATCHED_WIDGET_ORIGIN_SNIPPET, - 1, - ) - return normalized.encode("utf-8") - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Download the latest Telegram Login Widget SDK into the local templates directory." - ) - parser.add_argument( - "--source-url", - default=SOURCE_URL, - help="Telegram Login Widget SDK URL to download from.", - ) - parser.add_argument( - "--target", - type=Path, - default=TARGET_PATH, - help="Output path for the vendored SDK.", - ) - args = parser.parse_args() - - data = _normalize_widget_sdk(_download(args.source_url)) - args.target.parent.mkdir(parents=True, exist_ok=True) - args.target.write_bytes(data) - print(f"Wrote {len(data)} bytes to {args.target}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_settings.py b/tests/test_settings.py index 9ef3068..a9e0d93 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -80,3 +80,14 @@ class SettingsTests(unittest.TestCase): self.assertIsNotNone(settings.tariffs_config) self.assertFalse(settings.traffic_sale_mode) + + def test_trial_traffic_strategy_is_available(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TRIAL_TRAFFIC_STRATEGY="WEEK", + ) + + self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK") diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index db24f97..b3f6f53 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -1,3 +1,4 @@ +import json import os import tempfile import unittest @@ -6,9 +7,83 @@ from types import SimpleNamespace from unittest.mock import patch from bot.app.web import subscription_webapp +from config.settings import Settings class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): + def test_serialize_plans_prefers_tariffs_config_over_legacy_packages(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text( + json.dumps( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "100 GB monthly"}, + "squad_uuids": ["uuid"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 150}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "enabled": True, + }, + { + "key": "traffic", + "names": {"en": "Traffic"}, + "descriptions": {"en": "Pay as you go"}, + "squad_uuids": ["uuid"], + "billing_model": "traffic", + "traffic_packages": { + "rub": [{"gb": 50, "price": 799}], + "stars": [{"gb": 50, "price": 2500}], + }, + "enabled": True, + }, + ], + } + ), + encoding="utf-8", + ) + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(path), + TRAFFIC_PACKAGES="10:199", + ) + + plans = subscription_webapp._serialize_plans(settings, "en") + + self.assertEqual([plan["tariff_key"] for plan in plans], ["standard", "traffic"]) + self.assertEqual(plans[0]["sale_mode"], "subscription") + self.assertEqual(plans[0]["months"], 1) + self.assertEqual(plans[1]["sale_mode"], "traffic_package") + self.assertEqual(plans[1]["traffic_gb"], 50.0) + self.assertEqual(plans[1]["stars_price"], 2500) + + def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + TRAFFIC_PACKAGES="10:199,50:799", + STARS_TRAFFIC_PACKAGES="50:2500", + ) + + plans = subscription_webapp._serialize_plans(settings, "en") + + self.assertEqual([plan["traffic_gb"] for plan in plans], [10.0, 50.0]) + self.assertEqual(plans[0]["sale_mode"], "traffic") + self.assertEqual(plans[0]["price"], 199.0) + self.assertEqual(plans[1]["stars_price"], 2500) + def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self): with tempfile.TemporaryDirectory() as tmpdir: asset_dir = Path(tmpdir)