diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a7a2687..2ad4d28 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -9,56 +9,13 @@ interface ConnectionModalProps { onClose: () => void } -// Platform SVG Icons -const IosIcon = () => ( - - - -) - -const AndroidIcon = () => ( - - - -) - -const WindowsIcon = () => ( - - - -) - -const MacosIcon = () => ( - - - -) - -const LinuxIcon = () => ( - - - -) - -const TvIcon = () => ( - - - - -) - +// Icons const CloseIcon = () => ( ) -const BackIcon = () => ( - - - -) - const CopyIcon = () => ( @@ -77,26 +34,15 @@ const LinkIcon = () => ( ) -const ChevronIcon = () => ( - - +const ChevronDownIcon = () => ( + + ) -// 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 = () => ( - + @@ -107,117 +53,79 @@ 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 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('verge')) { - return - } - if (name.includes('clash') || name.includes('meta')) { - return - } - // Default icons - return isFeatured ? '⭐' : '📦' + 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 📦 } -// 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 - } + 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 - } + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false return lowerUrl.includes('://') } function detectPlatform(): string | null { - if (typeof window === 'undefined' || !navigator?.userAgent) { - return 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 (/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 isMobile(): boolean { + if (typeof window === 'undefined') return false + return window.innerWidth < 768 +} + 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 modalRef = useRef(null) - // Telegram Mini App support const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() - // Calculate safe area - prefer Telegram values, fallback to CSS env - const safeTop = isTelegramWebApp - ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) - : 0 const safeBottom = isTelegramWebApp - ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) - : 0 + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom, 16) + : 16 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -228,54 +136,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { 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) - } + 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 - useEffect(() => { - const scrollY = window.scrollY - - // Prevent all touch/wheel scroll on backdrop - const preventScroll = (e: TouchEvent) => { - const target = e.target as HTMLElement - if (target.closest('[data-modal-content]')) return - e.preventDefault() - } - - const preventWheel = (e: WheelEvent) => { - const target = e.target as HTMLElement - if (target.closest('[data-modal-content]')) return - e.preventDefault() - } - - document.addEventListener('touchmove', preventScroll, { passive: false }) - document.addEventListener('wheel', preventWheel, { passive: false }) document.body.style.overflow = 'hidden' - - return () => { - document.removeEventListener('touchmove', preventScroll) - document.removeEventListener('wheel', preventWheel) - document.body.style.overflow = '' - window.scrollTo(0, scrollY) - } + return () => { document.body.style.overflow = '' } }, []) const getLocalizedText = (text: LocalizedText | undefined): string => { @@ -284,34 +156,21 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { 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 - ) + const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) if (detectedPlatform && available.includes(detectedPlatform)) { - const filtered = available.filter(p => p !== detectedPlatform) - return [detectedPlatform, ...filtered] + return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] } return available }, [appConfig, detectedPlatform]) - // Get all apps for selector (must be before any conditional returns) - const allAppsForSelector = useMemo(() => { + const allApps = useMemo(() => { if (!appConfig?.platforms) return [] - const result: { platform: string; apps: AppInfo[] }[] = [] + const result: AppInfo[] = [] for (const platform of availablePlatforms) { const apps = appConfig.platforms[platform] - if (apps?.length) { - result.push({ platform, apps }) - } + if (apps?.length) result.push(...apps) } return result }, [appConfig, availablePlatforms]) @@ -335,256 +194,163 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } const handleConnect = (app: AppInfo) => { - if (!app.deepLink || !isValidDeepLink(app.deepLink)) { - console.warn('Invalid or missing deep link:', app.deepLink) - return - } + 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 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) { + if (tg?.openLink) { try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return - } catch (e) { - console.warn('tg.openLink failed:', e) - } + } catch { /* fallback */ } } window.location.href = redirectUrl } - // Modal wrapper - fullscreen on mobile, centered on desktop - const ModalWrapper = ({ children }: { children: React.ReactNode }) => { - // For Telegram, use JS values; for browser, use CSS env() - const closeButtonTop = isTelegramWebApp - ? `${12 + safeTop}px` - : `calc(0.75rem + env(safe-area-inset-top, 0px))` - - const safeTopStyle = isTelegramWebApp - ? `${safeTop}px` - : 'env(safe-area-inset-top, 0px)' - - const safeBottomStyle = isTelegramWebApp - ? `${safeBottom}px` - : 'env(safe-area-inset-bottom, 0px)' + const mobile = isMobile() || isTelegramWebApp + // Loading + if (isLoading) { return ( -
- {/* Mobile: fullscreen */} -
e.stopPropagation()} - > - {/* Mobile close button - fixed top right */} - - - {/* Mobile safe area spacer - top */} -
- - {children} - - {/* Mobile safe area spacer - bottom */} -
+
+
e.stopPropagation()}> +
) } - // Loading state - if (isLoading) { - return ( - -
-
-
- - ) - } - - // Error state + // Error or no config if (error || !appConfig) { return ( - -
-
- 😕 -
-

{t('common.error')}

- +
+
e.stopPropagation()}> +
😕
+

{t('common.error')}

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

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

-

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

- +
+
e.stopPropagation()}> +
📱
+

{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
+ // App selector dropdown + if (showAppSelector) { + return ( +
+
e.stopPropagation()} + > + {/* Handle bar for mobile */} + {mobile &&
} + + {/* Header */} +
+

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

+ +
+ + {/* Apps list */} +
+ {allApps.map(app => ( + + ))} +
+
+
+ ) + } + + // Main view - Instructions + return ( +
+
e.stopPropagation()} + > + {/* Handle bar for mobile */} + {mobile &&
} + + {/* Header with app selector */} +
+ + {!mobile && ( + + )} +
+ + {/* Content */} +
+ {/* Step 1: Install (compact) */} + {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) => ( + {selectedApp.installationStep.buttons?.length > 0 && ( +
+ {selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( {getLocalizedText(btn.buttonText)} @@ -594,68 +360,49 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { )}
-
- )} + )} - {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-
-
2
-
-

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

-

+ {/* Step 2: Connect (main action) */} + {selectedApp?.addSubscriptionStep && ( +

+
2
+
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

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

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

-

- {getLocalizedText(selectedApp.connectAndUseStep.description)} -

-
+ {/* Step 3: Ready (minimal) */} + {selectedApp?.connectAndUseStep && ( +
+
3
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

-
- )} + )} +
- - {/* Footer - hidden on mobile since we have close button on top */} -
- -
- +
) }