From 763c4ad1bbd9dc4ced3bec3070c3e95151ac689d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 11 May 2026 07:57:34 +0300 Subject: [PATCH] refactor: slice web app wip --- bot/app/web/frontend/src/App.svelte | 2125 +------ .../web/frontend/src/admin/AdminPanel.svelte | 544 +- .../src/admin/sections/PaymentsSection.svelte | 82 + .../src/admin/sections/SettingsSection.svelte | 89 + .../src/admin/sections/StatsSection.svelte | 88 + .../src/admin/sections/TariffsSection.svelte | 115 + .../src/admin/sections/UsersSection.svelte | 182 + .../web/frontend/src/lib/webapp/browser.js | 44 + .../web/frontend/src/lib/webapp/constants.js | 42 + .../web/frontend/src/lib/webapp/mockApi.js | 203 + .../frontend/src/lib/webapp/previewMock.js | 314 + .../web/frontend/src/lib/webapp/publicApi.js | 46 + bot/app/web/frontend/src/lib/webapp/routes.js | 47 + .../web/frontend/src/lib/webapp/session.js | 62 + .../frontend/src/lib/webapp/telegramSdk.js | 169 + bot/app/web/frontend/src/styles.css | 5435 +---------------- bot/app/web/frontend/src/styles/admin.css | 2734 +++++++++ bot/app/web/frontend/src/styles/controls.css | 4 + bot/app/web/frontend/src/styles/dialogs.css | 4 + bot/app/web/frontend/src/styles/webapp.css | 2695 ++++++++ .../web/frontend/src/webapp/BottomNav.svelte | 53 + .../frontend/src/webapp/PaymentDialogs.svelte | 308 + .../frontend/src/webapp/TariffDialogs.svelte | 365 ++ .../src/webapp/screens/DevicesScreen.svelte | 99 + .../src/webapp/screens/HomeScreen.svelte | 167 + .../src/webapp/screens/InviteScreen.svelte | 103 + .../src/webapp/screens/SettingsScreen.svelte | 184 + pytest.ini | 2 + 28 files changed, 8567 insertions(+), 7738 deletions(-) create mode 100644 bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/SettingsSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/StatsSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/TariffsSection.svelte create mode 100644 bot/app/web/frontend/src/admin/sections/UsersSection.svelte create mode 100644 bot/app/web/frontend/src/lib/webapp/browser.js create mode 100644 bot/app/web/frontend/src/lib/webapp/constants.js create mode 100644 bot/app/web/frontend/src/lib/webapp/mockApi.js create mode 100644 bot/app/web/frontend/src/lib/webapp/previewMock.js create mode 100644 bot/app/web/frontend/src/lib/webapp/publicApi.js create mode 100644 bot/app/web/frontend/src/lib/webapp/routes.js create mode 100644 bot/app/web/frontend/src/lib/webapp/session.js create mode 100644 bot/app/web/frontend/src/lib/webapp/telegramSdk.js create mode 100644 bot/app/web/frontend/src/styles/admin.css create mode 100644 bot/app/web/frontend/src/styles/controls.css create mode 100644 bot/app/web/frontend/src/styles/dialogs.css create mode 100644 bot/app/web/frontend/src/styles/webapp.css create mode 100644 bot/app/web/frontend/src/webapp/BottomNav.svelte create mode 100644 bot/app/web/frontend/src/webapp/PaymentDialogs.svelte create mode 100644 bot/app/web/frontend/src/webapp/TariffDialogs.svelte create mode 100644 bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte create mode 100644 bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte create mode 100644 bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte create mode 100644 bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte create mode 100644 pytest.ini diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 93f406b..e7134ea 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -14,7 +14,6 @@ FileText, Gift, Globe2, - Home, LockKeyhole, Mail, Plus, @@ -22,7 +21,6 @@ Send, Smartphone, TriangleAlert, - Settings as SettingsIcon, Shield, Ticket, UserRound, @@ -37,189 +35,48 @@ import Input from "./lib/components/ui/input.svelte"; import PreviewBoard from "./PreviewBoard.svelte"; import AdminPanel from "./admin/AdminPanel.svelte"; + import BottomNav from "./webapp/BottomNav.svelte"; + import PaymentDialogs from "./webapp/PaymentDialogs.svelte"; + import TariffDialogs from "./webapp/TariffDialogs.svelte"; + import DevicesScreen from "./webapp/screens/DevicesScreen.svelte"; + import HomeScreen from "./webapp/screens/HomeScreen.svelte"; + import InviteScreen from "./webapp/screens/InviteScreen.svelte"; + import SettingsScreen from "./webapp/screens/SettingsScreen.svelte"; - const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout"; - const LANGUAGE_LABELS = { - ru: "Русский", - en: "English", - de: "Deutsch", - es: "Español", - fr: "Français", - tr: "Türkçe", - uk: "Українська", - }; - const LANGUAGE_FLAGS = { - ru: "🇷🇺", - en: "🇬🇧", - de: "🇩🇪", - es: "🇪🇸", - fr: "🇫🇷", - tr: "🇹🇷", - uk: "🇺🇦", - }; - const WEBAPP_LANGUAGE_ORDER = ["ru", "en"]; - const APP_SECTION_PATHS = { - home: "/home", - invite: "/invite", - devices: "/devices", - settings: "/settings", - admin: "/admin", - }; - const ADMIN_SECTIONS = new Set([ - "stats", - "users", - "payments", - "promos", - "ads", - "broadcast", - "logs", - "tariffs", - "settings", - ]); - const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; - const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900; - const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800; - const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000; - - const DEV_MOCK = { - config: { - title: "/minishop", - primaryColor: "#00fe7a", - logoUrl: "", - logoEmoji: "🫥", - 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", - telegramLoginBotId: 1234567890, - telegramOAuthClientId: 1234567890, - telegramOAuthRequestAccess: ["write"], - appVersion: "dev+local", - appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop", - }, - data: { - ok: true, - user: { - id: 100200300, - username: "username", - email: "user@example.com", - email_verified: true, - telegram_id: 100200300, - telegram_linked: true, - telegram_photo_url: "", - first_name: "Preview", - language_code: "ru", - is_admin: true, - }, - subscription: { - active: true, - status: "ACTIVE", - remaining_text: "25 д. 8 ч.", - end_date_text: "24.05.2026", - days_left: 25, - config_link: "https://sub.example.com/sub/preview-token", - connect_url: "https://sub.example.com/connect/preview-token", - traffic_used: "18.4 GB", - traffic_limit: "100 GB", - traffic_used_bytes: 19756849561, - traffic_limit_bytes: 107374182400, - premium_used: "32.0 GB", - premium_limit: "50.0 GB", - premium_used_bytes: 34359738368, - premium_limit_bytes: 53687091200, - premium_baseline_bytes: 53687091200, - premium_topup_balance_bytes: 0, - premium_is_limited: false, - premium_title: "Premium-серверы", - premium_node_labels: ["Premium NL-1", "Premium DE-1"], - can_topup_regular_traffic: true, - can_topup_premium_traffic: true, - max_devices: 5, - }, - devices: { - ok: true, - enabled: true, - current_devices: 3, - max_devices: 5, - max_devices_label: "5", - devices: [ - { - index: 1, - display_name: "iPhone 15 Pro", - platform_label: "iOS 18.4", - user_agent: "Streisand/1.6 CFNetwork", - created_at_text: "28.04.2026 16:12", - hwid_short: "A1B2C3D4...98FA01", - token: "preview-device-1", - can_disconnect: true, - }, - { - index: 2, - display_name: "MacBook Air", - platform_label: "macOS 15.4", - user_agent: "Happ/3.1.0", - created_at_text: "29.04.2026 09:40", - hwid_short: "F0E1D2C3...44AB22", - token: "preview-device-2", - can_disconnect: true, - }, - { - index: 3, - display_name: "Android Phone", - platform_label: "Android 15", - user_agent: "v2rayNG/1.9.35", - created_at_text: "30.04.2026 07:55", - hwid_short: "778899AA...BCDD10", - token: "preview-device-3", - can_disconnect: true, - }, - ], - }, - plans: [ - { months: 1, price: 290, currency: "RUB", title: "1 месяц" }, - { months: 3, price: 790, currency: "RUB", title: "3 месяца" }, - { months: 6, price: 1490, currency: "RUB", title: "6 месяцев" }, - { months: 12, price: 2690, currency: "RUB", title: "12 месяцев" }, - ], - payment_methods: [ - { id: "yookassa", name: "Карта" }, - { id: "platega_sbp", name: "Telegram Pay" }, - { id: "cryptopay", name: "Криптовалюта" }, - { id: "freekassa", name: "Другие способы" }, - ], - referral: { - code: "ABCD1234", - bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", - webapp_link: "https://minishop.app/ref/ABCD1234", - invited_count: 4, - purchased_count: 2, - welcome_bonus_days: 3, - one_bonus_per_referee: false, - bonus_details: [ - { months: 1, title: "1 месяц", inviter_days: 14, friend_days: 7 }, - { months: 3, title: "3 месяца", inviter_days: 21, friend_days: 14 }, - { months: 6, title: "6 месяцев", inviter_days: 31, friend_days: 21 }, - { months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 }, - ], - }, - settings: { - support_url: "https://t.me/support", - traffic_mode: false, - my_devices_enabled: false, - user_hwid_device_limit: 5, - trial_enabled: true, - trial_available: true, - trial_duration_days: 5, - trial_traffic_limit_gb: 10, - trial_traffic_strategy: "NO_RESET", - email_auth_enabled: true, - }, - }, - }; + import { + LANGUAGE_FLAGS, + LANGUAGE_LABELS, + MANUAL_LOGOUT_FLAG_KEY, + TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS, + TELEGRAM_SDK_ACTION_TIMEOUT_MS, + TELEGRAM_SDK_BOOT_TIMEOUT_MS, + TELEGRAM_WEBAPP_SCRIPT_URL, + WEBAPP_LANGUAGE_ORDER, + } from "./lib/webapp/constants.js"; + import { applyFavicon, readJsonScript, structuredCloneSafe } from "./lib/webapp/browser.js"; + import { createApiClient } from "./lib/webapp/publicApi.js"; + import { + clearManualLogoutFlag as clearManualLogoutFlagInStorage, + clearStoredToken, + CSRF_COOKIE_NAME, + isManuallyLoggedOut as readManualLogoutFlag, + markManualLogout as markManualLogoutInStorage, + persistToken, + readCookie, + readReferral, + readStoredToken, + rememberReferral, + } from "./lib/webapp/session.js"; + import { createTelegramSdk } from "./lib/webapp/telegramSdk.js"; + import { mockApi as runMockApi } from "./lib/webapp/mockApi.js"; + import { DEV_MOCK, applyPreviewMock } from "./lib/webapp/previewMock.js"; + import { + adminSectionFromPath, + adminUserIdFromPath, + normalizeSection, + sectionFromPath, + syncSectionPath, + } from "./lib/webapp/routes.js"; const query = new URLSearchParams(window.location.search); applyPreviewMock(query.get("mock")); @@ -236,10 +93,7 @@ ...(injectedConfig || {}), }; const I18N = injectedI18n || {}; - let tg = resolveTelegramWebApp(); - let telegramSdkStatus = tg ? "ready" : "idle"; - let telegramSdkPromise = null; - let telegramLaunchParamsDetected = false; + let telegramSdkStatus = "idle"; let telegramMiniAppInitData = ""; let mode = isPreviewBoard ? "preview" : "loading"; @@ -312,186 +166,38 @@ let emailCode = ""; let emailAvatarUrl = ""; let avatarHashToken = ""; - let token = MOCK ? "local-preview" : localStorage.getItem("rw_webapp_token") || ""; - let csrfToken = MOCK ? "" : readCookie("rw_webapp_csrf") || ""; + let token = MOCK ? "local-preview" : readStoredToken(); + let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || ""; let linkEmailResendCooldown = 0; let linkEmailResendTimer = null; let scrollLockApplied = false; + let tg = null; + const telegramSdk = createTelegramSdk({ + scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL, + bootTimeoutMs: TELEGRAM_SDK_BOOT_TIMEOUT_MS, + actionTimeoutMs: TELEGRAM_SDK_ACTION_TIMEOUT_MS, + miniAppAuthTimeoutMs: TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS, + onStatusChange: (status) => (telegramSdkStatus = status), + onInitDataChange: (initData) => (telegramMiniAppInitData = initData || ""), + }); + tg = telegramSdk.refresh(); + telegramSdkStatus = tg ? "ready" : "idle"; + telegramMiniAppInitData = telegramSdk.initData; + const apiClient = createApiClient({ + apiBase: CFG.apiBase, + csrfCookieName: CSRF_COOKIE_NAME, + getToken: () => token, + getCsrfToken: () => csrfToken, + onUnauthorized: () => { + clearToken(); + showLogin(); + }, + mockApi: MOCK + ? (path, options, context) => runMockApi(path, options, context) + : null, + getMockContext: () => ({ currentLang, normalizeLangCode, clone: structuredCloneSafe }), + }); - function applyPreviewMock(kind) { - const mode = String(kind || "").trim().toLowerCase(); - if (mode === "traffic") { - DEV_MOCK.data.settings.traffic_mode = true; - DEV_MOCK.data.settings.trial_available = false; - DEV_MOCK.data.subscription = { - ...DEV_MOCK.data.subscription, - active: true, - status: "ACTIVE", - remaining_text: "Навсегда", - end_date_text: "01.01.2099 00:00", - days_left: 26000, - traffic_used: "18.4 GB", - traffic_limit: "100 GB", - traffic_used_bytes: 19756849561, - traffic_limit_bytes: 107374182400, - traffic_limit_strategy: "NO_RESET", - }; - DEV_MOCK.data.plans = [ - { months: 10, traffic_gb: 10, price: 199, currency: "RUB", title: "10 GB", sale_mode: "traffic" }, - { months: 50, traffic_gb: 50, price: 799, currency: "RUB", title: "50 GB", sale_mode: "traffic" }, - { months: 100, traffic_gb: 100, price: 1390, currency: "RUB", title: "100 GB", sale_mode: "traffic" }, - { months: 300, traffic_gb: 300, price: 3490, currency: "RUB", title: "300 GB", sale_mode: "traffic" }, - ]; - } else if (mode === "tariffs") { - DEV_MOCK.data.settings.traffic_mode = false; - DEV_MOCK.data.subscription = { - ...DEV_MOCK.data.subscription, - tariff_key: "standard", - tariff_name: "Стандарт", - tariff_description: "100 GB каждый месяц", - billing_model: "period", - traffic_limit_strategy: "MONTH", - }; - DEV_MOCK.data.plans = [ - { - id: "standard:period:1", - tariff_key: "standard", - tariff_name: "Стандарт", - billing_model: "period", - sale_mode: "subscription", - months: 1, - price: 150, - currency: "RUB", - title: "Стандарт", - subtitle: "1 месяц", - description: "100 GB каждый месяц", - monthly_gb: 100, - }, - { - id: "standard:period:3", - tariff_key: "standard", - tariff_name: "Стандарт", - billing_model: "period", - sale_mode: "subscription", - months: 3, - price: 400, - currency: "RUB", - title: "Стандарт", - subtitle: "3 месяца", - description: "100 GB каждый месяц", - monthly_gb: 100, - }, - { - id: "business:period:1", - tariff_key: "business", - tariff_name: "Бизнес", - billing_model: "period", - sale_mode: "subscription", - months: 1, - price: 350, - currency: "RUB", - title: "Бизнес", - subtitle: "1 месяц", - description: "300 GB и приоритетные серверы", - monthly_gb: 300, - }, - { - id: "traffic:traffic:50", - tariff_key: "traffic", - tariff_name: "Трафик", - billing_model: "traffic", - sale_mode: "traffic_package", - months: 50, - traffic_gb: 50, - price: 799, - currency: "RUB", - title: "Трафик", - subtitle: "50 GB", - description: "Пакет без срока действия", - }, - ]; - DEV_MOCK.data.tariff_change_options = { - ok: true, - current: { - tariff_key: "standard", - title: "Стандарт", - description: "100 GB каждый месяц", - billing_model: "period", - }, - targets: [ - { - tariff_key: "business", - title: "Бизнес", - description: "300 GB и приоритетные серверы", - billing_model: "period", - monthly_gb: 300, - actions: [ - { mode: "recalc_days", kind: "free", title: "recalc_days", days_after: 10, remaining_days: 25 }, - { mode: "paid_diff", kind: "payment", title: "paid_diff", price: 190, currency: "RUB" }, - ], - }, - { - tariff_key: "traffic", - title: "Трафик", - description: "Пакеты без срока действия", - billing_model: "traffic", - actions: [ - { mode: "convert_days_to_gb", kind: "free", title: "convert_days_to_gb", converted_gb: 18, remaining_days: 25 }, - { mode: "buy_package", kind: "payment", title: "+50 GB", traffic_gb: 50, price: 799, currency: "RUB" }, - ], - }, - ], - }; - DEV_MOCK.data.topup_options = { - ok: true, - tariff_key: "standard", - tariff_name: "Стандарт", - traffic_percent: 86, - warning_levels: [85, 90, 95], - plans: [ - { id: "standard:topup:10", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 10, months: 10, price: 99, currency: "RUB", title: "10 GB", subtitle: "Стандарт" }, - { id: "standard:topup:50", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 50, months: 50, price: 399, currency: "RUB", title: "50 GB", subtitle: "Стандарт" }, - { id: "standard:topup:200", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 200, months: 200, price: 1299, currency: "RUB", title: "200 GB", subtitle: "Стандарт" }, - ], - }; - DEV_MOCK.data.device_topup_options = { - ok: true, - tariff_key: "standard", - tariff_name: "Стандарт", - current_limit: 5, - plans: [ - { id: "standard:hwid:1", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "hwid_devices", device_count: 1, months: 1, price: 99, currency: "RUB", title: "+1", subtitle: "Стандарт" }, - { id: "standard:hwid:3", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "hwid_devices", device_count: 3, months: 3, price: 249, currency: "RUB", title: "+3", subtitle: "Стандарт" }, - ], - }; - } else if (mode === "devices") { - DEV_MOCK.data.settings.my_devices_enabled = true; - DEV_MOCK.data.subscription = { - ...DEV_MOCK.data.subscription, - active: true, - max_devices: 5, - }; - } else if (mode === "trial") { - DEV_MOCK.data.settings.traffic_mode = false; - DEV_MOCK.data.settings.trial_enabled = true; - DEV_MOCK.data.settings.trial_available = true; - DEV_MOCK.data.settings.trial_duration_days = 5; - DEV_MOCK.data.settings.trial_traffic_limit_gb = 10; - DEV_MOCK.data.subscription = { - active: false, - status: "INACTIVE", - remaining_text: "Подписка не активна", - end_date_text: "", - days_left: 0, - config_link: null, - connect_url: null, - traffic_used: "0 B", - traffic_limit: "10 GB", - traffic_used_bytes: 0, - traffic_limit_bytes: 10737418240, - }; - } - } $: brandTitle = CFG.title || "/minishop"; $: brandEmoji = CFG.logoEmoji || "🫥"; @@ -634,43 +340,6 @@ }; }); - 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; - } - } - - function structuredCloneSafe(value) { - try { - return structuredClone(value); - } catch { - return JSON.parse(JSON.stringify(value)); - } - } - - function applyFavicon(logoUrl, emoji) { - if (typeof document === "undefined") return; - const favicon = document.getElementById("app-favicon"); - if (!favicon) return; - - const normalizedLogoUrl = String(logoUrl || "").trim(); - if (normalizedLogoUrl) { - favicon.setAttribute("href", normalizedLogoUrl); - return; - } - - const normalizedEmoji = String(emoji || "🫥").trim() || "🫥"; - const svg = `${escapeHtml( - normalizedEmoji, - )}`; - const encoded = encodeURIComponent(svg); - favicon.setAttribute("href", `data:image/svg+xml,${encoded}`); - } function syncBodyScrollLock(locked) { if (typeof document === "undefined") return; @@ -714,14 +383,6 @@ }, 260); } - function escapeHtml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); - } function normalizeLangCode(lang) { const key = String(lang || "").trim().toLowerCase(); @@ -751,184 +412,41 @@ return formatTemplate(raw, params); } - function normalizeSection(value) { - const section = String(value || "").trim().toLowerCase(); - if (section === "invite" || section === "devices" || section === "settings" || section === "admin") { - return section; - } - return "home"; - } - - function sectionFromPath(pathname) { - const normalizedPath = String(pathname || "") - .trim() - .toLowerCase() - .replace(/\/+$/, ""); - if (!normalizedPath || normalizedPath === "/") return "home"; - if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin"; - const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath; - return normalizeSection(section); - } - - function adminSectionFromPath(pathname) { - const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); - const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/); - if (m && ADMIN_SECTIONS.has(m[1])) return m[1]; - return "stats"; - } - - function adminUserIdFromPath(pathname) { - const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); - const m = normalized.match(/^\/admin\/users\/(-?\d+)$/); - return m ? Number(m[1]) : null; - } - - function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) { - if (window.location.protocol === "file:") return; - const normalized = normalizeSection(section); - let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; - if (normalized === "admin") { - const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats"; - const uid = adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null); - targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`; - } - if (window.location.pathname === targetPath) return; - const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; - window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); - } function resolveTelegramWebApp() { - return window.Telegram?.WebApp || null; + return telegramSdk.tg; } function refreshTelegramWebApp() { - tg = resolveTelegramWebApp(); - if (tg) telegramSdkStatus = "ready"; - telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); - if (telegramMiniAppInitData) telegramLaunchParamsDetected = true; + tg = telegramSdk.refresh(); + telegramMiniAppInitData = telegramSdk.initData; return tg; } function readTelegramMiniAppInitDataFromLocation() { - const queryText = window.location.search.replace(/^\?/, ""); - const hashText = window.location.hash.replace(/^#/, ""); - for (const text of [queryText, hashText]) { - if (!text) continue; - const params = new URLSearchParams(text); - const initData = params.get("tgWebAppData"); - if (initData) return initData; - } - return ""; + return telegramSdk.readInitDataFromLocation(); } function hasTelegramLaunchParams() { - refreshTelegramWebApp(); - if (telegramLaunchParamsDetected || telegramMiniAppInitData) { - telegramLaunchParamsDetected = true; - return true; - } - const queryText = window.location.search.replace(/^\?/, ""); - const hashText = window.location.hash.replace(/^#/, ""); - const detected = [queryText, hashText].some((text) => { - if (!text) return false; - const params = new URLSearchParams(text); - return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some((key) => - params.has(key), - ); - }); - if (detected) telegramLaunchParamsDetected = true; - return detected; + return telegramSdk.hasLaunchParams(); } function loadTelegramSdk(timeoutMs = TELEGRAM_SDK_BOOT_TIMEOUT_MS) { - if (refreshTelegramWebApp()) return Promise.resolve(tg); - if (telegramSdkPromise) return telegramSdkPromise; - if (typeof document === "undefined") return Promise.resolve(null); - - telegramSdkStatus = "loading"; - telegramSdkPromise = new Promise((resolve) => { - const existingScript = document.querySelector("script[data-rw-telegram-web-app-sdk]"); - const script = existingScript || document.createElement("script"); - let resolved = false; - let timeoutId = null; - - const resolveOnce = (value) => { - if (resolved) return; - resolved = true; - if (timeoutId) window.clearTimeout(timeoutId); - resolve(value); - }; - - const refreshFromScript = () => { - tg = resolveTelegramWebApp(); - telegramSdkStatus = tg ? "ready" : "unavailable"; - return tg; - }; - - script.addEventListener("load", () => resolveOnce(refreshFromScript()), { once: true }); - script.addEventListener( - "error", - () => { - telegramSdkStatus = "unavailable"; - resolveOnce(null); - }, - { once: true }, - ); - - if (!existingScript) { - script.src = TELEGRAM_WEBAPP_SCRIPT_URL; - script.async = true; - script.defer = true; - script.dataset.rwTelegramWebAppSdk = "1"; - document.head.appendChild(script); - } - - timeoutId = window.setTimeout(() => { - if (!tg) telegramSdkStatus = "unavailable"; - resolveOnce(tg); - }, timeoutMs); - }).finally(() => { - telegramSdkPromise = null; + return telegramSdk.load(timeoutMs).then((value) => { + tg = value; + telegramMiniAppInitData = telegramSdk.initData; + return value; }); - return telegramSdkPromise; } async function ensureTelegramSdkForAction() { - if (refreshTelegramWebApp()) return tg; - return await loadTelegramSdk(TELEGRAM_SDK_ACTION_TIMEOUT_MS); + tg = await telegramSdk.ensureForAction(); + telegramMiniAppInitData = telegramSdk.initData; + return tg; } function createTelegramMiniAppAuthTimeout() { - const controller = typeof AbortController === "undefined" ? null : new AbortController(); - let timedOut = false; - let timeoutId = null; - let timeoutPromise = new Promise(() => {}); - - if (typeof window !== "undefined") { - timeoutPromise = new Promise((_, reject) => { - timeoutId = window.setTimeout(() => { - timedOut = true; - controller?.abort(); - const error = new Error("telegram_mini_app_auth_timeout"); - error.name = "AbortError"; - reject(error); - }, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS); - }); - } - - return { - promise: timeoutPromise, - get signal() { - return controller?.signal; - }, - get timedOut() { - return timedOut; - }, - clear() { - if (timeoutId) window.clearTimeout(timeoutId); - timeoutId = null; - }, - }; + return telegramSdk.createMiniAppAuthTimeout(); } function shouldWaitForTelegramSdkBeforeOAuth() { @@ -1033,266 +551,36 @@ } async function api(path, options = {}) { - if (MOCK) return mockApi(path, options); - const method = String(options.method || "GET").toUpperCase(); - const headers = { ...(options.headers || {}) }; - if (token) headers.Authorization = `Bearer ${token}`; - const csrf = csrfToken || readCookie("rw_webapp_csrf") || ""; - if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { - headers["X-CSRF-Token"] = csrf; - } - if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; - const response = await fetch(`${CFG.apiBase}${path}`, { ...options, headers }); - const payload = await response.json().catch(() => ({})); - if (response.status === 401) { - clearToken(); - showLogin(); - } - return payload; + return apiClient.api(path, options); } async function publicApi(path, payload = {}, options = {}) { - 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), - signal: options.signal, - }); - return response.json(); - } - - async function mockApi(path, options = {}) { - await new Promise((resolve) => window.setTimeout(resolve, 120)); - const cleanPath = String(path || "").split("?")[0]; - const adminUsers = [ - { - user_id: 100200300, - telegram_id: 100200300, - username: "anna_ops", - first_name: "Анна", - last_name: "Смирнова", - email: "anna@example.com", - telegram_photo_url: "", - registration_date: "2026-04-24T10:20:00Z", - is_banned: false, - }, - { - user_id: 100200301, - telegram_id: 87543123, - username: "client_pro", - first_name: "Максим", - last_name: "Котов", - email: "", - telegram_photo_url: "", - registration_date: "2026-04-26T08:15:00Z", - is_banned: false, - }, - { - user_id: 100200302, - telegram_id: 88440011, - username: "", - first_name: "Daria", - last_name: "", - email: "daria@example.com", - telegram_photo_url: "", - registration_date: "2026-04-29T16:45:00Z", - is_banned: true, - }, - ]; - if (path === "/admin/stats") { - return { - ok: true, - users: { total_users: 248, active_subscriptions: 172, banned_users: 3 }, - financial: { total_revenue: 186240, successful_payments_count: 934 }, - panel_sync: { status: "success", last_sync_time: new Date().toISOString(), users_processed: 172, subscriptions_synced: 168 }, - recent_payments: [ - { payment_id: 1, user_id: 100200300, user_label: "anna_ops", amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: new Date().toISOString() }, - ], - }; - } - if (cleanPath === "/admin/users") return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 }; - if (cleanPath.startsWith("/admin/users/")) { - const id = Number(cleanPath.split("/")[3]); - const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0]; - return { - ok: true, - user, - active_subscription: { - subscription_id: 10, - end_date: "2026-06-08T12:00:00Z", - tariff_key: "standard", - auto_renew_enabled: true, - provider: "yookassa", - }, - subscriptions: [ - { subscription_id: 10, end_date: "2026-06-08T12:00:00Z", tariff_key: "standard", is_active: true, status_from_panel: "ACTIVE" }, - { subscription_id: 9, end_date: "2026-05-08T12:00:00Z", tariff_key: "standard", is_active: false, status_from_panel: "EXPIRED" }, - ], - total_paid: 2380, - recent_payments: [ - { payment_id: 12, amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: "2026-05-01T14:15:00Z" }, - { payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" }, - ], - log_count: 18, - subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP", - referral: { - code: "ABCD1234", - bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", - webapp_link: "https://app.example.com/?ref=uABCD1234", - }, - }; - } - if (path === "/admin/tariffs") { - return { - ok: true, - path: "config/tariffs.json", - catalog: { - default_tariff: "standard", - topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] }, - tariffs: [ - { - key: "standard", - names: { ru: "Стандарт", en: "Standard" }, - descriptions: { ru: "Базовый набор серверов" }, - squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"], - billing_model: "period", - monthly_gb: 500, - prices_rub: { "1": 150, "3": 400 }, - prices_stars: { "1": 0, "3": 0 }, - enabled_periods: [1, 3], - enabled: true, - }, - ], - }, - }; - } - if (path === "/admin/settings") return { ok: true, sections: [] }; - if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; - if (path === "/me") return structuredCloneSafe(DEV_MOCK.data); - if (path === "/auth/email/request") return { ok: true }; - if (path === "/auth/email/verify" || path === "/auth/email/magic") { - return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; - } - if (path === "/auth/token") { - return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; - } - if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; - if (path === "/devices") return structuredCloneSafe(DEV_MOCK.data.devices); - if (path === "/devices/topup-options") return structuredCloneSafe(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] }); - if (cleanPath === "/tariffs/topup-options") { - const kind = new URLSearchParams(String(path || "").split("?")[1] || "").get("kind") || "regular"; - const payload = structuredCloneSafe(DEV_MOCK.data.topup_options || { ok: true, plans: [] }); - payload.topup_kind = kind; - payload.plans = (payload.plans || []).filter((plan) => kind === "premium" ? plan.sale_mode === "premium_topup" : plan.sale_mode !== "premium_topup"); - return payload; - } - if (path === "/tariffs/change-options") return structuredCloneSafe(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] }); - if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") { - let payload = {}; - try { - payload = options?.body ? JSON.parse(String(options.body)) : {}; - } catch {} - DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter((device) => device.token !== payload.token); - DEV_MOCK.data.devices.current_devices = DEV_MOCK.data.devices.devices.length; - return { ok: true }; - } - if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") { - DEV_MOCK.data.subscription = { - ...DEV_MOCK.data.subscription, - active: true, - status: "TRIAL", - remaining_text: "5 д. 0 ч.", - end_date_text: "05.05.2026 12:00", - days_left: 5, - traffic_limit: "10 GB", - traffic_limit_bytes: 10737418240, - traffic_used: "0 B", - traffic_used_bytes: 0, - }; - DEV_MOCK.data.settings.trial_available = false; - return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" }; - } - if (path === "/auth/logout") return { ok: true }; - if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") { - let payload = {}; - try { - payload = options?.body ? JSON.parse(String(options.body)) : {}; - } catch {} - const language = normalizeLangCode(payload?.language || currentLang); - DEV_MOCK.data.user.language_code = language; - return { ok: true, language }; - } - if (path === "/account/email/request" && String(options.method || "").toUpperCase() === "POST") { - return { ok: true }; - } - if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") { - return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; - } - if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") { - return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; - } - 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 === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") { - return { ok: true, tariff_key: "business" }; - } - if (path === "/tariffs/change-payment" && String(options.method || "").toUpperCase() === "POST") { - return { - ok: true, - action: "open_link", - payment_url: "https://example.com/tariff-change-payment-preview", - payment_id: 10002, - }; - } - return { ok: false, error: "not_found" }; - } - - function readCookie(name) { - const prefix = `${name}=`; - const cookie = document.cookie.split("; ").find((part) => part.startsWith(prefix)); - return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ""; + return apiClient.publicApi(path, payload, options); } function setToken(nextToken, nextCsrf = "") { clearManualLogoutFlag(); token = nextToken || ""; - csrfToken = nextCsrf || readCookie("rw_webapp_csrf") || ""; - if (token && !MOCK) localStorage.setItem("rw_webapp_token", token); + csrfToken = nextCsrf || readCookie(CSRF_COOKIE_NAME) || ""; + if (token && !MOCK) persistToken(token); } function clearToken() { token = ""; csrfToken = ""; - localStorage.removeItem("rw_webapp_token"); + clearStoredToken(); } function markManualLogout() { - try { - localStorage.setItem(MANUAL_LOGOUT_FLAG_KEY, "1"); - } catch {} + markManualLogoutInStorage(MANUAL_LOGOUT_FLAG_KEY); } function clearManualLogoutFlag() { - try { - localStorage.removeItem(MANUAL_LOGOUT_FLAG_KEY); - } catch {} + clearManualLogoutFlagInStorage(MANUAL_LOGOUT_FLAG_KEY); } function isManuallyLoggedOut() { - try { - return localStorage.getItem(MANUAL_LOGOUT_FLAG_KEY) === "1"; - } catch { - return false; - } + return readManualLogoutFlag(MANUAL_LOGOUT_FLAG_KEY); } function readReferralParam() { @@ -1300,11 +588,7 @@ const fromQuery = params.get("ref") || params.get("start") || params.get("start_param") || ""; const fromTelegram = 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") || ""; + return value ? rememberReferral(value) : readReferral(); } function readTelegramAuthStatus() { @@ -3024,1049 +2308,207 @@ {/if} {#if screen === "home"} -
-
- -

{brandTitle}

-
- -
- - {#if subscription.active} -
- -
-

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

- {#if hasActiveTariffSubscription && hasMultipleTariffs && currentTariffName} -

{t("wa_current_tariff", { tariff: currentTariffName })}

- {/if} -

{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} - - {#if canOpenRegularTopupModal} - - {/if} -
- {t("wa_home_traffic_used")} - {trafficLabel(subscription)} -
-
- -
-
- {trafficResetLabel(subscription)} - {trafficPercent(subscription)}% -
-
- {#if Number(subscription?.premium_limit_bytes || 0) > 0} - - {#if canOpenPremiumTopupModal} - - {/if} -
- {premiumTitle(subscription)} - {premiumTrafficLabel(subscription)} -
-
- -
-
- {#if premiumServerLabels(subscription).length} -
- - {subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")} - - -
- {t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")} -
- {#each premiumServerLabels(subscription).slice(0, 8) as label} - {label} - {/each} -
-
-
- {:else} - {subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")} - {/if} - {premiumTrafficPercent(subscription)}% -
-
- {/if} - {: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} - - {/if} - - {#if !subscription.active && appSettings?.trial_enabled && appSettings?.trial_available} - - {/if} - {#if canChangeTariff} - - {/if} - {#if canShowTopupButton} - - {/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} -
-
+ (promoFieldError = "")} + {copyText} + {t} + /> {:else if screen === "devices"} -
- -
- - - {t("wa_devices_title")} - {devicesCountLabel()} - - -
-
- -
- {#if subscription?.active && subscription?.max_devices !== 0} - - {/if} -
- - {#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} -
-
- {#if isAdmin} -
- - -
- {/if} - - {#if languageMenuOpen || languageClickGuard} - - {/if} -
-
- - - - - {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} - -
- {#if tariffMode && !singleTariffMode && paymentStep === "tariff"} - {#if tariffCatalog.length} -
- {#each tariffCatalog as tariff} - - {/each} -
- - {:else} - {t("wa_no_tariff_change_options")} - {/if} - {:else} - {#if tariffMode} - {#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))} - - {/if} - {#if hasMultipleTariffs && selectedTariff} -

{t("wa_selected_tariff", { tariff: selectedTariff.title })}

- {/if} - {/if} - {#if selectedTariffPlans.length} -
- {#each selectedTariffPlans as plan} - - {/each} -
- -
- {#if methods.length} - {#each methods as method} - {@const meta = methodMeta(method)} - - {/each} - {:else} - {t("wa_payment_methods_not_configured")} - {/if} -
- - {:else} - {t("wa_no_tariff_change_options")} - {/if} - {/if} - {#if !tariffMode} -
- {#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} -
-
+ - -
- {#if !changeOptions} -
-
- {#each [1, 2] as _} -
- - - - - -
- {/each} -
- -
- {#each [1, 2] as _} -
- - - - -
- {/each} -
-
-
- {:else if changeOptions?.targets?.length} -

{t("wa_tariff_change_targets_title")}

-
- {#each changeOptions.targets as target} - - {/each} -
- {#if selectedChangeTarget?.actions?.length} - -

{t("wa_tariff_change_strategy_title")}

-
- {#each selectedChangeTarget.actions as action} - - {/each} -
- {#if selectedChangeAction?.kind === "payment"} -
- {#each methods as method} - {@const meta = methodMeta(method)} - - {/each} -
- {/if} - - {:else} - {t("wa_no_tariff_change_options")} - {/if} - {:else} - {t("wa_no_tariff_change_options")} - {/if} -
-
- - -
- - {#each tariffChangeSummary() as row} -

{row}

- {/each} -
- - -
-
- - -
- {#if !topupOptions} -
-
- {#each [1, 2, 3] as _} -
- - - - - - - - -
- {/each} -
-
- {#each [1, 2] as _} -
- - -
- {/each} -
-
-
- {:else if topupOptions?.plans?.length} -
- {#each topupOptions.plans as plan} - - {/each} -
- {@const carryoverNotes = topupCarryoverNotes()} - {#if carryoverNotes.length} -
- {#each carryoverNotes as note} -

{note}

- {/each} -
- {/if} -
- {#each methods as method} - {@const meta = methodMeta(method)} - - {/each} -
- - {:else} - {t("wa_no_topup_options")} - {/if} -
-
- - -
- {#if !deviceTopupOptions} -
-
- {#each [1, 2, 3] as _} -
- - - - - - - -
- {/each} -
-
- {#each [1, 2] as _} -
- - -
- {/each} -
-
-
- {:else if deviceTopupOptions?.plans?.length} -
- {#each deviceTopupOptions.plans as plan} - - {/each} -
-
- {#each methods as method} - {@const meta = methodMeta(method)} - - {/each} -
- - {:else} - {t("wa_no_hwid_device_options")} - {/if} -
-
- - -
- - -
-
- - -
- {#if !linkEmailPending} -
- - (linkEmailFieldError = "")} - /> - {#if linkEmailFieldError} - - - - {/if} - {#if linkEmailFieldError} - - {linkEmailFieldError} - - {/if} - -
- - {:else} - - {/if} - {#if linkEmailStatus} -

{linkEmailStatus}

- {/if} -
-
+ {/if} {#if toastText} @@ -4076,4 +2518,3 @@ {/if} {/key} - diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index e960de1..78de358 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -41,6 +41,11 @@ import BrandMark from "../BrandMark.svelte"; import Dialog from "../lib/components/ui/dialog.svelte"; + import PaymentsSection from "./sections/PaymentsSection.svelte"; + import SettingsSection from "./sections/SettingsSection.svelte"; + import StatsSection from "./sections/StatsSection.svelte"; + import TariffsSection from "./sections/TariffsSection.svelte"; + import UsersSection from "./sections/UsersSection.svelte"; export let api; export let onClose = () => {}; @@ -1631,309 +1636,57 @@
{#if active === "stats"} - {#if statsError} -
Не удалось загрузить статистику: {statsError}
- {:else if statsLoading || !stats} -
Загрузка…
- {:else} -
-
- Пользователи - {stats.users?.total_users ?? 0} - В бане: {stats.users?.banned_users ?? 0} -
-
- Платные подписки - {stats.users?.paid_subscriptions ?? 0} - Триалы: {stats.users?.trial_users ?? 0} -
-
- Доход за день - {fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)} - {stats.financial?.today_payments_count ?? 0} платежей -
-
- За неделю - {fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)} - Месяц: {fmtMoney(stats.financial?.month_revenue, stats.currency_symbol)} -
-
- Всё время - {fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)} - Sync: {stats.panel_sync?.status ?? "—"} -
- {#if stats.queue} -
- Очередь - {stats.queue.user_queue_size ?? 0} - Группы: {stats.queue.group_queue_size ?? 0} -
- {/if} -
- -
-
-

Последние платежи

- {(stats.recent_payments || []).length} записей -
- {#if (stats.recent_payments || []).length} - - - - - - - - - - - - - {#each stats.recent_payments as p} - - - - - - - - - {/each} - -
IDПользовательСуммаПровайдерСтатусДата
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider} - {p.status} - {fmtDate(p.created_at)}
- {:else} -
Нет данных
- {/if} -
- {/if} + {/if} {#if active === "users"} -
- - -
- - {at("filter", {}, "Фильтр")} - { usersFilter = value; usersPage = 0; loadUsers(); }} - > - - {optionLabel(USERS_FILTER_OPTIONS, usersFilter)} - - - - - {#each USERS_FILTER_OPTIONS as opt} - - {opt.label} - - - {/each} - - - - - - - {at("panel_status", {}, "Статус панели")} - { usersPanelStatus = value; usersPage = 0; loadUsers(); }} - > - - {optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)} - - - - - {#each USERS_PANEL_STATUS_OPTIONS as opt} - - {opt.label} - - - {/each} - - - - - - - {at("sort", {}, "Сортировка")} - { usersSort = value; usersPage = 0; loadUsers(); }} - > - - {optionLabel(USERS_SORT_OPTIONS, usersSort)} - - - - - {#each USERS_SORT_OPTIONS as opt} - - {opt.label} - - - {/each} - - - - - -
- {at("total", {}, "Всего")} - {usersTotal} -
-
-
- -
- {#if usersLoading} - - {:else if !users.length} -
{at("users_empty", {}, "Никого не найдено")}
- {:else} -
    - {#each users as user} - {@const avatar = resolvedAvatarUrl(user)} - {@const badge = panelStatusBadge(user)} -
  • - -
  • - {/each} -
- {/if} -
- -
- {at("page", {}, "Страница")} {usersPage + 1} -
- - -
-
+ {/if} {#if active === "payments"} -
- {#if paymentsLoading} - - - - - - - - {#each Array(8) as _, i (i)} - - - - - - - - - - {/each} - - - {:else if !payments.length} -
{at("payments_empty", {}, "Нет платежей")}
- {:else} - - - - - - - - - - - - - - {#each payments as p} - - - - - - - - - - {/each} - -
ID{at("user", {}, "Пользователь")}{at("amount", {}, "Сумма")}{at("provider", {}, "Провайдер")}{at("description", {}, "Описание")}{at("status", {}, "Статус")}{at("date", {}, "Дата")}
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider}{p.description || "—"} - {p.status} - {fmtDate(p.created_at)}
- {/if} -
- -
- {at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal} -
- - -
-
+ {/if} {#if active === "promos"} @@ -2200,177 +1953,38 @@ {/if} {#if active === "tariffs"} - {#if tariffsLoading} -
Загрузка…
- {:else} -
-
- Всего тарифов - {tariffsCatalog.tariffs.length} - Включено: {enabledTariffs.length} -
-
- По умолчанию - {tariffsCatalog.default_tariff || "—"} - Используется для новых подписок -
-
- Отключено - {disabledTariffs} - Скрыто с витрины -
-
- -
-
-
-

Каталог тарифов

- {tariffsPath || "config/tariffs.json"} -
- -
-
- {#if !tariffsCatalog.tariffs.length} -
- Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога. -
- {:else} -
- {#each tariffsCatalog.tariffs as tariff} -
-
-
-
- {tariffName(tariff)} - {#if tariff.key === tariffsCatalog.default_tariff} - Default - {/if} -
- {tariff.key} -
- {#if tariff.enabled === false} - Выключен - {:else} - Активен - {/if} -
-

{tariff.descriptions?.ru || tariff.descriptions?.en || "Без описания"}

-
- {tariff.billing_model === "traffic" ? "Трафик" : "Периоды"} - {tariffPriceSummary(tariff)} - Squads: {(tariff.squad_uuids || []).length} - Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"} - Устройства: {tariff.hwid_device_limit ?? "env"} -
-
- - - - -
-
- {/each} -
- {/if} -
-
- {/if} + {/if} {#if active === "settings"} - {#if settingsLoading || !settingsSections.length} -
{settingsLoading ? "Загрузка…" : "Нет данных"}
- {:else} -
-

- Изменения в админке имеют приоритет над .env. Кнопка «Восстановить» возвращает значение из переменных окружения. -

- -
- - {#each settingsSections as section} - {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} - {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} - - - - {sectionTitle(section.id)} - - {section.fields.length} параметров{#if overriddenInSection} · {overriddenInSection} override{/if}{#if dirtyInSection} · {dirtyInSection} изм.{/if} - - - - - - {@const groups = groupSectionFields(section)} - {@const rootGroup = groups.find((g) => !g.label)} - {@const labelGroups = groups.filter((g) => g.label)} -
- {#if rootGroup} - {#each rootGroup.fields as field} - {@render renderField(field)} - {/each} - {/if} - {#if labelGroups.length} - (settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })} - class="admin-subsection-accordion" - > - {#each labelGroups as group} - {@const subDirty = group.fields.filter((f) => Boolean(settingsDirty[f.key])).length} - {@const subOverridden = group.fields.filter((f) => isOverridden(f)).length} - - - - {group.label} - - {group.fields.length} полей{#if subOverridden} · {subOverridden} override{/if}{#if subDirty} · {subDirty} изм.{/if} - - - - - -
- {#each group.fields as field} - {@render renderField(field)} - {/each} -
-
-
- {/each} -
- {/if} -
-
-
- {/each} -
- {/if} + {/if}
diff --git a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte b/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte new file mode 100644 index 0000000..38d29af --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte @@ -0,0 +1,82 @@ + + +
+ {#if paymentsLoading} + + + + + + + + {#each Array(8) as _, i (i)} + + + + + + + + + + {/each} + + + {:else if !payments.length} +
{at("payments_empty", {}, "Нет платежей")}
+ {:else} + + + + + + + + + + + + + + {#each payments as p} + + + + + + + + + + {/each} + +
ID{at("user", {}, "Пользователь")}{at("amount", {}, "Сумма")}{at("provider", {}, "Провайдер")}{at("description", {}, "Описание")}{at("status", {}, "Статус")}{at("date", {}, "Дата")}
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider}{p.description || "—"} + {p.status} + {fmtDate(p.created_at)}
+ {/if} +
+ +
+ {at("page_short", {}, "Стр.")} {paymentsPage + 1} · {at("total", {}, "Всего")} {paymentsTotal} +
+ + +
+
diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte new file mode 100644 index 0000000..fcd304c --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte @@ -0,0 +1,89 @@ + + +{#if settingsLoading || !settingsSections.length} +
{settingsLoading ? "Загрузка…" : "Нет данных"}
+{:else} +
+

+ Изменения в админке имеют приоритет над .env. Кнопка «Восстановить» возвращает значение из переменных окружения. +

+ +
+ + {#each settingsSections as section} + {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} + {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} + + + + {sectionTitle(section.id)} + + {section.fields.length} параметров{#if overriddenInSection} · {overriddenInSection} override{/if}{#if dirtyInSection} · {dirtyInSection} изм.{/if} + + + + + + {@const groups = groupSectionFields(section)} + {@const rootGroup = groups.find((g) => !g.label)} + {@const labelGroups = groups.filter((g) => g.label)} +
+ {#if rootGroup} + {#each rootGroup.fields as field} + {@render renderField(field)} + {/each} + {/if} + {#if labelGroups.length} + (settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })} + class="admin-subsection-accordion" + > + {#each labelGroups as group} + {@const subDirty = group.fields.filter((f) => Boolean(settingsDirty[f.key])).length} + {@const subOverridden = group.fields.filter((f) => isOverridden(f)).length} + + + + {group.label} + + {group.fields.length} полей{#if subOverridden} · {subOverridden} override{/if}{#if subDirty} · {subDirty} изм.{/if} + + + + + +
+ {#each group.fields as field} + {@render renderField(field)} + {/each} +
+
+
+ {/each} +
+ {/if} +
+
+
+ {/each} +
+{/if} diff --git a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte b/bot/app/web/frontend/src/admin/sections/StatsSection.svelte new file mode 100644 index 0000000..c009ec5 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/StatsSection.svelte @@ -0,0 +1,88 @@ + + +{#if statsError} +
Не удалось загрузить статистику: {statsError}
+{:else if statsLoading || !stats} +
Загрузка…
+{:else} +
+
+ Пользователи + {stats.users?.total_users ?? 0} + В бане: {stats.users?.banned_users ?? 0} +
+
+ Платные подписки + {stats.users?.paid_subscriptions ?? 0} + Триалы: {stats.users?.trial_users ?? 0} +
+
+ Доход за день + {fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)} + {stats.financial?.today_payments_count ?? 0} платежей +
+
+ За неделю + {fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)} + Месяц: {fmtMoney(stats.financial?.month_revenue, stats.currency_symbol)} +
+
+ Всё время + {fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)} + Sync: {stats.panel_sync?.status ?? "—"} +
+ {#if stats.queue} +
+ Очередь + {stats.queue.user_queue_size ?? 0} + Группы: {stats.queue.group_queue_size ?? 0} +
+ {/if} +
+ +
+
+

Последние платежи

+ {(stats.recent_payments || []).length} записей +
+ {#if (stats.recent_payments || []).length} + + + + + + + + + + + + + {#each stats.recent_payments as p} + + + + + + + + + {/each} + +
IDПользовательСуммаПровайдерСтатусДата
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider} + {p.status} + {fmtDate(p.created_at)}
+ {:else} +
Нет данных
+ {/if} +
+{/if} diff --git a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte new file mode 100644 index 0000000..13adde6 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte @@ -0,0 +1,115 @@ + + +{#if tariffsLoading} +
Загрузка…
+{:else} +
+
+ Всего тарифов + {tariffsCatalog.tariffs.length} + Включено: {enabledTariffs.length} +
+
+ По умолчанию + {tariffsCatalog.default_tariff || "—"} + Используется для новых подписок +
+
+ Отключено + {disabledTariffs} + Скрыто с витрины +
+
+ +
+
+
+

Каталог тарифов

+ {tariffsPath || "config/tariffs.json"} +
+ +
+
+ {#if !tariffsCatalog.tariffs.length} +
+ Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога. +
+ {:else} +
+ {#each tariffsCatalog.tariffs as tariff} +
+
+
+
+ {tariffName(tariff)} + {#if tariff.key === tariffsCatalog.default_tariff} + Default + {/if} +
+ {tariff.key} +
+ {#if tariff.enabled === false} + Выключен + {:else} + Активен + {/if} +
+

{tariff.descriptions?.ru || tariff.descriptions?.en || "Без описания"}

+
+ {tariff.billing_model === "traffic" ? "Трафик" : "Периоды"} + {tariffPriceSummary(tariff)} + Squads: {(tariff.squad_uuids || []).length} + Premium: {(tariff.premium_squad_uuids || []).length ? `${tariff.premium_monthly_gb || 0} GB` : "—"} + Устройства: {tariff.hwid_device_limit ?? "env"} +
+
+ + + + +
+
+ {/each} +
+ {/if} +
+
+{/if} diff --git a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte b/bot/app/web/frontend/src/admin/sections/UsersSection.svelte new file mode 100644 index 0000000..f1e4701 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/UsersSection.svelte @@ -0,0 +1,182 @@ + + +
+ + +
+ + {at("filter", {}, "Фильтр")} + { usersFilter = value; usersPage = 0; loadUsers(); }} + > + + {optionLabel(USERS_FILTER_OPTIONS, usersFilter)} + + + + + {#each USERS_FILTER_OPTIONS as opt} + + {opt.label} + + + {/each} + + + + + + + {at("panel_status", {}, "Статус панели")} + { usersPanelStatus = value; usersPage = 0; loadUsers(); }} + > + + {optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus)} + + + + + {#each USERS_PANEL_STATUS_OPTIONS as opt} + + {opt.label} + + + {/each} + + + + + + + {at("sort", {}, "Сортировка")} + { usersSort = value; usersPage = 0; loadUsers(); }} + > + + {optionLabel(USERS_SORT_OPTIONS, usersSort)} + + + + + {#each USERS_SORT_OPTIONS as opt} + + {opt.label} + + + {/each} + + + + + +
+ {at("total", {}, "Всего")} + {usersTotal} +
+
+
+ +
+ {#if usersLoading} + + {:else if !users.length} +
{at("users_empty", {}, "Никого не найдено")}
+ {:else} +
    + {#each users as user} + {@const avatar = resolvedAvatarUrl(user)} + {@const badge = panelStatusBadge(user)} +
  • + +
  • + {/each} +
+ {/if} +
+ +
+ {at("page", {}, "Страница")} {usersPage + 1} +
+ + +
+
diff --git a/bot/app/web/frontend/src/lib/webapp/browser.js b/bot/app/web/frontend/src/lib/webapp/browser.js new file mode 100644 index 0000000..1f97762 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/browser.js @@ -0,0 +1,44 @@ +export 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; + } +} + +export function structuredCloneSafe(value) { + try { + return structuredClone(value); + } catch { + return JSON.parse(JSON.stringify(value)); + } +} + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function applyFavicon(logoUrl, emoji) { + if (typeof document === 'undefined') return; + const favicon = document.getElementById('app-favicon'); + if (!favicon) return; + + const normalizedLogoUrl = String(logoUrl || '').trim(); + if (normalizedLogoUrl) { + favicon.setAttribute('href', normalizedLogoUrl); + return; + } + + const normalizedEmoji = String(emoji || '????').trim() || '????'; + const svg = `${escapeHtml(normalizedEmoji)}`; + const encoded = encodeURIComponent(svg); + favicon.setAttribute('href', `data:image/svg+xml,${encoded}`); +} diff --git a/bot/app/web/frontend/src/lib/webapp/constants.js b/bot/app/web/frontend/src/lib/webapp/constants.js new file mode 100644 index 0000000..71c2f97 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/constants.js @@ -0,0 +1,42 @@ +export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout"; +export const LANGUAGE_LABELS = { + ru: "Русский", + en: "English", + de: "Deutsch", + es: "Español", + fr: "Français", + tr: "Türkçe", + uk: "Українська", +}; +export const LANGUAGE_FLAGS = { + ru: "🇷🇺", + en: "🇬🇧", + de: "🇩🇪", + es: "🇪🇸", + fr: "🇫🇷", + tr: "🇹🇷", + uk: "🇺🇦", +}; +export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"]; +export const APP_SECTION_PATHS = { + home: "/home", + invite: "/invite", + devices: "/devices", + settings: "/settings", + admin: "/admin", +}; +export const ADMIN_SECTIONS = new Set([ + "stats", + "users", + "payments", + "promos", + "ads", + "broadcast", + "logs", + "tariffs", + "settings", +]); +export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; +export const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900; +export const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800; +export const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000; diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/bot/app/web/frontend/src/lib/webapp/mockApi.js new file mode 100644 index 0000000..8e0e905 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/mockApi.js @@ -0,0 +1,203 @@ +import { DEV_MOCK } from "./previewMock.js"; + +function defaultClone(value) { + try { + return structuredClone(value); + } catch { + return JSON.parse(JSON.stringify(value)); + } +} + +export async function mockApi(path, options = {}, context = {}) { + const { currentLang = "ru", normalizeLangCode = (value) => value || "ru", clone = defaultClone } = context; + await new Promise((resolve) => window.setTimeout(resolve, 120)); + const cleanPath = String(path || "").split("?")[0]; + const adminUsers = [ + { + user_id: 100200300, + telegram_id: 100200300, + username: "anna_ops", + first_name: "Анна", + last_name: "Смирнова", + email: "anna@example.com", + telegram_photo_url: "", + registration_date: "2026-04-24T10:20:00Z", + is_banned: false, + }, + { + user_id: 100200301, + telegram_id: 87543123, + username: "client_pro", + first_name: "Максим", + last_name: "Котов", + email: "", + telegram_photo_url: "", + registration_date: "2026-04-26T08:15:00Z", + is_banned: false, + }, + { + user_id: 100200302, + telegram_id: 88440011, + username: "", + first_name: "Daria", + last_name: "", + email: "daria@example.com", + telegram_photo_url: "", + registration_date: "2026-04-29T16:45:00Z", + is_banned: true, + }, + ]; + if (path === "/admin/stats") { + return { + ok: true, + users: { total_users: 248, active_subscriptions: 172, banned_users: 3 }, + financial: { total_revenue: 186240, successful_payments_count: 934 }, + panel_sync: { status: "success", last_sync_time: new Date().toISOString(), users_processed: 172, subscriptions_synced: 168 }, + recent_payments: [ + { payment_id: 1, user_id: 100200300, user_label: "anna_ops", amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: new Date().toISOString() }, + ], + }; + } + if (cleanPath === "/admin/users") return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 }; + if (cleanPath.startsWith("/admin/users/")) { + const id = Number(cleanPath.split("/")[3]); + const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0]; + return { + ok: true, + user, + active_subscription: { + subscription_id: 10, + end_date: "2026-06-08T12:00:00Z", + tariff_key: "standard", + auto_renew_enabled: true, + provider: "yookassa", + }, + subscriptions: [ + { subscription_id: 10, end_date: "2026-06-08T12:00:00Z", tariff_key: "standard", is_active: true, status_from_panel: "ACTIVE" }, + { subscription_id: 9, end_date: "2026-05-08T12:00:00Z", tariff_key: "standard", is_active: false, status_from_panel: "EXPIRED" }, + ], + total_paid: 2380, + recent_payments: [ + { payment_id: 12, amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: "2026-05-01T14:15:00Z" }, + { payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" }, + ], + log_count: 18, + subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP", + referral: { + code: "ABCD1234", + bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", + webapp_link: "https://app.example.com/?ref=uABCD1234", + }, + }; + } + if (path === "/admin/tariffs") { + return { + ok: true, + path: "config/tariffs.json", + catalog: { + default_tariff: "standard", + topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] }, + tariffs: [ + { + key: "standard", + names: { ru: "Стандарт", en: "Standard" }, + descriptions: { ru: "Базовый набор серверов" }, + squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"], + billing_model: "period", + monthly_gb: 500, + prices_rub: { "1": 150, "3": 400 }, + prices_stars: { "1": 0, "3": 0 }, + enabled_periods: [1, 3], + enabled: true, + }, + ], + }, + }; + } + if (path === "/admin/settings") return { ok: true, sections: [] }; + if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; + if (path === "/me") return clone(DEV_MOCK.data); + if (path === "/auth/email/request") return { ok: true }; + if (path === "/auth/email/verify" || path === "/auth/email/magic") { + return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; + } + if (path === "/auth/token") { + return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; + } + if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; + if (path === "/devices") return clone(DEV_MOCK.data.devices); + if (path === "/devices/topup-options") return clone(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] }); + if (cleanPath === "/tariffs/topup-options") { + const kind = new URLSearchParams(String(path || "").split("?")[1] || "").get("kind") || "regular"; + const payload = clone(DEV_MOCK.data.topup_options || { ok: true, plans: [] }); + payload.topup_kind = kind; + payload.plans = (payload.plans || []).filter((plan) => kind === "premium" ? plan.sale_mode === "premium_topup" : plan.sale_mode !== "premium_topup"); + return payload; + } + if (path === "/tariffs/change-options") return clone(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] }); + if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") { + let payload = {}; + try { + payload = options?.body ? JSON.parse(String(options.body)) : {}; + } catch {} + DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter((device) => device.token !== payload.token); + DEV_MOCK.data.devices.current_devices = DEV_MOCK.data.devices.devices.length; + return { ok: true }; + } + if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") { + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + status: "TRIAL", + remaining_text: "5 д. 0 ч.", + end_date_text: "05.05.2026 12:00", + days_left: 5, + traffic_limit: "10 GB", + traffic_limit_bytes: 10737418240, + traffic_used: "0 B", + traffic_used_bytes: 0, + }; + DEV_MOCK.data.settings.trial_available = false; + return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" }; + } + if (path === "/auth/logout") return { ok: true }; + if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") { + let payload = {}; + try { + payload = options?.body ? JSON.parse(String(options.body)) : {}; + } catch {} + const language = normalizeLangCode(payload?.language || currentLang); + DEV_MOCK.data.user.language_code = language; + return { ok: true, language }; + } + if (path === "/account/email/request" && String(options.method || "").toUpperCase() === "POST") { + return { ok: true }; + } + if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") { + return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; + } + if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") { + return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; + } + 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 === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") { + return { ok: true, tariff_key: "business" }; + } + if (path === "/tariffs/change-payment" && String(options.method || "").toUpperCase() === "POST") { + return { + ok: true, + action: "open_link", + payment_url: "https://example.com/tariff-change-payment-preview", + payment_id: 10002, + }; + } + return { ok: false, error: "not_found" }; +} + diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/bot/app/web/frontend/src/lib/webapp/previewMock.js new file mode 100644 index 0000000..c430ce9 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/previewMock.js @@ -0,0 +1,314 @@ +export const DEV_MOCK = { + config: { + title: "/minishop", + primaryColor: "#00fe7a", + logoUrl: "", + logoEmoji: "🫥", + 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", + telegramLoginBotId: 1234567890, + telegramOAuthClientId: 1234567890, + telegramOAuthRequestAccess: ["write"], + appVersion: "dev+local", + appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop", + }, + data: { + ok: true, + user: { + id: 100200300, + username: "username", + email: "user@example.com", + email_verified: true, + telegram_id: 100200300, + telegram_linked: true, + telegram_photo_url: "", + first_name: "Preview", + language_code: "ru", + is_admin: true, + }, + subscription: { + active: true, + status: "ACTIVE", + remaining_text: "25 д. 8 ч.", + end_date_text: "24.05.2026", + days_left: 25, + config_link: "https://sub.example.com/sub/preview-token", + connect_url: "https://sub.example.com/connect/preview-token", + traffic_used: "18.4 GB", + traffic_limit: "100 GB", + traffic_used_bytes: 19756849561, + traffic_limit_bytes: 107374182400, + premium_used: "32.0 GB", + premium_limit: "50.0 GB", + premium_used_bytes: 34359738368, + premium_limit_bytes: 53687091200, + premium_baseline_bytes: 53687091200, + premium_topup_balance_bytes: 0, + premium_is_limited: false, + premium_title: "Premium-серверы", + premium_node_labels: ["Premium NL-1", "Premium DE-1"], + can_topup_regular_traffic: true, + can_topup_premium_traffic: true, + max_devices: 5, + }, + devices: { + ok: true, + enabled: true, + current_devices: 3, + max_devices: 5, + max_devices_label: "5", + devices: [ + { + index: 1, + display_name: "iPhone 15 Pro", + platform_label: "iOS 18.4", + user_agent: "Streisand/1.6 CFNetwork", + created_at_text: "28.04.2026 16:12", + hwid_short: "A1B2C3D4...98FA01", + token: "preview-device-1", + can_disconnect: true, + }, + { + index: 2, + display_name: "MacBook Air", + platform_label: "macOS 15.4", + user_agent: "Happ/3.1.0", + created_at_text: "29.04.2026 09:40", + hwid_short: "F0E1D2C3...44AB22", + token: "preview-device-2", + can_disconnect: true, + }, + { + index: 3, + display_name: "Android Phone", + platform_label: "Android 15", + user_agent: "v2rayNG/1.9.35", + created_at_text: "30.04.2026 07:55", + hwid_short: "778899AA...BCDD10", + token: "preview-device-3", + can_disconnect: true, + }, + ], + }, + plans: [ + { months: 1, price: 290, currency: "RUB", title: "1 месяц" }, + { months: 3, price: 790, currency: "RUB", title: "3 месяца" }, + { months: 6, price: 1490, currency: "RUB", title: "6 месяцев" }, + { months: 12, price: 2690, currency: "RUB", title: "12 месяцев" }, + ], + payment_methods: [ + { id: "yookassa", name: "Карта" }, + { id: "platega_sbp", name: "Telegram Pay" }, + { id: "cryptopay", name: "Криптовалюта" }, + { id: "freekassa", name: "Другие способы" }, + ], + referral: { + code: "ABCD1234", + bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", + webapp_link: "https://minishop.app/ref/ABCD1234", + invited_count: 4, + purchased_count: 2, + welcome_bonus_days: 3, + one_bonus_per_referee: false, + bonus_details: [ + { months: 1, title: "1 месяц", inviter_days: 14, friend_days: 7 }, + { months: 3, title: "3 месяца", inviter_days: 21, friend_days: 14 }, + { months: 6, title: "6 месяцев", inviter_days: 31, friend_days: 21 }, + { months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 }, + ], + }, + settings: { + support_url: "https://t.me/support", + traffic_mode: false, + my_devices_enabled: false, + user_hwid_device_limit: 5, + trial_enabled: true, + trial_available: true, + trial_duration_days: 5, + trial_traffic_limit_gb: 10, + trial_traffic_strategy: "NO_RESET", + email_auth_enabled: true, + }, + }, +}; + +export function applyPreviewMock(kind) { + const mode = String(kind || "").trim().toLowerCase(); + if (mode === "traffic") { + DEV_MOCK.data.settings.traffic_mode = true; + DEV_MOCK.data.settings.trial_available = false; + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + status: "ACTIVE", + remaining_text: "Навсегда", + end_date_text: "01.01.2099 00:00", + days_left: 26000, + traffic_used: "18.4 GB", + traffic_limit: "100 GB", + traffic_used_bytes: 19756849561, + traffic_limit_bytes: 107374182400, + traffic_limit_strategy: "NO_RESET", + }; + DEV_MOCK.data.plans = [ + { months: 10, traffic_gb: 10, price: 199, currency: "RUB", title: "10 GB", sale_mode: "traffic" }, + { months: 50, traffic_gb: 50, price: 799, currency: "RUB", title: "50 GB", sale_mode: "traffic" }, + { months: 100, traffic_gb: 100, price: 1390, currency: "RUB", title: "100 GB", sale_mode: "traffic" }, + { months: 300, traffic_gb: 300, price: 3490, currency: "RUB", title: "300 GB", sale_mode: "traffic" }, + ]; + } else if (mode === "tariffs") { + DEV_MOCK.data.settings.traffic_mode = false; + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + tariff_key: "standard", + tariff_name: "Стандарт", + tariff_description: "100 GB каждый месяц", + billing_model: "period", + traffic_limit_strategy: "MONTH", + }; + DEV_MOCK.data.plans = [ + { + id: "standard:period:1", + tariff_key: "standard", + tariff_name: "Стандарт", + billing_model: "period", + sale_mode: "subscription", + months: 1, + price: 150, + currency: "RUB", + title: "Стандарт", + subtitle: "1 месяц", + description: "100 GB каждый месяц", + monthly_gb: 100, + }, + { + id: "standard:period:3", + tariff_key: "standard", + tariff_name: "Стандарт", + billing_model: "period", + sale_mode: "subscription", + months: 3, + price: 400, + currency: "RUB", + title: "Стандарт", + subtitle: "3 месяца", + description: "100 GB каждый месяц", + monthly_gb: 100, + }, + { + id: "business:period:1", + tariff_key: "business", + tariff_name: "Бизнес", + billing_model: "period", + sale_mode: "subscription", + months: 1, + price: 350, + currency: "RUB", + title: "Бизнес", + subtitle: "1 месяц", + description: "300 GB и приоритетные серверы", + monthly_gb: 300, + }, + { + id: "traffic:traffic:50", + tariff_key: "traffic", + tariff_name: "Трафик", + billing_model: "traffic", + sale_mode: "traffic_package", + months: 50, + traffic_gb: 50, + price: 799, + currency: "RUB", + title: "Трафик", + subtitle: "50 GB", + description: "Пакет без срока действия", + }, + ]; + DEV_MOCK.data.tariff_change_options = { + ok: true, + current: { + tariff_key: "standard", + title: "Стандарт", + description: "100 GB каждый месяц", + billing_model: "period", + }, + targets: [ + { + tariff_key: "business", + title: "Бизнес", + description: "300 GB и приоритетные серверы", + billing_model: "period", + monthly_gb: 300, + actions: [ + { mode: "recalc_days", kind: "free", title: "recalc_days", days_after: 10, remaining_days: 25 }, + { mode: "paid_diff", kind: "payment", title: "paid_diff", price: 190, currency: "RUB" }, + ], + }, + { + tariff_key: "traffic", + title: "Трафик", + description: "Пакеты без срока действия", + billing_model: "traffic", + actions: [ + { mode: "convert_days_to_gb", kind: "free", title: "convert_days_to_gb", converted_gb: 18, remaining_days: 25 }, + { mode: "buy_package", kind: "payment", title: "+50 GB", traffic_gb: 50, price: 799, currency: "RUB" }, + ], + }, + ], + }; + DEV_MOCK.data.topup_options = { + ok: true, + tariff_key: "standard", + tariff_name: "Стандарт", + traffic_percent: 86, + warning_levels: [85, 90, 95], + plans: [ + { id: "standard:topup:10", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 10, months: 10, price: 99, currency: "RUB", title: "10 GB", subtitle: "Стандарт" }, + { id: "standard:topup:50", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 50, months: 50, price: 399, currency: "RUB", title: "50 GB", subtitle: "Стандарт" }, + { id: "standard:topup:200", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "topup", traffic_gb: 200, months: 200, price: 1299, currency: "RUB", title: "200 GB", subtitle: "Стандарт" }, + ], + }; + DEV_MOCK.data.device_topup_options = { + ok: true, + tariff_key: "standard", + tariff_name: "Стандарт", + current_limit: 5, + plans: [ + { id: "standard:hwid:1", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "hwid_devices", device_count: 1, months: 1, price: 99, currency: "RUB", title: "+1", subtitle: "Стандарт" }, + { id: "standard:hwid:3", tariff_key: "standard", tariff_name: "Стандарт", sale_mode: "hwid_devices", device_count: 3, months: 3, price: 249, currency: "RUB", title: "+3", subtitle: "Стандарт" }, + ], + }; + } else if (mode === "devices") { + DEV_MOCK.data.settings.my_devices_enabled = true; + DEV_MOCK.data.subscription = { + ...DEV_MOCK.data.subscription, + active: true, + max_devices: 5, + }; + } else if (mode === "trial") { + DEV_MOCK.data.settings.traffic_mode = false; + DEV_MOCK.data.settings.trial_enabled = true; + DEV_MOCK.data.settings.trial_available = true; + DEV_MOCK.data.settings.trial_duration_days = 5; + DEV_MOCK.data.settings.trial_traffic_limit_gb = 10; + DEV_MOCK.data.subscription = { + active: false, + status: "INACTIVE", + remaining_text: "Подписка не активна", + end_date_text: "", + days_left: 0, + config_link: null, + connect_url: null, + traffic_used: "0 B", + traffic_limit: "10 GB", + traffic_used_bytes: 0, + traffic_limit_bytes: 10737418240, + }; + } +} diff --git a/bot/app/web/frontend/src/lib/webapp/publicApi.js b/bot/app/web/frontend/src/lib/webapp/publicApi.js new file mode 100644 index 0000000..b4ccf8e --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/publicApi.js @@ -0,0 +1,46 @@ +import { readCookie } from "./session.js"; + +export function createApiClient({ + apiBase = "", + csrfCookieName = "rw_webapp_csrf", + getToken = () => "", + getCsrfToken = () => "", + onUnauthorized = () => {}, + mockApi = null, + getMockContext = () => ({}), +} = {}) { + async function api(path, options = {}) { + if (mockApi) return mockApi(path, options, getMockContext()); + + const method = String(options.method || "GET").toUpperCase(); + const headers = { ...(options.headers || {}) }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + const csrf = getCsrfToken() || readCookie(csrfCookieName) || ""; + if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + headers["X-CSRF-Token"] = csrf; + } + if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + + const response = await fetch(`${apiBase}${path}`, { ...options, headers }); + const payload = await response.json().catch(() => ({})); + if (response.status === 401) onUnauthorized(); + return payload; + } + + async function publicApi(path, payload = {}, options = {}) { + if (mockApi) { + return mockApi(path, { method: "POST", body: JSON.stringify(payload) }, getMockContext()); + } + const response = await fetch(`${apiBase}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: options.signal, + }); + return response.json(); + } + + return { api, publicApi }; +} diff --git a/bot/app/web/frontend/src/lib/webapp/routes.js b/bot/app/web/frontend/src/lib/webapp/routes.js new file mode 100644 index 0000000..972d0fe --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/routes.js @@ -0,0 +1,47 @@ +import { ADMIN_SECTIONS, APP_SECTION_PATHS } from './constants.js'; + +export function normalizeSection(value) { + const section = String(value || '').trim().toLowerCase(); + if (section === 'invite' || section === 'devices' || section === 'settings' || section === 'admin') { + return section; + } + return 'home'; +} + +export function sectionFromPath(pathname) { + const normalizedPath = String(pathname || '') + .trim() + .toLowerCase() + .replace(/\/+$/, ''); + if (!normalizedPath || normalizedPath === '/') return 'home'; + if (normalizedPath === '/admin' || normalizedPath.startsWith('/admin/')) return 'admin'; + const section = normalizedPath.startsWith('/') ? normalizedPath.slice(1) : normalizedPath; + return normalizeSection(section); +} + +export function adminSectionFromPath(pathname) { + const normalized = String(pathname || '').toLowerCase().replace(/\/+$/, ''); + const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/); + if (m && ADMIN_SECTIONS.has(m[1])) return m[1]; + return 'stats'; +} + +export function adminUserIdFromPath(pathname) { + const normalized = String(pathname || '').toLowerCase().replace(/\/+$/, ''); + const m = normalized.match(/^\/admin\/users\/(-?\d+)$/); + return m ? Number(m[1]) : null; +} + +export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) { + if (window.location.protocol === 'file:') return; + const normalized = normalizeSection(section); + let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; + if (normalized === 'admin') { + const adm = adminSection || adminSectionFromPath(window.location.pathname) || 'stats'; + const uid = adminUserId ?? (adm === 'users' ? adminUserIdFromPath(window.location.pathname) : null); + targetPath = adm === 'users' && uid ? `/admin/users/${uid}` : `/admin/${adm}`; + } + if (window.location.pathname === targetPath) return; + const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; + window.history[replace ? 'replaceState' : 'pushState'](null, '', nextUrl); +} diff --git a/bot/app/web/frontend/src/lib/webapp/session.js b/bot/app/web/frontend/src/lib/webapp/session.js new file mode 100644 index 0000000..da941d9 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/session.js @@ -0,0 +1,62 @@ +export const TOKEN_STORAGE_KEY = "rw_webapp_token"; +export const CSRF_COOKIE_NAME = "rw_webapp_csrf"; +export const REFERRAL_STORAGE_KEY = "rw_webapp_referral"; + +export function readCookie(name) { + if (typeof document === "undefined") return ""; + const prefix = `${name}=`; + const cookie = document.cookie.split("; ").find((part) => part.startsWith(prefix)); + return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ""; +} + +export function readStoredToken(storageKey = TOKEN_STORAGE_KEY) { + if (typeof localStorage === "undefined") return ""; + return localStorage.getItem(storageKey) || ""; +} + +export function persistToken(token, storageKey = TOKEN_STORAGE_KEY) { + if (!token || typeof localStorage === "undefined") return; + localStorage.setItem(storageKey, token); +} + +export function clearStoredToken(storageKey = TOKEN_STORAGE_KEY) { + if (typeof localStorage === "undefined") return; + localStorage.removeItem(storageKey); +} + +export function markManualLogout(flagKey) { + try { + localStorage.setItem(flagKey, "1"); + } catch {} +} + +export function clearManualLogoutFlag(flagKey) { + try { + localStorage.removeItem(flagKey); + } catch {} +} + +export function isManuallyLoggedOut(flagKey) { + try { + return localStorage.getItem(flagKey) === "1"; + } catch { + return false; + } +} + +export function rememberReferral(value) { + const normalized = String(value || "").trim(); + if (!normalized) return readReferral(); + try { + localStorage.setItem(REFERRAL_STORAGE_KEY, normalized); + } catch {} + return normalized; +} + +export function readReferral() { + try { + return localStorage.getItem(REFERRAL_STORAGE_KEY) || ""; + } catch { + return ""; + } +} diff --git a/bot/app/web/frontend/src/lib/webapp/telegramSdk.js b/bot/app/web/frontend/src/lib/webapp/telegramSdk.js new file mode 100644 index 0000000..a6d7464 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/telegramSdk.js @@ -0,0 +1,169 @@ +export function readTelegramMiniAppInitDataFromLocation() { + if (typeof window === "undefined") return ""; + const queryText = window.location.search.replace(/^\?/, ""); + const hashText = window.location.hash.replace(/^#/, ""); + for (const text of [queryText, hashText]) { + if (!text) continue; + const params = new URLSearchParams(text); + const initData = params.get("tgWebAppData"); + if (initData) return initData; + } + return ""; +} + +export function createTelegramSdk({ + scriptUrl, + bootTimeoutMs, + actionTimeoutMs, + miniAppAuthTimeoutMs, + onStatusChange = () => {}, + onInitDataChange = () => {}, +} = {}) { + let tg = resolve(); + let sdkPromise = null; + let launchParamsDetected = false; + let initData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (initData) launchParamsDetected = true; + + function resolve() { + return window.Telegram?.WebApp || null; + } + + function setStatus(status) { + onStatusChange(status); + } + + function refresh() { + tg = resolve(); + if (tg) setStatus("ready"); + initData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); + onInitDataChange(initData); + if (initData) launchParamsDetected = true; + return tg; + } + + function hasLaunchParams() { + refresh(); + if (launchParamsDetected || initData) { + launchParamsDetected = true; + return true; + } + const queryText = window.location.search.replace(/^\?/, ""); + const hashText = window.location.hash.replace(/^#/, ""); + const detected = [queryText, hashText].some((text) => { + if (!text) return false; + const params = new URLSearchParams(text); + return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some((key) => + params.has(key), + ); + }); + if (detected) launchParamsDetected = true; + return detected; + } + + function load(timeoutMs = bootTimeoutMs) { + if (refresh()) return Promise.resolve(tg); + if (sdkPromise) return sdkPromise; + if (typeof document === "undefined") return Promise.resolve(null); + + setStatus("loading"); + sdkPromise = new Promise((resolvePromise) => { + const existingScript = document.querySelector("script[data-rw-telegram-web-app-sdk]"); + const script = existingScript || document.createElement("script"); + let resolved = false; + let timeoutId = null; + + const resolveOnce = (value) => { + if (resolved) return; + resolved = true; + if (timeoutId) window.clearTimeout(timeoutId); + resolvePromise(value); + }; + + const refreshFromScript = () => { + tg = resolve(); + setStatus(tg ? "ready" : "unavailable"); + return tg; + }; + + script.addEventListener("load", () => resolveOnce(refreshFromScript()), { once: true }); + script.addEventListener( + "error", + () => { + setStatus("unavailable"); + resolveOnce(null); + }, + { once: true }, + ); + + if (!existingScript) { + script.src = scriptUrl; + script.async = true; + script.defer = true; + script.dataset.rwTelegramWebAppSdk = "1"; + document.head.appendChild(script); + } + + timeoutId = window.setTimeout(() => { + if (!tg) setStatus("unavailable"); + resolveOnce(tg); + }, timeoutMs); + }).finally(() => { + sdkPromise = null; + }); + return sdkPromise; + } + + async function ensureForAction() { + if (refresh()) return tg; + return await load(actionTimeoutMs); + } + + function createMiniAppAuthTimeout() { + const controller = typeof AbortController === "undefined" ? null : new AbortController(); + let timedOut = false; + let timeoutId = null; + let timeoutPromise = new Promise(() => {}); + + if (typeof window !== "undefined") { + timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + timedOut = true; + controller?.abort(); + const error = new Error("telegram_mini_app_auth_timeout"); + error.name = "AbortError"; + reject(error); + }, miniAppAuthTimeoutMs); + }); + } + + return { + promise: timeoutPromise, + get signal() { + return controller?.signal; + }, + get timedOut() { + return timedOut; + }, + clear() { + if (timeoutId) window.clearTimeout(timeoutId); + timeoutId = null; + }, + }; + } + + return { + get tg() { + return tg; + }, + get initData() { + return initData; + }, + refresh, + hasLaunchParams, + load, + ensureForAction, + createMiniAppAuthTimeout, + readInitDataFromLocation: readTelegramMiniAppInitDataFromLocation, + }; +} diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index 99fbb92..bba0ee0 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -1,5431 +1,4 @@ -@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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, 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; - - /* Admin design tokens — kept on :root so portal-rendered admin - surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ - --admin-bg: var(--bg); - --admin-surface: var(--panel); - --admin-surface-2: var(--panel-2); - --admin-elev: var(--panel-3); - --admin-border: var(--border); - --admin-border-strong: var(--border-strong); - --admin-text: var(--text); - --admin-muted: var(--muted); - --admin-dim: var(--dim); - --admin-ring: color-mix(in srgb, var(--accent) 50%, transparent); - --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 { - position: relative; - display: grid; - width: 38px; - height: 38px; - flex: 0 0 auto; - overflow: hidden; - place-items: center; - color: var(--accent); - font-size: 26px; - line-height: 1; -} - -.brand-mark img { - width: 100%; - height: 100%; - object-fit: contain; - opacity: 0; - transition: opacity 0.18s ease; -} - -.brand-mark img.loaded { - opacity: 1; -} - -.brand-mark-spinner { - position: absolute; - width: 42%; - height: 42%; - border: 2px solid color-mix(in srgb, var(--accent) 24%, transparent); - border-top-color: var(--accent); - border-radius: 999px; - animation: brand-mark-spin 0.72s linear infinite; -} - -@keyframes brand-mark-spin { - to { - transform: rotate(360deg); - } -} - -.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; -} - -.sub-status .current-tariff-line { - font-weight: 850; - opacity: 1; -} - -.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; -} - -.traffic-card-clickable { - cursor: pointer; -} - -.card-click-target { - position: absolute; - z-index: 2; - inset: 0; - width: 100%; - border: 0; - background: transparent; - cursor: pointer; - -webkit-tap-highlight-color: transparent; -} - -.card-click-target:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -3px; -} - -.premium-traffic-card { - display: block; -} - -.premium-traffic-card-limited { - border-color: rgba(255, 193, 7, 0.34); -} - -.premium-progress span { - background: linear-gradient(90deg, #38bdf8, var(--accent)); -} - -.premium-traffic-meta { - align-items: flex-start; -} - -.premium-server-dropdown { - position: relative; - z-index: 3; - min-width: 0; -} - -.premium-server-dropdown summary { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - min-height: 32px; - margin: -6px -8px; - padding: 6px 8px; - cursor: pointer; - list-style: none; - -webkit-tap-highlight-color: transparent; -} - -.premium-server-dropdown summary:focus { - outline: none; -} - -.premium-server-dropdown summary::-webkit-details-marker { - display: none; -} - -.premium-server-dropdown summary span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.premium-server-dropdown svg { - flex: 0 0 auto; - opacity: 0.72; -} - -.premium-server-list-dropdown { - margin-top: 8px; -} - -.premium-detail-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.premium-detail-grid span { - display: grid; - gap: 3px; - min-width: 0; -} - -.premium-detail-grid small, -.premium-server-list small, -.topup-summary-card small { - color: var(--muted); - font-size: 11px; - line-height: 1.3; -} - -.premium-detail-grid strong { - font-size: 12px; - line-height: 1.2; -} - -.premium-server-list { - display: grid; - gap: 6px; -} - -.premium-server-list > div { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.premium-server-list span { - max-width: 100%; - padding: 4px 8px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 999px; - color: var(--text); - font-size: 11px; - white-space: normal; - overflow-wrap: anywhere; - word-break: break-word; -} - -.topup-summary-card { - display: grid; - gap: 10px; - padding: 12px; -} - -.topup-summary-card > div:not(.premium-server-list) { - display: grid; - gap: 3px; -} - -.topup-summary-card strong { - font-size: 12px; - line-height: 1.2; -} - -.premium-server-list-modal { - margin-top: 0; -} - -.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; -} - -.option-list { - display: grid; - gap: 8px; -} - -.option-row, -.tariff-selected-card { - display: flex; - width: 100%; - min-height: 66px; - align-items: center; - justify-content: space-between; - gap: 12px; - 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); - padding: 12px; - text-align: left; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); -} - -.option-row-main, -.tariff-selected-card span { - display: grid; - gap: 4px; - min-width: 0; -} - -.option-row-main strong, -.tariff-selected-card strong { - font-size: 13px; - font-weight: 900; - line-height: 1.2; - overflow-wrap: anywhere; -} - -.option-row-main small, -.tariff-selected-card small { - color: var(--muted); - font-size: 11px; - line-height: 1.35; - overflow-wrap: anywhere; -} - -.option-row-meta { - display: grid; - justify-items: end; - gap: 3px; - flex: 0 0 auto; - max-width: 42%; - text-align: right; -} - -.option-row-meta em, -.tariff-selected-card em { - color: var(--accent); - font-size: 12px; - font-style: normal; - font-weight: 900; - line-height: 1.2; - overflow-wrap: anywhere; -} - -.option-row-meta small { - color: var(--muted); - font-size: 10px; - line-height: 1.2; -} - -.option-row.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); -} - -.tariff-row { - min-height: 74px; -} - -.plan-row, -.change-action-row { - min-height: 62px; -} - -.change-action-row { - align-items: flex-start; -} - -.change-action-row > svg { - flex: 0 0 auto; - margin-top: 1px; - color: var(--accent); -} - -.dialog-skeleton { - display: grid; - gap: 12px; -} - -.topup-carryover-note { - display: grid; - gap: 6px; - padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); -} - -.topup-carryover-note p { - margin: 0; - color: var(--muted); - font-size: 11px; - line-height: 1.35; -} - -.skeleton-row, -.skeleton-method, -.skeleton-pay-button { - pointer-events: none; -} - -.skeleton-line, -.skeleton-dot, -.skeleton-pay-button { - display: block; - overflow: hidden; - border-radius: 999px; - background: - linear-gradient( - 90deg, - rgba(255, 255, 255, 0.07) 0%, - rgba(255, 255, 255, 0.14) 42%, - rgba(255, 255, 255, 0.07) 84% - ); - background-size: 220% 100%; - animation: skeleton-shimmer 1.15s ease-in-out infinite; -} - -.skeleton-line-title { - width: 118px; - height: 13px; -} - -.skeleton-line-short { - width: 76px; - height: 10px; - opacity: 0.75; -} - -.skeleton-line-price { - width: 58px; - height: 12px; -} - -.skeleton-line-tiny { - width: 34px; - height: 9px; - opacity: 0.7; -} - -.skeleton-line-method { - width: 72px; - height: 12px; -} - -.skeleton-dot { - width: 18px; - height: 18px; - flex: 0 0 auto; -} - -.skeleton-method { - gap: 8px; -} - -.skeleton-pay-button { - width: 100%; - height: 46px; - border-radius: var(--radius); -} - -@keyframes skeleton-shimmer { - 0% { - background-position: 120% 0; - } - 100% { - background-position: -120% 0; - } -} - -.back-inline { - display: inline-flex; - width: fit-content; - align-items: center; - gap: 6px; - border: 0; - background: transparent; - color: var(--muted); - padding: 0; - font-size: 12px; - font-weight: 850; -} - -.tariff-step-caption, -.section-kicker { - margin: 0; - color: var(--muted); - font-size: 12px; - font-weight: 800; - line-height: 1.3; -} - -.section-kicker { - color: var(--dim); - text-transform: uppercase; -} - -.tariff-selected-card { - min-height: 58px; - background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); -} - -.tariff-action-list { - display: grid; - gap: 8px; -} - -.tariff-action-card, -.tariff-warning-card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - 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); - padding: 12px; - text-align: left; -} - -.tariff-action-card span { - display: grid; - gap: 4px; - min-width: 0; -} - -.tariff-action-card strong, -.tariff-warning-card span { - font-size: 13px; - font-weight: 850; -} - -.tariff-action-card small { - color: var(--muted); - font-size: 11px; -} - -.tariff-action-card em { - color: var(--accent); - font-size: 10px; - font-style: normal; - font-weight: 850; - white-space: nowrap; -} - -.tariff-action-card.active { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); -} - -.tariff-warning-card { - justify-content: flex-start; - color: var(--warning, #ffd166); -} - -.confirm-summary-card { - display: grid; - gap: 8px; -} - -.confirm-summary-card p { - margin: 0; - color: var(--text); - font-size: 13px; - font-weight: 750; - line-height: 1.35; -} - -.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; -} - -.settings-list.settings-list--language-open .settings-row:not(.settings-row-language) { - pointer-events: none; -} - -.language-select-guard { - appearance: none; - -webkit-tap-highlight-color: transparent; - position: fixed; - inset: 0; - z-index: 100; - border: 0; - background: transparent; - box-shadow: none; - outline: none; - padding: 0; - pointer-events: auto; - touch-action: manipulation; -} - -.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; - background: rgba(255, 255, 255, 0.03); -} - -.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: 140; - 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); -} - -.rail-admin-entry { - display: none !important; -} - -.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-button.unavailable:disabled { - cursor: not-allowed; -} - -.telegram-login-button.checking:disabled { - opacity: 0.82; -} - -.telegram-login-text { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 100%; -} - -.telegram-button-spinner { - width: 17px; - height: 17px; - border: 2px solid rgba(255, 255, 255, 0.35); - border-top-color: #fff; - border-radius: 999px; - animation: telegram-button-spin 0.72s linear infinite; -} - -@keyframes telegram-button-spin { - to { - transform: rotate(360deg); - } -} - -.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: 120; - 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); - } -} - -/* ============================================================ - Desktop layout for the user-facing Mini App (≥ 1024px) - A full-bleed dashboard: brand + nav rail on the left (the - mobile bottom-nav is repositioned, no markup changes), main - content area takes the rest of the viewport with a generous - max-width. Auth screens are kept centered. - Mobile (≤ 1023px) is intentionally untouched. - ============================================================ */ - -:root { - --desktop-rail-width: 252px; - --desktop-page-gutter: clamp(28px, 4vw, 72px); -} - -@media (min-width: 1024px) { - body { - background: #02070b; - } - - .app-shell { - min-height: 100dvh; - display: block; - padding: 0; - background: transparent !important; - } - - /* Full-width canvas: the side rail is a fixed sibling, so we leave room - for it on the left and center content inside the right-hand work area. */ - .phone-screen { - width: auto; - max-width: none; - min-height: 100dvh; - margin: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; - padding: - max(28px, env(safe-area-inset-top)) - var(--desktop-page-gutter) - 40px - calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); - overflow-x: visible; - } - - /* Centre and cap the width of the actual content blocks. */ - .phone-screen > .app-header, - .phone-screen > main, - .phone-screen > .home-layout, - .phone-screen > nav.bottom-nav ~ * { - max-width: 1080px; - margin-left: auto; - margin-right: auto; - } - - /* Auth screen: keep it as a focused card in the centre, hide rail. */ - .phone-screen.auth-screen { - width: min(100%, 460px); - min-height: 100dvh; - padding: - max(48px, env(safe-area-inset-top)) - clamp(24px, 4vw, 48px) - 48px; - margin: 0 auto; - } - - .phone-screen.auth-screen ~ .bottom-nav, - .auth-screen .bottom-nav { - display: none !important; - } - - .phone-screen.home-screen { - display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: stretch; - min-height: 100dvh; - padding: - max(32px, env(safe-area-inset-top)) - var(--desktop-page-gutter) - 42px - calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); - } - - /* Home: keep the phone-style vertical flow, but center it in the - right-hand desktop area and let the action block sit on the bottom. */ - .home-layout { - width: min(100%, 680px); - min-height: calc(100dvh - 74px); - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(280px, 1fr) auto; - justify-self: center; - gap: clamp(24px, 4vh, 52px); - max-width: none; - margin: 0; - padding-bottom: 0; - align-items: stretch; - } - - .home-layout > .home-brand { - grid-column: auto; - align-self: center; - justify-items: center; - text-align: center; - gap: 22px; - } - - .home-brand h1 { - max-width: 560px; - font-size: clamp(36px, 4vw, 58px); - letter-spacing: 0; - line-height: 1.02; - } - - /* Make the brand mark a focal element on desktop. */ - .home-brand .brand-mark, - .home-brand .brand-mark-lg, - .home-brand .brand-mark-xl { - width: clamp(180px, 18vw, 260px); - height: clamp(180px, 18vw, 260px); - font-size: clamp(124px, 13vw, 184px); - } - - .home-bottom { - align-self: end; - width: min(100%, 560px); - justify-self: center; - gap: 12px; - } - - /* Top header (brand on settings/invite/devices) becomes a flat strip. */ - .app-header.accent-title { - display: none; - } - - /* Reset clearance — the bottom-nav is now a sidebar. */ - .content.with-nav { - padding-bottom: 32px; - } - - /* Transform the mobile bottom-nav into a fixed-left vertical rail. */ - .bottom-nav { - position: fixed !important; - left: 0 !important; - top: 0 !important; - bottom: 0 !important; - right: auto !important; - width: var(--desktop-rail-width) !important; - height: 100dvh !important; - transform: none !important; - display: grid !important; - grid-template-columns: 1fr !important; - grid-template-rows: none !important; - grid-auto-rows: auto !important; - align-content: start !important; - gap: 2px !important; - padding: 28px 14px !important; - border: 0 !important; - border-right: 1px solid var(--border) !important; - border-radius: 0 !important; - background: rgba(7, 12, 17, 0.55) !important; - backdrop-filter: blur(14px) !important; - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important; - z-index: 40; - } - - .bottom-nav-devices { - grid-template-columns: 1fr !important; - } - - .bottom-nav button { - display: grid !important; - grid-template-columns: 22px 1fr !important; - grid-template-rows: auto !important; - align-items: center !important; - justify-items: start !important; - gap: 12px !important; - padding: 12px 14px !important; - border-radius: 10px !important; - text-align: left !important; - font-size: 13px !important; - color: var(--muted); - border: 1px solid transparent; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; - } - - .bottom-nav button > svg { - width: 20px; - height: 20px; - } - - .bottom-nav button > span { - text-align: left !important; - font-size: 13px !important; - font-weight: 600; - } - - .bottom-nav button:hover { - background: rgba(255, 255, 255, 0.04); - color: var(--text); - } - - .bottom-nav button.active { - background: color-mix(in srgb, var(--accent) 16%, transparent); - border-color: color-mix(in srgb, var(--accent) 30%, transparent); - color: var(--text); - } - - .bottom-nav .nav-attention-dot { - top: 14px; - right: 14px; - } - - .bottom-nav .rail-admin-entry { - display: grid !important; - } - - .settings-admin-block { - display: none !important; - } - - .phone-screen > main.content.with-nav { - padding-top: 0; - } - - .settings-profile { - padding: 12px; - } - - .settings-links-block { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - } - - .settings-links-block .settings-divider { - display: none; - } - - .settings-links-block .settings-row, - .settings-links-block .settings-telegram-link-btn { - min-height: 48px; - } - - .settings-list { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - } - - .settings-list .settings-row { - min-height: 48px; - } - - .settings-row-language, - .settings-row-support, - .settings-row-logout { - grid-column: 1 / -1; - } - - /* Wider modals on desktop. */ - .dialog-card { - width: min(100%, 560px); - } - - .payment-dialog-card, - .link-email-dialog-card { - width: min(100%, 640px); - } -} - -@media (min-width: 1280px) { - :root { - --desktop-rail-width: 264px; - } -} - -/* Brand block sits at the top of the desktop side rail; hidden on mobile. */ -.rail-brand { - display: none; -} - -@media (min-width: 1024px) { - .bottom-nav .rail-brand { - display: flex; - align-items: center; - gap: 12px; - padding: 4px 12px 18px; - margin-bottom: 8px; - border-bottom: 1px solid var(--border); - color: var(--text); - } - - .bottom-nav .rail-brand strong { - font-size: 14px; - font-weight: 800; - letter-spacing: -0.01em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - } -} - -/* ============================================================ - Admin panel — shadcn-svelte dashboard treatment - Sidebar + sticky header + cards + semantic tables. - ============================================================ */ - -.admin-screen-wrap { - --admin-sidebar-w: var(--desktop-rail-width); - --admin-header-h: 60px; - - position: fixed; - inset: 0; - width: 100vw; - height: 100dvh; - background: var(--admin-bg); - color: var(--admin-text); - overflow: hidden; - overscroll-behavior: contain; - z-index: 60; - display: flex; - flex-direction: row; - /* Forces a new compositing layer — required for reliable scrolling - inside position:fixed on iOS Safari. */ - transform: translateZ(0); -} - -.admin-sidebar { - flex: 0 0 var(--admin-sidebar-w); - min-height: 0; - height: 100%; - display: flex; - flex-direction: column; - gap: 4px; - padding: 14px 12px 18px; - border-right: 1px solid var(--admin-border); - background: var(--admin-surface); - overflow-y: auto; -} - -.admin-sidebar > *:not(.admin-sidebar-footer) { - flex-shrink: 0; -} - -.admin-sidebar-brand { - display: grid; - grid-template-columns: 36px minmax(0, 1fr) 32px; - align-items: center; - gap: 10px; - padding: 6px 6px 14px; - margin-bottom: 6px; - border-bottom: 1px solid var(--admin-border); -} - -.admin-sidebar-brand .admin-brand-mark { - width: 36px; - height: 36px; - display: grid; - place-items: center; - color: var(--accent); -} - -.admin-sidebar-brand strong { - display: block; - font-size: 13px; - font-weight: 700; - letter-spacing: -0.01em; -} - -.admin-sidebar-brand small { - display: block; - color: var(--admin-muted); - font-size: 11px; -} - -.admin-sidebar-section-label { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--admin-dim); - padding: 14px 12px 6px; -} - -.admin-nav { - display: grid; - gap: 1px; -} - -.admin-nav-item { - position: relative; - display: grid; - grid-template-columns: 18px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - padding: 9px 12px; - border-radius: 8px; - border: 0; - background: transparent; - color: var(--admin-muted); - font-size: 13px; - font-weight: 500; - text-align: left; - width: 100%; - cursor: pointer; - transition: background 0.12s ease, color 0.12s ease; -} - -.admin-nav-item:hover { - background: var(--admin-surface-2); - color: var(--admin-text); -} - -.admin-nav-item.active { - background: var(--admin-elev); - color: var(--admin-text); - font-weight: 600; -} - -.admin-nav-item.active::before { - content: ""; - position: absolute; - width: 3px; - height: 18px; - left: -4px; - top: 50%; - transform: translateY(-50%); - border-radius: 2px; - background: var(--accent); -} - -.admin-sidebar-footer { - margin-top: auto; - padding: 10px 6px 0; - border-top: 1px solid var(--admin-border); - font-size: 11px; - color: var(--admin-dim); - display: grid; - gap: 10px; -} - -.admin-language-switch { - display: grid; - grid-template-columns: 18px minmax(0, 1fr); - align-items: center; - gap: 8px; -} - -.admin-language-switch > svg { - color: var(--admin-muted); -} - -.admin-language-trigger { - display: flex; - width: 100%; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 8px; - border: 1px solid var(--admin-border); - border-radius: 8px; - background: var(--admin-surface-2); - color: var(--admin-text); - padding: 8px 10px; - text-align: left; - cursor: pointer; -} - -.admin-language-trigger:focus-visible { - outline: 2px solid var(--admin-ring); - outline-offset: 2px; -} - -.admin-language-trigger > span { - display: grid; - min-width: 0; - gap: 2px; -} - -.admin-language-trigger strong { - overflow: hidden; - font-size: 12px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-language-trigger small { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 5px; - overflow: hidden; - color: var(--admin-muted); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-version-link { - order: 2; - display: grid; - gap: 2px; - max-width: 100%; - overflow: hidden; - color: var(--admin-muted); - font-family: var(--font-mono); - font-size: 11px; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.12s ease; -} - -.admin-version-link span { - overflow: hidden; - text-overflow: ellipsis; -} - -.admin-version-link:hover { - color: var(--accent); -} - -.admin-content { - flex: 1 1 auto; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - height: 100%; - overflow: hidden; -} - -.admin-header { - flex: 0 0 auto; - min-height: var(--admin-header-h); - z-index: 5; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 16px; - padding: 0 24px; - border-bottom: 1px solid var(--admin-border); - background: var(--admin-bg); -} - -.admin-header-title { - display: grid; - gap: 2px; - min-width: 0; -} - -.admin-header-title h2 { - margin: 0; - font-size: 16px; - font-weight: 700; - letter-spacing: -0.01em; -} - -.admin-header-title small { - color: var(--admin-muted); - font-size: 12px; -} - -.admin-header-actions { - display: flex; - align-items: center; - gap: 8px; -} - -.admin-mobile-toggle { - display: none; - width: 36px; - height: 36px; - border-radius: 8px; - border: 1px solid var(--admin-border); - background: var(--admin-surface); - color: var(--admin-text); - align-items: center; - justify-content: center; -} - -.admin-main { - flex: 1 1 auto; - min-height: 0; - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; - overflow-y: auto; - overflow-x: hidden; - padding: 22px 24px 32px; - display: flex; - flex-direction: column; - gap: 16px; -} - -/* Flex children must NOT shrink — otherwise they collapse to fit the - container and the main never overflows, breaking scroll. */ -.admin-main > * { - flex-shrink: 0; -} - -.admin-card { - border: 1px solid var(--admin-border); - background: var(--admin-surface); - border-radius: 12px; - display: grid; - grid-template-columns: minmax(0, 1fr); - min-width: 0; - overflow: hidden; -} - -.admin-card-head { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - padding: 14px 18px; - border-bottom: 1px solid var(--admin-border); - min-width: 0; -} - -.admin-card-head h3 { - margin: 0; - font-size: 13px; - font-weight: 600; - letter-spacing: 0; - text-transform: none; - color: var(--admin-text); - word-break: break-word; -} - -.admin-card-head small { - color: var(--admin-muted); - font-size: 12px; - word-break: break-word; -} - -.admin-card-body { - padding: 16px 18px; - display: grid; - gap: 12px; - min-width: 0; -} - -.admin-empty { - display: grid; - place-items: center; - padding: 48px 16px; - border: 1px dashed var(--admin-border); - border-radius: 12px; - background: var(--admin-surface); - color: var(--admin-muted); - font-size: 13px; -} - -.admin-muted { - color: var(--admin-muted); - font-size: 12px; -} - -.admin-stat-grid { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 12px; -} - -@media (min-width: 560px) { - .admin-stat-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (min-width: 1024px) { - .admin-stat-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - -@media (min-width: 1440px) { - .admin-stat-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} - -.admin-stat-card { - border: 1px solid var(--admin-border); - background: var(--admin-surface); - border-radius: 12px; - padding: 16px 18px; - display: grid; - gap: 6px; - min-height: 96px; -} - -.admin-stat-card .admin-stat-label { - display: flex; - align-items: center; - gap: 8px; - color: var(--admin-muted); - font-size: 12px; - font-weight: 500; -} - -.admin-stat-card .admin-stat-value { - font-size: 26px; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--admin-text); -} - -.admin-stat-card .admin-stat-trend { - color: var(--admin-muted); - font-size: 12px; -} - -.admin-toolbar { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; -} - -.admin-toolbar-users { - display: grid; - grid-template-columns: minmax(260px, 1fr); - gap: 10px; - padding: 12px; - border: 1px solid var(--admin-border); - border-radius: 12px; - background: var(--admin-surface); -} - -.admin-toolbar-card { - display: grid; - grid-template-columns: minmax(260px, 1fr) auto; - align-items: end; - gap: 8px; - padding: 12px; - border: 1px solid var(--admin-border); - border-radius: 12px; - background: var(--admin-surface); -} - -.admin-toolbar-search { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - min-width: 0; -} - -.admin-toolbar-search-actions { - grid-template-columns: minmax(0, 1fr) auto auto; -} - -.admin-toolbar-search .input, -.admin-toolbar-search .admin-btn { - height: 36px; -} - -.admin-toolbar-controls { - display: grid; - grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto); - align-items: end; - gap: 8px; -} - -.admin-toolbar > .input, -.admin-toolbar input[type="text"], -.admin-toolbar input[type="number"], -.admin-toolbar input[type="search"] { - flex: 1 1 200px; - min-width: 180px; -} - -.admin-toolbar > .admin-btn { - height: 36px; -} - -.admin-toolbar-field { - display: grid; - align-items: start; - gap: 6px; - min-width: 0; -} - -.admin-toolbar-field-label { - color: var(--admin-muted); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; -} - -.admin-toolbar-select { - height: 36px; - min-width: 0; -} - -.admin-toolbar-summary { - display: grid; - min-height: 36px; - min-width: 96px; - align-content: center; - justify-items: end; - gap: 2px; - padding: 0 2px; - white-space: nowrap; -} - -.admin-toolbar-summary strong { - color: var(--admin-text); - font-size: 14px; - font-weight: 650; - line-height: 1; -} - -@media (max-width: 900px) { - .admin-toolbar-controls { - grid-template-columns: repeat(2, minmax(150px, 1fr)); - } - - .admin-toolbar-summary { - justify-items: start; - } -} - -@media (max-width: 560px) { - .admin-toolbar-search, - .admin-toolbar-search-actions, - .admin-toolbar-card, - .admin-toolbar-controls { - grid-template-columns: minmax(0, 1fr); - } - - .admin-toolbar-summary { - justify-items: start; - } -} - -.admin-table-wrap { - border: 1px solid var(--admin-border); - border-radius: 12px; - background: var(--admin-surface); - overflow: hidden; -} - -.admin-table-wrap > .admin-card-head { - border-bottom: 1px solid var(--admin-border); -} - -.admin-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - font-size: 13px; -} - -.admin-table thead th { - text-align: left; - font-weight: 500; - color: var(--admin-muted); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 12px 16px; - background: var(--admin-surface-2); - border-bottom: 1px solid var(--admin-border); - white-space: nowrap; -} - -.admin-table tbody td { - padding: 12px 16px; - border-bottom: 1px solid var(--admin-border); - vertical-align: middle; - color: var(--admin-text); -} - -.admin-table tbody tr:last-child td { - border-bottom: 0; -} - -.admin-table tbody tr { - transition: background 0.08s ease; -} - -.admin-table tbody tr:hover { - background: color-mix(in srgb, var(--admin-elev) 50%, transparent); -} - -.admin-table td.admin-cell-mono { - font-family: var(--font-mono); - font-size: 12px; -} - -.admin-table td.admin-cell-actions { - text-align: right; - white-space: nowrap; -} - -.admin-table td.admin-cell-actions .admin-btn { - margin-left: 4px; -} - -.admin-table td.admin-cell-wrap { - white-space: normal; - max-width: 320px; - word-break: break-word; - color: var(--admin-muted); - font-size: 12px; -} - -.admin-table td.admin-cell-id { - color: var(--admin-muted); - font-family: var(--font-mono); - font-size: 12px; - width: 80px; -} - -.admin-table tbody tr.is-clickable { - cursor: pointer; -} - -.admin-user-list { - display: grid; - list-style: none; - margin: 0; - padding: 0; - gap: 0; -} - -.admin-user-list > li { - border-bottom: 1px solid var(--admin-border); -} - -.admin-user-list > li:last-child { - border-bottom: 0; -} - -.admin-user-row { - display: grid; - grid-template-columns: 36px minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - width: 100%; - border: 0; - background: transparent; - color: var(--admin-text); - /* Card height = avatar (36px) + padding-y * 2 = 52px. */ - padding: 8px 14px; - text-align: left; - cursor: pointer; - transition: background 0.12s ease; -} - -.admin-user-row:hover { - background: color-mix(in srgb, var(--admin-elev) 60%, transparent); -} - -.admin-user-row:focus-visible { - outline: 2px solid var(--admin-ring); - outline-offset: -2px; -} - -.admin-user-side { - display: flex; - align-items: center; - gap: 10px; - flex: 0 0 auto; - white-space: nowrap; -} - -.admin-user-tertiary { - color: var(--admin-dim); - font-size: 11px; - white-space: nowrap; -} - -.admin-avatar.admin-avatar-sm { - width: 36px; - height: 36px; - font-size: 11px; -} - -@media (max-width: 480px) { - /* On very narrow screens, drop the date but keep the badge inline so the - row still fits within `avatar + padding` height. */ - .admin-user-tertiary { - display: none; - } - - .admin-user-row { - gap: 10px; - } -} - -.admin-avatar { - display: inline-grid; - width: 42px; - height: 42px; - flex: 0 0 auto; - place-items: center; - overflow: hidden; - border-radius: 8px; - border: 1px solid var(--admin-border); - background: color-mix(in srgb, var(--accent) 16%, var(--admin-surface-2)); - color: var(--accent); - font-size: 12px; - font-weight: 700; - letter-spacing: 0; -} - -.admin-avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.admin-avatar-lg { - width: 58px; - height: 58px; - font-size: 16px; -} - -.admin-user-main, -.admin-user-meta, -.admin-user-profile-head > div { - display: grid; - gap: 3px; - min-width: 0; -} - -.admin-user-main strong, -.admin-user-profile-head strong { - overflow: hidden; - color: var(--admin-text); - font-size: 13px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-user-main small, -.admin-user-profile-head small, -.admin-user-meta span { - overflow: hidden; - color: var(--admin-muted); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-user-meta strong { - overflow: hidden; - color: var(--admin-text); - font-size: 12px; - font-weight: 500; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-user-status { - justify-self: end; -} - -.admin-user-profile-head { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - padding-bottom: 12px; - border-bottom: 1px solid var(--admin-border); -} - -.admin-mini-list { - display: grid; - gap: 0; -} - -.admin-mini-list-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - padding: 9px 0; - border-bottom: 1px solid var(--admin-border); -} - -.admin-mini-list-row:last-child { - border-bottom: 0; -} - -.admin-mini-list-row div { - display: grid; - gap: 3px; - min-width: 0; -} - -.admin-mini-list-row strong { - overflow: hidden; - color: var(--admin-text); - font-size: 13px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-mini-list-row small { - overflow: hidden; - color: var(--admin-muted); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-pagination { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 4px 0; -} - -.admin-pagination .admin-pagination-meta { - color: var(--admin-muted); - font-size: 12px; -} - -.admin-pagination-buttons { - display: flex; - gap: 6px; -} - -.admin-screen-wrap input, -.admin-screen-wrap textarea, -.admin-screen-wrap select, -.admin-screen-wrap .input { - min-width: 0; - max-width: 100%; -} - -.admin-screen-wrap .input, -.admin-screen-wrap input[type="text"], -.admin-screen-wrap input[type="number"], -.admin-screen-wrap input[type="email"], -.admin-screen-wrap input[type="search"], -.admin-screen-wrap input[type="password"], -.admin-screen-wrap input[type="url"], -.admin-screen-wrap select { - height: 36px; - border-radius: 8px; - border: 1px solid var(--admin-border-strong); - background: var(--admin-bg); - color: var(--admin-text); - padding: 0 12px; - font-size: 13px; - outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; -} - -.admin-screen-wrap .input::placeholder, -.admin-screen-wrap input::placeholder, -.admin-screen-wrap textarea::placeholder { - color: var(--admin-dim); -} - -.admin-screen-wrap .input:focus, -.admin-screen-wrap input:focus, -.admin-screen-wrap select:focus { - border-color: var(--admin-ring); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.admin-select { - width: 100%; - appearance: auto; -} - -.admin-textarea, -.admin-screen-wrap textarea { - width: 100%; - min-height: 110px; - border-radius: 10px; - border: 1px solid var(--admin-border-strong); - background: var(--admin-bg); - color: var(--admin-text); - padding: 12px; - font: inherit; - font-size: 13px; - resize: vertical; - outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; -} - -.admin-textarea:focus { - border-color: var(--admin-ring); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.admin-form { - display: grid; - gap: 12px; -} - -.admin-form-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 12px; -} - -.admin-form-row.admin-form-row-3 { - grid-template-columns: minmax(180px, 1.1fr) minmax(160px, 0.8fr) minmax(140px, 0.6fr); -} - -.admin-form label { - display: grid; - gap: 6px; - font-size: 12px; - color: var(--admin-muted); - font-weight: 500; -} - -.admin-form label > span { - color: var(--admin-text); - font-weight: 500; -} - -.admin-form label > small { - color: var(--admin-dim); - font-weight: 400; -} - -.admin-switch.admin-switch-field { - align-self: end; - min-height: 36px; - display: flex; -} - -.admin-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 34px; - padding: 0 14px; - border-radius: 8px; - border: 1px solid var(--admin-border); - background: var(--admin-surface-2); - color: var(--admin-text); - font-size: 13px; - font-weight: 500; - cursor: pointer; - white-space: nowrap; - transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; -} - -.admin-btn:hover:not(:disabled) { - background: var(--admin-elev); -} - -.admin-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.admin-btn.admin-btn-primary { - background: var(--accent); - color: #02110a; - border-color: color-mix(in srgb, var(--accent) 70%, #000); - font-weight: 600; -} - -.admin-btn.admin-btn-primary:hover:not(:disabled) { - background: color-mix(in srgb, var(--accent) 90%, #fff); -} - -.admin-btn.admin-btn-ghost { - background: transparent; - border-color: transparent; - color: var(--admin-muted); -} - -.admin-btn.admin-btn-ghost:hover:not(:disabled) { - background: var(--admin-surface-2); - color: var(--admin-text); -} - -.admin-btn.admin-btn-danger { - background: color-mix(in srgb, #ff5757 80%, #000); - color: #fff; - border-color: color-mix(in srgb, #ff5757 60%, #000); - font-weight: 600; -} - -.admin-btn.admin-btn-danger:hover:not(:disabled) { - background: #ff5757; - border-color: #ff5757; -} - -.admin-btn.admin-btn-danger-soft { - color: #ffb4b4; - background: var(--admin-surface-2); - border-color: color-mix(in srgb, #ff6b6b 30%, transparent); -} - -.admin-btn.admin-btn-danger-soft:hover:not(:disabled) { - background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); - border-color: color-mix(in srgb, #ff6b6b 50%, transparent); -} - -.admin-btn.admin-btn-icon { - width: 34px; - padding: 0; -} - -.admin-btn.admin-btn-sm { - height: 28px; - padding: 0 10px; - font-size: 12px; - border-radius: 6px; -} - -/* ============================================================ - Admin skeletons (users list, payments / logs / promos / ads - tables). Rendered while data is loading to soften the empty - state. Kept aria-hidden in markup; purely decorative. - ============================================================ */ - -.admin-skeleton { - display: inline-block; - background: linear-gradient( - 90deg, - var(--admin-surface-2) 0%, - color-mix(in srgb, var(--admin-elev) 65%, transparent) 50%, - var(--admin-surface-2) 100% - ); - background-size: 200% 100%; - border-radius: 6px; - animation: admin-skeleton-shimmer 1.2s ease-in-out infinite; -} - -.admin-skeleton-line { - display: block; - width: 100%; - height: 10px; - border-radius: 4px; -} - -.admin-skeleton-line-strong { - height: 12px; - width: 60%; -} - -.admin-skeleton-line-soft { - height: 9px; - width: 40%; - opacity: 0.7; -} - -.admin-skeleton-line-short { - width: 50%; -} - -.admin-skeleton-line-tiny { - width: 32%; -} - -.admin-skeleton-avatar { - width: 36px; - height: 36px; - border-radius: 50%; -} - -.admin-skeleton-badge { - width: 64px; - height: 18px; - border-radius: 999px; -} - -.admin-user-row-skeleton { - cursor: default; - pointer-events: none; -} - -.admin-user-row-skeleton .admin-user-main { - display: grid; - gap: 6px; -} - -.admin-user-row-skeleton .admin-user-side { - gap: 8px; -} - -.admin-table-skeleton td { - padding: 12px 16px; -} - -@keyframes admin-skeleton-shimmer { - 0% { - background-position: 100% 0; - } - 100% { - background-position: -100% 0; - } -} - -@media (prefers-reduced-motion: reduce) { - .admin-skeleton { - animation: none; - } -} - -.admin-badge { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid var(--admin-border); - background: var(--admin-surface-2); - color: var(--admin-muted); - white-space: nowrap; -} - -.admin-badge.admin-badge-success { - border-color: color-mix(in srgb, var(--accent) 36%, transparent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); -} - -.admin-badge.admin-badge-danger { - border-color: color-mix(in srgb, #ff6b6b 32%, transparent); - color: #ffb4b4; - background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); -} - -.admin-badge.admin-badge-warning { - border-color: color-mix(in srgb, #ffd166 32%, transparent); - color: #ffd166; - background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); -} - -.admin-badge.admin-badge-muted { - color: var(--admin-muted); -} - -.admin-detail-grid { - display: grid; - grid-template-columns: 1fr; - gap: 14px; -} - -@media (min-width: 900px) { - .admin-detail-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.admin-meta-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; -} - -.admin-meta-list li { - display: grid; - grid-template-columns: 130px minmax(0, 1fr); - align-items: baseline; - gap: 12px; - padding: 8px 0; - border-bottom: 1px solid var(--admin-border); - font-size: 13px; -} - -.admin-meta-list li:last-child { - border-bottom: 0; -} - -.admin-meta-list li > span { - color: var(--admin-muted); - font-size: 12px; -} - -.admin-meta-list li > strong { - font-weight: 500; - color: var(--admin-text); - word-break: break-word; -} - -.admin-accordion { - display: grid; - gap: 10px; -} - -.admin-accordion-item.admin-card { - display: block; - min-width: 0; -} - -.admin-accordion-header { - margin: 0; - display: block; -} - -.admin-accordion-trigger { - appearance: none; - background: transparent; - border: 0; - text-align: left; - padding: 14px 18px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 12px; - color: var(--admin-text); - cursor: pointer; - font: inherit; - width: 100%; - transition: background 0.12s ease; - outline: none; -} - -.admin-accordion-trigger:hover { - background: var(--admin-surface-2); -} - -.admin-accordion-trigger:focus-visible { - background: var(--admin-surface-2); - box-shadow: inset 0 0 0 2px var(--admin-ring); -} - -.admin-accordion-trigger[data-state="open"] { - border-bottom: 1px solid var(--admin-border); -} - -.admin-accordion-title { - font-size: 13px; - font-weight: 600; - color: var(--admin-text); - text-transform: capitalize; - word-break: break-word; - min-width: 0; -} - -.admin-accordion-meta { - color: var(--admin-muted); - font-size: 12px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.admin-accordion-chev { - transition: transform 0.2s ease; - color: var(--admin-muted); - flex: 0 0 auto; -} - -.admin-accordion-trigger[data-state="open"] .admin-accordion-chev { - transform: rotate(90deg); -} - -.admin-accordion-content { - overflow: hidden; -} - -.admin-accordion-content[data-state="closed"] { - display: none; -} - -@media (max-width: 720px) { - .admin-accordion-trigger { - padding: 12px 14px; - gap: 8px; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-areas: - "title chev" - "meta meta"; - } - - .admin-accordion-title { - grid-area: title; - } - - .admin-accordion-chev { - grid-area: chev; - } - - .admin-accordion-meta { - grid-area: meta; - white-space: normal; - font-size: 11px; - } -} - -.admin-setting { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); - gap: 24px; - padding: 16px 18px; - border-bottom: 1px solid var(--admin-border); - align-items: center; -} - -.admin-setting:last-child { - border-bottom: 0; -} - -.admin-setting.is-overridden { - background: color-mix(in srgb, var(--accent) 5%, transparent); -} - -.admin-setting-meta { - display: grid; - gap: 4px; - min-width: 0; -} - -.admin-setting-meta strong { - font-size: 13px; - font-weight: 600; - color: var(--admin-text); - display: inline-flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - word-break: break-word; -} - -.admin-setting-meta code { - font-family: var(--font-mono); - font-size: 11px; - color: var(--admin-dim); - word-break: break-all; - overflow-wrap: anywhere; -} - -.admin-setting-meta small { - color: var(--admin-muted); - font-size: 12px; - line-height: 1.5; - overflow-wrap: anywhere; -} - -.admin-setting-control { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - min-width: 0; -} - -.admin-setting-control .input, -.admin-setting-control input[type="text"], -.admin-setting-control input[type="number"] { - flex: 1 1 160px; - min-width: 0; - width: 100%; -} - -.admin-setting-control .admin-color { - width: 36px; - height: 36px; - flex: 0 0 36px; - border-radius: 8px; - border: 1px solid var(--admin-border); - background: var(--admin-surface-2); - padding: 0; - cursor: pointer; -} - -.admin-setting-switch { - display: inline-flex; - min-height: 36px; - align-items: center; - gap: 10px; - color: var(--admin-text); - font-size: 12px; - font-weight: 500; -} - -.admin-tabs { - display: inline-flex; - border-bottom: 1px solid var(--admin-border); - gap: 2px; -} - -.admin-tabs button { - padding: 9px 12px; - border: 0; - background: transparent; - color: var(--admin-muted); - font-size: 13px; - font-weight: 500; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; -} - -.admin-tabs button.active { - color: var(--admin-text); - border-bottom-color: var(--accent); -} - -/* Admin dialogs don't share the user-facing bottom-nav, so reset the .dialog - wrapper's padding (which reserves space for the mobile nav) and let the - card use the full viewport for its scroll area. */ -.dialog:has(.admin-dialog) { - padding: max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-right)) - max(12px, env(safe-area-inset-bottom)) - max(12px, env(safe-area-inset-left)); - z-index: 90; -} - -.admin-dialog { - max-height: 100%; - width: 100%; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; -} - -/* User-detail dialog: constrain on desktop and lay out as a two-column - sidebar (profile facts) + main content (tabs). On mobile it stacks. */ -.admin-user-dialog { - width: min(100%, 1040px); - max-height: min(100%, 760px); -} - -.admin-user-dialog-body { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 16px; - min-width: 0; -} - -.admin-user-aside { - display: flex; - flex-direction: column; - gap: 12px; - min-width: 0; -} - -.admin-user-main { - display: flex; - flex-direction: column; - min-width: 0; -} - -.admin-user-stats { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.admin-user-stat { - display: flex; - flex-direction: column; - gap: 2px; - padding: 10px 12px; - border-radius: 10px; - border: 1px solid var(--admin-border); - background: var(--admin-surface); - min-width: 0; -} - -.admin-user-stat span { - color: var(--admin-muted); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.admin-user-stat strong { - color: var(--admin-text); - font-size: 15px; - font-weight: 700; - word-break: break-word; -} - -.admin-link-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.admin-link-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid var(--admin-border); - background: var(--admin-surface); - min-width: 0; -} - -.admin-link-row-meta { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.admin-link-row-label { - color: var(--admin-muted); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.admin-link-row-url { - color: var(--admin-text); - font-family: var(--font-mono); - font-size: 11px; - text-decoration: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.admin-link-row-url:hover { - color: var(--accent); - text-decoration: underline; -} - -.admin-btn.admin-btn-icon { - width: 30px; - height: 30px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; -} - -.admin-meta-truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-traffic-summary { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 10px; -} - -.admin-traffic-card { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - padding: 12px; - border: 1px solid var(--admin-border); - border-radius: 10px; - background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-bg)); -} - -.admin-traffic-card-premium { - border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border)); -} - -.admin-traffic-card-warning { - border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border)); -} - -.admin-traffic-head, -.admin-traffic-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - min-width: 0; -} - -.admin-traffic-head span { - color: var(--admin-muted); - font-size: 12px; - font-weight: 600; -} - -.admin-traffic-head strong { - color: var(--admin-text); - font-size: 13px; - font-weight: 700; - text-align: right; -} - -.admin-traffic-bar { - height: 8px; - border-radius: 999px; - background: var(--admin-bg); - overflow: hidden; - box-shadow: inset 0 0 0 1px var(--admin-border); -} - -.admin-traffic-bar span { - display: block; - height: 100%; - max-width: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #ffffff)); -} - -.admin-traffic-bar-premium span { - background: linear-gradient(90deg, #66e3ff, var(--accent)); -} - -.admin-traffic-card-warning .admin-traffic-bar span { - background: linear-gradient(90deg, #ffb454, #ff6b6b); -} - -.admin-traffic-meta { - color: var(--admin-muted); - font-size: 11px; - font-weight: 600; -} - -@media (min-width: 860px) { - .admin-user-dialog-body { - grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); - gap: 20px; - align-items: start; - } - - .admin-user-aside { - position: sticky; - top: 0; - } -} - -.admin-tariff-grid { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 12px; -} - -@media (min-width: 720px) { - .admin-tariff-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (min-width: 1280px) { - .admin-tariff-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - -.admin-tariff-card { - display: grid; - gap: 12px; - min-width: 0; - border: 1px solid var(--admin-border); - border-radius: 8px; - background: var(--admin-surface-2); - padding: 14px; -} - -.admin-tariff-card.is-disabled { - opacity: 0.72; -} - -.admin-tariff-top, -.admin-tariff-title, -.admin-tariff-actions, -.admin-editor-section-head { - display: flex; - align-items: center; - gap: 8px; -} - -.admin-tariff-top { - justify-content: space-between; -} - -.admin-tariff-title { - min-width: 0; - flex-wrap: wrap; -} - -.admin-tariff-title strong { - color: var(--admin-text); - font-size: 15px; -} - -.admin-tariff-card code { - display: block; - margin-top: 4px; - color: var(--admin-dim); - font-family: var(--font-mono); - font-size: 11px; -} - -.admin-tariff-card p { - margin: 0; - color: var(--admin-muted); - font-size: 13px; - line-height: 1.45; -} - -.admin-tariff-facts { - display: grid; - gap: 6px; - color: var(--admin-muted); - font-size: 12px; -} - -.admin-tariff-facts span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-tariff-actions { - flex-wrap: wrap; -} - -.admin-tariff-dialog { - width: min(980px, calc(100vw - 32px)); -} - -.admin-editor-section { - display: grid; - gap: 10px; - border: 1px solid var(--admin-border); - border-radius: 8px; - background: color-mix(in srgb, var(--admin-surface-2) 62%, transparent); - padding: 12px; -} - -.admin-editor-section-head { - justify-content: space-between; - min-height: 28px; -} - -.admin-editor-section-head strong { - color: var(--admin-text); - font-size: 13px; -} - -.admin-editor-section-head > div { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -/* Section title block: stacks `` heading + `` description. - Higher specificity than `.admin-editor-section-head > div` (which would - otherwise force flex-row on the wrapper). */ -.admin-editor-section-head > .admin-editor-section-title { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1 1 200px; - min-width: 0; -} - -.admin-editor-section-head > .admin-editor-section-title small { - color: var(--admin-muted); - font-size: 12px; - line-height: 1.4; - font-weight: 400; - text-transform: none; - letter-spacing: 0; -} - -/* Column headers above an `.admin-row-editor` list — visually distinct, - inherits the same grid template as input rows so columns line up. */ -.admin-row-editor-line.admin-row-editor-header { - color: var(--admin-muted); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0 4px; -} - -.admin-row-editor-line.admin-row-editor-header span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.admin-row-editor { - display: grid; - gap: 8px; - min-width: 0; -} - -.admin-row-editor-line { - display: grid; - grid-template-columns: minmax(90px, 1fr) minmax(110px, 1fr) 32px; - gap: 8px; - align-items: center; -} - -.admin-row-editor-line.admin-row-editor-4 { - grid-template-columns: minmax(80px, 0.8fr) minmax(100px, 1fr) minmax(100px, 1fr) 32px; -} - -.admin-row-editor-line .admin-btn { - width: 32px; - padding: 0; -} - -.admin-package-columns { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.admin-dialog-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - padding-top: 2px; -} - -@media (max-width: 1023px) { - .admin-screen-wrap { - --admin-header-h: 56px; - } - - .admin-sidebar { - position: fixed; - inset: 0 auto 0 0; - width: 280px; - flex: 0 0 auto; - z-index: 80; - transform: translateX(-100%); - transition: transform 0.22s ease; - box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); - } - - .admin-screen-wrap.is-sidebar-open .admin-sidebar { - transform: translateX(0); - } - - .admin-sidebar-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 70; - border: 0; - cursor: pointer; - } - - .admin-mobile-toggle { - display: inline-flex; - } - - .admin-header { - padding: 8px 16px; - grid-template-columns: minmax(0, 1fr); - grid-auto-flow: row; - grid-auto-rows: auto; - align-content: center; - gap: 6px; - min-height: var(--admin-header-h); - } - - .admin-header-actions { - overflow-x: auto; - padding-bottom: 2px; - scrollbar-width: none; - } - - .admin-header-actions::-webkit-scrollbar { - display: none; - } - - .admin-main { - padding: 16px; - } -} - -@media (max-width: 720px) { - .admin-setting { - grid-template-columns: minmax(0, 1fr); - gap: 10px; - padding: 14px 14px; - } - - .admin-card-head { - padding: 12px 14px; - } - - .admin-card-body { - padding: 14px; - } - - .admin-tariff-card { - padding: 12px; - } - - .admin-tariff-actions .admin-btn { - flex: 1 1 calc(50% - 4px); - } - - .admin-tariff-actions .admin-btn[aria-label] { - flex: 0 0 auto; - } - - /* (legacy three-row user-row layout removed in favour of the compact - single-row layout defined above) */ - - .admin-user-meta strong { - font-size: 11px; - } - - .admin-form-row.admin-form-row-3, - .admin-package-columns, - .admin-row-editor-line, - .admin-row-editor-line.admin-row-editor-4 { - grid-template-columns: 1fr; - } - - .admin-dialog-actions { - justify-content: stretch; - flex-direction: column-reverse; - } - - .admin-dialog-actions .admin-btn { - width: 100%; - } - - .admin-table thead { - display: none; - } - - .admin-table tbody td { - display: block; - padding: 6px 16px; - border: 0; - } - - .admin-table tbody tr { - display: block; - padding: 10px 0; - border-bottom: 1px solid var(--admin-border); - } - - .admin-table tbody tr:last-child { - border-bottom: 0; - } - - .admin-table tbody td::before { - content: attr(data-label); - display: inline-block; - min-width: 110px; - color: var(--admin-muted); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-right: 8px; - } - - .admin-table td.admin-cell-actions { - text-align: left; - } -} - -.settings-admin-block { - display: grid; - gap: 10px; - margin: 6px 0 0; -} - -.settings-row.settings-row-admin { - border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - border-radius: var(--radius); - padding: 10px 12px; - width: 100%; - cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; -} - -.settings-row.settings-row-admin:hover { - background: color-mix(in srgb, #f59e0b 18%, transparent); - border-color: color-mix(in srgb, #f59e0b 58%, transparent); -} - -.settings-row.settings-row-admin > svg:first-child { - color: #fbbf24; - opacity: 1; -} - -.settings-row.settings-row-admin strong { - color: var(--text); -} - -/* ============================================================ - shadcn-svelte primitives: Tabs / Select / Switch / Label - bits-ui exposes data-state attributes; styling here matches - shadcn-svelte defaults adapted to the dark admin palette. - ============================================================ */ - -.admin-tabs-root { - display: flex; - flex-direction: column; - gap: 14px; - min-width: 0; -} - -.admin-tabs-list { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px; - border-radius: 10px; - background: var(--admin-surface-2); - border: 1px solid var(--admin-border); - overflow-x: auto; - scrollbar-width: none; - flex: 0 0 auto; -} - -.admin-tabs-list::-webkit-scrollbar { - display: none; -} - -.admin-tabs-trigger { - appearance: none; - border: 0; - background: transparent; - color: var(--admin-muted); - font: inherit; - font-size: 13px; - font-weight: 500; - padding: 7px 14px; - border-radius: 7px; - cursor: pointer; - white-space: nowrap; - transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; - outline: none; -} - -.admin-tabs-trigger:hover { - color: var(--admin-text); -} - -.admin-tabs-trigger[data-state="active"] { - background: var(--admin-surface); - color: var(--admin-text); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); -} - -.admin-tabs-trigger:focus-visible { - box-shadow: 0 0 0 2px var(--admin-ring); -} - -.admin-tabs-content { - display: flex; - flex-direction: column; - gap: 14px; - outline: none; -} - -.admin-tabs-content[data-state="inactive"] { - display: none; -} - -.admin-user-tabs-root { - gap: 0; -} - -.admin-user-tabs-root .admin-tabs-list { - gap: 2px; - padding: 4px 4px 0; - border-radius: 10px 10px 0 0; - background: color-mix(in srgb, var(--admin-surface-2) 74%, var(--admin-bg)); - border-bottom: 0; - width: fit-content; - max-width: 100%; -} - -.admin-user-tabs-root .admin-tabs-trigger { - padding: 8px 14px 9px; - border-radius: 7px 7px 0 0; -} - -.admin-user-tabs-root .admin-tabs-trigger[data-state="active"] { - position: relative; - z-index: 1; - box-shadow: - 0 -1px 0 var(--admin-border), - 1px 0 0 var(--admin-border), - -1px 0 0 var(--admin-border), - 0 1px 0 var(--admin-surface); -} - -.admin-user-tabs-root .admin-tabs-content { - margin-top: -1px; - padding: 16px; - border: 1px solid var(--admin-border); - border-radius: 0 10px 10px 10px; - background: var(--admin-surface); -} - -/* Switch (bits-ui Switch.Root + Thumb) */ -.admin-switch-root { - appearance: none; - position: relative; - display: inline-flex; - align-items: center; - width: 40px; - height: 22px; - flex: 0 0 auto; - padding: 0; - border-radius: 999px; - border: 1px solid var(--admin-border); - background: var(--admin-elev); - cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease; - outline: none; -} - -.admin-switch-root[data-state="checked"] { - background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); - border-color: color-mix(in srgb, var(--accent) 50%, transparent); -} - -.admin-switch-root:focus-visible { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); -} - -.admin-switch-root:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.admin-switch-thumb { - display: block; - width: 16px; - height: 16px; - border-radius: 999px; - background: #fff; - margin-left: 2px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); - transition: transform 0.18s ease; -} - -.admin-switch-root[data-state="checked"] .admin-switch-thumb { - transform: translateX(18px); -} - -/* Select (bits-ui Select.Root + Trigger + Content + Item) */ -.admin-select-trigger { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - height: 36px; - padding: 0 12px; - border: 1px solid var(--admin-border-strong); - border-radius: 8px; - background: var(--admin-bg); - color: var(--admin-text); - font: inherit; - font-size: 13px; - cursor: pointer; - text-align: left; - outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; -} - -.admin-select-trigger:hover { - border-color: var(--admin-border-strong); -} - -.admin-select-trigger:focus-visible, -.admin-select-trigger[data-state="open"] { - border-color: var(--admin-ring); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.admin-select-icon { - color: var(--admin-muted); - transition: transform 0.18s ease; - flex: 0 0 auto; -} - -.admin-select-trigger[data-state="open"] .admin-select-icon { - transform: rotate(180deg); -} - -.admin-select-content { - z-index: 90; - min-width: var(--bits-select-anchor-width); - max-height: var(--bits-select-content-available-height, 320px); - overflow-y: auto; - border: 1px solid var(--admin-border); - border-radius: 10px; - background: var(--admin-surface); - padding: 4px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); - outline: none; - display: flex; - flex-direction: column; - gap: 1px; -} - -.admin-select-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 10px; - border-radius: 7px; - font-size: 13px; - color: var(--admin-text); - cursor: pointer; - outline: none; - user-select: none; -} - -.admin-select-item[data-highlighted], -.admin-select-item:hover { - background: var(--admin-surface-2); -} - -.admin-select-item-check { - color: var(--accent); - opacity: 0; -} - -.admin-select-item[data-state="checked"] .admin-select-item-check { - opacity: 1; -} - -.admin-chip-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - min-height: 0; -} - -.admin-chip { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 28px; - max-width: 100%; - padding: 5px 8px; - border: 1px solid var(--admin-border); - border-radius: 8px; - background: var(--admin-surface-2); - color: var(--admin-text); - font: inherit; - font-size: 12px; - cursor: pointer; -} - -.admin-chip:hover { - border-color: var(--admin-border-strong); -} - -/* Label primitive (bits-ui Label.Root) */ -.admin-field-label { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - color: var(--admin-muted); - font-weight: 500; - min-width: 0; -} - -.admin-field-label > span { - color: var(--admin-text); - font-weight: 500; - font-size: 13px; -} - -.admin-field-label > small { - color: var(--admin-dim); - font-weight: 400; - font-size: 12px; - line-height: 1.45; -} - -.admin-field-label > small code, -.admin-editor-section-title small code { - display: inline-block; - padding: 0 4px; - border-radius: 4px; - background: color-mix(in srgb, var(--admin-elev) 70%, transparent); - color: var(--admin-text); - font-family: var(--font-mono); - font-size: 11px; -} - -.admin-field-label > small b, -.admin-editor-section-title small b { - color: var(--admin-text); - font-weight: 600; -} - -/* Action rows in dialogs */ -.admin-action-row { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 0; - min-width: 0; -} - -.admin-action-row-bordered { - border: 1px solid var(--admin-border); - border-radius: 10px; - padding: 12px 14px; - background: var(--admin-surface-2); -} - -.admin-action-label { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - font-size: 13px; - color: var(--admin-text); - cursor: pointer; -} - -.admin-action-label > strong { - font-weight: 600; -} - -.admin-action-label > small { - color: var(--admin-muted); - font-size: 12px; - line-height: 1.4; -} - -.admin-action-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - align-items: end; -} - -.admin-action-grid > .admin-btn { - width: 100%; - height: 36px; - align-self: end; -} - -.admin-user-quick-actions { - display: grid; - grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.25fr); - gap: 12px; - align-items: end; -} - -.admin-reset-trial-btn { - width: 100%; - height: 36px; -} - -.admin-extend-field { - gap: 6px; - min-width: 0; -} - -.admin-action-inline-field { - gap: 6px; -} - -.admin-extend-control { - display: grid; - grid-template-columns: minmax(72px, 1fr) auto; - align-items: stretch; - min-width: 0; - height: 36px; - border: 1px solid var(--admin-border-strong); - border-radius: 8px; - background: var(--admin-bg); - overflow: hidden; -} - -.admin-extend-control .input { - height: 100%; - min-height: 0; - border: 0; - border-radius: 0; - background: transparent; - padding: 0 12px; - line-height: 34px; - box-shadow: none; -} - -.admin-extend-control .input:focus { - box-shadow: none; -} - -.admin-extend-control .admin-btn { - display: inline-flex; - align-items: center; - justify-content: center; - height: 100%; - min-height: 0; - padding: 0 12px; - border-width: 0 0 0 1px; - border-radius: 0; - border-color: var(--admin-border-strong); - background: var(--admin-surface-2); - line-height: 1; - white-space: nowrap; -} - -.admin-extend-control .admin-btn svg { - flex: 0 0 auto; -} - -.admin-extend-control:focus-within { - border-color: var(--admin-ring); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.admin-input-row { - display: grid; - grid-template-columns: minmax(72px, 1fr) auto; - gap: 8px; - align-items: stretch; - min-width: 0; -} - -.admin-input-row .input, -.admin-input-row .admin-btn { - height: 36px; -} - -.admin-actions-tab > .admin-btn { - width: 100%; - height: 36px; -} - -.admin-message-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px; -} - -.admin-message-actions .admin-btn { - height: 36px; -} - -.admin-confirm-message-preview { - max-height: min(280px, 45vh); - overflow: auto; - padding: 12px 14px; - border: 1px solid var(--admin-border); - border-radius: 10px; - background: var(--admin-bg); - color: var(--admin-text); - font-size: 13px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; -} - -@media (max-width: 640px) { - .admin-action-grid, - .admin-user-quick-actions, - .admin-message-actions, - .admin-input-row { - grid-template-columns: minmax(0, 1fr); - } - - .admin-extend-control { - grid-template-columns: minmax(0, 1fr); - height: auto; - } - - .admin-extend-control .input, - .admin-extend-control .admin-btn { - height: 36px; - } - - .admin-extend-control .admin-btn { - border-width: 1px 0 0; - } -} - -/* Two-column variant of admin-form-row */ -.admin-form-row.admin-form-row-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -@media (max-width: 720px) { - .admin-form-row.admin-form-row-2 { - grid-template-columns: minmax(0, 1fr); - } -} - -/* User summary header inside the user dialog */ -.admin-user-summary { - display: grid; - grid-template-columns: 56px minmax(0, 1fr); - align-items: center; - gap: 14px; - padding: 14px 14px 16px; - border-radius: 12px; - background: var(--admin-surface-2); - border: 1px solid var(--admin-border); - min-width: 0; -} - -.admin-user-summary-meta { - display: grid; - gap: 4px; - min-width: 0; -} - -.admin-user-summary-meta strong { - font-size: 15px; - font-weight: 700; - color: var(--admin-text); - word-break: break-word; -} - -.admin-user-summary-meta small { - color: var(--admin-muted); - font-size: 12px; - word-break: break-word; -} - -.admin-user-summary-tags { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 4px; -} - -.admin-avatar.admin-avatar-lg { - width: 56px; - height: 56px; - border-radius: 16px; - overflow: hidden; - display: grid; - place-items: center; - background: color-mix(in srgb, var(--accent) 18%, var(--admin-surface)); - color: var(--accent); - font-weight: 700; - font-size: 18px; - flex: 0 0 auto; -} - -.admin-avatar.admin-avatar-lg img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.admin-subsection-title { - font-size: 12px; - font-weight: 600; - color: var(--admin-muted); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.admin-separator { - height: 1px; - background: var(--admin-border); - border: 0; - margin: 4px 0; -} - -/* Editor section header tweaks */ -.admin-editor-section-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.admin-row-editor-caption { - font-size: 11px; - font-weight: 600; - color: var(--admin-muted); - text-transform: uppercase; - letter-spacing: 0.05em; -} - - -.admin-danger-zone { - border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); - border-radius: 12px; - background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 4px; -} - -.admin-danger-zone-head { - display: flex; - flex-direction: column; - gap: 2px; -} - -.admin-danger-zone-head strong { - font-size: 13px; - font-weight: 700; - color: #ffb4b4; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.admin-danger-zone-head small { - color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted)); - font-size: 12px; - line-height: 1.45; -} - -/* Subsection grouping inside settings sections (per-payment-provider). - Each subsection is a nested Accordion.Item that defaults to closed — - users see provider names and expand only the one they want to edit. */ -.admin-subsection-accordion { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 8px; -} - -.admin-settings-subsection { - border: 1px solid var(--admin-border); - border-radius: 10px; - background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent); - overflow: hidden; -} - -.admin-settings-subsection-trigger { - appearance: none; - background: transparent; - border: 0; - text-align: left; - width: 100%; - padding: 10px 14px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 12px; - color: var(--admin-text); - font: inherit; - cursor: pointer; - outline: none; - transition: background 0.12s ease; -} - -.admin-settings-subsection-trigger:hover { - background: var(--admin-surface); -} - -.admin-settings-subsection-trigger[data-state="open"] { - background: var(--admin-surface); - border-bottom: 1px solid var(--admin-border); -} - -.admin-settings-subsection-trigger:focus-visible { - background: var(--admin-surface); - box-shadow: inset 0 0 0 2px var(--admin-ring); -} - -.admin-settings-subsection-trigger strong { - font-size: 12px; - font-weight: 700; - color: var(--admin-text); - text-transform: uppercase; - letter-spacing: 0.06em; - min-width: 0; -} - -.admin-settings-subsection-meta { - color: var(--admin-muted); - font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.admin-settings-subsection-trigger .admin-accordion-chev { - color: var(--admin-muted); - transition: transform 0.18s ease; - flex: 0 0 auto; -} - -.admin-settings-subsection-trigger[data-state="open"] .admin-accordion-chev { - transform: rotate(90deg); -} - -.admin-settings-subsection-body .admin-setting:last-child { - border-bottom: 0; -} - -@media (max-width: 720px) { - .admin-settings-subsection-trigger { - padding: 9px 12px; - grid-template-columns: minmax(0, 1fr) auto; - grid-template-areas: - "title chev" - "meta meta"; - row-gap: 4px; - } - - .admin-settings-subsection-trigger strong { - grid-area: title; - } - - .admin-settings-subsection-trigger .admin-accordion-chev { - grid-area: chev; - } - - .admin-settings-subsection-meta { - grid-area: meta; - white-space: normal; - } -} +@import "./styles/webapp.css"; +@import "./styles/controls.css"; +@import "./styles/dialogs.css"; +@import "./styles/admin.css"; diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css new file mode 100644 index 0000000..dc900e7 --- /dev/null +++ b/bot/app/web/frontend/src/styles/admin.css @@ -0,0 +1,2734 @@ +/* ============================================================ + Admin panel — shadcn-svelte dashboard treatment + Sidebar + sticky header + cards + semantic tables. + ============================================================ */ + +.admin-screen-wrap { + --admin-sidebar-w: var(--desktop-rail-width); + --admin-header-h: 60px; + + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + background: var(--admin-bg); + color: var(--admin-text); + overflow: hidden; + overscroll-behavior: contain; + z-index: 60; + display: flex; + flex-direction: row; + /* Forces a new compositing layer — required for reliable scrolling + inside position:fixed on iOS Safari. */ + transform: translateZ(0); +} + +.admin-sidebar { + flex: 0 0 var(--admin-sidebar-w); + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 12px 18px; + border-right: 1px solid var(--admin-border); + background: var(--admin-surface); + overflow-y: auto; +} + +.admin-sidebar > *:not(.admin-sidebar-footer) { + flex-shrink: 0; +} + +.admin-sidebar-brand { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 32px; + align-items: center; + gap: 10px; + padding: 6px 6px 14px; + margin-bottom: 6px; + border-bottom: 1px solid var(--admin-border); +} + +.admin-sidebar-brand .admin-brand-mark { + width: 36px; + height: 36px; + display: grid; + place-items: center; + color: var(--accent); +} + +.admin-sidebar-brand strong { + display: block; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.admin-sidebar-brand small { + display: block; + color: var(--admin-muted); + font-size: 11px; +} + +.admin-sidebar-section-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--admin-dim); + padding: 14px 12px 6px; +} + +.admin-nav { + display: grid; + gap: 1px; +} + +.admin-nav-item { + position: relative; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 8px; + border: 0; + background: transparent; + color: var(--admin-muted); + font-size: 13px; + font-weight: 500; + text-align: left; + width: 100%; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.admin-nav-item:hover { + background: var(--admin-surface-2); + color: var(--admin-text); +} + +.admin-nav-item.active { + background: var(--admin-elev); + color: var(--admin-text); + font-weight: 600; +} + +.admin-nav-item.active::before { + content: ""; + position: absolute; + width: 3px; + height: 18px; + left: -4px; + top: 50%; + transform: translateY(-50%); + border-radius: 2px; + background: var(--accent); +} + +.admin-sidebar-footer { + margin-top: auto; + padding: 10px 6px 0; + border-top: 1px solid var(--admin-border); + font-size: 11px; + color: var(--admin-dim); + display: grid; + gap: 10px; +} + +.admin-language-switch { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.admin-language-switch > svg { + color: var(--admin-muted); +} + +.admin-language-trigger { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + color: var(--admin-text); + padding: 8px 10px; + text-align: left; + cursor: pointer; +} + +.admin-language-trigger:focus-visible { + outline: 2px solid var(--admin-ring); + outline-offset: 2px; +} + +.admin-language-trigger > span { + display: grid; + min-width: 0; + gap: 2px; +} + +.admin-language-trigger strong { + overflow: hidden; + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-language-trigger small { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 5px; + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-version-link { + order: 2; + display: grid; + gap: 2px; + max-width: 100%; + overflow: hidden; + color: var(--admin-muted); + font-family: var(--font-mono); + font-size: 11px; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.12s ease; +} + +.admin-version-link span { + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-version-link:hover { + color: var(--accent); +} + +.admin-content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.admin-header { + flex: 0 0 auto; + min-height: var(--admin-header-h); + z-index: 5; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 0 24px; + border-bottom: 1px solid var(--admin-border); + background: var(--admin-bg); +} + +.admin-header-title { + display: grid; + gap: 2px; + min-width: 0; +} + +.admin-header-title h2 { + margin: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.admin-header-title small { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-mobile-toggle { + display: none; + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + color: var(--admin-text); + align-items: center; + justify-content: center; +} + +.admin-main { + flex: 1 1 auto; + min-height: 0; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + overflow-y: auto; + overflow-x: hidden; + padding: 22px 24px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Flex children must NOT shrink — otherwise they collapse to fit the + container and the main never overflows, breaking scroll. */ +.admin-main > * { + flex-shrink: 0; +} + +.admin-card { + border: 1px solid var(--admin-border); + background: var(--admin-surface); + border-radius: 12px; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; + overflow: hidden; +} + +.admin-card-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--admin-border); + min-width: 0; +} + +.admin-card-head h3 { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + color: var(--admin-text); + word-break: break-word; +} + +.admin-card-head small { + color: var(--admin-muted); + font-size: 12px; + word-break: break-word; +} + +.admin-card-body { + padding: 16px 18px; + display: grid; + gap: 12px; + min-width: 0; +} + +.admin-empty { + display: grid; + place-items: center; + padding: 48px 16px; + border: 1px dashed var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); + color: var(--admin-muted); + font-size: 13px; +} + +.admin-muted { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-stat-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@media (min-width: 560px) { + .admin-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .admin-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1440px) { + .admin-stat-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.admin-stat-card { + border: 1px solid var(--admin-border); + background: var(--admin-surface); + border-radius: 12px; + padding: 16px 18px; + display: grid; + gap: 6px; + min-height: 96px; +} + +.admin-stat-card .admin-stat-label { + display: flex; + align-items: center; + gap: 8px; + color: var(--admin-muted); + font-size: 12px; + font-weight: 500; +} + +.admin-stat-card .admin-stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--admin-text); +} + +.admin-stat-card .admin-stat-trend { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.admin-toolbar-users { + display: grid; + grid-template-columns: minmax(260px, 1fr); + gap: 10px; + padding: 12px; + border: 1px solid var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); +} + +.admin-toolbar-card { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + align-items: end; + gap: 8px; + padding: 12px; + border: 1px solid var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); +} + +.admin-toolbar-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; +} + +.admin-toolbar-search-actions { + grid-template-columns: minmax(0, 1fr) auto auto; +} + +.admin-toolbar-search .input, +.admin-toolbar-search .admin-btn { + height: 36px; +} + +.admin-toolbar-controls { + display: grid; + grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(96px, auto); + align-items: end; + gap: 8px; +} + +.admin-toolbar > .input, +.admin-toolbar input[type="text"], +.admin-toolbar input[type="number"], +.admin-toolbar input[type="search"] { + flex: 1 1 200px; + min-width: 180px; +} + +.admin-toolbar > .admin-btn { + height: 36px; +} + +.admin-toolbar-field { + display: grid; + align-items: start; + gap: 6px; + min-width: 0; +} + +.admin-toolbar-field-label { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.admin-toolbar-select { + height: 36px; + min-width: 0; +} + +.admin-toolbar-summary { + display: grid; + min-height: 36px; + min-width: 96px; + align-content: center; + justify-items: end; + gap: 2px; + padding: 0 2px; + white-space: nowrap; +} + +.admin-toolbar-summary strong { + color: var(--admin-text); + font-size: 14px; + font-weight: 650; + line-height: 1; +} + +@media (max-width: 900px) { + .admin-toolbar-controls { + grid-template-columns: repeat(2, minmax(150px, 1fr)); + } + + .admin-toolbar-summary { + justify-items: start; + } +} + +@media (max-width: 560px) { + .admin-toolbar-search, + .admin-toolbar-search-actions, + .admin-toolbar-card, + .admin-toolbar-controls { + grid-template-columns: minmax(0, 1fr); + } + + .admin-toolbar-summary { + justify-items: start; + } +} + +.admin-table-wrap { + border: 1px solid var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); + overflow: hidden; +} + +.admin-table-wrap > .admin-card-head { + border-bottom: 1px solid var(--admin-border); +} + +.admin-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.admin-table thead th { + text-align: left; + font-weight: 500; + color: var(--admin-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + background: var(--admin-surface-2); + border-bottom: 1px solid var(--admin-border); + white-space: nowrap; +} + +.admin-table tbody td { + padding: 12px 16px; + border-bottom: 1px solid var(--admin-border); + vertical-align: middle; + color: var(--admin-text); +} + +.admin-table tbody tr:last-child td { + border-bottom: 0; +} + +.admin-table tbody tr { + transition: background 0.08s ease; +} + +.admin-table tbody tr:hover { + background: color-mix(in srgb, var(--admin-elev) 50%, transparent); +} + +.admin-table td.admin-cell-mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.admin-table td.admin-cell-actions { + text-align: right; + white-space: nowrap; +} + +.admin-table td.admin-cell-actions .admin-btn { + margin-left: 4px; +} + +.admin-table td.admin-cell-wrap { + white-space: normal; + max-width: 320px; + word-break: break-word; + color: var(--admin-muted); + font-size: 12px; +} + +.admin-table td.admin-cell-id { + color: var(--admin-muted); + font-family: var(--font-mono); + font-size: 12px; + width: 80px; +} + +.admin-table tbody tr.is-clickable { + cursor: pointer; +} + +.admin-user-list { + display: grid; + list-style: none; + margin: 0; + padding: 0; + gap: 0; +} + +.admin-user-list > li { + border-bottom: 1px solid var(--admin-border); +} + +.admin-user-list > li:last-child { + border-bottom: 0; +} + +.admin-user-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + border: 0; + background: transparent; + color: var(--admin-text); + /* Card height = avatar (36px) + padding-y * 2 = 52px. */ + padding: 8px 14px; + text-align: left; + cursor: pointer; + transition: background 0.12s ease; +} + +.admin-user-row:hover { + background: color-mix(in srgb, var(--admin-elev) 60%, transparent); +} + +.admin-user-row:focus-visible { + outline: 2px solid var(--admin-ring); + outline-offset: -2px; +} + +.admin-user-side { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + white-space: nowrap; +} + +.admin-user-tertiary { + color: var(--admin-dim); + font-size: 11px; + white-space: nowrap; +} + +.admin-avatar.admin-avatar-sm { + width: 36px; + height: 36px; + font-size: 11px; +} + +@media (max-width: 480px) { + /* On very narrow screens, drop the date but keep the badge inline so the + row still fits within `avatar + padding` height. */ + .admin-user-tertiary { + display: none; + } + + .admin-user-row { + gap: 10px; + } +} + +.admin-avatar { + display: inline-grid; + width: 42px; + height: 42px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: color-mix(in srgb, var(--accent) 16%, var(--admin-surface-2)); + color: var(--accent); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} + +.admin-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.admin-avatar-lg { + width: 58px; + height: 58px; + font-size: 16px; +} + +.admin-user-main, +.admin-user-meta, +.admin-user-profile-head > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.admin-user-main strong, +.admin-user-profile-head strong { + overflow: hidden; + color: var(--admin-text); + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-main small, +.admin-user-profile-head small, +.admin-user-meta span { + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-meta strong { + overflow: hidden; + color: var(--admin-text); + font-size: 12px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-status { + justify-self: end; +} + +.admin-user-profile-head { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--admin-border); +} + +.admin-mini-list { + display: grid; + gap: 0; +} + +.admin-mini-list-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--admin-border); +} + +.admin-mini-list-row:last-child { + border-bottom: 0; +} + +.admin-mini-list-row div { + display: grid; + gap: 3px; + min-width: 0; +} + +.admin-mini-list-row strong { + overflow: hidden; + color: var(--admin-text); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-mini-list-row small { + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 4px 0; +} + +.admin-pagination .admin-pagination-meta { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-pagination-buttons { + display: flex; + gap: 6px; +} + +.admin-screen-wrap input, +.admin-screen-wrap textarea, +.admin-screen-wrap select, +.admin-screen-wrap .input { + min-width: 0; + max-width: 100%; +} + +.admin-screen-wrap .input, +.admin-screen-wrap input[type="text"], +.admin-screen-wrap input[type="number"], +.admin-screen-wrap input[type="email"], +.admin-screen-wrap input[type="search"], +.admin-screen-wrap input[type="password"], +.admin-screen-wrap input[type="url"], +.admin-screen-wrap select { + height: 36px; + border-radius: 8px; + border: 1px solid var(--admin-border-strong); + background: var(--admin-bg); + color: var(--admin-text); + padding: 0 12px; + font-size: 13px; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.admin-screen-wrap .input::placeholder, +.admin-screen-wrap input::placeholder, +.admin-screen-wrap textarea::placeholder { + color: var(--admin-dim); +} + +.admin-screen-wrap .input:focus, +.admin-screen-wrap input:focus, +.admin-screen-wrap select:focus { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-select { + width: 100%; + appearance: auto; +} + +.admin-textarea, +.admin-screen-wrap textarea { + width: 100%; + min-height: 110px; + border-radius: 10px; + border: 1px solid var(--admin-border-strong); + background: var(--admin-bg); + color: var(--admin-text); + padding: 12px; + font: inherit; + font-size: 13px; + resize: vertical; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.admin-textarea:focus { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-form { + display: grid; + gap: 12px; +} + +.admin-form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.admin-form-row.admin-form-row-3 { + grid-template-columns: minmax(180px, 1.1fr) minmax(160px, 0.8fr) minmax(140px, 0.6fr); +} + +.admin-form label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--admin-muted); + font-weight: 500; +} + +.admin-form label > span { + color: var(--admin-text); + font-weight: 500; +} + +.admin-form label > small { + color: var(--admin-dim); + font-weight: 400; +} + +.admin-switch.admin-switch-field { + align-self: end; + min-height: 36px; + display: flex; +} + +.admin-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-2); + color: var(--admin-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.admin-btn:hover:not(:disabled) { + background: var(--admin-elev); +} + +.admin-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-btn.admin-btn-primary { + background: var(--accent); + color: #02110a; + border-color: color-mix(in srgb, var(--accent) 70%, #000); + font-weight: 600; +} + +.admin-btn.admin-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 90%, #fff); +} + +.admin-btn.admin-btn-ghost { + background: transparent; + border-color: transparent; + color: var(--admin-muted); +} + +.admin-btn.admin-btn-ghost:hover:not(:disabled) { + background: var(--admin-surface-2); + color: var(--admin-text); +} + +.admin-btn.admin-btn-danger { + background: color-mix(in srgb, #ff5757 80%, #000); + color: #fff; + border-color: color-mix(in srgb, #ff5757 60%, #000); + font-weight: 600; +} + +.admin-btn.admin-btn-danger:hover:not(:disabled) { + background: #ff5757; + border-color: #ff5757; +} + +.admin-btn.admin-btn-danger-soft { + color: #ffb4b4; + background: var(--admin-surface-2); + border-color: color-mix(in srgb, #ff6b6b 30%, transparent); +} + +.admin-btn.admin-btn-danger-soft:hover:not(:disabled) { + background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); + border-color: color-mix(in srgb, #ff6b6b 50%, transparent); +} + +.admin-btn.admin-btn-icon { + width: 34px; + padding: 0; +} + +.admin-btn.admin-btn-sm { + height: 28px; + padding: 0 10px; + font-size: 12px; + border-radius: 6px; +} + +/* ============================================================ + Admin skeletons (users list, payments / logs / promos / ads + tables). Rendered while data is loading to soften the empty + state. Kept aria-hidden in markup; purely decorative. + ============================================================ */ + +.admin-skeleton { + display: inline-block; + background: linear-gradient( + 90deg, + var(--admin-surface-2) 0%, + color-mix(in srgb, var(--admin-elev) 65%, transparent) 50%, + var(--admin-surface-2) 100% + ); + background-size: 200% 100%; + border-radius: 6px; + animation: admin-skeleton-shimmer 1.2s ease-in-out infinite; +} + +.admin-skeleton-line { + display: block; + width: 100%; + height: 10px; + border-radius: 4px; +} + +.admin-skeleton-line-strong { + height: 12px; + width: 60%; +} + +.admin-skeleton-line-soft { + height: 9px; + width: 40%; + opacity: 0.7; +} + +.admin-skeleton-line-short { + width: 50%; +} + +.admin-skeleton-line-tiny { + width: 32%; +} + +.admin-skeleton-avatar { + width: 36px; + height: 36px; + border-radius: 50%; +} + +.admin-skeleton-badge { + width: 64px; + height: 18px; + border-radius: 999px; +} + +.admin-user-row-skeleton { + cursor: default; + pointer-events: none; +} + +.admin-user-row-skeleton .admin-user-main { + display: grid; + gap: 6px; +} + +.admin-user-row-skeleton .admin-user-side { + gap: 8px; +} + +.admin-table-skeleton td { + padding: 12px 16px; +} + +@keyframes admin-skeleton-shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .admin-skeleton { + animation: none; + } +} + +.admin-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-2); + color: var(--admin-muted); + white-space: nowrap; +} + +.admin-badge.admin-badge-success { + border-color: color-mix(in srgb, var(--accent) 36%, transparent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); +} + +.admin-badge.admin-badge-danger { + border-color: color-mix(in srgb, #ff6b6b 32%, transparent); + color: #ffb4b4; + background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); +} + +.admin-badge.admin-badge-warning { + border-color: color-mix(in srgb, #ffd166 32%, transparent); + color: #ffd166; + background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); +} + +.admin-badge.admin-badge-muted { + color: var(--admin-muted); +} + +.admin-detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +@media (min-width: 900px) { + .admin-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.admin-meta-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; +} + +.admin-meta-list li { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + align-items: baseline; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--admin-border); + font-size: 13px; +} + +.admin-meta-list li:last-child { + border-bottom: 0; +} + +.admin-meta-list li > span { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-meta-list li > strong { + font-weight: 500; + color: var(--admin-text); + word-break: break-word; +} + +.admin-accordion { + display: grid; + gap: 10px; +} + +.admin-accordion-item.admin-card { + display: block; + min-width: 0; +} + +.admin-accordion-header { + margin: 0; + display: block; +} + +.admin-accordion-trigger { + appearance: none; + background: transparent; + border: 0; + text-align: left; + padding: 14px 18px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; + color: var(--admin-text); + cursor: pointer; + font: inherit; + width: 100%; + transition: background 0.12s ease; + outline: none; +} + +.admin-accordion-trigger:hover { + background: var(--admin-surface-2); +} + +.admin-accordion-trigger:focus-visible { + background: var(--admin-surface-2); + box-shadow: inset 0 0 0 2px var(--admin-ring); +} + +.admin-accordion-trigger[data-state="open"] { + border-bottom: 1px solid var(--admin-border); +} + +.admin-accordion-title { + font-size: 13px; + font-weight: 600; + color: var(--admin-text); + text-transform: capitalize; + word-break: break-word; + min-width: 0; +} + +.admin-accordion-meta { + color: var(--admin-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.admin-accordion-chev { + transition: transform 0.2s ease; + color: var(--admin-muted); + flex: 0 0 auto; +} + +.admin-accordion-trigger[data-state="open"] .admin-accordion-chev { + transform: rotate(90deg); +} + +.admin-accordion-content { + overflow: hidden; +} + +.admin-accordion-content[data-state="closed"] { + display: none; +} + +@media (max-width: 720px) { + .admin-accordion-trigger { + padding: 12px 14px; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "title chev" + "meta meta"; + } + + .admin-accordion-title { + grid-area: title; + } + + .admin-accordion-chev { + grid-area: chev; + } + + .admin-accordion-meta { + grid-area: meta; + white-space: normal; + font-size: 11px; + } +} + +.admin-setting { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 24px; + padding: 16px 18px; + border-bottom: 1px solid var(--admin-border); + align-items: center; +} + +.admin-setting:last-child { + border-bottom: 0; +} + +.admin-setting.is-overridden { + background: color-mix(in srgb, var(--accent) 5%, transparent); +} + +.admin-setting-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-setting-meta strong { + font-size: 13px; + font-weight: 600; + color: var(--admin-text); + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + word-break: break-word; +} + +.admin-setting-meta code { + font-family: var(--font-mono); + font-size: 11px; + color: var(--admin-dim); + word-break: break-all; + overflow-wrap: anywhere; +} + +.admin-setting-meta small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.admin-setting-control { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.admin-setting-control .input, +.admin-setting-control input[type="text"], +.admin-setting-control input[type="number"] { + flex: 1 1 160px; + min-width: 0; + width: 100%; +} + +.admin-setting-control .admin-color { + width: 36px; + height: 36px; + flex: 0 0 36px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-2); + padding: 0; + cursor: pointer; +} + +.admin-setting-switch { + display: inline-flex; + min-height: 36px; + align-items: center; + gap: 10px; + color: var(--admin-text); + font-size: 12px; + font-weight: 500; +} + +.admin-tabs { + display: inline-flex; + border-bottom: 1px solid var(--admin-border); + gap: 2px; +} + +.admin-tabs button { + padding: 9px 12px; + border: 0; + background: transparent; + color: var(--admin-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.admin-tabs button.active { + color: var(--admin-text); + border-bottom-color: var(--accent); +} + +/* Admin dialogs don't share the user-facing bottom-nav, so reset the .dialog + wrapper's padding (which reserves space for the mobile nav) and let the + card use the full viewport for its scroll area. */ +.dialog:has(.admin-dialog) { + padding: max(12px, env(safe-area-inset-top)) + max(12px, env(safe-area-inset-right)) + max(12px, env(safe-area-inset-bottom)) + max(12px, env(safe-area-inset-left)); + z-index: 90; +} + +.admin-dialog { + max-height: 100%; + width: 100%; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +/* User-detail dialog: constrain on desktop and lay out as a two-column + sidebar (profile facts) + main content (tabs). On mobile it stacks. */ +.admin-user-dialog { + width: min(100%, 1040px); + max-height: min(100%, 760px); +} + +.admin-user-dialog-body { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 0; +} + +.admin-user-aside { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.admin-user-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.admin-user-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.admin-user-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + min-width: 0; +} + +.admin-user-stat span { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-user-stat strong { + color: var(--admin-text); + font-size: 15px; + font-weight: 700; + word-break: break-word; +} + +.admin-link-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.admin-link-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + min-width: 0; +} + +.admin-link-row-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.admin-link-row-label { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-link-row-url { + color: var(--admin-text); + font-family: var(--font-mono); + font-size: 11px; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.admin-link-row-url:hover { + color: var(--accent); + text-decoration: underline; +} + +.admin-btn.admin-btn-icon { + width: 30px; + height: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} + +.admin-meta-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-traffic-summary { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; +} + +.admin-traffic-card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px; + border: 1px solid var(--admin-border); + border-radius: 10px; + background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-bg)); +} + +.admin-traffic-card-premium { + border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border)); +} + +.admin-traffic-card-warning { + border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border)); +} + +.admin-traffic-head, +.admin-traffic-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.admin-traffic-head span { + color: var(--admin-muted); + font-size: 12px; + font-weight: 600; +} + +.admin-traffic-head strong { + color: var(--admin-text); + font-size: 13px; + font-weight: 700; + text-align: right; +} + +.admin-traffic-bar { + height: 8px; + border-radius: 999px; + background: var(--admin-bg); + overflow: hidden; + box-shadow: inset 0 0 0 1px var(--admin-border); +} + +.admin-traffic-bar span { + display: block; + height: 100%; + max-width: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #ffffff)); +} + +.admin-traffic-bar-premium span { + background: linear-gradient(90deg, #66e3ff, var(--accent)); +} + +.admin-traffic-card-warning .admin-traffic-bar span { + background: linear-gradient(90deg, #ffb454, #ff6b6b); +} + +.admin-traffic-meta { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; +} + +@media (min-width: 860px) { + .admin-user-dialog-body { + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + gap: 20px; + align-items: start; + } + + .admin-user-aside { + position: sticky; + top: 0; + } +} + +.admin-tariff-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@media (min-width: 720px) { + .admin-tariff-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1280px) { + .admin-tariff-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.admin-tariff-card { + display: grid; + gap: 12px; + min-width: 0; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + padding: 14px; +} + +.admin-tariff-card.is-disabled { + opacity: 0.72; +} + +.admin-tariff-top, +.admin-tariff-title, +.admin-tariff-actions, +.admin-editor-section-head { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-tariff-top { + justify-content: space-between; +} + +.admin-tariff-title { + min-width: 0; + flex-wrap: wrap; +} + +.admin-tariff-title strong { + color: var(--admin-text); + font-size: 15px; +} + +.admin-tariff-card code { + display: block; + margin-top: 4px; + color: var(--admin-dim); + font-family: var(--font-mono); + font-size: 11px; +} + +.admin-tariff-card p { + margin: 0; + color: var(--admin-muted); + font-size: 13px; + line-height: 1.45; +} + +.admin-tariff-facts { + display: grid; + gap: 6px; + color: var(--admin-muted); + font-size: 12px; +} + +.admin-tariff-facts span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-tariff-actions { + flex-wrap: wrap; +} + +.admin-tariff-dialog { + width: min(980px, calc(100vw - 32px)); +} + +.admin-editor-section { + display: grid; + gap: 10px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: color-mix(in srgb, var(--admin-surface-2) 62%, transparent); + padding: 12px; +} + +.admin-editor-section-head { + justify-content: space-between; + min-height: 28px; +} + +.admin-editor-section-head strong { + color: var(--admin-text); + font-size: 13px; +} + +.admin-editor-section-head > div { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* Section title block: stacks `` heading + `` description. + Higher specificity than `.admin-editor-section-head > div` (which would + otherwise force flex-row on the wrapper). */ +.admin-editor-section-head > .admin-editor-section-title { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 1 200px; + min-width: 0; +} + +.admin-editor-section-head > .admin-editor-section-title small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.4; + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +/* Column headers above an `.admin-row-editor` list — visually distinct, + inherits the same grid template as input rows so columns line up. */ +.admin-row-editor-line.admin-row-editor-header { + color: var(--admin-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0 4px; +} + +.admin-row-editor-line.admin-row-editor-header span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-row-editor { + display: grid; + gap: 8px; + min-width: 0; +} + +.admin-row-editor-line { + display: grid; + grid-template-columns: minmax(90px, 1fr) minmax(110px, 1fr) 32px; + gap: 8px; + align-items: center; +} + +.admin-row-editor-line.admin-row-editor-4 { + grid-template-columns: minmax(80px, 0.8fr) minmax(100px, 1fr) minmax(100px, 1fr) 32px; +} + +.admin-row-editor-line .admin-btn { + width: 32px; + padding: 0; +} + +.admin-package-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.admin-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 2px; +} + +@media (max-width: 1023px) { + .admin-screen-wrap { + --admin-header-h: 56px; + } + + .admin-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 280px; + flex: 0 0 auto; + z-index: 80; + transform: translateX(-100%); + transition: transform 0.22s ease; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); + } + + .admin-screen-wrap.is-sidebar-open .admin-sidebar { + transform: translateX(0); + } + + .admin-sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 70; + border: 0; + cursor: pointer; + } + + .admin-mobile-toggle { + display: inline-flex; + } + + .admin-header { + padding: 8px 16px; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: row; + grid-auto-rows: auto; + align-content: center; + gap: 6px; + min-height: var(--admin-header-h); + } + + .admin-header-actions { + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; + } + + .admin-header-actions::-webkit-scrollbar { + display: none; + } + + .admin-main { + padding: 16px; + } +} + +@media (max-width: 720px) { + .admin-setting { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + padding: 14px 14px; + } + + .admin-card-head { + padding: 12px 14px; + } + + .admin-card-body { + padding: 14px; + } + + .admin-tariff-card { + padding: 12px; + } + + .admin-tariff-actions .admin-btn { + flex: 1 1 calc(50% - 4px); + } + + .admin-tariff-actions .admin-btn[aria-label] { + flex: 0 0 auto; + } + + /* (legacy three-row user-row layout removed in favour of the compact + single-row layout defined above) */ + + .admin-user-meta strong { + font-size: 11px; + } + + .admin-form-row.admin-form-row-3, + .admin-package-columns, + .admin-row-editor-line, + .admin-row-editor-line.admin-row-editor-4 { + grid-template-columns: 1fr; + } + + .admin-dialog-actions { + justify-content: stretch; + flex-direction: column-reverse; + } + + .admin-dialog-actions .admin-btn { + width: 100%; + } + + .admin-table thead { + display: none; + } + + .admin-table tbody td { + display: block; + padding: 6px 16px; + border: 0; + } + + .admin-table tbody tr { + display: block; + padding: 10px 0; + border-bottom: 1px solid var(--admin-border); + } + + .admin-table tbody tr:last-child { + border-bottom: 0; + } + + .admin-table tbody td::before { + content: attr(data-label); + display: inline-block; + min-width: 110px; + color: var(--admin-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-right: 8px; + } + + .admin-table td.admin-cell-actions { + text-align: left; + } +} + +.settings-admin-block { + display: grid; + gap: 10px; + margin: 6px 0 0; +} + +.settings-row.settings-row-admin { + border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); + border-radius: var(--radius); + padding: 10px 12px; + width: 100%; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.settings-row.settings-row-admin:hover { + background: color-mix(in srgb, #f59e0b 18%, transparent); + border-color: color-mix(in srgb, #f59e0b 58%, transparent); +} + +.settings-row.settings-row-admin > svg:first-child { + color: #fbbf24; + opacity: 1; +} + +.settings-row.settings-row-admin strong { + color: var(--text); +} + +/* ============================================================ + shadcn-svelte primitives: Tabs / Select / Switch / Label + bits-ui exposes data-state attributes; styling here matches + shadcn-svelte defaults adapted to the dark admin palette. + ============================================================ */ + +.admin-tabs-root { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.admin-tabs-list { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border-radius: 10px; + background: var(--admin-surface-2); + border: 1px solid var(--admin-border); + overflow-x: auto; + scrollbar-width: none; + flex: 0 0 auto; +} + +.admin-tabs-list::-webkit-scrollbar { + display: none; +} + +.admin-tabs-trigger { + appearance: none; + border: 0; + background: transparent; + color: var(--admin-muted); + font: inherit; + font-size: 13px; + font-weight: 500; + padding: 7px 14px; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; + outline: none; +} + +.admin-tabs-trigger:hover { + color: var(--admin-text); +} + +.admin-tabs-trigger[data-state="active"] { + background: var(--admin-surface); + color: var(--admin-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} + +.admin-tabs-trigger:focus-visible { + box-shadow: 0 0 0 2px var(--admin-ring); +} + +.admin-tabs-content { + display: flex; + flex-direction: column; + gap: 14px; + outline: none; +} + +.admin-tabs-content[data-state="inactive"] { + display: none; +} + +.admin-user-tabs-root { + gap: 0; +} + +.admin-user-tabs-root .admin-tabs-list { + gap: 2px; + padding: 4px 4px 0; + border-radius: 10px 10px 0 0; + background: color-mix(in srgb, var(--admin-surface-2) 74%, var(--admin-bg)); + border-bottom: 0; + width: fit-content; + max-width: 100%; +} + +.admin-user-tabs-root .admin-tabs-trigger { + padding: 8px 14px 9px; + border-radius: 7px 7px 0 0; +} + +.admin-user-tabs-root .admin-tabs-trigger[data-state="active"] { + position: relative; + z-index: 1; + box-shadow: + 0 -1px 0 var(--admin-border), + 1px 0 0 var(--admin-border), + -1px 0 0 var(--admin-border), + 0 1px 0 var(--admin-surface); +} + +.admin-user-tabs-root .admin-tabs-content { + margin-top: -1px; + padding: 16px; + border: 1px solid var(--admin-border); + border-radius: 0 10px 10px 10px; + background: var(--admin-surface); +} + +/* Switch (bits-ui Switch.Root + Thumb) */ +.admin-switch-root { + appearance: none; + position: relative; + display: inline-flex; + align-items: center; + width: 40px; + height: 22px; + flex: 0 0 auto; + padding: 0; + border-radius: 999px; + border: 1px solid var(--admin-border); + background: var(--admin-elev); + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease; + outline: none; +} + +.admin-switch-root[data-state="checked"] { + background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} + +.admin-switch-root:focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.admin-switch-root:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-switch-thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 999px; + background: #fff; + margin-left: 2px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: transform 0.18s ease; +} + +.admin-switch-root[data-state="checked"] .admin-switch-thumb { + transform: translateX(18px); +} + +/* Select (bits-ui Select.Root + Trigger + Content + Item) */ +.admin-select-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + height: 36px; + padding: 0 12px; + border: 1px solid var(--admin-border-strong); + border-radius: 8px; + background: var(--admin-bg); + color: var(--admin-text); + font: inherit; + font-size: 13px; + cursor: pointer; + text-align: left; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.admin-select-trigger:hover { + border-color: var(--admin-border-strong); +} + +.admin-select-trigger:focus-visible, +.admin-select-trigger[data-state="open"] { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-select-icon { + color: var(--admin-muted); + transition: transform 0.18s ease; + flex: 0 0 auto; +} + +.admin-select-trigger[data-state="open"] .admin-select-icon { + transform: rotate(180deg); +} + +.admin-select-content { + z-index: 90; + min-width: var(--bits-select-anchor-width); + max-height: var(--bits-select-content-available-height, 320px); + overflow-y: auto; + border: 1px solid var(--admin-border); + border-radius: 10px; + background: var(--admin-surface); + padding: 4px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); + outline: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +.admin-select-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 7px; + font-size: 13px; + color: var(--admin-text); + cursor: pointer; + outline: none; + user-select: none; +} + +.admin-select-item[data-highlighted], +.admin-select-item:hover { + background: var(--admin-surface-2); +} + +.admin-select-item-check { + color: var(--accent); + opacity: 0; +} + +.admin-select-item[data-state="checked"] .admin-select-item-check { + opacity: 1; +} + +.admin-chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 0; +} + +.admin-chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + max-width: 100%; + padding: 5px 8px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + color: var(--admin-text); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.admin-chip:hover { + border-color: var(--admin-border-strong); +} + +/* Label primitive (bits-ui Label.Root) */ +.admin-field-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--admin-muted); + font-weight: 500; + min-width: 0; +} + +.admin-field-label > span { + color: var(--admin-text); + font-weight: 500; + font-size: 13px; +} + +.admin-field-label > small { + color: var(--admin-dim); + font-weight: 400; + font-size: 12px; + line-height: 1.45; +} + +.admin-field-label > small code, +.admin-editor-section-title small code { + display: inline-block; + padding: 0 4px; + border-radius: 4px; + background: color-mix(in srgb, var(--admin-elev) 70%, transparent); + color: var(--admin-text); + font-family: var(--font-mono); + font-size: 11px; +} + +.admin-field-label > small b, +.admin-editor-section-title small b { + color: var(--admin-text); + font-weight: 600; +} + +/* Action rows in dialogs */ +.admin-action-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + min-width: 0; +} + +.admin-action-row-bordered { + border: 1px solid var(--admin-border); + border-radius: 10px; + padding: 12px 14px; + background: var(--admin-surface-2); +} + +.admin-action-label { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 13px; + color: var(--admin-text); + cursor: pointer; +} + +.admin-action-label > strong { + font-weight: 600; +} + +.admin-action-label > small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.4; +} + +.admin-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-items: end; +} + +.admin-action-grid > .admin-btn { + width: 100%; + height: 36px; + align-self: end; +} + +.admin-user-quick-actions { + display: grid; + grid-template-columns: minmax(180px, 0.75fr) minmax(260px, 1.25fr); + gap: 12px; + align-items: end; +} + +.admin-reset-trial-btn { + width: 100%; + height: 36px; +} + +.admin-extend-field { + gap: 6px; + min-width: 0; +} + +.admin-action-inline-field { + gap: 6px; +} + +.admin-extend-control { + display: grid; + grid-template-columns: minmax(72px, 1fr) auto; + align-items: stretch; + min-width: 0; + height: 36px; + border: 1px solid var(--admin-border-strong); + border-radius: 8px; + background: var(--admin-bg); + overflow: hidden; +} + +.admin-extend-control .input { + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + padding: 0 12px; + line-height: 34px; + box-shadow: none; +} + +.admin-extend-control .input:focus { + box-shadow: none; +} + +.admin-extend-control .admin-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 0; + padding: 0 12px; + border-width: 0 0 0 1px; + border-radius: 0; + border-color: var(--admin-border-strong); + background: var(--admin-surface-2); + line-height: 1; + white-space: nowrap; +} + +.admin-extend-control .admin-btn svg { + flex: 0 0 auto; +} + +.admin-extend-control:focus-within { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-input-row { + display: grid; + grid-template-columns: minmax(72px, 1fr) auto; + gap: 8px; + align-items: stretch; + min-width: 0; +} + +.admin-input-row .input, +.admin-input-row .admin-btn { + height: 36px; +} + +.admin-actions-tab > .admin-btn { + width: 100%; + height: 36px; +} + +.admin-message-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.admin-message-actions .admin-btn { + height: 36px; +} + +.admin-confirm-message-preview { + max-height: min(280px, 45vh); + overflow: auto; + padding: 12px 14px; + border: 1px solid var(--admin-border); + border-radius: 10px; + background: var(--admin-bg); + color: var(--admin-text); + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 640px) { + .admin-action-grid, + .admin-user-quick-actions, + .admin-message-actions, + .admin-input-row { + grid-template-columns: minmax(0, 1fr); + } + + .admin-extend-control { + grid-template-columns: minmax(0, 1fr); + height: auto; + } + + .admin-extend-control .input, + .admin-extend-control .admin-btn { + height: 36px; + } + + .admin-extend-control .admin-btn { + border-width: 1px 0 0; + } +} + +/* Two-column variant of admin-form-row */ +.admin-form-row.admin-form-row-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (max-width: 720px) { + .admin-form-row.admin-form-row-2 { + grid-template-columns: minmax(0, 1fr); + } +} + +/* User summary header inside the user dialog */ +.admin-user-summary { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + align-items: center; + gap: 14px; + padding: 14px 14px 16px; + border-radius: 12px; + background: var(--admin-surface-2); + border: 1px solid var(--admin-border); + min-width: 0; +} + +.admin-user-summary-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-user-summary-meta strong { + font-size: 15px; + font-weight: 700; + color: var(--admin-text); + word-break: break-word; +} + +.admin-user-summary-meta small { + color: var(--admin-muted); + font-size: 12px; + word-break: break-word; +} + +.admin-user-summary-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.admin-avatar.admin-avatar-lg { + width: 56px; + height: 56px; + border-radius: 16px; + overflow: hidden; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--accent) 18%, var(--admin-surface)); + color: var(--accent); + font-weight: 700; + font-size: 18px; + flex: 0 0 auto; +} + +.admin-avatar.admin-avatar-lg img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.admin-subsection-title { + font-size: 12px; + font-weight: 600; + color: var(--admin-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-separator { + height: 1px; + background: var(--admin-border); + border: 0; + margin: 4px 0; +} + +/* Editor section header tweaks */ +.admin-editor-section-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-row-editor-caption { + font-size: 11px; + font-weight: 600; + color: var(--admin-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + + +.admin-danger-zone { + border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); + border-radius: 12px; + background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 4px; +} + +.admin-danger-zone-head { + display: flex; + flex-direction: column; + gap: 2px; +} + +.admin-danger-zone-head strong { + font-size: 13px; + font-weight: 700; + color: #ffb4b4; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-danger-zone-head small { + color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted)); + font-size: 12px; + line-height: 1.45; +} + +/* Subsection grouping inside settings sections (per-payment-provider). + Each subsection is a nested Accordion.Item that defaults to closed — + users see provider names and expand only the one they want to edit. */ +.admin-subsection-accordion { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.admin-settings-subsection { + border: 1px solid var(--admin-border); + border-radius: 10px; + background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent); + overflow: hidden; +} + +.admin-settings-subsection-trigger { + appearance: none; + background: transparent; + border: 0; + text-align: left; + width: 100%; + padding: 10px 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; + color: var(--admin-text); + font: inherit; + cursor: pointer; + outline: none; + transition: background 0.12s ease; +} + +.admin-settings-subsection-trigger:hover { + background: var(--admin-surface); +} + +.admin-settings-subsection-trigger[data-state="open"] { + background: var(--admin-surface); + border-bottom: 1px solid var(--admin-border); +} + +.admin-settings-subsection-trigger:focus-visible { + background: var(--admin-surface); + box-shadow: inset 0 0 0 2px var(--admin-ring); +} + +.admin-settings-subsection-trigger strong { + font-size: 12px; + font-weight: 700; + color: var(--admin-text); + text-transform: uppercase; + letter-spacing: 0.06em; + min-width: 0; +} + +.admin-settings-subsection-meta { + color: var(--admin-muted); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-settings-subsection-trigger .admin-accordion-chev { + color: var(--admin-muted); + transition: transform 0.18s ease; + flex: 0 0 auto; +} + +.admin-settings-subsection-trigger[data-state="open"] .admin-accordion-chev { + transform: rotate(90deg); +} + +.admin-settings-subsection-body .admin-setting:last-child { + border-bottom: 0; +} + +@media (max-width: 720px) { + .admin-settings-subsection-trigger { + padding: 9px 12px; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "title chev" + "meta meta"; + row-gap: 4px; + } + + .admin-settings-subsection-trigger strong { + grid-area: title; + } + + .admin-settings-subsection-trigger .admin-accordion-chev { + grid-area: chev; + } + + .admin-settings-subsection-meta { + grid-area: meta; + white-space: normal; + } +} diff --git a/bot/app/web/frontend/src/styles/controls.css b/bot/app/web/frontend/src/styles/controls.css new file mode 100644 index 0000000..4f1ca36 --- /dev/null +++ b/bot/app/web/frontend/src/styles/controls.css @@ -0,0 +1,4 @@ +/* + Shared control primitives live in webapp.css for now to preserve cascade order + while the Svelte views are being split into screen components. +*/ diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/bot/app/web/frontend/src/styles/dialogs.css new file mode 100644 index 0000000..066813f --- /dev/null +++ b/bot/app/web/frontend/src/styles/dialogs.css @@ -0,0 +1,4 @@ +/* + Dialog-specific selectors are kept importable as a separate bundle boundary. + Existing rules remain in webapp.css to avoid visual churn during componentization. +*/ diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css new file mode 100644 index 0000000..27b86bb --- /dev/null +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -0,0 +1,2695 @@ +@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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, 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; + + /* Admin design tokens — kept on :root so portal-rendered admin + surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ + --admin-bg: var(--bg); + --admin-surface: var(--panel); + --admin-surface-2: var(--panel-2); + --admin-elev: var(--panel-3); + --admin-border: var(--border); + --admin-border-strong: var(--border-strong); + --admin-text: var(--text); + --admin-muted: var(--muted); + --admin-dim: var(--dim); + --admin-ring: color-mix(in srgb, var(--accent) 50%, transparent); + --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 { + position: relative; + display: grid; + width: 38px; + height: 38px; + flex: 0 0 auto; + overflow: hidden; + place-items: center; + color: var(--accent); + font-size: 26px; + line-height: 1; +} + +.brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0; + transition: opacity 0.18s ease; +} + +.brand-mark img.loaded { + opacity: 1; +} + +.brand-mark-spinner { + position: absolute; + width: 42%; + height: 42%; + border: 2px solid color-mix(in srgb, var(--accent) 24%, transparent); + border-top-color: var(--accent); + border-radius: 999px; + animation: brand-mark-spin 0.72s linear infinite; +} + +@keyframes brand-mark-spin { + to { + transform: rotate(360deg); + } +} + +.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; +} + +.sub-status .current-tariff-line { + font-weight: 850; + opacity: 1; +} + +.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; +} + +.traffic-card-clickable { + cursor: pointer; +} + +.card-click-target { + position: absolute; + z-index: 2; + inset: 0; + width: 100%; + border: 0; + background: transparent; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.card-click-target:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -3px; +} + +.premium-traffic-card { + display: block; +} + +.premium-traffic-card-limited { + border-color: rgba(255, 193, 7, 0.34); +} + +.premium-progress span { + background: linear-gradient(90deg, #38bdf8, var(--accent)); +} + +.premium-traffic-meta { + align-items: flex-start; +} + +.premium-server-dropdown { + position: relative; + z-index: 3; + min-width: 0; +} + +.premium-server-dropdown summary { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + min-height: 32px; + margin: -6px -8px; + padding: 6px 8px; + cursor: pointer; + list-style: none; + -webkit-tap-highlight-color: transparent; +} + +.premium-server-dropdown summary:focus { + outline: none; +} + +.premium-server-dropdown summary::-webkit-details-marker { + display: none; +} + +.premium-server-dropdown summary span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.premium-server-dropdown svg { + flex: 0 0 auto; + opacity: 0.72; +} + +.premium-server-list-dropdown { + margin-top: 8px; +} + +.premium-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.premium-detail-grid span { + display: grid; + gap: 3px; + min-width: 0; +} + +.premium-detail-grid small, +.premium-server-list small, +.topup-summary-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.3; +} + +.premium-detail-grid strong { + font-size: 12px; + line-height: 1.2; +} + +.premium-server-list { + display: grid; + gap: 6px; +} + +.premium-server-list > div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.premium-server-list span { + max-width: 100%; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + color: var(--text); + font-size: 11px; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.topup-summary-card { + display: grid; + gap: 10px; + padding: 12px; +} + +.topup-summary-card > div:not(.premium-server-list) { + display: grid; + gap: 3px; +} + +.topup-summary-card strong { + font-size: 12px; + line-height: 1.2; +} + +.premium-server-list-modal { + margin-top: 0; +} + +.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; +} + +.option-list { + display: grid; + gap: 8px; +} + +.option-row, +.tariff-selected-card { + display: flex; + width: 100%; + min-height: 66px; + align-items: center; + justify-content: space-between; + gap: 12px; + 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); + padding: 12px; + text-align: left; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +.option-row-main, +.tariff-selected-card span { + display: grid; + gap: 4px; + min-width: 0; +} + +.option-row-main strong, +.tariff-selected-card strong { + font-size: 13px; + font-weight: 900; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.option-row-main small, +.tariff-selected-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.option-row-meta { + display: grid; + justify-items: end; + gap: 3px; + flex: 0 0 auto; + max-width: 42%; + text-align: right; +} + +.option-row-meta em, +.tariff-selected-card em { + color: var(--accent); + font-size: 12px; + font-style: normal; + font-weight: 900; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.option-row-meta small { + color: var(--muted); + font-size: 10px; + line-height: 1.2; +} + +.option-row.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); +} + +.tariff-row { + min-height: 74px; +} + +.plan-row, +.change-action-row { + min-height: 62px; +} + +.change-action-row { + align-items: flex-start; +} + +.change-action-row > svg { + flex: 0 0 auto; + margin-top: 1px; + color: var(--accent); +} + +.dialog-skeleton { + display: grid; + gap: 12px; +} + +.topup-carryover-note { + display: grid; + gap: 6px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.035); +} + +.topup-carryover-note p { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.skeleton-row, +.skeleton-method, +.skeleton-pay-button { + pointer-events: none; +} + +.skeleton-line, +.skeleton-dot, +.skeleton-pay-button { + display: block; + overflow: hidden; + border-radius: 999px; + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.07) 0%, + rgba(255, 255, 255, 0.14) 42%, + rgba(255, 255, 255, 0.07) 84% + ); + background-size: 220% 100%; + animation: skeleton-shimmer 1.15s ease-in-out infinite; +} + +.skeleton-line-title { + width: 118px; + height: 13px; +} + +.skeleton-line-short { + width: 76px; + height: 10px; + opacity: 0.75; +} + +.skeleton-line-price { + width: 58px; + height: 12px; +} + +.skeleton-line-tiny { + width: 34px; + height: 9px; + opacity: 0.7; +} + +.skeleton-line-method { + width: 72px; + height: 12px; +} + +.skeleton-dot { + width: 18px; + height: 18px; + flex: 0 0 auto; +} + +.skeleton-method { + gap: 8px; +} + +.skeleton-pay-button { + width: 100%; + height: 46px; + border-radius: var(--radius); +} + +@keyframes skeleton-shimmer { + 0% { + background-position: 120% 0; + } + 100% { + background-position: -120% 0; + } +} + +.back-inline { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: var(--muted); + padding: 0; + font-size: 12px; + font-weight: 850; +} + +.tariff-step-caption, +.section-kicker { + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 800; + line-height: 1.3; +} + +.section-kicker { + color: var(--dim); + text-transform: uppercase; +} + +.tariff-selected-card { + min-height: 58px; + background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); +} + +.tariff-action-list { + display: grid; + gap: 8px; +} + +.tariff-action-card, +.tariff-warning-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + 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); + padding: 12px; + text-align: left; +} + +.tariff-action-card span { + display: grid; + gap: 4px; + min-width: 0; +} + +.tariff-action-card strong, +.tariff-warning-card span { + font-size: 13px; + font-weight: 850; +} + +.tariff-action-card small { + color: var(--muted); + font-size: 11px; +} + +.tariff-action-card em { + color: var(--accent); + font-size: 10px; + font-style: normal; + font-weight: 850; + white-space: nowrap; +} + +.tariff-action-card.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); +} + +.tariff-warning-card { + justify-content: flex-start; + color: var(--warning, #ffd166); +} + +.confirm-summary-card { + display: grid; + gap: 8px; +} + +.confirm-summary-card p { + margin: 0; + color: var(--text); + font-size: 13px; + font-weight: 750; + line-height: 1.35; +} + +.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; +} + +.settings-list.settings-list--language-open .settings-row:not(.settings-row-language) { + pointer-events: none; +} + +.language-select-guard { + appearance: none; + -webkit-tap-highlight-color: transparent; + position: fixed; + inset: 0; + z-index: 100; + border: 0; + background: transparent; + box-shadow: none; + outline: none; + padding: 0; + pointer-events: auto; + touch-action: manipulation; +} + +.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; + background: rgba(255, 255, 255, 0.03); +} + +.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: 140; + 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); +} + +.rail-admin-entry { + display: none !important; +} + +.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-button.unavailable:disabled { + cursor: not-allowed; +} + +.telegram-login-button.checking:disabled { + opacity: 0.82; +} + +.telegram-login-text { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; +} + +.telegram-button-spinner { + width: 17px; + height: 17px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 999px; + animation: telegram-button-spin 0.72s linear infinite; +} + +@keyframes telegram-button-spin { + to { + transform: rotate(360deg); + } +} + +.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: 120; + 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); + } +} + +/* ============================================================ + Desktop layout for the user-facing Mini App (≥ 1024px) + A full-bleed dashboard: brand + nav rail on the left (the + mobile bottom-nav is repositioned, no markup changes), main + content area takes the rest of the viewport with a generous + max-width. Auth screens are kept centered. + Mobile (≤ 1023px) is intentionally untouched. + ============================================================ */ + +:root { + --desktop-rail-width: 252px; + --desktop-page-gutter: clamp(28px, 4vw, 72px); +} + +@media (min-width: 1024px) { + body { + background: #02070b; + } + + .app-shell { + min-height: 100dvh; + display: block; + padding: 0; + background: transparent !important; + } + + /* Full-width canvas: the side rail is a fixed sibling, so we leave room + for it on the left and center content inside the right-hand work area. */ + .phone-screen { + width: auto; + max-width: none; + min-height: 100dvh; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + padding: + max(28px, env(safe-area-inset-top)) + var(--desktop-page-gutter) + 40px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + overflow-x: visible; + } + + /* Centre and cap the width of the actual content blocks. */ + .phone-screen > .app-header, + .phone-screen > main, + .phone-screen > .home-layout, + .phone-screen > nav.bottom-nav ~ * { + max-width: 1080px; + margin-left: auto; + margin-right: auto; + } + + /* Auth screen: keep it as a focused card in the centre, hide rail. */ + .phone-screen.auth-screen { + width: min(100%, 460px); + min-height: 100dvh; + padding: + max(48px, env(safe-area-inset-top)) + clamp(24px, 4vw, 48px) + 48px; + margin: 0 auto; + } + + .phone-screen.auth-screen ~ .bottom-nav, + .auth-screen .bottom-nav { + display: none !important; + } + + .phone-screen.home-screen { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + min-height: 100dvh; + padding: + max(32px, env(safe-area-inset-top)) + var(--desktop-page-gutter) + 42px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + } + + /* Home: keep the phone-style vertical flow, but center it in the + right-hand desktop area and let the action block sit on the bottom. */ + .home-layout { + width: min(100%, 680px); + min-height: calc(100dvh - 74px); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(280px, 1fr) auto; + justify-self: center; + gap: clamp(24px, 4vh, 52px); + max-width: none; + margin: 0; + padding-bottom: 0; + align-items: stretch; + } + + .home-layout > .home-brand { + grid-column: auto; + align-self: center; + justify-items: center; + text-align: center; + gap: 22px; + } + + .home-brand h1 { + max-width: 560px; + font-size: clamp(36px, 4vw, 58px); + letter-spacing: 0; + line-height: 1.02; + } + + /* Make the brand mark a focal element on desktop. */ + .home-brand .brand-mark, + .home-brand .brand-mark-lg, + .home-brand .brand-mark-xl { + width: clamp(180px, 18vw, 260px); + height: clamp(180px, 18vw, 260px); + font-size: clamp(124px, 13vw, 184px); + } + + .home-bottom { + align-self: end; + width: min(100%, 560px); + justify-self: center; + gap: 12px; + } + + /* Top header (brand on settings/invite/devices) becomes a flat strip. */ + .app-header.accent-title { + display: none; + } + + /* Reset clearance — the bottom-nav is now a sidebar. */ + .content.with-nav { + padding-bottom: 32px; + } + + /* Transform the mobile bottom-nav into a fixed-left vertical rail. */ + .bottom-nav { + position: fixed !important; + left: 0 !important; + top: 0 !important; + bottom: 0 !important; + right: auto !important; + width: var(--desktop-rail-width) !important; + height: 100dvh !important; + transform: none !important; + display: grid !important; + grid-template-columns: 1fr !important; + grid-template-rows: none !important; + grid-auto-rows: auto !important; + align-content: start !important; + gap: 2px !important; + padding: 28px 14px !important; + border: 0 !important; + border-right: 1px solid var(--border) !important; + border-radius: 0 !important; + background: rgba(7, 12, 17, 0.55) !important; + backdrop-filter: blur(14px) !important; + box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important; + z-index: 40; + } + + .bottom-nav-devices { + grid-template-columns: 1fr !important; + } + + .bottom-nav button { + display: grid !important; + grid-template-columns: 22px 1fr !important; + grid-template-rows: auto !important; + align-items: center !important; + justify-items: start !important; + gap: 12px !important; + padding: 12px 14px !important; + border-radius: 10px !important; + text-align: left !important; + font-size: 13px !important; + color: var(--muted); + border: 1px solid transparent; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + } + + .bottom-nav button > svg { + width: 20px; + height: 20px; + } + + .bottom-nav button > span { + text-align: left !important; + font-size: 13px !important; + font-weight: 600; + } + + .bottom-nav button:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--text); + } + + .bottom-nav button.active { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--text); + } + + .bottom-nav .nav-attention-dot { + top: 14px; + right: 14px; + } + + .bottom-nav .rail-admin-entry { + display: grid !important; + } + .settings-admin-block { + display: none !important; + } + + .phone-screen > main.content.with-nav { + padding-top: 0; + } + + .settings-profile { + padding: 12px; + } + + .settings-links-block { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .settings-links-block .settings-divider { + display: none; + } + + .settings-links-block .settings-row, + .settings-links-block .settings-telegram-link-btn { + min-height: 48px; + } + + .settings-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .settings-list .settings-row { + min-height: 48px; + } + + .settings-row-language, + .settings-row-support, + .settings-row-logout { + grid-column: 1 / -1; + } + + /* Wider modals on desktop. */ + .dialog-card { + width: min(100%, 560px); + } + + .payment-dialog-card, + .link-email-dialog-card { + width: min(100%, 640px); + } +} + +@media (min-width: 1280px) { + :root { + --desktop-rail-width: 264px; + } +} + +/* Brand block sits at the top of the desktop side rail; hidden on mobile. */ +.rail-brand { + display: none; +} + +@media (min-width: 1024px) { + .bottom-nav .rail-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 12px 18px; + margin-bottom: 8px; + border-bottom: 1px solid var(--border); + color: var(--text); + } + + .bottom-nav .rail-brand strong { + font-size: 14px; + font-weight: 800; + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } +} diff --git a/bot/app/web/frontend/src/webapp/BottomNav.svelte b/bot/app/web/frontend/src/webapp/BottomNav.svelte new file mode 100644 index 0000000..40376c1 --- /dev/null +++ b/bot/app/web/frontend/src/webapp/BottomNav.svelte @@ -0,0 +1,53 @@ + + + diff --git a/bot/app/web/frontend/src/webapp/PaymentDialogs.svelte b/bot/app/web/frontend/src/webapp/PaymentDialogs.svelte new file mode 100644 index 0000000..f9633b4 --- /dev/null +++ b/bot/app/web/frontend/src/webapp/PaymentDialogs.svelte @@ -0,0 +1,308 @@ + + + +
+ {#if tariffMode && !singleTariffMode && paymentStep === "tariff"} + {#if tariffCatalog.length} +
+ {#each tariffCatalog as tariff} + + {/each} +
+ + {:else} + {t("wa_no_tariff_change_options")} + {/if} + {:else} + {#if tariffMode} + {#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))} + + {/if} + {#if hasMultipleTariffs && selectedTariff} +

{t("wa_selected_tariff", { tariff: selectedTariff.title })}

+ {/if} + {/if} + {#if selectedTariffPlans.length} +
+ {#each selectedTariffPlans as plan} + + {/each} +
+ +
+ {#if methods.length} + {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} + {:else} + {t("wa_payment_methods_not_configured")} + {/if} +
+ + {:else} + {t("wa_no_tariff_change_options")} + {/if} + {/if} + {#if !tariffMode} +
+ {#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} +
+
+ + +
+ + +
+
+ + +
+ {#if !linkEmailPending} +
+ + (linkEmailFieldError = "")} + /> + {#if linkEmailFieldError} + + + + {/if} + {#if linkEmailFieldError} + + {linkEmailFieldError} + + {/if} + +
+ + {:else} + + {/if} + {#if linkEmailStatus} +

{linkEmailStatus}

+ {/if} +
+
diff --git a/bot/app/web/frontend/src/webapp/TariffDialogs.svelte b/bot/app/web/frontend/src/webapp/TariffDialogs.svelte new file mode 100644 index 0000000..c75a44c --- /dev/null +++ b/bot/app/web/frontend/src/webapp/TariffDialogs.svelte @@ -0,0 +1,365 @@ + + + +
+ {#if !changeOptions} +
+
+ {#each [1, 2] as _} +
+ + + + + +
+ {/each} +
+ +
+ {#each [1, 2] as _} +
+ + + + +
+ {/each} +
+
+
+ {:else if changeOptions?.targets?.length} +

{t("wa_tariff_change_targets_title")}

+
+ {#each changeOptions.targets as target} + + {/each} +
+ {#if selectedChangeTarget?.actions?.length} + +

{t("wa_tariff_change_strategy_title")}

+
+ {#each selectedChangeTarget.actions as action} + + {/each} +
+ {#if selectedChangeAction?.kind === "payment"} +
+ {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} +
+ {/if} + + {:else} + {t("wa_no_tariff_change_options")} + {/if} + {:else} + {t("wa_no_tariff_change_options")} + {/if} +
+
+ + +
+ + {#each tariffChangeSummary() as row} +

{row}

+ {/each} +
+ + +
+
+ + +
+ {#if !topupOptions} +
+
+ {#each [1, 2, 3] as _} +
+ + + + + + + + +
+ {/each} +
+
+ {#each [1, 2] as _} +
+ + +
+ {/each} +
+
+
+ {:else if topupOptions?.plans?.length} +
+ {#each topupOptions.plans as plan} + + {/each} +
+ {@const carryoverNotes = topupCarryoverNotes()} + {#if carryoverNotes.length} +
+ {#each carryoverNotes as note} +

{note}

+ {/each} +
+ {/if} +
+ {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} +
+ + {:else} + {t("wa_no_topup_options")} + {/if} +
+
+ + +
+ {#if !deviceTopupOptions} +
+
+ {#each [1, 2, 3] as _} +
+ + + + + + + +
+ {/each} +
+
+ {#each [1, 2] as _} +
+ + +
+ {/each} +
+
+
+ {:else if deviceTopupOptions?.plans?.length} +
+ {#each deviceTopupOptions.plans as plan} + + {/each} +
+
+ {#each methods as method} + {@const meta = methodMeta(method)} + + {/each} +
+ + {:else} + {t("wa_no_hwid_device_options")} + {/if} +
+
+ diff --git a/bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte b/bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte new file mode 100644 index 0000000..59387f0 --- /dev/null +++ b/bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte @@ -0,0 +1,99 @@ + + +
+ +
+ + + {t("wa_devices_title")} + {devicesCountLabel()} + + +
+
+ +
+ {#if subscription?.active && subscription?.max_devices !== 0} + + {/if} +
+ + {#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} +
diff --git a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte b/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte new file mode 100644 index 0000000..a225f1b --- /dev/null +++ b/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte @@ -0,0 +1,167 @@ + + +
+ + +
+ + {#if subscription.active} +
+ +
+

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

+ {#if hasActiveTariffSubscription && hasMultipleTariffs && currentTariffName} +

{t("wa_current_tariff", { tariff: currentTariffName })}

+ {/if} +

{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} + + {#if canOpenRegularTopupModal} + + {/if} +
+ {t("wa_home_traffic_used")} + {trafficLabel(subscription)} +
+
+ +
+
+ {trafficResetLabel(subscription)} + {trafficPercent(subscription)}% +
+
+ {#if Number(subscription?.premium_limit_bytes || 0) > 0} + + {#if canOpenPremiumTopupModal} + + {/if} +
+ {premiumTitle(subscription)} + {premiumTrafficLabel(subscription)} +
+
+ +
+
+ {#if premiumServerLabels(subscription).length} +
+ + {subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")} + + +
+ {t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")} +
+ {#each premiumServerLabels(subscription).slice(0, 8) as label} + {label} + {/each} +
+
+
+ {:else} + {subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")} + {/if} + {premiumTrafficPercent(subscription)}% +
+
+ {/if} + {: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} + + {/if} + + {#if !subscription.active && appSettings?.trial_enabled && appSettings?.trial_available} + + {/if} + {#if canChangeTariff} + + {/if} + {#if canShowTopupButton} + + {/if} +
+
+
diff --git a/bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte b/bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte new file mode 100644 index 0000000..dd39798 --- /dev/null +++ b/bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte @@ -0,0 +1,103 @@ + + +
+ +
+ +
+ {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")} +

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

{promoStatus}

+ {/if} +
+
diff --git a/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte b/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte new file mode 100644 index 0000000..897cab3 --- /dev/null +++ b/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte @@ -0,0 +1,184 @@ + + +
+ +
+ {#if profileAvatarUrl} + {t("wa_settings_avatar_alt")} + {:else} + + {/if} +
+
+ {telegramProfileName} + {profileEmail} + {profileTelegramId} +
+
+ {#if isAdmin} +
+ + +
+ {/if} + + {#if languageMenuOpen || languageClickGuard} + + {/if} +
+
+ + + + + {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} + +
+
diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = .