diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index fe54b1b..0557e2a 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -4,16 +4,11 @@ import { brandingApi } from '../api/branding' const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' -// Detect low-performance device (mobile in Telegram WebApp) +// Detect if user prefers reduced motion const isLowPerformance = (): boolean => { - // Check if running in Telegram WebApp - const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp - // Check if mobile - const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) - // Check for reduced motion preference + // Only check for reduced motion preference - let animation run everywhere else const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - - return prefersReducedMotion || (isTelegramWebApp && isMobile) + return prefersReducedMotion } // Get cached value from localStorage diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a7a2687..a5a0b45 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useRef } from 'react' +import { useState, useMemo, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' @@ -9,92 +9,42 @@ 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, -} +const BackIcon = () => ( + + + +) -// App icons const HappIcon = () => ( @@ -109,20 +59,6 @@ const HappIcon = () => ( const ClashMetaIcon = () => ( - - - - - - -) - -const ClashVergeIcon = () => ( - - - - - ) @@ -134,90 +70,71 @@ 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 useIsMobile() { + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + 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 [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) - const modalContentRef = useRef(null) - // Telegram Mini App support const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const isMobileScreen = useIsMobile() + const isMobile = isMobileScreen || isTelegramWebApp - // 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 + const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) : 0 + const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -228,54 +145,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,38 +165,15 @@ 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(() => { - 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 { @@ -335,185 +193,158 @@ 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)' - - return ( -
- {/* Mobile: fullscreen */} -
e.stopPropagation()} - > - {/* Mobile close button - fixed top right */} - - - {/* Mobile safe area spacer - top */} -
- - {children} - - {/* Mobile safe area spacer - bottom */} -
-
+ // Desktop modal wrapper + const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
e.stopPropagation()}> + {children}
- ) - } +
+ ) - // Loading state + // Mobile fullscreen wrapper - like React Native Modal with animationType="slide" + const MobileWrapper = ({ children }: { children: React.ReactNode }) => ( + <> + {/* Backdrop */} +
+ {/* Modal - slides from bottom */} +
+ {/* Close button */} + + {children} +
+ + ) + + const Wrapper = isMobile ? MobileWrapper : DesktopWrapper + + // Loading if (isLoading) { return ( - -
+ +
- + ) } - // Error state + // Error if (error || !appConfig) { return ( - -
-
- 😕 -
-

{t('common.error')}

- + +
+
😕
+

{t('common.error')}

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

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

-

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

- + +
+
📱
+

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

+

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

+
- +
) } - // App selector view - if (showAppSelector || !selectedApp) { + // App selector + if (showAppSelector) { + const platformNames: Record = { + ios: 'iOS', + android: 'Android', + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', + androidTV: 'Android TV', + appleTV: 'Apple TV' + } + return ( - + {/* Header */} -
-
- {selectedApp && ( - - )} -
-

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

-

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

-
-
- +

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

- {/* Apps by platform */} -
- {allAppsForSelector.map(({ platform, apps }) => { - const IconComponent = platformIconComponents[platform] + {/* Apps grouped by platform */} +
+ {availablePlatforms.map(platform => { + const apps = appConfig.platforms[platform] + if (!apps?.length) return null const isCurrentPlatform = platform === detectedPlatform + return (
-
-
- {IconComponent && } -
- - {getPlatformName(platform)} - {isCurrentPlatform && ({t('subscription.connection.yourDevice')})} + {/* Platform header */} +
+ + {platformNames[platform] || platform} + {isCurrentPlatform && ( + + {t('subscription.connection.yourDevice')} + + )}
-
- {apps.map((app) => ( + + {/* Apps for this platform */} +
+ {apps.map(app => ( ))}
@@ -521,141 +352,111 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) })}
- - {/* Copy link */} -
- -
- + ) } - // App instructions (main view) + // Main view return ( - - {/* Header with app info and change button */} -
+ + {/* Header - app selector */} +
-
- {/* 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)} - - ))} -
- )} -
+ {/* Content */} +
+ {/* Step 1 */} + {selectedApp?.installationStep && ( +
+
+
1
+

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

-
- )} - - {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-
-
2
-
-

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

-

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

-
- {selectedApp.deepLink && ( - - )} - -
+ + {getLocalizedText(btn.buttonText)} + + ))}
+ )} +
+ )} + + {/* Step 2 */} + {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)} -

-
+ {/* Step 3 */} + {selectedApp?.connectAndUseStep && ( +
+
+
3
+

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

+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

)}
- {/* Footer - hidden on mobile since we have close button on top */} -
- -
- + {/* Desktop footer */} + {!isMobile && ( +
+ +
+ )} + ) }