import { useState, useMemo, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' import type { AppInfo, AppConfig, LocalizedText } from '../types' interface ConnectionModalProps { onClose: () => void } // Platform SVG Icons const IosIcon = () => ( ) const AndroidIcon = () => ( ) const WindowsIcon = () => ( ) const MacosIcon = () => ( ) const LinuxIcon = () => ( ) const TvIcon = () => ( ) const CloseIcon = () => ( ) const BackIcon = () => ( ) const CopyIcon = () => ( ) const CheckIcon = () => ( ) const LinkIcon = () => ( ) const ChevronIcon = () => ( ) // Platform icon components map const platformIconComponents: Record = { ios: IosIcon, android: AndroidIcon, macos: MacosIcon, windows: WindowsIcon, linux: LinuxIcon, androidTV: TvIcon, appleTV: TvIcon, } // App icons const HappIcon = () => ( ) const ClashMetaIcon = () => ( ) const ClashVergeIcon = () => ( ) const ShadowrocketIcon = () => ( ) const StreisandIcon = () => ( ) // App icon mapping by name (case-insensitive) const getAppIcon = (appName: string, isFeatured: boolean): 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('verge')) { return } if (name.includes('clash') || name.includes('meta')) { return } // Default icons return isFeatured ? '⭐' : '📦' } // Platform order for display const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] // Dangerous schemes that should never be allowed 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)) { if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV' return 'android' } if (/macintosh|mac os x/.test(ua)) return 'macos' if (/windows/.test(ua)) return 'windows' if (/linux/.test(ua)) return 'linux' return null } export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation() const [selectedApp, setSelectedApp] = useState(null) const [copied, setCopied] = useState(false) const [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) const modalContentRef = useRef(null) const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }) useEffect(() => { setDetectedPlatform(detectPlatform()) }, []) // Auto-select platform and app when data is loaded useEffect(() => { if (!appConfig?.platforms || selectedApp) return const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0) if (!platform || !appConfig.platforms[platform]?.length) return const apps = appConfig.platforms[platform] // Prefer featured app, otherwise first app const app = apps.find(a => a.isFeatured) || apps[0] if (app) { setSelectedApp(app) } }, [appConfig, detectedPlatform, selectedApp]) // Scroll modal content to top when switching views useEffect(() => { modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) }, [showAppSelector]) // Scroll lock when modal is open (cross-platform including Android) useEffect(() => { const scrollY = window.scrollY const body = document.body const html = document.documentElement // Save original styles const originalStyles = { bodyOverflow: body.style.overflow, bodyPosition: body.style.position, bodyTop: body.style.top, bodyWidth: body.style.width, bodyHeight: body.style.height, htmlOverflow: html.style.overflow, htmlHeight: html.style.height, bodyTouchAction: body.style.touchAction, } // Lock scroll body.style.overflow = 'hidden' body.style.position = 'fixed' body.style.top = `-${scrollY}px` body.style.width = '100%' body.style.height = '100%' body.style.touchAction = 'none' html.style.overflow = 'hidden' html.style.height = '100%' // Prevent touchmove on backdrop (Android fix) const preventScroll = (e: TouchEvent) => { const target = e.target as HTMLElement // Allow scroll inside modal content if (target.closest('[data-modal-content]')) return e.preventDefault() } document.addEventListener('touchmove', preventScroll, { passive: false }) return () => { body.style.overflow = originalStyles.bodyOverflow body.style.position = originalStyles.bodyPosition body.style.top = originalStyles.bodyTop body.style.width = originalStyles.bodyWidth body.style.height = originalStyles.bodyHeight body.style.touchAction = originalStyles.bodyTouchAction html.style.overflow = originalStyles.htmlOverflow html.style.height = originalStyles.htmlHeight document.removeEventListener('touchmove', preventScroll) window.scrollTo(0, scrollY) } }, []) 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] || '' } const getPlatformName = (platformKey: string): string => { if (!appConfig?.platformNames?.[platformKey]) { return platformKey } return getLocalizedText(appConfig.platformNames[platformKey]) } const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] const available = platformOrder.filter( (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 ) if (detectedPlatform && available.includes(detectedPlatform)) { const filtered = available.filter(p => p !== detectedPlatform) return [detectedPlatform, ...filtered] } return available }, [appConfig, detectedPlatform]) // Get all apps for selector (must be before any conditional returns) const allAppsForSelector = useMemo(() => { if (!appConfig?.platforms) return [] const result: { platform: string; apps: AppInfo[] }[] = [] for (const platform of availablePlatforms) { const apps = appConfig.platforms[platform] if (apps?.length) { result.push({ platform, apps }) } } return result }, [appConfig, availablePlatforms]) 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 handleConnect = (app: AppInfo) => { if (!app.deepLink || !isValidDeepLink(app.deepLink)) { console.warn('Invalid or missing deep link:', app.deepLink) return } const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp if (isCustomScheme && tg?.openLink) { try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return } catch (e) { console.warn('tg.openLink failed:', e) } } window.location.href = redirectUrl } // Modal wrapper - top aligned with auto-focus const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
e.stopPropagation()} > {children}
) // Loading state if (isLoading) { return (
) } // Error state if (error || !appConfig) { return (
😕

{t('common.error')}

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

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

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

) } // App selector view if (showAppSelector || !selectedApp) { return ( {/* Header */}
{selectedApp && ( )}

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

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

{/* Apps by platform */}
{allAppsForSelector.map(({ platform, apps }) => { const IconComponent = platformIconComponents[platform] const isCurrentPlatform = platform === detectedPlatform return (
{IconComponent && }
{getPlatformName(platform)} {isCurrentPlatform && ({t('subscription.connection.yourDevice')})}
{apps.map((app) => ( ))}
) })}
{/* Copy link */}
) } // App instructions (main view) return ( {/* Header with app info and change button */}
{/* Instructions */}
{/* Step 1: Install */} {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(btn.buttonLink)) .map((btn, idx) => ( {getLocalizedText(btn.buttonText)} ))}
)}
)} {/* Step 2: Add subscription */} {selectedApp.addSubscriptionStep && (
2

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

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

{selectedApp.deepLink && ( )}
)} {/* Step 3: Connect */} {selectedApp.connectAndUseStep && (
3

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

{getLocalizedText(selectedApp.connectAndUseStep.description)}

)}
{/* Footer */}
) }