import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' import { useTelegramWebApp } from '../hooks/useTelegramWebApp' import type { AppInfo, AppConfig, LocalizedText } from '../types' interface ConnectionModalProps { onClose: () => void } // Icons const CloseIcon = () => ( ) 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'] 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 } function useIsMobile() { const [isMobile, setIsMobile] = useState(() => { if (typeof window === 'undefined') return false return window.innerWidth < 768 }) useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) return isMobile } export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation() const [selectedApp, setSelectedApp] = useState(null) const [copied, setCopied] = useState(false) const [showAppSelector, setShowAppSelector] = useState(false) const [selectedPlatform, setSelectedPlatform] = useState(null) const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() const isMobileScreen = useIsMobile() const isMobile = isMobileScreen const scrollContainerRef = useRef(null) // Ref для хранСния Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ° BackButton (фикс мигания) const backButtonHandlerRef = useRef<() => void>(() => {}) // Prevent scroll events from bubbling to parent/Telegram const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => { const container = e.currentTarget const { scrollTop, scrollHeight, clientHeight } = container const isAtTop = scrollTop === 0 const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1 // Prevent scroll propagation when not at boundaries, or when scrolling away from boundary if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) { e.stopPropagation() } }, []) const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }) const detectedPlatform = useMemo(() => detectPlatform(), []) useEffect(() => { if (!appConfig?.platforms || selectedApp) return let platform = detectedPlatform if (!platform || !appConfig.platforms[platform]?.length) { platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null } if (!platform || !appConfig.platforms[platform]?.length) return const apps = appConfig.platforms[platform] const app = apps.find(a => a.isFeatured) || apps[0] if (app) setSelectedApp(app) }, [appConfig, detectedPlatform, selectedApp]) const handleClose = useCallback(() => { onClose() }, [onClose]) const handleBack = useCallback(() => { if (selectedPlatform) { setSelectedPlatform(null) } else { setShowAppSelector(false) } }, [selectedPlatform]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() if (showAppSelector) handleBack() else handleClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [handleClose, handleBack, showAppSelector]) // ОбновляСм ref ΠΏΡ€ΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ Π»ΠΎΠ³ΠΈΠΊΠΈ (Π±Π΅Π· пСрСзапуска эффСкта BackButton) useEffect(() => { backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose }, [showAppSelector, handleBack, handleClose]) // Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ BackButton β€” эффСкт запускаСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΡ€ΠΈ mount/unmount // Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ Ρ‡Π΅Ρ€Π΅Π· ref, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΈΠ·Π±Π΅ΠΆΠ°Ρ‚ΡŒ мигания useEffect(() => { if (!webApp?.BackButton) return const stableHandler = () => { backButtonHandlerRef.current() } webApp.BackButton.show() webApp.BackButton.onClick(stableHandler) return () => { webApp.BackButton.offClick(stableHandler) webApp.BackButton.hide() } }, [webApp]) // Волько webApp Π² зависимостях! useEffect(() => { document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = '' } }, []) 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 availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) if (detectedPlatform && available.includes(detectedPlatform)) { return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] } return available }, [appConfig, detectedPlatform]) 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)) return const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp if (tg?.openLink) { try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return } catch { /* fallback */ } } window.location.href = redirectUrl } // Wrapper component const Wrapper = ({ children }: { children: React.ReactNode }) => { if (isMobile) { const content = (
{children}
) if (typeof document !== 'undefined') return createPortal(content, document.body) return content } // Desktop centered - positioned higher return (
e.stopPropagation()} > {children}
) } // 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')}

) } // App selector 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 (

{t('subscription.connection.selectPlatform') || 'Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ ΠΏΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΡƒ'}

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

{platformNames[selectedPlatform] || selectedPlatform}

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

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

{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)} ))}
)}
)} {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)}

)}
) }