From abbbc6a216f643a21131ebee0a34c638f3fbe777 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 15 May 2026 05:34:38 +0300 Subject: [PATCH 01/10] fix(onboarding): prevent invisible overlay blocking clicks and stuck tour - Tie spotlight/tooltip pointer-events to isVisible so opacity:0 stops trapping taps during step transitions - Render target click-catcher only when overlay is fully visible and clear stale targetRect on step change - Retry target lookup up to 6 times and auto-skip step (or complete tour) when target is missing, instead of leaving overlay stuck invisible-but-interactive - Persist completion to localStorage before unmounting the tour, so the flag survives even if unmount throws --- src/components/Onboarding.tsx | 62 +++++++++++++++++++++++++++-------- src/pages/Dashboard.tsx | 2 +- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 03af869..63ae244 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -43,27 +43,52 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp const [isVisible, setIsVisible] = useState(false); const tooltipRef = useRef(null); + const onCompleteRef = useRef(onComplete); + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + const step = steps[currentStep]; - // Find and highlight target element useEffect(() => { - const findTarget = () => { + let cancelled = false; + let attempts = 0; + const maxAttempts = 6; + const isLastStep = currentStep === steps.length - 1; + + setIsVisible(false); + setTargetRect(null); + + const tryFind = () => { + if (cancelled) return; const target = document.querySelector(`[data-onboarding="${step.target}"]`); if (target) { const rect = target.getBoundingClientRect(); setTargetRect(rect); - - // Scroll element into view if needed target.scrollIntoView({ behavior: 'smooth', block: 'center' }); - - // Delay visibility for smooth animation - setTimeout(() => setIsVisible(true), 100); + window.setTimeout(() => { + if (!cancelled) setIsVisible(true); + }, 100); + return; + } + attempts += 1; + if (attempts < maxAttempts) { + window.setTimeout(tryFind, 200); + return; + } + if (isLastStep) { + onCompleteRef.current(); + } else { + setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)); } }; - setIsVisible(false); - const timer = setTimeout(findTarget, 300); - return () => clearTimeout(timer); + const timer = window.setTimeout(tryFind, 300); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [step.target]); // Recalculate position on resize/scroll @@ -170,13 +195,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp return createPortal(
{/* Spotlight */} -
+
{/* Tooltip */}
{/* Progress indicator */}
@@ -222,8 +256,8 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
- {/* Click handler to advance on target click */} - {targetRect && ( + {/* Click handler to advance on target click — only when overlay is fully visible */} + {targetRect && isVisible && (
{ - setShowOnboarding(false); completeOnboarding(); + setShowOnboarding(false); }; return ( From f301d44f2443681b49d006e6a5be5eb8bad29ce9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 01:11:00 +0300 Subject: [PATCH 02/10] fix(admin): show flag for every country code, not just hardcoded 25-36 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported the EE (Estonia) flag rendered as plain text 'EE' on the admin servers page. Root cause: getCountryFlag() in AdminServers.tsx and AdminServerEdit.tsx hardcoded a 25-entry codeMap that didn't include EE; falling through to 'return code' produced raw text. Two other admin pages (AdminRemnawaveSquadDetail, AdminUserDetail) had bigger 35-entry maps that did include EE — so EE worked there but MX/AR/EG/ZA and friends still wouldn't. AdminRemnawave repeated the same 35-entry map. AdminTrafficUsage already had the correct algorithmic ISO→regional-indicator code but as a local duplicate of utils/subscriptionHelpers. Unify all six on the single algorithmic helper: - getFlagEmoji() in utils/subscriptionHelpers.ts now: * accepts string | null | undefined (callers don't need to guard) * trims whitespace * validates [A-Za-z]{2} before composing regional indicators - Each admin page now either imports getFlagEmoji directly or wraps it with the page's preferred fallback character (e.g. '🌍' for empty codes where the UI expects a placeholder). --- src/pages/AdminRemnawave.tsx | 46 ++--------------------- src/pages/AdminRemnawaveSquadDetail.tsx | 49 +++---------------------- src/pages/AdminServerEdit.tsx | 34 +---------------- src/pages/AdminServers.tsx | 32 +--------------- src/pages/AdminTrafficUsage.tsx | 12 ++---- src/pages/AdminUserDetail.tsx | 47 +++--------------------- src/utils/subscriptionHelpers.ts | 9 +++-- 7 files changed, 27 insertions(+), 202 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 3c6b53d..4f040f6 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -12,6 +12,7 @@ import { } from '../api/adminRemnawave'; import { usePlatform } from '../platform/hooks/usePlatform'; import { formatUptime } from '../utils/format'; +import { getFlagEmoji } from '../utils/subscriptionHelpers'; import Twemoji from 'react-twemoji'; import { ServerIcon, @@ -46,48 +47,9 @@ const formatBytes = (bytes: number): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -const getCountryFlag = (code: string | null | undefined): string => { - if (!code) return '🌍'; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - UK: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - NO: '🇳🇴', - PL: '🇵🇱', - TR: '🇹🇷', - JP: '🇯🇵', - SG: '🇸🇬', - HK: '🇭🇰', - KR: '🇰🇷', - AU: '🇦🇺', - CA: '🇨🇦', - CH: '🇨🇭', - AT: '🇦🇹', - IT: '🇮🇹', - ES: '🇪🇸', - BR: '🇧🇷', - IN: '🇮🇳', - AE: '🇦🇪', - IL: '🇮🇱', - KZ: '🇰🇿', - UA: '🇺🇦', - CZ: '🇨🇿', - RO: '🇷🇴', - LV: '🇱🇻', - LT: '🇱🇹', - EE: '🇪🇪', - BG: '🇧🇬', - HU: '🇭🇺', - MD: '🇲🇩', - }; - return codeMap[code.toUpperCase()] || code; -}; +// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback +// сохранён для случая пустого кода (важно для UI-плейсхолдеров). +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍'; interface StatCardProps { label: string; diff --git a/src/pages/AdminRemnawaveSquadDetail.tsx b/src/pages/AdminRemnawaveSquadDetail.tsx index d59ee53..c13ad9f 100644 --- a/src/pages/AdminRemnawaveSquadDetail.tsx +++ b/src/pages/AdminRemnawaveSquadDetail.tsx @@ -5,49 +5,12 @@ import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave'; import { AdminBackButton } from '../components/admin'; import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons'; import Twemoji from 'react-twemoji'; -// Country flags helper -const getCountryFlag = (code: string | null | undefined): string => { - if (!code) return '🌍'; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - UK: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - NO: '🇳🇴', - PL: '🇵🇱', - TR: '🇹🇷', - JP: '🇯🇵', - SG: '🇸🇬', - HK: '🇭🇰', - KR: '🇰🇷', - AU: '🇦🇺', - CA: '🇨🇦', - CH: '🇨🇭', - AT: '🇦🇹', - IT: '🇮🇹', - ES: '🇪🇸', - BR: '🇧🇷', - IN: '🇮🇳', - AE: '🇦🇪', - IL: '🇮🇱', - KZ: '🇰🇿', - UA: '🇺🇦', - CZ: '🇨🇿', - RO: '🇷🇴', - LV: '🇱🇻', - LT: '🇱🇹', - EE: '🇪🇪', - BG: '🇧🇬', - HU: '🇭🇺', - MD: '🇲🇩', - }; - return codeMap[code.toUpperCase()] || code; -}; +import { getFlagEmoji } from '../utils/subscriptionHelpers'; + +// Country flag helper. Алгоритмический ISO 3166-1 alpha-2 → regional indicator, +// чтобы не плодить хардкод-словари (исторически у каждого экрана был свой +// неполный, и EE/MX/AR не покрывались). При отсутствии кода — глобус. +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍'; export default function AdminRemnawaveSquadDetail() { const { t } = useTranslation(); diff --git a/src/pages/AdminServerEdit.tsx b/src/pages/AdminServerEdit.tsx index 8204c15..a3c7026 100644 --- a/src/pages/AdminServerEdit.tsx +++ b/src/pages/AdminServerEdit.tsx @@ -7,39 +7,7 @@ import { AdminBackButton } from '../components/admin'; import { ServerIcon } from '../components/icons'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import Twemoji from 'react-twemoji'; - -// Country flags (simple emoji mapping) -const getCountryFlag = (code: string | null): string => { - if (!code) return ''; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - PL: '🇵🇱', - CZ: '🇨🇿', - AT: '🇦🇹', - CH: '🇨🇭', - UA: '🇺🇦', - KZ: '🇰🇿', - JP: '🇯🇵', - KR: '🇰🇷', - SG: '🇸🇬', - HK: '🇭🇰', - CA: '🇨🇦', - AU: '🇦🇺', - BR: '🇧🇷', - IN: '🇮🇳', - TR: '🇹🇷', - IL: '🇮🇱', - AE: '🇦🇪', - }; - return codeMap[code.toUpperCase()] || code; -}; +import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers'; export default function AdminServerEdit() { const { t } = useTranslation(); diff --git a/src/pages/AdminServers.tsx b/src/pages/AdminServers.tsx index f04bead..9080cde 100644 --- a/src/pages/AdminServers.tsx +++ b/src/pages/AdminServers.tsx @@ -20,37 +20,7 @@ const BackIcon = () => ( ); // Country flags (simple emoji mapping) -const getCountryFlag = (code: string | null): string => { - if (!code) return ''; - const codeMap: Record = { - RU: '🇷🇺', - US: '🇺🇸', - DE: '🇩🇪', - NL: '🇳🇱', - GB: '🇬🇧', - FR: '🇫🇷', - FI: '🇫🇮', - SE: '🇸🇪', - PL: '🇵🇱', - CZ: '🇨🇿', - AT: '🇦🇹', - CH: '🇨🇭', - UA: '🇺🇦', - KZ: '🇰🇿', - JP: '🇯🇵', - KR: '🇰🇷', - SG: '🇸🇬', - HK: '🇭🇰', - CA: '🇨🇦', - AU: '🇦🇺', - BR: '🇧🇷', - IN: '🇮🇳', - TR: '🇹🇷', - IL: '🇮🇱', - AE: '🇦🇪', - }; - return codeMap[code.toUpperCase()] || code; -}; +import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers'; export default function AdminServers() { const { t } = useTranslation(); diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index b28c07d..c64fe86 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -18,6 +18,7 @@ import { type TrafficEnrichmentData, } from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { getFlagEmoji as _sharedGetFlagEmoji } from '../utils/subscriptionHelpers'; // ============ TanStack Table module augmentation ============ @@ -40,14 +41,9 @@ const formatBytes = (bytes: number): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -const getFlagEmoji = (countryCode: string): string => { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode - .toUpperCase() - .split('') - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -}; +// Локальная обёртка над общим helper'ом, чтобы внутренние сигнатуры (string) +// оставались как были и call-sites не меняли. +const getFlagEmoji = (countryCode: string): string => _sharedGetFlagEmoji(countryCode); const formatCurrency = (kopeks: number): string => { const rubles = kopeks / 100; diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 163beff..d1fc4f5 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -24,51 +24,14 @@ import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { linkifyText } from '../utils/linkify'; +import { getFlagEmoji } from '../utils/subscriptionHelpers'; // ============ Helpers ============ -const getCountryFlag = (code: string | null | undefined): string => { - if (!code) return ''; - const codeMap: Record = { - RU: '\u{1F1F7}\u{1F1FA}', - US: '\u{1F1FA}\u{1F1F8}', - DE: '\u{1F1E9}\u{1F1EA}', - NL: '\u{1F1F3}\u{1F1F1}', - GB: '\u{1F1EC}\u{1F1E7}', - UK: '\u{1F1EC}\u{1F1E7}', - FR: '\u{1F1EB}\u{1F1F7}', - FI: '\u{1F1EB}\u{1F1EE}', - SE: '\u{1F1F8}\u{1F1EA}', - NO: '\u{1F1F3}\u{1F1F4}', - PL: '\u{1F1F5}\u{1F1F1}', - TR: '\u{1F1F9}\u{1F1F7}', - JP: '\u{1F1EF}\u{1F1F5}', - SG: '\u{1F1F8}\u{1F1EC}', - HK: '\u{1F1ED}\u{1F1F0}', - KR: '\u{1F1F0}\u{1F1F7}', - AU: '\u{1F1E6}\u{1F1FA}', - CA: '\u{1F1E8}\u{1F1E6}', - CH: '\u{1F1E8}\u{1F1ED}', - AT: '\u{1F1E6}\u{1F1F9}', - IT: '\u{1F1EE}\u{1F1F9}', - ES: '\u{1F1EA}\u{1F1F8}', - BR: '\u{1F1E7}\u{1F1F7}', - IN: '\u{1F1EE}\u{1F1F3}', - AE: '\u{1F1E6}\u{1F1EA}', - IL: '\u{1F1EE}\u{1F1F1}', - KZ: '\u{1F1F0}\u{1F1FF}', - UA: '\u{1F1FA}\u{1F1E6}', - CZ: '\u{1F1E8}\u{1F1FF}', - RO: '\u{1F1F7}\u{1F1F4}', - LV: '\u{1F1F1}\u{1F1FB}', - LT: '\u{1F1F1}\u{1F1F9}', - EE: '\u{1F1EA}\u{1F1EA}', - BG: '\u{1F1E7}\u{1F1EC}', - HU: '\u{1F1ED}\u{1F1FA}', - MD: '\u{1F1F2}\u{1F1E9}', - }; - return codeMap[code.toUpperCase()] || code; -}; +// Алгоритмический ISO 3166-1 alpha-2 → regional indicator (вместо хардкод-словаря, +// который не покрывал все страны: например, EE раньше плыл сырым текстом в одних +// местах, MX/AR/EG до сих пор отсутствуют в других). Единая точка истины в utils. +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code); // ============ Icons ============ diff --git a/src/utils/subscriptionHelpers.ts b/src/utils/subscriptionHelpers.ts index 8be7f94..944ba69 100644 --- a/src/utils/subscriptionHelpers.ts +++ b/src/utils/subscriptionHelpers.ts @@ -32,9 +32,12 @@ export const getInsufficientBalanceError = ( return null; }; -export const getFlagEmoji = (countryCode: string): string => { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode +export const getFlagEmoji = (countryCode: string | null | undefined): string => { + // Trim + длина строго 2 буквы — иначе Unicode regional indicators не дадут флаг. + // Принимаем null/undefined чтобы вызывающие коду не приходилось страховаться. + const code = (countryCode ?? '').trim(); + if (code.length !== 2 || !/^[A-Za-z]{2}$/.test(code)) return ''; + const codePoints = code .toUpperCase() .split('') .map((char) => 127397 + char.charCodeAt(0)); From 6fa4afd5c1b67331a02127dc395476df1fbbd7e5 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 02:15:17 +0300 Subject: [PATCH 03/10] feat(antilopay): inject apay-tag meta on cabinet boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the bedolaga-bot commit that exposes GET /cabinet/public/site-verification (JSON: { apay_tag: string | null }). New useSiteVerification hook fires once on App mount, fetches the configured tag value, and upserts into document.head. When the bot returns null we proactively remove any previously-rendered tag so toggling the env var off cleans up the page. Failure modes are silent — verification is best-effort and must never block the cabinet from rendering. No admin UI field is needed: the value lives in the bot's .env (ANTILOPAY_APAY_VERIFICATION_TAG), matching how all other Antilopay credentials are configured. --- src/App.tsx | 4 +++ src/api/siteVerification.ts | 16 +++++++++ src/hooks/useSiteVerification.ts | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/api/siteVerification.ts create mode 100644 src/hooks/useSiteVerification.ts diff --git a/src/App.tsx b/src/App.tsx index 33f220f..fce6599 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'; import { PermissionRoute } from '@/components/auth/PermissionRoute'; import { saveReturnUrl } from './utils/token'; import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; +import { useSiteVerification } from './hooks/useSiteVerification'; // Auth pages - load immediately (small) import Login from './pages/Login'; import TelegramCallback from './pages/TelegramCallback'; @@ -229,6 +230,9 @@ function LegacySubscriptionRedirect() { function App() { useAnalyticsCounters(); + // Pulls site-verification tokens (Antilopay apay-tag etc.) from the bot + // backend and injects matching tags into document.head. + useSiteVerification(); return ( <> diff --git a/src/api/siteVerification.ts b/src/api/siteVerification.ts new file mode 100644 index 0000000..c11686b --- /dev/null +++ b/src/api/siteVerification.ts @@ -0,0 +1,16 @@ +import { apiClient } from './client'; + +export interface SiteVerification { + apay_tag: string | null; +} + +/** + * Public, unauthenticated endpoint — payment-provider crawlers (Antilopay) + * need to be able to read these values to validate site ownership. + */ +export const siteVerificationApi = { + async get(): Promise { + const { data } = await apiClient.get('/cabinet/public/site-verification'); + return data; + }, +}; diff --git a/src/hooks/useSiteVerification.ts b/src/hooks/useSiteVerification.ts new file mode 100644 index 0000000..b7b68db --- /dev/null +++ b/src/hooks/useSiteVerification.ts @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; +import { siteVerificationApi } from '../api/siteVerification'; + +/** + * Fetches the configured site-verification tokens from the bot backend + * and injects the matching `` tags into `document.head`. + * + * Used by Antilopay's verification flow (lk.antilopay.com → Проект → + * Верификация → Способ 1: мета-тег). Once the merchant copies the + * Antilopay-provided value into `ANTILOPAY_APAY_VERIFICATION_TAG`, + * the cabinet will start rendering `` + * automatically — no rebuild required. + * + * The hook is no-op on backend errors (verification is a best-effort + * concern and must never block the cabinet from rendering). + */ +export function useSiteVerification(): void { + useEffect(() => { + let cancelled = false; + + siteVerificationApi + .get() + .then((data) => { + if (cancelled) return; + if (data.apay_tag) { + upsertMetaTag('apay-tag', data.apay_tag); + } else { + removeMetaTag('apay-tag'); + } + }) + .catch(() => { + // Silent fail — verification meta is best-effort, doesn't block UI. + }); + + return () => { + cancelled = true; + }; + }, []); +} + +function upsertMetaTag(name: string, content: string): void { + let tag = document.head.querySelector(`meta[name="${name}"]`); + if (!tag) { + tag = document.createElement('meta'); + tag.setAttribute('name', name); + document.head.appendChild(tag); + } + tag.setAttribute('content', content); +} + +function removeMetaTag(name: string): void { + const tag = document.head.querySelector(`meta[name="${name}"]`); + if (tag) { + tag.remove(); + } +} From d85da9e03fbb82ac88af3abfd92755418debc1b9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 02:25:37 +0300 Subject: [PATCH 04/10] docs(antilopay): document XSS invariant on setAttribute('content', ...) Security reviewer flagged that the safety of injecting payment-provider verification tokens into rests entirely on the use of Element.setAttribute() (plain-string attribute, browser does not parse as HTML). A future contributor switching to innerHTML or template- string concatenation into would turn this hook into an XSS sink. Add a comment that calls this out explicitly so the invariant survives maintenance. --- src/hooks/useSiteVerification.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/hooks/useSiteVerification.ts b/src/hooks/useSiteVerification.ts index b7b68db..935dade 100644 --- a/src/hooks/useSiteVerification.ts +++ b/src/hooks/useSiteVerification.ts @@ -39,6 +39,12 @@ export function useSiteVerification(): void { } function upsertMetaTag(name: string, content: string): void { + // Security note: `setAttribute('content', value)` stores `value` as a plain + // string attribute — the browser does NOT parse it as HTML. Even if a + // compromised backend returned ``, it would + // be stored literally inside `content="..."` and never executed. DO NOT + // switch to `innerHTML` / string concatenation into — that would + // make this an XSS sink. let tag = document.head.querySelector(`meta[name="${name}"]`); if (!tag) { tag = document.createElement('meta'); From 321c65b68b70248e96ba102783c8af32e2686258 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 03:02:45 +0300 Subject: [PATCH 05/10] feat(devices): inline rename UI for connected HWID devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the bot commit that adds the user_device_aliases table. Surfaces the new local_name field on the existing devices list in both the user-facing subscription page and the admin user-detail page, with the same inline-edit pattern in both places. User-side (src/pages/Subscription.tsx): - pencil button next to each device row toggles edit mode - input is focused automatically, capped at 64 chars (matches the backend ALIAS_MAX_LENGTH and DB column width) - Enter saves, Escape cancels, empty input clears the alias - display priority: local_name → device_model → platform - works identically in classic / single-tariff / multi-tariff — subscriptionId is forwarded as a query param like every other device-management endpoint already does Admin-side (src/pages/AdminUserDetail.tsx): - same pencil + inline input pattern, admin acts on behalf of the user. notify.success on save, loadDevices() refresh. API (src/api/subscription.ts + src/api/adminUsers.ts): - new renameDevice(hwid, name, subscriptionId?) on subscriptionApi - new renameUserDevice(userId, hwid, name) on adminUsersApi - existing getDevices/getUserDevices contracts widened with local_name?: string | null on the returned device shape Locales (src/locales/ru.json): - subscription.renameDevice / .renameDeviceSave / .renameDeviceCancel / .renameDevicePlaceholder / .deviceRenamed - admin.users.detail.devices.rename / .renameSave / .renamed --- src/api/adminUsers.ts | 21 ++- src/api/subscription.ts | 22 +++ src/locales/ru.json | 10 +- src/pages/AdminUserDetail.tsx | 189 ++++++++++++++++++----- src/pages/Subscription.tsx | 273 ++++++++++++++++++++++++++-------- src/types/index.ts | 6 + 6 files changed, 421 insertions(+), 100 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index c348e46..388a79b 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -719,7 +719,13 @@ export const adminUsersApi = { userId: number, subscriptionId?: number, ): Promise<{ - devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[]; + devices: { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; + }[]; total: number; device_limit: number; }> => { @@ -729,6 +735,19 @@ export const adminUsersApi = { return response.data; }, + // Set / clear local device alias on behalf of the user (admin override). + renameUserDevice: async ( + userId: number, + hwid: string, + name: string | null, + ): Promise<{ hwid: string; local_name: string | null }> => { + const response = await apiClient.patch( + `/cabinet/admin/users/${userId}/devices/${encodeURIComponent(hwid)}/name`, + { name }, + ); + return response.data; + }, + // Delete single device deleteUserDevice: async ( userId: number, diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 832954d..c80bad4 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -264,6 +264,7 @@ export const subscriptionApi = { platform: string; device_model: string; created_at: string | null; + local_name?: string | null; }>; total: number; device_limit: number; @@ -275,6 +276,27 @@ export const subscriptionApi = { return response.data; }, + /** + * Persist a user-local alias for a device. Pass an empty/null `name` to + * clear the alias. Returns the final `local_name` after server-side + * normalization (trimmed + length-capped). + * + * Scope is per-(user, hwid), so the same alias appears across all of + * the user's subscriptions in multi-tariff mode. + */ + renameDevice: async ( + hwid: string, + name: string | null, + subscriptionId?: number, + ): Promise<{ hwid: string; local_name: string | null }> => { + const response = await apiClient.patch( + `/cabinet/subscription/devices/${encodeURIComponent(hwid)}/name`, + { name }, + withSubId(subscriptionId), + ); + return response.data; + }, + deleteDevice: async ( hwid: string, subscriptionId?: number, diff --git a/src/locales/ru.json b/src/locales/ru.json index 394b2a0..f6c5cac 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -492,6 +492,11 @@ "confirmDeleteAllDevices": "Удалить все устройства?", "deviceDeleted": "Устройство удалено", "allDevicesDeleted": "Все устройства удалены", + "renameDevice": "Переименовать", + "renameDeviceSave": "Сохранить", + "renameDeviceCancel": "Отмена", + "renameDevicePlaceholder": "Имя устройства", + "deviceRenamed": "Имя устройства обновлено", "platform": "Платформа", "model": "Модель", "connectedAt": "Подключено", @@ -3581,7 +3586,10 @@ "none": "Нет подключённых устройств", "resetAll": "Сбросить все", "deleted": "Устройство удалено", - "allDeleted": "Все устройства сброшены" + "allDeleted": "Все устройства сброшены", + "rename": "Переименовать", + "renameSave": "Сохранить", + "renamed": "Имя устройства обновлено" }, "referral": { "title": "Реферальная программа", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index d1fc4f5..59d8e33 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -338,11 +338,20 @@ export default function AdminUserDetail() { // Devices const [devices, setDevices] = useState< - { hwid: string; platform: string; device_model: string; created_at: string | null }[] + { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; + }[] >([]); const [devicesTotal, setDevicesTotal] = useState(0); const [deviceLimit, setDeviceLimit] = useState(0); const [devicesLoading, setDevicesLoading] = useState(false); + const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); + const [editingDeviceName, setEditingDeviceName] = useState(''); + const [renameSaving, setRenameSaving] = useState(false); // Gifts const [giftsData, setGiftsData] = useState(null); @@ -760,6 +769,25 @@ export default function AdminUserDetail() { } }; + // Admin renames a device on behalf of the user. Empty/whitespace input + // clears the alias and falls back to the platform/model default. + const handleRenameDevice = async (hwid: string) => { + if (!userId) return; + setRenameSaving(true); + try { + const trimmed = editingDeviceName.trim(); + await adminUsersApi.renameUserDevice(userId, hwid, trimmed || null); + notify.success(t('admin.users.detail.devices.renamed', 'Имя устройства обновлено')); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + await loadDevices(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setRenameSaving(false); + } + }; + const handleResetDevices = async () => { if (!userId) return; setActionLoading(true); @@ -2427,44 +2455,135 @@ export default function AdminUserDetail() {
) : devices.length > 0 ? (
- {devices.map((device) => ( -
-
-
- {device.platform || device.device_model || device.hwid.slice(0, 12)} -
-
- {device.device_model && device.platform && ( - {device.device_model} + {devices.map((device) => { + const isEditing = editingDeviceHwid === device.hwid; + // Display priority: alias \u2192 model \u2192 platform \u2192 hwid prefix. + const displayName = + (device.local_name && device.local_name.trim()) || + device.platform || + device.device_model || + device.hwid.slice(0, 12); + + return ( +
+
+ {isEditing ? ( + setEditingDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRenameDevice(device.hwid); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + } + }} + className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50" + /> + ) : ( +
+ {displayName} +
)} - {device.hwid.slice(0, 8)}... - {device.created_at && ( - - {new Date(device.created_at).toLocaleDateString(locale)} - +
+ {device.device_model && device.platform && ( + {device.device_model} + )} + {device.hwid.slice(0, 8)}... + {device.created_at && ( + + {new Date(device.created_at).toLocaleDateString(locale)} + + )} +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + )}
- -
- ))} + ); + })}
) : (
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 4e367cf..bd8f2d5 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -308,6 +308,23 @@ export default function Subscription() { }, }); + // Local device alias (rename) state. Only one device can be in edit-mode + // at a time — `editingDeviceHwid` doubles as both the toggle and the + // identifier of the row being edited. + const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); + const [editingDeviceName, setEditingDeviceName] = useState(''); + const DEVICE_ALIAS_MAX_LENGTH = 64; + + const renameDeviceMutation = useMutation({ + mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) => + subscriptionApi.renameDevice(hwid, name, subscriptionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + }, + }); + // Pause subscription mutation const pauseMutation = useMutation({ mutationFn: () => subscriptionApi.togglePause(subscriptionId), @@ -2403,73 +2420,203 @@ export default function Subscription() { ? `${devicesData.total} · ∞` : `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
- {devicesData.devices.map((device) => ( -
-
-
-
+
+
- - + +
+
+ {isEditing ? ( + setEditingDeviceName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const trimmed = editingDeviceName.trim(); + renameDeviceMutation.mutate({ + hwid: device.hwid, + name: trimmed || null, + }); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingDeviceHwid(null); + setEditingDeviceName(''); + } + }} + className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1" + style={{ + background: g.trackBg, + boxShadow: `inset 0 0 0 1px ${g.innerBorder}`, + }} + /> + ) : ( +
+ {displayName} +
+ )} +
+ {device.platform} + + {device.hwid.slice(0, 8).toUpperCase()} + +
+
-
-
- {device.device_model || device.platform} -
-
- {device.platform} - - {device.hwid.slice(0, 8).toUpperCase()} - -
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )}
- -
- ))} + ); + })}
) : (
diff --git a/src/types/index.ts b/src/types/index.ts index 5b1abde..2208030 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -141,6 +141,12 @@ export interface Device { platform: string; device_model: string; created_at: string | null; + /** + * User-set local alias persisted in the bot DB (`user_device_aliases`). + * `null` when the user hasn't renamed the device — clients fall back + * to `device_model` / `platform` for display. + */ + local_name?: string | null; } export interface DevicesResponse { From d1e5ce873b3dd1c3ede0c82c73f47f91346c0b04 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 03:53:49 +0300 Subject: [PATCH 06/10] fix(devices): unicode escape bug + onSuccess race + en locale + apiErr surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up on 321c65b. - prettier ate the raw glyphs (✓/✕/✏️) in AdminUserDetail.tsx and emitted \u2713 / \u2715 / \u270F\uFE0F as JSX text children. JSX does NOT process \u… escapes inside element children, so admin users saw the literal six-character strings on Save/Cancel/Rename buttons. Wrap each glyph in a JS expression {'…'} so it survives both prettier and JSX. - renameDeviceMutation.onSuccess on Subscription.tsx no longer unconditionally clears the edit state. If the user already navigated to a different device while the previous save was in flight, we keep their in-progress input. Same shape applied to AdminUserDetail.tsx — the alias is snapshot before the await so a race can't smuggle the wrong value into the request, and the post-success reset is guarded. - AdminUserDetail rename error path now surfaces the backend's response.data.detail (e.g. '64-char limit' message) instead of the generic localized userActions.error toast. - Add the new rename i18n keys to src/locales/en.json so English users no longer fall back to Russian labels. ua/zh/fa to follow in a dedicated translation pass. --- src/locales/en.json | 10 +++++++++- src/pages/AdminUserDetail.tsx | 22 +++++++++++++--------- src/pages/Subscription.tsx | 9 ++++++--- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index dbb137c..f50417f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -468,6 +468,11 @@ "confirmDeleteAllDevices": "Delete all devices?", "deviceDeleted": "Device deleted", "allDevicesDeleted": "All devices deleted", + "renameDevice": "Rename", + "renameDeviceSave": "Save", + "renameDeviceCancel": "Cancel", + "renameDevicePlaceholder": "Device name", + "deviceRenamed": "Device name updated", "platform": "Platform", "model": "Model", "connectedAt": "Connected", @@ -3176,7 +3181,10 @@ "none": "No connected devices", "resetAll": "Reset all", "deleted": "Device removed", - "allDeleted": "All devices reset" + "allDeleted": "All devices reset", + "rename": "Rename", + "renameSave": "Save", + "renamed": "Device name updated" }, "referral": { "title": "Referral program", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 59d8e33..19fd343 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -774,15 +774,19 @@ export default function AdminUserDetail() { const handleRenameDevice = async (hwid: string) => { if (!userId) return; setRenameSaving(true); + // Snapshot inputs BEFORE the await so a fast click on another device + // mid-flight doesn't smuggle a different alias into this hwid's request. + const snapshotName = editingDeviceName.trim(); try { - const trimmed = editingDeviceName.trim(); - await adminUsersApi.renameUserDevice(userId, hwid, trimmed || null); + await adminUsersApi.renameUserDevice(userId, hwid, snapshotName || null); notify.success(t('admin.users.detail.devices.renamed', 'Имя устройства обновлено')); - setEditingDeviceHwid(null); - setEditingDeviceName(''); + // Reset edit state only if user is still on the saved row. + setEditingDeviceHwid((current) => (current === hwid ? null : current)); await loadDevices(); - } catch { - notify.error(t('admin.users.userActions.error'), t('common.error')); + } catch (err) { + const apiMessage = (err as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail; + notify.error(apiMessage || t('admin.users.userActions.error'), t('common.error')); } finally { setRenameSaving(false); } @@ -2527,7 +2531,7 @@ export default function AdminUserDetail() { ), )} > - \u2713 + {'\u2713'} ) : ( @@ -2559,7 +2563,7 @@ export default function AdminUserDetail() { '\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C', )} > - \u270F\uFE0F + {'\u270F\uFE0F'} ) : ( @@ -2557,13 +2590,29 @@ export default function AdminUserDetail() { setEditingDeviceHwid(device.hwid); setEditingDeviceName(device.local_name || ''); }} - className="rounded-lg px-2 py-1 text-xs text-dark-500 transition-all hover:bg-accent-500/15 hover:text-accent-400" + className="rounded-lg px-2 py-1 text-dark-500 transition-all hover:bg-accent-500/15 hover:text-accent-400" title={t( 'admin.users.detail.devices.rename', '\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C', )} + aria-label={t( + 'admin.users.detail.devices.rename', + '\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C', + )} > - {'\u270F\uFE0F'} + + )} + +
+ +

{t('blocking.accountDeleted.hint')}

+
+
+ ); +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts index d72b87d..94bced2 100644 --- a/src/components/blocking/index.ts +++ b/src/components/blocking/index.ts @@ -1,3 +1,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen'; export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'; export { default as BlacklistedScreen } from './BlacklistedScreen'; +export { default as AccountDeletedScreen } from './AccountDeletedScreen'; diff --git a/src/locales/en.json b/src/locales/en.json index f50417f..1627841 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4666,6 +4666,13 @@ "defaultMessage": "Your account has been blocked.", "reason": "Reason", "contactSupport": "If you believe this is an error, please contact support." + }, + "accountDeleted": { + "title": "Account deactivated", + "description": "Your account was deactivated after a long period of inactivity. To restore access, open the bot in Telegram and send /start.", + "openBot": "Open the bot", + "retry": "I pressed /start, retry", + "hint": "After running /start, return here and tap «Retry»." } }, "merge": { diff --git a/src/locales/fa.json b/src/locales/fa.json index d67089d..f55f2f2 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -4203,6 +4203,13 @@ "defaultMessage": "حساب شما مسدود شده است.", "reason": "دلیل", "contactSupport": "اگر فکر می‌کنید این اشتباه است، با پشتیبانی تماس بگیرید." + }, + "accountDeleted": { + "title": "حساب غیرفعال شد", + "description": "حساب شما به دلیل عدم فعالیت طولانی غیرفعال شده است. برای بازیابی دسترسی، ربات را در تلگرام باز کرده و دستور /start را ارسال کنید.", + "openBot": "باز کردن ربات", + "retry": "من /start را زدم، دوباره امتحان کن", + "hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید." } }, "merge": { diff --git a/src/locales/ru.json b/src/locales/ru.json index f6c5cac..da3ccc8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -5218,6 +5218,13 @@ "defaultMessage": "Ваш аккаунт заблокирован.", "reason": "Причина", "contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку." + }, + "accountDeleted": { + "title": "Аккаунт деактивирован", + "description": "Ваш аккаунт был деактивирован за длительную неактивность. Чтобы восстановить доступ, откройте бота в Telegram и отправьте команду /start.", + "openBot": "Открыть бота", + "retry": "Я нажал /start, попробовать снова", + "hint": "После /start вернитесь сюда и нажмите «Попробовать снова»." } }, "merge": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 5b5860f..728d600 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -4084,6 +4084,13 @@ "defaultMessage": "您的帐户已被封锁。", "reason": "原因", "contactSupport": "如果您认为这是错误,请联系客服。" + }, + "accountDeleted": { + "title": "账户已停用", + "description": "由于长期未活动,您的账户已被停用。请在 Telegram 中打开机器人并发送 /start 以恢复访问。", + "openBot": "打开机器人", + "retry": "我已按 /start,重试", + "hint": "运行 /start 后,返回此处并点击「重试」。" } }, "banSystem": { diff --git a/src/store/blocking.ts b/src/store/blocking.ts index cd4d53a..160f371 100644 --- a/src/store/blocking.ts +++ b/src/store/blocking.ts @@ -1,6 +1,11 @@ import { create } from 'zustand'; -export type BlockingType = 'maintenance' | 'channel_subscription' | 'blacklisted' | null; +export type BlockingType = + | 'maintenance' + | 'channel_subscription' + | 'blacklisted' + | 'account_deleted' + | null; interface MaintenanceInfo { message: string; @@ -29,15 +34,26 @@ interface BlacklistedInfo { message: string; } +interface AccountDeletedInfo { + /** Backend-provided localized message. We may override with i18n key on render. */ + message: string; + /** Bot username (without @) for building the Telegram deep link client-side as fallback. */ + bot_username?: string; + /** Full Telegram deep-link URL (`https://t.me/?start=revive`). Empty when bot is unconfigured. */ + telegram_deep_link?: string; +} + interface BlockingState { blockingType: BlockingType; maintenanceInfo: MaintenanceInfo | null; channelInfo: ChannelSubscriptionInfo | null; blacklistedInfo: BlacklistedInfo | null; + accountDeletedInfo: AccountDeletedInfo | null; setMaintenance: (info: MaintenanceInfo) => void; setChannelSubscription: (info: ChannelSubscriptionInfo) => void; setBlacklisted: (info: BlacklistedInfo) => void; + setAccountDeleted: (info: AccountDeletedInfo) => void; clearBlocking: () => void; } @@ -46,6 +62,7 @@ export const useBlockingStore = create((set) => ({ maintenanceInfo: null, channelInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, setMaintenance: (info) => set({ @@ -53,6 +70,7 @@ export const useBlockingStore = create((set) => ({ maintenanceInfo: info, channelInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, }), setChannelSubscription: (info) => @@ -61,6 +79,7 @@ export const useBlockingStore = create((set) => ({ channelInfo: info, maintenanceInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, }), setBlacklisted: (info) => @@ -69,6 +88,16 @@ export const useBlockingStore = create((set) => ({ blacklistedInfo: info, maintenanceInfo: null, channelInfo: null, + accountDeletedInfo: null, + }), + + setAccountDeleted: (info) => + set({ + blockingType: 'account_deleted', + accountDeletedInfo: info, + maintenanceInfo: null, + channelInfo: null, + blacklistedInfo: null, }), clearBlocking: () => @@ -77,5 +106,6 @@ export const useBlockingStore = create((set) => ({ maintenanceInfo: null, channelInfo: null, blacklistedInfo: null, + accountDeletedInfo: null, }), })); From 8c336d16c72fef8ee062e01b9c2cf32a15c8c6f7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 05:25:41 +0300 Subject: [PATCH 10/10] fix(blocking): open bot deep-link via platform adapter, not window.open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the Telegram WebView, window.open is intercepted by the client and the new-tab fallback is blocked on most platforms (Android, iOS). Use usePlatform().openTelegramLink(url) — same pattern as the rest of the codebase (Support.tsx, etc.). The TelegramAdapter dispatches to the WebApp SDK's openTelegramLink in TG and falls back to window.open in the standalone web build, so both runtimes work. --- src/components/blocking/AccountDeletedScreen.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/blocking/AccountDeletedScreen.tsx b/src/components/blocking/AccountDeletedScreen.tsx index 8cd04a4..fc453c8 100644 --- a/src/components/blocking/AccountDeletedScreen.tsx +++ b/src/components/blocking/AccountDeletedScreen.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { usePlatform } from '@/platform'; import { useBlockingStore } from '../../store/blocking'; /** @@ -18,12 +19,18 @@ import { useBlockingStore } from '../../store/blocking'; */ export default function AccountDeletedScreen() { const { t } = useTranslation(); + const { openTelegramLink } = usePlatform(); const info = useBlockingStore((state) => state.accountDeletedInfo); const deepLink = info?.telegram_deep_link?.trim() || null; + // Route through the platform adapter, not raw window.open. Inside the + // Telegram WebView, window.open is intercepted by the client and the + // new-tab fallback is blocked on most platforms (Android, iOS). The + // TelegramAdapter dispatches to the WebApp SDK's openTelegramLink in + // Telegram and falls back to window.open in the standalone web build. const handleOpenBot = () => { if (deepLink) { - window.open(deepLink, '_blank', 'noopener,noreferrer'); + openTelegramLink(deepLink); } };