import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react'; import DOMPurify from 'dompurify'; import { subscriptionApi } from '../api/subscription'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; import { useBackButton, useHaptic } from '@/platform'; import { resolveTemplate, hasTemplates } from '../utils/templateEngine'; import { useAuthStore } from '../store/auth'; import type { AppInfo, AppConfig, LocalizedText, RemnawaveAppClient, RemnawavePlatformData, } from '../types'; // Icons const CopyIcon = () => ( ); const CheckIcon = () => ( ); const LinkIcon = () => ( ); const ChevronIcon = () => ( ); const BackIcon = () => ( ); // App icons const HappIcon = () => ( ); const ClashMetaIcon = () => ( ); const ShadowrocketIcon = () => ( ); const StreisandIcon = () => ( ); const getAppIcon = (appName: string): React.ReactNode => { const name = appName.toLowerCase(); if (name.includes('happ')) return ; if (name.includes('shadowrocket') || name.includes('rocket')) return ; if (name.includes('streisand')) return ; if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return ; return πŸ“¦; }; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; // eslint-disable-next-line no-script-url -- listing dangerous schemes to block them const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false; const lowerUrl = url.toLowerCase().trim(); if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); } function isValidDeepLink(url: string | undefined): boolean { if (!url) return false; const lowerUrl = url.toLowerCase().trim(); if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; return lowerUrl.includes('://'); } function detectPlatform(): string | null { if (typeof window === 'undefined' || !navigator?.userAgent) return null; const ua = navigator.userAgent.toLowerCase(); if (/iphone|ipad|ipod/.test(ua)) return 'ios'; if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'; if (/macintosh|mac os x/.test(ua)) return 'macos'; if (/windows/.test(ua)) return 'windows'; if (/linux/.test(ua)) return 'linux'; return null; } // Type guards for platform data function isRemnawavePlatform( data: AppInfo[] | RemnawavePlatformData, ): data is RemnawavePlatformData { return data && !Array.isArray(data) && 'apps' in data; } function getPlatformAppsCount(data: AppInfo[] | RemnawavePlatformData): number { if (isRemnawavePlatform(data)) return data.apps.length; return Array.isArray(data) ? data.length : 0; } function getRemnawaveApps(data: AppInfo[] | RemnawavePlatformData): RemnawaveAppClient[] { if (isRemnawavePlatform(data)) return data.apps; return []; } function getClassicApps(data: AppInfo[] | RemnawavePlatformData): AppInfo[] { if (Array.isArray(data)) return data; return []; } export default function Connection() { const { t, i18n } = useTranslation(); const navigate = useNavigate(); const { user } = useAuthStore(); const [selectedApp, setSelectedApp] = useState(null); const [selectedRemnawaveApp, setSelectedRemnawaveApp] = useState(null); const [copied, setCopied] = useState(false); const [showAppSelector, setShowAppSelector] = useState(false); const [selectedPlatform, setSelectedPlatform] = useState(null); const [activePlatformKey, setActivePlatformKey] = useState(null); const { isTelegramWebApp } = useTelegramWebApp(); const { impact: hapticImpact } = useHaptic(); const scrollContainerRef = useRef(null); // Ref для хранСния Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ° BackButton (фикс мигания) const backButtonHandlerRef = useRef<() => void>(() => {}); // Ref for haptic to avoid recreating handleBackButton const hapticRef = useRef(hapticImpact); hapticRef.current = hapticImpact; const { data: appConfig, isLoading, error, } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }); const isRemnawave = appConfig?.isRemnawave === true; const detectedPlatform = useMemo(() => detectPlatform(), []); // Auto-select app on load (classic format) useEffect(() => { if (!appConfig?.platforms || isRemnawave || selectedApp) return; let platform = detectedPlatform; if (!platform || !getPlatformAppsCount(appConfig.platforms[platform] ?? [])) { platform = platformOrder.find((p) => getPlatformAppsCount(appConfig.platforms[p] ?? []) > 0) || null; } if (!platform) return; const apps = getClassicApps(appConfig.platforms[platform] ?? []); if (!apps.length) return; const app = apps.find((a) => a.isFeatured) || apps[0]; if (app) setSelectedApp(app); }, [appConfig, detectedPlatform, selectedApp, isRemnawave]); // Auto-select app on load (RemnaWave format) useEffect(() => { if (!appConfig?.platforms || !isRemnawave || selectedRemnawaveApp) return; let platform = detectedPlatform; if (!platform || !getPlatformAppsCount(appConfig.platforms[platform] ?? [])) { platform = platformOrder.find((p) => getPlatformAppsCount(appConfig.platforms[p] ?? []) > 0) || null; } if (!platform) return; const apps = getRemnawaveApps(appConfig.platforms[platform] ?? []); if (!apps.length) return; const app = apps.find((a) => a.featured) || apps[0]; if (app) { setSelectedRemnawaveApp(app); setActivePlatformKey(platform); } }, [appConfig, detectedPlatform, selectedRemnawaveApp, isRemnawave]); const handleGoBack = useCallback(() => { navigate(-1); }, [navigate]); const handleBack = useCallback(() => { if (showAppSelector) { if (selectedPlatform) { setSelectedPlatform(null); } else { setShowAppSelector(false); } } else { navigate(-1); } }, [showAppSelector, selectedPlatform, navigate]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); if (showAppSelector) handleBack(); else handleGoBack(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleGoBack, handleBack, showAppSelector]); // ОбновляСм ref ΠΏΡ€ΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ Π»ΠΎΠ³ΠΈΠΊΠΈ (Π±Π΅Π· пСрСзапуска эффСкта BackButton) useEffect(() => { backButtonHandlerRef.current = showAppSelector ? handleBack : handleGoBack; }, [showAppSelector, handleBack, handleGoBack]); // BackButton using platform hook - always close/back, ref provides current handler const handleBackButton = useCallback(() => { hapticRef.current('light'); backButtonHandlerRef.current(); }, []); useBackButton(handleBackButton); const getLocalizedText = (text: LocalizedText | undefined): string => { if (!text) return ''; const lang = i18n.language || 'en'; return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; }; // Get translation from RemnaWave baseTranslations with i18n fallback const getBaseTranslation = ( key: string, i18nKey: string, interpolation?: Record, ): string => { const bt = appConfig?.baseTranslations; if (bt && key in bt) { const text = getLocalizedText(bt[key as keyof typeof bt] as LocalizedText); if (text) return text; } return t(i18nKey, interpolation); }; const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return []; const available = platformOrder.filter( (key) => getPlatformAppsCount(appConfig.platforms[key] ?? []) > 0, ); if (detectedPlatform && available.includes(detectedPlatform)) { return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; } return available; }, [appConfig, detectedPlatform]); // Resolve templates in a URL using subscription URL + username const resolveUrl = useCallback( (url: string): string => { if (!hasTemplates(url) || !appConfig?.subscriptionUrl) return url; return resolveTemplate(url, { subscriptionUrl: appConfig.subscriptionUrl, username: user?.username ?? undefined, }); }, [appConfig?.subscriptionUrl, user?.username], ); const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return; try { await navigator.clipboard.writeText(appConfig.subscriptionUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { const textarea = document.createElement('textarea'); textarea.value = appConfig.subscriptionUrl; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const openDeepLink = useCallback( (deepLink: string) => { let resolved = deepLink; if (hasTemplates(resolved)) { resolved = resolveUrl(resolved); } const isMobilePlatform = detectedPlatform === 'ios' || detectedPlatform === 'android'; // Desktop: deep links need redirect page (browser can't handle custom schemes directly) // Mobile: system browser handles custom schemes natively const finalUrl = isMobilePlatform ? resolved : `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`; // In Telegram Mini App β€” sdkOpenLink opens URL in system browser, // where custom URL schemes work (WebView itself can't handle them) if (isTelegramWebApp) { try { sdkOpenLink(finalUrl, { tryInstantView: false }); return; } catch { // SDK not available, fallback } } window.location.href = finalUrl; }, [detectedPlatform, isTelegramWebApp, i18n.language, resolveUrl], ); const handleConnect = (app: AppInfo) => { if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; openDeepLink(app.deepLink); }; // Resolve button links for step buttons const resolveButtonLink = (link: string): string => { return resolveUrl(link); }; // Get sanitized SVG HTML const getSvgHtml = (svgKey: string | undefined): string => { if (!svgKey || !appConfig?.svgLibrary?.[svgKey]) return ''; const entry = appConfig.svgLibrary[svgKey]; const raw = typeof entry === 'string' ? entry : entry.svgString; if (!raw) return ''; return DOMPurify.sanitize(raw, { USE_PROFILES: { svg: true, svgFilters: true }, }); }; // Loading if (isLoading) { return (
); } // Error if (error || !appConfig) { return (

{t('common.error')}

); } // No subscription if (!appConfig.hasSubscription) { return (

{t('subscription.connection.title')}

{t('subscription.connection.noSubscription')}

); } // ============================================= // RemnaWave format rendering // ============================================= if (isRemnawave) { const currentApp = selectedRemnawaveApp; // Get platform display name from config or fallback const getPlatformDisplayName = (key: string): string => { // 1. displayName ΠΈΠ· Π΄Π°Π½Π½Ρ‹Ρ… ΠΏΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΡ‹ (RemnaWave) const platformData = appConfig.platforms[key]; if (platformData && !Array.isArray(platformData) && platformData.displayName) { const name = getLocalizedText(platformData.displayName); if (name) return name; } // 2. platformNames ΠΈΠ· бэкСнда (фоллбэк) if (appConfig.platformNames?.[key]) { return getLocalizedText(appConfig.platformNames[key]); } // 3. Π₯Π°Ρ€Π΄ΠΊΠΎΠ΄ фоллбэк (послСдний Ρ€ΡƒΠ±Π΅ΠΆ) const fallback: Record = { ios: 'iOS', android: 'Android', windows: 'Windows', macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV', }; return fallback[key] || key; }; // Current platform apps for chips const currentPlatformKey = activePlatformKey || availablePlatforms[0]; const currentPlatformData = currentPlatformKey ? appConfig.platforms[currentPlatformKey] : undefined; const currentPlatformApps = currentPlatformData ? getRemnawaveApps(currentPlatformData) : []; // Current platform SVG icon for dropdown const currentPlatformSvgKey = currentPlatformData && isRemnawavePlatform(currentPlatformData) ? currentPlatformData.svgIconKey : undefined; const currentPlatformSvg = getSvgHtml(currentPlatformSvgKey); // Main RemnaWave view β€” inline app chips + blocks return (
{/* Header + platform dropdown */}
{!isTelegramWebApp && ( )}

{t('subscription.connection.title')}

{/* Platform dropdown with icon */} {availablePlatforms.length > 1 && (
{currentPlatformSvg && (
)}
)}
{/* App cards grid (2 cols mobile, row on desktop) */} {currentPlatformApps.length > 0 && (
{currentPlatformApps.map((app, idx) => { const isSelected = currentApp?.name === app.name; const appIconSvg = getSvgHtml(app.svgIconKey); return ( ); })}
)} {/* Tutorial button */} {appConfig.baseSettings?.isShowTutorialButton && appConfig.baseSettings?.tutorialUrl && ( {getBaseTranslation('tutorial', 'subscription.connection.tutorial')} )} {/* Blocks */} {currentApp && (
{currentApp.blocks.map((block, blockIdx) => { const svgHtml = getSvgHtml(block.svgIconKey); const colorMap: Record = { violet: '#8B5CF6', cyan: '#06B6D4', teal: '#14B8A6', red: '#EF4444', blue: '#3B82F6', green: '#22C55E', yellow: '#EAB308', orange: '#F97316', pink: '#EC4899', indigo: '#6366F1', amber: '#F59E0B', }; const rawColor = block.svgIconColor || 'violet'; const iconColor = colorMap[rawColor] || rawColor; // Fallback block title from baseTranslations by block index const blockTitleFallbackKeys = ['installApp', 'addSubscription', 'connectAndUse']; const blockTitleFallbackI18n = [ 'subscription.connection.installApp', 'subscription.connection.addSubscription', 'subscription.connection.connectVpn', ]; const blockTitle = getLocalizedText(block.title) || (blockIdx < blockTitleFallbackKeys.length ? getBaseTranslation( blockTitleFallbackKeys[blockIdx], blockTitleFallbackI18n[blockIdx], ) : ''); return (
{/* SVG icon */} {svgHtml && (
)}

{blockTitle}

{getLocalizedText(block.description)}

{/* Buttons */} {block.buttons && block.buttons.length > 0 && (
{block.buttons.map((btn, btnIdx) => { const btnText = getLocalizedText(btn.text) || getBaseTranslation('openApp', 'subscription.connection.openLink'); if (btn.type === 'subscriptionLink') { // Use app deepLink first, fallback to resolved button URL const deepLink = currentApp?.deepLink || btn.resolvedUrl; if (!deepLink || !isValidDeepLink(deepLink)) return null; const subBtnSvg = getSvgHtml(btn.svgIconKey); return ( ); } if (btn.type === 'copyButton') { if (appConfig?.hideLink) return null; return ( ); } // external link const href = btn.resolvedUrl || btn.url || btn.link || ''; if (!isValidExternalUrl(href)) return null; // Render SVG icon for button if available const btnSvgHtml = getSvgHtml(btn.svgIconKey); return ( {btnSvgHtml ? ( )}
); })}
)}
); } // ============================================= // Classic format rendering (fallback) // ============================================= // App selector (classic) if (showAppSelector) { const platformNames: Record = { ios: 'iOS', android: 'Android', windows: 'Windows', macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV', }; const platformIcons: Record = { ios: ( ), android: ( ), windows: ( ), macos: ( ), linux: ( ), androidTV: ( ), appleTV: ( ), }; // Step 1: Platform selection if (!selectedPlatform) { return (
{!isTelegramWebApp && ( )}

{t('subscription.connection.selectPlatform')}

{availablePlatforms.map((platform) => { const platformData = appConfig.platforms[platform]; if (!platformData) return null; const apps = getClassicApps(platformData); if (!apps.length) return null; const isCurrentPlatform = platform === detectedPlatform; const appCount = apps.length; return ( ); })}
); } // Step 2: App selection for chosen platform const platformData = appConfig.platforms[selectedPlatform]; const apps = platformData ? getClassicApps(platformData) : []; const isCurrentPlatform = selectedPlatform === detectedPlatform; return (
{!isTelegramWebApp && ( )}

{platformNames[selectedPlatform] || selectedPlatform}

{isCurrentPlatform && ( {t('subscription.connection.yourDevice')} )}
{apps.map((app) => { const isSelected = selectedApp?.id === app.id; return ( ); })}
); } // Main view (classic) return (

{t('subscription.connection.title')}

{!isTelegramWebApp && ( )}
{selectedApp?.installationStep && (
1 {t('subscription.connection.installApp')}

{getLocalizedText(selectedApp.installationStep.description)}

{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
{selectedApp.installationStep.buttons .filter((btn) => isValidExternalUrl(resolveButtonLink(btn.buttonLink))) .map((btn, idx) => ( {getLocalizedText(btn.buttonText)} ))}
)}
)} {selectedApp?.addSubscriptionStep && (
2 {t('subscription.connection.addSubscription')}

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

{selectedApp.deepLink && ( )} {/* Copy link button - hidden when hideLink is true */} {!appConfig?.hideLink && ( )}
)} {selectedApp?.connectAndUseStep && (
3 {t('subscription.connection.connectVpn')}

{getLocalizedText(selectedApp.connectAndUseStep.description)}

)}
); }