From dd85a465953f97793e65b7725b9cbfb514a68b71 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:26:25 +0300 Subject: [PATCH] Refactor ConnectionModal component: update platform icons to a consistent size, enhance loading and error states with improved UI, and streamline platform detection logic. Add new icons for close, back, copy, check, link, and chevron actions. Optimize layout and styling for better user experience. --- src/components/ConnectionModal.tsx | 520 +++++++++++++---------------- 1 file changed, 233 insertions(+), 287 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 473022d..11c299a 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -10,40 +10,75 @@ interface ConnectionModalProps { // 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 = () => ( + + ) @@ -64,72 +99,37 @@ const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV // Dangerous schemes that should never be allowed const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] -/** - * Validate URL to prevent XSS via javascript: and other dangerous schemes - * Only allows http, https, and known app store URLs - */ function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - - // Block dangerous schemes - const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - - // Allow only http/https URLs return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') } -/** - * Validate deep link URL - blocks dangerous schemes only - * Allows any custom app scheme (clash://, hiddify://, etc.) - */ function isValidDeepLink(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - - // Block dangerous schemes if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - - // Allow any URL that has a scheme (contains ://) return lowerUrl.includes('://') } -// Detect user's platform from user agent function detectPlatform(): string | null { if (typeof window === 'undefined' || !navigator?.userAgent) { return null } - const ua = navigator.userAgent.toLowerCase() - - // Check for mobile devices first - if (/iphone|ipad|ipod/.test(ua)) { - return 'ios' - } + if (/iphone|ipad|ipod/.test(ua)) return 'ios' if (/android/.test(ua)) { - // Check if it's Android TV - if (/tv|television|smart-tv|smarttv/.test(ua)) { - return 'androidTV' - } + if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV' return 'android' } - - // Desktop platforms - if (/macintosh|mac os x/.test(ua)) { - return 'macos' - } - if (/windows/.test(ua)) { - return 'windows' - } - if (/linux/.test(ua)) { - return 'linux' - } - + if (/macintosh|mac os x/.test(ua)) return 'macos' + if (/windows/.test(ua)) return 'windows' + if (/linux/.test(ua)) return 'linux' return null } @@ -145,20 +145,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { queryFn: () => subscriptionApi.getAppConfig(), }) - // Auto-detect platform on mount useEffect(() => { - const detected = detectPlatform() - setDetectedPlatform(detected) + setDetectedPlatform(detectPlatform()) }, []) - // Helper to get localized text 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 platform name const getPlatformName = (platformKey: string): string => { if (!appConfig?.platformNames?.[platformKey]) { return platformKey @@ -166,13 +162,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return getLocalizedText(appConfig.platformNames[platformKey]) } - // Get available platforms sorted (detected platform first) const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] const available = platformOrder.filter( (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 ) - // Move detected platform to the front if (detectedPlatform && available.includes(detectedPlatform)) { const filtered = available.filter(p => p !== detectedPlatform) return [detectedPlatform, ...filtered] @@ -180,13 +174,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return available }, [appConfig, detectedPlatform]) - // Get apps for selected platform const platformApps = useMemo(() => { if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] return appConfig.platforms[selectedPlatform] }, [selectedPlatform, appConfig]) - // Copy subscription link const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -194,7 +186,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { - // Fallback for older browsers const textarea = document.createElement('textarea') textarea.value = appConfig.subscriptionUrl document.body.appendChild(textarea) @@ -206,24 +197,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } } - // Handle deep link click - use miniapp redirect page like in miniapp index.html const handleConnect = (app: AppInfo) => { - // Validate deep link to prevent XSS 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}` - - // Check if it's a custom URL scheme (not http/https) const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) - const tg = (window as any).Telegram?.WebApp - + const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp if (isCustomScheme && tg?.openLink) { - // For custom URL schemes - open redirect page in external browser via Telegram - // try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных) try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return @@ -231,67 +214,55 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { console.warn('tg.openLink failed:', e) } } - - // Fallback - direct navigation window.location.href = redirectUrl } - // Modal wrapper classes - bottom sheet on mobile, centered on desktop - const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center" - const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden" - const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6" - // Allow the sheet to almost fill the viewport height on phones - const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]" - + // Loading state if (isLoading) { return ( -
-
e.stopPropagation()}> -
-
-
-
+
+
e.stopPropagation()}> +
+
) } + // Error state if (error || !appConfig) { return ( -
-
e.stopPropagation()}> -
-
-

{t('common.error')}

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

{t('common.error')}

+
) } + // No subscription if (!appConfig.hasSubscription) { return ( -
-
e.stopPropagation()}> -
-
-

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

- -
-
-

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

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

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

+

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

+
@@ -301,75 +272,86 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 1: Select platform if (!selectedPlatform) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-

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

-
- {/* Scrollable content */} -
-

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

- -
+ {/* Content */} +
+
{availablePlatforms.map((platform) => { const IconComponent = platformIconComponents[platform] + const isDetected = platform === detectedPlatform return ( ) })}
- {/* Footer - fixed with safe area */} -
+ {/* Footer */} +
@@ -379,62 +361,65 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) } - // Step 2: Select app (if not selected yet) + // Step 2: Select app if (!selectedApp) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
+
+
e.stopPropagation()} + > + {/* Header */} +
- -

- {getPlatformName(selectedPlatform)} -

+
+

{getPlatformName(selectedPlatform)}

+

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

+
-
- {/* Scrollable content */} -
-

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

- + {/* Content */} +
{platformApps.length === 0 ? ( -
+
+
+ 📭 +

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

) : ( -
+
{platformApps.map((app) => ( ))}
@@ -445,46 +430,44 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) } - // Step 3: Show app instructions and connect button + // Step 3: App instructions return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
+
+
e.stopPropagation()} + > + {/* Header */} +
- -

- {selectedApp.name} -

+
+

{selectedApp.name}

+

Инструкция по подключению

+
-
- {/* Scrollable content */} -
-
+ {/* Content */} +
+
{/* Step 1: Install */} {selectedApp.installationStep && ( -
-

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

-

+

+
+
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) => ( @@ -495,9 +478,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { rel="noopener noreferrer" className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm hover:bg-dark-600 transition-all" > - - - + {getLocalizedText(btn.buttonText)} ))} @@ -506,88 +487,53 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
)} - {/* Additional before add subscription */} - {selectedApp.additionalBeforeAddSubscriptionStep && ( -
- {selectedApp.additionalBeforeAddSubscriptionStep.title && ( -

- {getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)} -

- )} -

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

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

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

-

+

+
+
2
+

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

+
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

- {/* Connect button */} - {selectedApp.deepLink && ( - - )} - - {/* Copy link fallback */} - )} - -
- )} - {/* Additional after add subscription */} - {selectedApp.additionalAfterAddSubscriptionStep && ( -
- {selectedApp.additionalAfterAddSubscriptionStep.title && ( -

- {getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)} -

- )} -

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

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

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

-

+

+
+
3
+

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

+
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

@@ -595,9 +541,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
- {/* Footer - fixed with safe area */} -
-