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
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"/>
</svg>
)
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"/>
</svg>
)
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"/>
</svg>
)
const MacosIcon = () => (
<svg className="w-8 h-8" 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"/>
<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 2v10h16V6H4z"/>
</svg>
)
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"/>
</svg>
)
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"/>
<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>
)
@@ -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 (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<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="flex justify-center py-12">
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
</div>
)
}
// Error state
if (error || !appConfig) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="text-center py-8">
<p className="text-error-400">{t('common.error')}</p>
<button onClick={onClose} className="btn-secondary mt-4">
{t('common.close')}
</button>
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<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="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">
<span className="text-3xl">😕</span>
</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>
)
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-dark-100">
{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 className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<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="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-warning-500/10 flex items-center justify-center">
<span className="text-3xl">📱</span>
</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>
@@ -301,75 +272,86 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
// Step 1: Select platform
if (!selectedPlatform) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<h2 className="text-lg font-semibold text-dark-100">
{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>
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<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 max-h-[90vh] flex flex-col animate-slide-up"
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-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center">
<span className="text-xl">📱</span>
</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>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-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-2 sm:gap-3">
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-2 gap-3">
{availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform]
const isDetected = platform === detectedPlatform
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`p-3 sm:p-4 rounded-xl border transition-all text-left group ${
platform === detectedPlatform
className={`relative p-4 rounded-2xl border-2 transition-all text-left group ${
isDetected
? '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">
<div className={`${
platform === detectedPlatform ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-200'
} transition-colors`}>
{IconComponent && <IconComponent />}
{isDetected && (
<div className="absolute -top-2 -right-2 px-2 py-0.5 rounded-full text-[10px] font-medium bg-accent-500 text-white">
{t('subscription.connection.yourDevice')}
</div>
{platform === detectedPlatform && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.yourDevice')}
</span>
)}
)}
<div className={`mb-3 ${isDetected ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-300'} transition-colors`}>
{IconComponent && <IconComponent />}
</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>
)
})}
</div>
</div>
{/* Footer - fixed with safe area */}
<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">
{/* Footer */}
<div className="p-4 border-t border-dark-800 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4">
<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"
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 ? (
<>
<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>
<div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center">
<CheckIcon />
</div>
<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}>
<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>
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center text-dark-400">
<CopyIcon />
</div>
<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>
@@ -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 (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<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 max-h-[90vh] flex flex-col animate-slide-up"
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">
<button
onClick={() => setSelectedPlatform(null)}
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 onClick={() => setSelectedPlatform(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<BackIcon />
</button>
<h2 className="text-lg font-semibold text-dark-100">
{getPlatformName(selectedPlatform)}
</h2>
<div>
<h2 className="font-semibold text-dark-100">{getPlatformName(selectedPlatform)}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectApp')}</p>
</div>
</div>
<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 onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
{/* Scrollable 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">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectApp')}</p>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4">
{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>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
<div className="space-y-2">
{platformApps.map((app) => (
<button
key={app.id}
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-2">
<span className="text-dark-100 font-medium">{app.name}</span>
{app.isFeatured && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.featured')}
</span>
)}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-dark-700 flex items-center justify-center text-lg">
{app.isFeatured ? '⭐' : '📦'}
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<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>
<svg className="w-5 h-5 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<div className="text-dark-500 group-hover:text-dark-300 transition-colors">
<ChevronIcon />
</div>
</button>
))}
</div>
@@ -445,46 +430,44 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
)
}
// Step 3: Show app instructions and connect button
// Step 3: App instructions
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center" onClick={onClose}>
<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 max-h-[90vh] flex flex-col animate-slide-up"
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">
<button
onClick={() => setSelectedApp(null)}
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 onClick={() => setSelectedApp(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<BackIcon />
</button>
<h2 className="text-lg font-semibold text-dark-100">
{selectedApp.name}
</h2>
<div>
<h2 className="font-semibold text-dark-100">{selectedApp.name}</h2>
<p className="text-xs text-dark-500">Инструкция по подключению</p>
</div>
</div>
<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 onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<div className="space-y-4 sm:space-y-6">
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Step 1: Install */}
{selectedApp.installationStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.installApp')}
</h3>
<p className="text-sm text-dark-400">
<div className="p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center gap-3 mb-3">
<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 className="font-medium text-dark-100">{t('subscription.connection.installApp')}</h3>
</div>
<p className="text-sm text-dark-400 mb-3 pl-11">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{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
.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"
>
<svg className="w-4 h-4" 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>
<LinkIcon />
{getLocalizedText(btn.buttonText)}
</a>
))}
@@ -506,88 +487,53 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</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 */}
{selectedApp.addSubscriptionStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.addSubscription')}
</h3>
<p className="text-sm text-dark-400">
<div className="p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center gap-3 mb-3">
<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 className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</h3>
</div>
<p className="text-sm text-dark-400 mb-4 pl-11">
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
{/* Connect button */}
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-2.5 sm:py-3 flex items-center justify-center gap-2 text-sm sm:text-base"
>
<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" />
</svg>
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</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>
</>
<div className="space-y-2 pl-11">
{/* Connect button */}
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
>
<LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
)}
</button>
</div>
)}
{/* Additional after add subscription */}
{selectedApp.additionalAfterAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalAfterAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)}
</p>
{/* Copy link */}
<button
onClick={copySubscriptionLink}
className={`w-full p-3 rounded-xl border transition-all flex items-center justify-center gap-2 ${
copied
? 'border-success-500/50 bg-success-500/10 text-success-400'
: 'border-dark-700 hover:border-dark-600 bg-dark-800/50 text-dark-300'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</button>
</div>
</div>
)}
{/* Step 3: Connect */}
{selectedApp.connectAndUseStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.connectVpn')}
</h3>
<p className="text-sm text-dark-400">
<div className="p-4 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center gap-3 mb-3">
<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 className="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</h3>
</div>
<p className="text-sm text-dark-400 pl-11">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div>
@@ -595,9 +541,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div>
</div>
{/* Footer - fixed with safe area */}
<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">
<button onClick={onClose} className="btn-secondary w-full text-sm sm:text-base">
{/* Footer */}
<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">
{t('common.close')}
</button>
</div>