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.

This commit is contained in:
PEDZEO
2026-01-20 02:26:25 +03:00
parent 64acfbee72
commit dd85a46595

View File

@@ -10,40 +10,75 @@ interface ConnectionModalProps {
// Platform SVG Icons // Platform SVG Icons
const IosIcon = () => ( const IosIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/> <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg> </svg>
) )
const AndroidIcon = () => ( const AndroidIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24a11.463 11.463 0 00-8.94 0L5.65 5.67c-.19-.29-.58-.38-.87-.2-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 003 18h18a10.78 10.78 0 00-3.4-8.52zM7 15.25a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5zm10 0a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z"/> <path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24a11.463 11.463 0 00-8.94 0L5.65 5.67c-.19-.29-.58-.38-.87-.2-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 003 18h18a10.78 10.78 0 00-3.4-8.52zM7 15.25a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5zm10 0a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z"/>
</svg> </svg>
) )
const WindowsIcon = () => ( const WindowsIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/> <path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/>
</svg> </svg>
) )
const MacosIcon = () => ( const MacosIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h16a2 2 0 012 2v10a2 2 0 01-2 2h-6v2h2a1 1 0 110 2H8a1 1 0 110-2h2v-2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v10h16V6H4zm8 2.5a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1z"/> <path d="M4 4h16a2 2 0 012 2v10a2 2 0 01-2 2h-6v2h2a1 1 0 110 2H8a1 1 0 110-2h2v-2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v10h16V6H4z"/>
</svg> </svg>
) )
const LinuxIcon = () => ( const LinuxIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C9.5 2 8 4.5 8 7c0 1.5.5 3 1 4-1.5 1-3 3-3 5 0 .5 0 1 .5 1.5-.5.5-1.5 1-1.5 2 0 1.5 2 2.5 4 2.5h6c2 0 4-1 4-2.5 0-1-1-1.5-1.5-2 .5-.5.5-1 .5-1.5 0-2-1.5-4-3-5 .5-1 1-2.5 1-4 0-2.5-1.5-5-4-5zm-2 5c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm4 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-2 3c1 0 2 .5 2 1s-1 1-2 1-2-.5-2-1 1-1 2-1z"/> <path d="M12 2C9.5 2 8 4.5 8 7c0 1.5.5 3 1 4-1.5 1-3 3-3 5 0 .5 0 1 .5 1.5-.5.5-1.5 1-1.5 2 0 1.5 2 2.5 4 2.5h6c2 0 4-1 4-2.5 0-1-1-1.5-1.5-2 .5-.5.5-1 .5-1.5 0-2-1.5-4-3-5 .5-1 1-2.5 1-4 0-2.5-1.5-5-4-5zm-2 5c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm4 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-2 3c1 0 2 .5 2 1s-1 1-2 1-2-.5-2-1 1-1 2-1z"/>
</svg> </svg>
) )
const TvIcon = () => ( const TvIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="13" rx="2" ry="2"/> <rect x="2" y="4" width="20" height="13" rx="2" ry="2"/>
<polyline points="8 21 12 17 16 21"/> <polyline points="8 21 12 17 16 21"/>
<line x1="12" y1="17" x2="12" y2="13"/> </svg>
)
const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const BackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
const CopyIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)
const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)
const LinkIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
)
const ChevronIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
) )
@@ -64,72 +99,37 @@ const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV
// Dangerous schemes that should never be allowed // Dangerous schemes that should never be allowed
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] 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 { function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false if (!url) return false
const lowerUrl = url.toLowerCase().trim() const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false return false
} }
// Allow only http/https URLs
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') 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 { function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false if (!url) return false
const lowerUrl = url.toLowerCase().trim() const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false return false
} }
// Allow any URL that has a scheme (contains ://)
return lowerUrl.includes('://') return lowerUrl.includes('://')
} }
// Detect user's platform from user agent
function detectPlatform(): string | null { function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) { if (typeof window === 'undefined' || !navigator?.userAgent) {
return null return null
} }
const ua = navigator.userAgent.toLowerCase() const ua = navigator.userAgent.toLowerCase()
if (/iphone|ipad|ipod/.test(ua)) return 'ios'
// Check for mobile devices first
if (/iphone|ipad|ipod/.test(ua)) {
return 'ios'
}
if (/android/.test(ua)) { 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' return 'android'
} }
if (/macintosh|mac os x/.test(ua)) return 'macos'
// Desktop platforms if (/windows/.test(ua)) return 'windows'
if (/macintosh|mac os x/.test(ua)) { if (/linux/.test(ua)) return 'linux'
return 'macos'
}
if (/windows/.test(ua)) {
return 'windows'
}
if (/linux/.test(ua)) {
return 'linux'
}
return null return null
} }
@@ -145,20 +145,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
queryFn: () => subscriptionApi.getAppConfig(), queryFn: () => subscriptionApi.getAppConfig(),
}) })
// Auto-detect platform on mount
useEffect(() => { useEffect(() => {
const detected = detectPlatform() setDetectedPlatform(detectPlatform())
setDetectedPlatform(detected)
}, []) }, [])
// Helper to get localized text
const getLocalizedText = (text: LocalizedText | undefined): string => { const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return '' if (!text) return ''
const lang = i18n.language || 'en' const lang = i18n.language || 'en'
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''
} }
// Get platform name
const getPlatformName = (platformKey: string): string => { const getPlatformName = (platformKey: string): string => {
if (!appConfig?.platformNames?.[platformKey]) { if (!appConfig?.platformNames?.[platformKey]) {
return platformKey return platformKey
@@ -166,13 +162,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return getLocalizedText(appConfig.platformNames[platformKey]) return getLocalizedText(appConfig.platformNames[platformKey])
} }
// Get available platforms sorted (detected platform first)
const availablePlatforms = useMemo(() => { const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return [] if (!appConfig?.platforms) return []
const available = platformOrder.filter( const available = platformOrder.filter(
(key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0
) )
// Move detected platform to the front
if (detectedPlatform && available.includes(detectedPlatform)) { if (detectedPlatform && available.includes(detectedPlatform)) {
const filtered = available.filter(p => p !== detectedPlatform) const filtered = available.filter(p => p !== detectedPlatform)
return [detectedPlatform, ...filtered] return [detectedPlatform, ...filtered]
@@ -180,13 +174,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return available return available
}, [appConfig, detectedPlatform]) }, [appConfig, detectedPlatform])
// Get apps for selected platform
const platformApps = useMemo(() => { const platformApps = useMemo(() => {
if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return []
return appConfig.platforms[selectedPlatform] return appConfig.platforms[selectedPlatform]
}, [selectedPlatform, appConfig]) }, [selectedPlatform, appConfig])
// Copy subscription link
const copySubscriptionLink = async () => { const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return if (!appConfig?.subscriptionUrl) return
try { try {
@@ -194,7 +186,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} catch { } catch {
// Fallback for older browsers
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea')
textarea.value = appConfig.subscriptionUrl textarea.value = appConfig.subscriptionUrl
document.body.appendChild(textarea) 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) => { const handleConnect = (app: AppInfo) => {
// Validate deep link to prevent XSS
if (!app.deepLink || !isValidDeepLink(app.deepLink)) { if (!app.deepLink || !isValidDeepLink(app.deepLink)) {
console.warn('Invalid or missing deep link:', app.deepLink) console.warn('Invalid or missing deep link:', app.deepLink)
return return
} }
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` 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 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) { if (isCustomScheme && tg?.openLink) {
// For custom URL schemes - open redirect page in external browser via Telegram
// try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных)
try { try {
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
return return
@@ -231,67 +214,55 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
console.warn('tg.openLink failed:', e) console.warn('tg.openLink failed:', e)
} }
} }
// Fallback - direct navigation
window.location.href = redirectUrl window.location.href = redirectUrl
} }
// Modal wrapper classes - bottom sheet on mobile, centered on desktop // Loading state
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]"
if (isLoading) { if (isLoading) {
return ( return (
<div className={modalOverlayClass} onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}> <div className="w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 p-6" onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}> <div className="flex justify-center py-12">
<div className="flex justify-center py-8"> <div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin" />
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div> </div>
</div> </div>
</div> </div>
) )
} }
// Error state
if (error || !appConfig) { if (error || !appConfig) {
return ( return (
<div className={modalOverlayClass} onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}> <div className="w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 p-6" onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}> <div className="text-center py-8">
<div className="text-center py-8"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-error-500/10 flex items-center justify-center">
<p className="text-error-400">{t('common.error')}</p> <span className="text-3xl">😕</span>
<button onClick={onClose} className="btn-secondary mt-4">
{t('common.close')}
</button>
</div> </div>
<p className="text-dark-300 mb-4">{t('common.error')}</p>
<button onClick={onClose} className="btn-primary">
{t('common.close')}
</button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
// No subscription
if (!appConfig.hasSubscription) { if (!appConfig.hasSubscription) {
return ( return (
<div className={modalOverlayClass} onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}> <div className="w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 p-6" onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}> <div className="text-center py-8">
<div className="flex justify-between items-center mb-6"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-warning-500/10 flex items-center justify-center">
<h2 className="text-lg font-semibold text-dark-100"> <span className="text-3xl">📱</span>
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="text-center py-8">
<p className="text-dark-400">{t('subscription.connection.noSubscription')}</p>
</div> </div>
<h3 className="text-lg font-semibold text-dark-100 mb-2">{t('subscription.connection.title')}</h3>
<p className="text-dark-400 mb-4">{t('subscription.connection.noSubscription')}</p>
<button onClick={onClose} className="btn-primary">
{t('common.close')}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -301,75 +272,86 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
// Step 1: Select platform // Step 1: Select platform
if (!selectedPlatform) { if (!selectedPlatform) {
return ( return (
<div className={modalOverlayClass} onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}> <div
{/* Header - fixed */} className="w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 max-h-[90vh] flex flex-col animate-slide-up"
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0"> onClick={(e) => e.stopPropagation()}
<h2 className="text-lg font-semibold text-dark-100"> >
{t('subscription.connection.title')} {/* Header */}
</h2> <div className="flex items-center justify-between p-4 border-b border-dark-800">
<button onClick={onClose} className="btn-icon"> <div className="flex items-center gap-3">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <span className="text-xl">📱</span>
</svg> </div>
<div>
<h2 className="font-semibold text-dark-100">{t('subscription.connection.title')}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectDevice')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button> </button>
</div> </div>
{/* Scrollable content */} {/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4"> <div className="flex-1 overflow-y-auto p-4">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectDevice')}</p> <div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{availablePlatforms.map((platform) => { {availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform] const IconComponent = platformIconComponents[platform]
const isDetected = platform === detectedPlatform
return ( return (
<button <button
key={platform} key={platform}
onClick={() => setSelectedPlatform(platform)} onClick={() => setSelectedPlatform(platform)}
className={`p-3 sm:p-4 rounded-xl border transition-all text-left group ${ className={`relative p-4 rounded-2xl border-2 transition-all text-left group ${
platform === detectedPlatform isDetected
? 'bg-accent-500/10 border-accent-500/50 hover:border-accent-500' ? 'bg-accent-500/10 border-accent-500/50 hover:border-accent-500'
: 'bg-dark-800/50 border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50' : 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600 hover:bg-dark-800'
}`} }`}
> >
<div className="flex items-start justify-between"> {isDetected && (
<div className={`${ <div className="absolute -top-2 -right-2 px-2 py-0.5 rounded-full text-[10px] font-medium bg-accent-500 text-white">
platform === detectedPlatform ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-200' {t('subscription.connection.yourDevice')}
} transition-colors`}>
{IconComponent && <IconComponent />}
</div> </div>
{platform === detectedPlatform && ( )}
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400"> <div className={`mb-3 ${isDetected ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-300'} transition-colors`}>
{t('subscription.connection.yourDevice')} {IconComponent && <IconComponent />}
</span>
)}
</div> </div>
<p className="text-dark-200 font-medium mt-2">{getPlatformName(platform)}</p> <p className={`font-medium ${isDetected ? 'text-accent-300' : 'text-dark-200'}`}>
{getPlatformName(platform)}
</p>
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
{/* Footer - fixed with safe area */} {/* Footer */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50"> <div className="p-4 border-t border-dark-800 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4">
<button <button
onClick={copySubscriptionLink} onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base" className={`w-full p-4 rounded-2xl border-2 border-dashed transition-all flex items-center justify-center gap-3 ${
copied
? 'border-success-500/50 bg-success-500/10'
: 'border-dark-700 hover:border-accent-500/50 hover:bg-dark-800/50'
}`}
> >
{copied ? ( {copied ? (
<> <>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <CheckIcon />
</svg> </div>
<span className="text-success-400">{t('subscription.connection.copied')}</span> <span className="font-medium text-success-400">{t('subscription.connection.copied')}</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center text-dark-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> <CopyIcon />
</svg> </div>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span> <div className="text-left">
<p className="font-medium text-dark-200">{t('subscription.connection.copyLink')}</p>
<p className="text-xs text-dark-500">Для ручного подключения</p>
</div>
</> </>
)} )}
</button> </button>
@@ -379,62 +361,65 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
) )
} }
// Step 2: Select app (if not selected yet) // Step 2: Select app
if (!selectedApp) { if (!selectedApp) {
return ( return (
<div className={modalOverlayClass} onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}> <div
{/* Header - fixed */} className="w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 max-h-[90vh] flex flex-col animate-slide-up"
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0"> onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button onClick={() => setSelectedPlatform(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
onClick={() => setSelectedPlatform(null)} <BackIcon />
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button> </button>
<h2 className="text-lg font-semibold text-dark-100"> <div>
{getPlatformName(selectedPlatform)} <h2 className="font-semibold text-dark-100">{getPlatformName(selectedPlatform)}</h2>
</h2> <p className="text-xs text-dark-500">{t('subscription.connection.selectApp')}</p>
</div>
</div> </div>
<button onClick={onClose} className="btn-icon"> <button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <CloseIcon />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
{/* Scrollable content */} {/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6"> <div className="flex-1 overflow-y-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectApp')}</p>
{platformApps.length === 0 ? ( {platformApps.length === 0 ? (
<div className="text-center py-6 sm:py-8"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<span className="text-3xl">📭</span>
</div>
<p className="text-dark-500">{t('subscription.connection.noApps')}</p> <p className="text-dark-500">{t('subscription.connection.noApps')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2 sm:space-y-3"> <div className="space-y-2">
{platformApps.map((app) => ( {platformApps.map((app) => (
<button <button
key={app.id} key={app.id}
onClick={() => setSelectedApp(app)} onClick={() => setSelectedApp(app)}
className="w-full p-3 sm:p-4 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50 transition-all text-left flex items-center justify-between" className="w-full p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50 hover:border-dark-600 hover:bg-dark-800 transition-all flex items-center justify-between group"
> >
<div> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="w-10 h-10 rounded-xl bg-dark-700 flex items-center justify-center text-lg">
<span className="text-dark-100 font-medium">{app.name}</span> {app.isFeatured ? '⭐' : '📦'}
{app.isFeatured && ( </div>
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400"> <div className="text-left">
{t('subscription.connection.featured')} <div className="flex items-center gap-2">
</span> <span className="font-medium text-dark-100">{app.name}</span>
)} {app.isFeatured && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-accent-500/20 text-accent-400">
{t('subscription.connection.featured')}
</span>
)}
</div>
</div> </div>
</div> </div>
<svg className="w-5 h-5 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <div className="text-dark-500 group-hover:text-dark-300 transition-colors">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <ChevronIcon />
</svg> </div>
</button> </button>
))} ))}
</div> </div>
@@ -445,46 +430,44 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
) )
} }
// Step 3: Show app instructions and connect button // Step 3: App instructions
return ( return (
<div className={modalOverlayClass} onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}> <div
{/* Header - fixed */} className="w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 max-h-[90vh] flex flex-col animate-slide-up"
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0"> onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button onClick={() => setSelectedApp(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
onClick={() => setSelectedApp(null)} <BackIcon />
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button> </button>
<h2 className="text-lg font-semibold text-dark-100"> <div>
{selectedApp.name} <h2 className="font-semibold text-dark-100">{selectedApp.name}</h2>
</h2> <p className="text-xs text-dark-500">Инструкция по подключению</p>
</div>
</div> </div>
<button onClick={onClose} className="btn-icon"> <button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <CloseIcon />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
{/* Scrollable content */} {/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4">
{/* Step 1: Install */} {/* Step 1: Install */}
{selectedApp.installationStep && ( {selectedApp.installationStep && (
<div className="space-y-2 sm:space-y-3"> <div className="p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<h3 className="text-sm font-medium text-accent-400"> <div className="flex items-center gap-3 mb-3">
{t('subscription.connection.installApp')} <div className="w-8 h-8 rounded-full bg-accent-500/20 flex items-center justify-center text-sm font-bold text-accent-400">1</div>
</h3> <h3 className="font-medium text-dark-100">{t('subscription.connection.installApp')}</h3>
<p className="text-sm text-dark-400"> </div>
<p className="text-sm text-dark-400 mb-3 pl-11">
{getLocalizedText(selectedApp.installationStep.description)} {getLocalizedText(selectedApp.installationStep.description)}
</p> </p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 pl-11">
{selectedApp.installationStep.buttons {selectedApp.installationStep.buttons
.filter((btn) => isValidExternalUrl(btn.buttonLink)) .filter((btn) => isValidExternalUrl(btn.buttonLink))
.map((btn, idx) => ( .map((btn, idx) => (
@@ -495,9 +478,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
rel="noopener noreferrer" 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" 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"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <LinkIcon />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{getLocalizedText(btn.buttonText)} {getLocalizedText(btn.buttonText)}
</a> </a>
))} ))}
@@ -506,88 +487,53 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div> </div>
)} )}
{/* Additional before add subscription */}
{selectedApp.additionalBeforeAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalBeforeAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 2: Add subscription */} {/* Step 2: Add subscription */}
{selectedApp.addSubscriptionStep && ( {selectedApp.addSubscriptionStep && (
<div className="space-y-2 sm:space-y-3"> <div className="p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<h3 className="text-sm font-medium text-accent-400"> <div className="flex items-center gap-3 mb-3">
{t('subscription.connection.addSubscription')} <div className="w-8 h-8 rounded-full bg-accent-500/20 flex items-center justify-center text-sm font-bold text-accent-400">2</div>
</h3> <h3 className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</h3>
<p className="text-sm text-dark-400"> </div>
<p className="text-sm text-dark-400 mb-4 pl-11">
{getLocalizedText(selectedApp.addSubscriptionStep.description)} {getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p> </p>
{/* Connect button */} <div className="space-y-2 pl-11">
{selectedApp.deepLink && ( {/* Connect button */}
<button {selectedApp.deepLink && (
onClick={() => handleConnect(selectedApp)} <button
className="btn-primary w-full py-2.5 sm:py-3 flex items-center justify-center gap-2 text-sm sm:text-base" onClick={() => handleConnect(selectedApp)}
> className="btn-primary w-full py-3 flex items-center justify-center gap-2"
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> >
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> <LinkIcon />
</svg> {t('subscription.connection.addToApp', { appName: selectedApp.name })}
{t('subscription.connection.addToApp', { appName: selectedApp.name })} </button>
</button>
)}
{/* Copy link fallback */}
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
)} )}
</button>
</div>
)}
{/* Additional after add subscription */} {/* Copy link */}
{selectedApp.additionalAfterAddSubscriptionStep && ( <button
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700"> onClick={copySubscriptionLink}
{selectedApp.additionalAfterAddSubscriptionStep.title && ( className={`w-full p-3 rounded-xl border transition-all flex items-center justify-center gap-2 ${
<h4 className="text-sm font-medium text-dark-200"> copied
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)} ? 'border-success-500/50 bg-success-500/10 text-success-400'
</h4> : 'border-dark-700 hover:border-dark-600 bg-dark-800/50 text-dark-300'
)} }`}
<p className="text-xs text-dark-400"> >
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)} {copied ? <CheckIcon /> : <CopyIcon />}
</p> {copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</button>
</div>
</div> </div>
)} )}
{/* Step 3: Connect */} {/* Step 3: Connect */}
{selectedApp.connectAndUseStep && ( {selectedApp.connectAndUseStep && (
<div className="space-y-2 sm:space-y-3"> <div className="p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<h3 className="text-sm font-medium text-accent-400"> <div className="flex items-center gap-3 mb-3">
{t('subscription.connection.connectVpn')} <div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center text-sm font-bold text-success-400">3</div>
</h3> <h3 className="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</h3>
<p className="text-sm text-dark-400"> </div>
<p className="text-sm text-dark-400 pl-11">
{getLocalizedText(selectedApp.connectAndUseStep.description)} {getLocalizedText(selectedApp.connectAndUseStep.description)}
</p> </p>
</div> </div>
@@ -595,9 +541,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div> </div>
</div> </div>
{/* Footer - fixed with safe area */} {/* Footer */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50"> <div className="p-4 border-t border-dark-800 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4">
<button onClick={onClose} className="btn-secondary w-full text-sm sm:text-base"> <button onClick={onClose} className="btn-secondary w-full">
{t('common.close')} {t('common.close')}
</button> </button>
</div> </div>