mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Fix modal issues: remove jerky animations, restore text platform names, fix PC payments and app selection
- Remove position:fixed scroll lock to prevent page jumping on modal open - Remove emoji platform icons, keep text-only platform names - Preserve popupRef pre-open logic for PC payment links - Change detectedPlatform from useState to useMemo to fix race condition in app selection
This commit is contained in:
@@ -35,9 +35,9 @@ const LinkIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ChevronIcon = ({ className = '' }: { className?: string }) => (
|
const ChevronIcon = () => (
|
||||||
<svg className={`w-5 h-5 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,12 +47,6 @@ const BackIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const DownloadIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// App icons
|
// App icons
|
||||||
const HappIcon = () => (
|
const HappIcon = () => (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
|
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
|
||||||
@@ -95,26 +89,6 @@ const getAppIcon = (appName: string): React.ReactNode => {
|
|||||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
||||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
||||||
|
|
||||||
const platformNames: Record<string, string> = {
|
|
||||||
ios: 'iOS',
|
|
||||||
android: 'Android',
|
|
||||||
windows: 'Windows',
|
|
||||||
macos: 'macOS',
|
|
||||||
linux: 'Linux',
|
|
||||||
androidTV: 'Android TV',
|
|
||||||
appleTV: 'Apple TV'
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformIcons: Record<string, string> = {
|
|
||||||
ios: '🍎',
|
|
||||||
android: '🤖',
|
|
||||||
windows: '💻',
|
|
||||||
macos: '🖥',
|
|
||||||
linux: '🐧',
|
|
||||||
androidTV: '📺',
|
|
||||||
appleTV: '📺'
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -153,19 +127,15 @@ function useIsMobile() {
|
|||||||
return isMobile
|
return isMobile
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch-friendly constants
|
|
||||||
const touchButtonClass = "select-none"
|
|
||||||
const minTouchTarget = "min-h-[44px] min-w-[44px]" // Apple HIG minimum
|
|
||||||
|
|
||||||
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
|
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [detectedPlatform, setDetectedPlatform] = useState<string | null>(null)
|
|
||||||
const [showAppSelector, setShowAppSelector] = useState(false)
|
const [showAppSelector, setShowAppSelector] = useState(false)
|
||||||
|
|
||||||
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
|
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
|
||||||
const isMobileScreen = useIsMobile()
|
const isMobileScreen = useIsMobile()
|
||||||
|
const isMobile = isMobileScreen
|
||||||
|
|
||||||
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
|
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
|
||||||
const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0
|
const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0
|
||||||
@@ -175,80 +145,64 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
queryFn: () => subscriptionApi.getAppConfig(),
|
queryFn: () => subscriptionApi.getAppConfig(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle close with memoization
|
// Detect platform ONCE on mount (stable reference)
|
||||||
const handleClose = useCallback(() => {
|
const detectedPlatform = useMemo(() => detectPlatform(), [])
|
||||||
onClose()
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
// Handle back (for app selector)
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
setShowAppSelector(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDetectedPlatform(detectPlatform())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
|
// Set initial app based on detected platform - AFTER appConfig loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appConfig?.platforms || selectedApp) return
|
if (!appConfig?.platforms || selectedApp) return
|
||||||
const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0)
|
|
||||||
|
// Priority: detected platform > first available platform
|
||||||
|
let platform = detectedPlatform
|
||||||
|
if (!platform || !appConfig.platforms[platform]?.length) {
|
||||||
|
platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null
|
||||||
|
}
|
||||||
|
|
||||||
if (!platform || !appConfig.platforms[platform]?.length) return
|
if (!platform || !appConfig.platforms[platform]?.length) return
|
||||||
|
|
||||||
const apps = appConfig.platforms[platform]
|
const apps = appConfig.platforms[platform]
|
||||||
|
// Select featured app or first app for the detected platform
|
||||||
const app = apps.find(a => a.isFeatured) || apps[0]
|
const app = apps.find(a => a.isFeatured) || apps[0]
|
||||||
if (app) setSelectedApp(app)
|
if (app) setSelectedApp(app)
|
||||||
}, [appConfig, detectedPlatform, selectedApp])
|
}, [appConfig, detectedPlatform, selectedApp])
|
||||||
|
|
||||||
// Keyboard support (Escape to close) - PC
|
const handleClose = useCallback(() => {
|
||||||
|
onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setShowAppSelector(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Keyboard: Escape to close (PC)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (showAppSelector) {
|
if (showAppSelector) handleBack()
|
||||||
handleBack()
|
else handleClose()
|
||||||
} else {
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleClose, handleBack, showAppSelector])
|
}, [handleClose, handleBack, showAppSelector])
|
||||||
|
|
||||||
// Telegram back button support - Android
|
// Telegram back button (Android)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webApp) return
|
if (!webApp?.BackButton) return
|
||||||
|
const handler = showAppSelector ? handleBack : handleClose
|
||||||
const backHandler = showAppSelector ? handleBack : handleClose
|
webApp.BackButton.show()
|
||||||
|
webApp.BackButton.onClick(handler)
|
||||||
if (webApp.BackButton) {
|
|
||||||
webApp.BackButton.show()
|
|
||||||
webApp.BackButton.onClick(backHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (webApp.BackButton) {
|
webApp.BackButton.offClick(handler)
|
||||||
webApp.BackButton.offClick(backHandler)
|
webApp.BackButton.hide()
|
||||||
webApp.BackButton.hide()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [webApp, handleClose, handleBack, showAppSelector])
|
}, [webApp, handleClose, handleBack, showAppSelector])
|
||||||
|
|
||||||
// Scroll lock - iOS/Android
|
// Scroll lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollY = window.scrollY
|
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
// iOS specific
|
return () => { document.body.style.overflow = '' }
|
||||||
document.body.style.position = 'fixed'
|
|
||||||
document.body.style.width = '100%'
|
|
||||||
document.body.style.top = `-${scrollY}px`
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
document.body.style.position = ''
|
|
||||||
document.body.style.width = ''
|
|
||||||
document.body.style.top = ''
|
|
||||||
window.scrollTo(0, scrollY)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getLocalizedText = (text: LocalizedText | undefined): string => {
|
const getLocalizedText = (text: LocalizedText | undefined): string => {
|
||||||
@@ -260,6 +214,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
const availablePlatforms = useMemo(() => {
|
const availablePlatforms = useMemo(() => {
|
||||||
if (!appConfig?.platforms) return []
|
if (!appConfig?.platforms) return []
|
||||||
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
|
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
|
||||||
|
// Put detected platform first
|
||||||
if (detectedPlatform && available.includes(detectedPlatform)) {
|
if (detectedPlatform && available.includes(detectedPlatform)) {
|
||||||
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
|
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
|
||||||
}
|
}
|
||||||
@@ -298,209 +253,154 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
window.location.href = redirectUrl
|
window.location.href = redirectUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile fullscreen modal
|
// Wrapper component
|
||||||
const MobileWrapper = ({ children }: { children: React.ReactNode }) => {
|
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
const content = (
|
if (isMobile) {
|
||||||
<>
|
// Mobile fullscreen
|
||||||
{/* Backdrop */}
|
const content = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9998] bg-black/60 animate-fade-in"
|
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col"
|
||||||
onClick={handleClose}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
/>
|
|
||||||
{/* Modal */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col animate-slide-up"
|
|
||||||
style={{
|
style={{
|
||||||
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
|
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
|
||||||
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)',
|
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)'
|
||||||
WebkitTapHighlightColor: 'transparent',
|
|
||||||
touchAction: 'manipulation'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)
|
||||||
)
|
if (typeof document !== 'undefined') return createPortal(content, document.body)
|
||||||
|
return content
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
return createPortal(content, document.body)
|
|
||||||
}
|
}
|
||||||
return content
|
|
||||||
|
// Desktop centered
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4" onClick={handleClose}>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md max-h-[85vh] bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl flex flex-col overflow-hidden"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop centered modal
|
// Loading
|
||||||
const DesktopWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4 animate-fade-in"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative w-full max-w-md max-h-[85vh] bg-dark-900 rounded-[24px] border border-dark-700/30 shadow-2xl flex flex-col overflow-hidden animate-scale-in"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex-1 flex items-center justify-center p-12">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="w-12 h-12 border-[3px] border-accent-500/30 border-t-accent-500 rounded-full animate-spin" />
|
<div className="w-10 h-10 border-[3px] border-accent-500/30 border-t-accent-500 rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error
|
||||||
if (error || !appConfig) {
|
if (error || !appConfig) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-error-500/10 flex items-center justify-center mb-4">
|
<p className="text-dark-300 text-lg mb-4">{t('common.error')}</p>
|
||||||
<span className="text-3xl">😕</span>
|
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
|
||||||
</div>
|
|
||||||
<p className="text-dark-300 text-lg mb-6">{t('common.error')}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className={`btn-primary px-8 py-3 text-base rounded-xl ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
>
|
|
||||||
{t('common.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No subscription state
|
// No subscription
|
||||||
if (!appConfig.hasSubscription) {
|
if (!appConfig.hasSubscription) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-accent-500/10 flex items-center justify-center mb-4">
|
|
||||||
<span className="text-3xl">📱</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-bold text-dark-100 text-xl mb-2">{t('subscription.connection.title')}</h3>
|
<h3 className="font-bold text-dark-100 text-xl mb-2">{t('subscription.connection.title')}</h3>
|
||||||
<p className="text-dark-400 mb-6">{t('subscription.connection.noSubscription')}</p>
|
<p className="text-dark-400 mb-4">{t('subscription.connection.noSubscription')}</p>
|
||||||
<button
|
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
|
||||||
onClick={handleClose}
|
|
||||||
className={`btn-primary px-8 py-3 text-base rounded-xl ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
>
|
|
||||||
{t('common.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// App selector view
|
// App selector
|
||||||
if (showAppSelector) {
|
if (showAppSelector) {
|
||||||
|
const platformNames: Record<string, string> = {
|
||||||
|
ios: 'iOS', android: 'Android', windows: 'Windows',
|
||||||
|
macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-4 border-b border-dark-800/50">
|
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
|
||||||
<button
|
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
|
||||||
onClick={handleBack}
|
|
||||||
className={`p-2 -ml-2 rounded-xl hover:bg-dark-800 active:bg-dark-700 text-dark-300 transition-colors ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
aria-label={t('common.back')}
|
|
||||||
>
|
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="font-bold text-dark-100 text-lg flex-1">{t('subscription.connection.selectApp')}</h2>
|
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectApp')}</h2>
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className={`p-2 -mr-2 rounded-xl hover:bg-dark-800 active:bg-dark-700 text-dark-400 transition-colors ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
aria-label={t('common.close')}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apps list */}
|
{/* Apps grouped by platform */}
|
||||||
<div
|
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-5`}>
|
||||||
className="flex-1 overflow-y-auto overscroll-contain"
|
{availablePlatforms.map(platform => {
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
const apps = appConfig.platforms[platform]
|
||||||
>
|
if (!apps?.length) return null
|
||||||
<div className="p-4 space-y-5">
|
const isCurrentPlatform = platform === detectedPlatform
|
||||||
{availablePlatforms.map(platform => {
|
|
||||||
const apps = appConfig.platforms[platform]
|
|
||||||
if (!apps?.length) return null
|
|
||||||
const isCurrentPlatform = platform === detectedPlatform
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={platform}>
|
<div key={platform}>
|
||||||
{/* Platform header */}
|
{/* Platform header */}
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-2 px-1">
|
||||||
<span className="text-lg">{platformIcons[platform]}</span>
|
<span className={`text-sm font-semibold ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-400'}`}>
|
||||||
<span className={`text-sm font-semibold ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-400'}`}>
|
{platformNames[platform] || platform}
|
||||||
{platformNames[platform] || platform}
|
</span>
|
||||||
|
{isCurrentPlatform && (
|
||||||
|
<span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full">
|
||||||
|
{t('subscription.connection.yourDevice')}
|
||||||
</span>
|
</span>
|
||||||
{isCurrentPlatform && (
|
)}
|
||||||
<span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full font-medium">
|
|
||||||
{t('subscription.connection.yourDevice')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Apps grid */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{apps.map(app => {
|
|
||||||
const isSelected = selectedApp?.id === app.id
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={app.id}
|
|
||||||
onClick={() => { setSelectedApp(app); setShowAppSelector(false) }}
|
|
||||||
className={`w-full p-4 rounded-2xl flex items-center gap-4 transition-all ${touchButtonClass} ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-accent-500/10 ring-2 ring-accent-500/40'
|
|
||||||
: 'bg-dark-800/40 hover:bg-dark-800/70 active:bg-dark-800'
|
|
||||||
}`}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${minTouchTarget} ${
|
|
||||||
isSelected ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700 text-dark-300'
|
|
||||||
}`}>
|
|
||||||
{getAppIcon(app.name)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<span className="font-semibold text-dark-100 block">{app.name}</span>
|
|
||||||
{app.isFeatured && (
|
|
||||||
<span className="text-xs text-accent-400 font-medium">
|
|
||||||
{t('subscription.connection.featured')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronIcon className="text-dark-500" />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})}
|
{/* Apps for this platform */}
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
{apps.map(app => (
|
||||||
|
<button
|
||||||
|
key={app.id}
|
||||||
|
onClick={() => { setSelectedApp(app); setShowAppSelector(false) }}
|
||||||
|
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all ${
|
||||||
|
selectedApp?.id === app.id
|
||||||
|
? 'bg-accent-500/10 ring-1 ring-accent-500/30'
|
||||||
|
: 'bg-dark-800/50 hover:bg-dark-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||||
|
selectedApp?.id === app.id ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700 text-dark-300'
|
||||||
|
}`}>
|
||||||
|
{getAppIcon(app.name)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<span className="font-medium text-dark-100">{app.name}</span>
|
||||||
|
{app.isFeatured && (
|
||||||
|
<span className="ml-2 text-xs text-accent-400">{t('subscription.connection.featured')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main connection view
|
// Main view
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
{/* Header with app selector */}
|
{/* Header */}
|
||||||
<div className="px-4 pt-4 pb-3 border-b border-dark-800/50">
|
<div className="p-4 border-b border-dark-800">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
|
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
|
||||||
<button
|
<button onClick={handleClose} className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400">
|
||||||
onClick={handleClose}
|
|
||||||
className={`p-2 -mr-2 rounded-xl hover:bg-dark-800 active:bg-dark-700 text-dark-400 transition-colors ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
aria-label={t('common.close')}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,118 +408,94 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
{/* App selector button */}
|
{/* App selector button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAppSelector(true)}
|
onClick={() => setShowAppSelector(true)}
|
||||||
className={`w-full flex items-center gap-4 p-3 rounded-2xl bg-dark-800/50 hover:bg-dark-800 active:bg-dark-700 transition-colors ${touchButtonClass}`}
|
className="w-full flex items-center gap-3 p-3 rounded-xl bg-dark-800/50 hover:bg-dark-800 transition-colors"
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10 flex items-center justify-center text-accent-400 ${minTouchTarget}`}>
|
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center text-accent-400">
|
||||||
{selectedApp && getAppIcon(selectedApp.name)}
|
{selectedApp && getAppIcon(selectedApp.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left">
|
||||||
<div className="font-bold text-dark-100 text-base truncate">{selectedApp?.name}</div>
|
<div className="font-medium text-dark-100">{selectedApp?.name}</div>
|
||||||
<div className="text-sm text-accent-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
|
<div className="text-sm text-dark-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronIcon className="text-dark-500 flex-shrink-0" />
|
<ChevronIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps content */}
|
{/* Steps */}
|
||||||
<div
|
<div className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} overflow-y-auto p-4 space-y-4`}>
|
||||||
className="flex-1 overflow-y-auto overscroll-contain"
|
{/* Step 1: Install */}
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
{selectedApp?.installationStep && (
|
||||||
>
|
<div className="space-y-2">
|
||||||
<div className="p-4 space-y-4">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">1</span>
|
||||||
{/* Step 1: Install */}
|
<span className="font-medium text-dark-100">{t('subscription.connection.installApp')}</span>
|
||||||
{selectedApp?.installationStep && (
|
|
||||||
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
|
|
||||||
<div className="flex items-start 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 flex-shrink-0`}>
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-dark-100 mb-1">{t('subscription.connection.installApp')}</h3>
|
|
||||||
<p className="text-dark-400 text-sm leading-relaxed">{getLocalizedText(selectedApp.installationStep.description)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 ml-11">
|
|
||||||
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={btn.buttonLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-dark-700/70 text-dark-200 text-sm font-medium hover:bg-dark-700 active:bg-dark-600 transition-colors ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
{getLocalizedText(btn.buttonText)}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.installationStep.description)}</p>
|
||||||
|
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
|
||||||
{/* Step 2: Add subscription */}
|
<div className="flex flex-wrap gap-2 ml-8">
|
||||||
{selectedApp?.addSubscriptionStep && (
|
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => (
|
||||||
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
|
<a
|
||||||
<div className="flex items-start gap-3 mb-4">
|
key={idx}
|
||||||
<div className="w-8 h-8 rounded-full bg-accent-500/20 flex items-center justify-center text-sm font-bold text-accent-400 flex-shrink-0">
|
href={btn.buttonLink}
|
||||||
2
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
<div className="flex-1 min-w-0">
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-800 text-dark-200 text-sm hover:bg-dark-700"
|
||||||
<h3 className="font-semibold text-dark-100 mb-1">{t('subscription.connection.addSubscription')}</h3>
|
|
||||||
<p className="text-dark-400 text-sm leading-relaxed">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 ml-11">
|
|
||||||
{/* Connect button */}
|
|
||||||
{selectedApp.deepLink && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleConnect(selectedApp)}
|
|
||||||
className={`w-full h-12 rounded-xl font-semibold text-sm transition-all flex items-center justify-center gap-2 bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/20 hover:shadow-accent-500/30 active:scale-[0.98] ${touchButtonClass} ${minTouchTarget}`}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
<LinkIcon />
|
{getLocalizedText(btn.buttonText)}
|
||||||
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
|
</a>
|
||||||
</button>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Copy link button */}
|
{/* Step 2: Add subscription */}
|
||||||
|
{selectedApp?.addSubscriptionStep && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">2</span>
|
||||||
|
<span className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 ml-8">
|
||||||
|
{/* Connect button */}
|
||||||
|
{selectedApp.deepLink && (
|
||||||
<button
|
<button
|
||||||
onClick={copySubscriptionLink}
|
onClick={() => handleConnect(selectedApp)}
|
||||||
className={`w-full h-12 rounded-xl border-2 transition-all flex items-center justify-center gap-2 text-sm font-semibold ${touchButtonClass} ${minTouchTarget} ${
|
className="w-full btn-primary h-11 text-sm font-semibold flex items-center justify-center gap-2"
|
||||||
copied
|
|
||||||
? 'border-success-500 bg-success-500/10 text-success-400'
|
|
||||||
: 'border-dark-600 hover:border-dark-500 text-dark-300 hover:text-dark-200 active:bg-dark-800'
|
|
||||||
}`}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
<LinkIcon />
|
||||||
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
|
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Connect */}
|
{/* Copy link */}
|
||||||
{selectedApp?.connectAndUseStep && (
|
<button
|
||||||
<div className="p-4 rounded-2xl bg-success-500/5 border border-success-500/20">
|
onClick={copySubscriptionLink}
|
||||||
<div className="flex items-start gap-3">
|
className={`w-full h-11 rounded-xl border transition-all flex items-center justify-center gap-2 text-sm font-medium ${
|
||||||
<div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center text-sm font-bold text-success-400 flex-shrink-0">
|
copied
|
||||||
3
|
? 'border-success-500 bg-success-500/10 text-success-400'
|
||||||
</div>
|
: 'border-dark-600 text-dark-300 hover:bg-dark-800'
|
||||||
<div className="flex-1 min-w-0">
|
}`}
|
||||||
<h3 className="font-semibold text-dark-100 mb-1">{t('subscription.connection.connectVpn')}</h3>
|
>
|
||||||
<p className="text-dark-400 text-sm leading-relaxed">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p>
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
</div>
|
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Connect */}
|
||||||
|
{selectedApp?.connectAndUseStep && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-success-500/20 flex items-center justify-center text-xs font-bold text-success-400">3</span>
|
||||||
|
<span className="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,17 +32,11 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
|
|||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const WalletIcon = () => (
|
|
||||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
method: PaymentMethod
|
method: PaymentMethod
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -52,10 +46,10 @@ interface TopUpModalProps {
|
|||||||
function useIsMobile() {
|
function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
return window.innerWidth < 768
|
return window.innerWidth < 640
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setIsMobile(window.innerWidth < 768)
|
const check = () => setIsMobile(window.innerWidth < 640)
|
||||||
window.addEventListener('resize', check)
|
window.addEventListener('resize', check)
|
||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -86,12 +80,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
)
|
)
|
||||||
const popupRef = useRef<Window | null>(null)
|
const popupRef = useRef<Window | null>(null)
|
||||||
|
|
||||||
// Handle close with memoization
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onClose()
|
onClose()
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
// Keyboard support (Escape to close) - PC
|
// Keyboard: Escape to close (PC)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -103,25 +96,18 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [handleClose])
|
}, [handleClose])
|
||||||
|
|
||||||
// Telegram back button support - Android
|
// Telegram back button (Android)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webApp) return
|
if (!webApp?.BackButton) return
|
||||||
|
webApp.BackButton.show()
|
||||||
// Show back button in Telegram
|
webApp.BackButton.onClick(handleClose)
|
||||||
if (webApp.BackButton) {
|
|
||||||
webApp.BackButton.show()
|
|
||||||
webApp.BackButton.onClick(handleClose)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (webApp.BackButton) {
|
webApp.BackButton.offClick(handleClose)
|
||||||
webApp.BackButton.offClick(handleClose)
|
webApp.BackButton.hide()
|
||||||
webApp.BackButton.hide()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [webApp, handleClose])
|
}, [webApp, handleClose])
|
||||||
|
|
||||||
// Scroll lock - iOS/Android rubber band prevention
|
// Scroll lock - simple version without position:fixed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollY = window.scrollY
|
const scrollY = window.scrollY
|
||||||
const preventScroll = (e: TouchEvent) => {
|
const preventScroll = (e: TouchEvent) => {
|
||||||
@@ -137,18 +123,10 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
document.addEventListener('wheel', preventWheel, { passive: false })
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
// iOS specific - prevent body scroll
|
|
||||||
document.body.style.position = 'fixed'
|
|
||||||
document.body.style.width = '100%'
|
|
||||||
document.body.style.top = `-${scrollY}px`
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('touchmove', preventScroll)
|
document.removeEventListener('touchmove', preventScroll)
|
||||||
document.removeEventListener('wheel', preventWheel)
|
document.removeEventListener('wheel', preventWheel)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
document.body.style.position = ''
|
|
||||||
document.body.style.width = ''
|
|
||||||
document.body.style.top = ''
|
|
||||||
window.scrollTo(0, scrollY)
|
window.scrollTo(0, scrollY)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
@@ -169,7 +147,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
|
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
|
||||||
try {
|
try {
|
||||||
webApp.openInvoice(data.invoice_url, (status) => {
|
webApp.openInvoice(data.invoice_url, (status) => {
|
||||||
if (status === 'paid') { setError(null); handleClose() }
|
if (status === 'paid') { setError(null); onClose() }
|
||||||
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
|
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
|
||||||
})
|
})
|
||||||
} catch (e) { setError('Ошибка: ' + String(e)) }
|
} catch (e) { setError('Ошибка: ' + String(e)) }
|
||||||
@@ -189,7 +167,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
const redirectUrl = data.payment_url || (data as any).invoice_url
|
const redirectUrl = data.payment_url || (data as any).invoice_url
|
||||||
if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current)
|
if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current)
|
||||||
popupRef.current = null
|
popupRef.current = null
|
||||||
handleClose()
|
onClose()
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {}
|
try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {}
|
||||||
@@ -216,6 +194,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
}
|
}
|
||||||
|
|
||||||
const amountKopeks = Math.round(amountRubles * 100)
|
const amountKopeks = Math.round(amountRubles * 100)
|
||||||
|
// IMPORTANT: Pre-open window on PC to avoid popup blockers
|
||||||
if (!isTelegramMiniApp) {
|
if (!isTelegramMiniApp) {
|
||||||
try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null }
|
try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null }
|
||||||
}
|
}
|
||||||
@@ -230,209 +209,165 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
: convertAmount(rub).toFixed(currencyDecimals)
|
: convertAmount(rub).toFixed(currencyDecimals)
|
||||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
|
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
|
||||||
|
|
||||||
// Auto-focus input on mount (delayed for animation)
|
// Auto-focus input (only on desktop)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
// Don't auto-focus on mobile to prevent keyboard from opening immediately
|
if (!isMobileScreen) inputRef.current?.focus()
|
||||||
if (!isMobileScreen) {
|
}, 150)
|
||||||
inputRef.current?.focus()
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [isMobileScreen])
|
}, [isMobileScreen])
|
||||||
|
|
||||||
// Mobile bottom sheet modal
|
// Mobile bottom sheet
|
||||||
const MobileWrapper = ({ children }: { children: React.ReactNode }) => {
|
const MobileModal = () => (
|
||||||
const content = (
|
<>
|
||||||
<>
|
<div className="fixed inset-0 z-[9998] bg-black/60" onClick={handleClose} />
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[9998] bg-black/60 animate-fade-in"
|
|
||||||
onClick={handleClose}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
/>
|
|
||||||
{/* Modal */}
|
|
||||||
<div
|
|
||||||
data-modal-content
|
|
||||||
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-[28px] animate-slide-up flex flex-col max-h-[90vh]"
|
|
||||||
style={{
|
|
||||||
paddingBottom: safeBottom ? `${safeBottom + 16}px` : 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
|
|
||||||
WebkitTapHighlightColor: 'transparent',
|
|
||||||
touchAction: 'manipulation'
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Handle bar - iOS style */}
|
|
||||||
<div
|
|
||||||
className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing"
|
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
>
|
|
||||||
<div className="w-9 h-1 rounded-full bg-dark-500" />
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
return createPortal(content, document.body)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop centered modal
|
|
||||||
const DesktopWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4 animate-fade-in"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
data-modal-content
|
data-modal-content
|
||||||
className="w-full max-w-[400px] bg-dark-900 rounded-[24px] border border-dark-700/30 shadow-2xl overflow-hidden animate-scale-in"
|
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-3xl max-h-[85vh] flex flex-col"
|
||||||
|
style={{ paddingBottom: safeBottom ? `${safeBottom + 16}px` : 'max(16px, env(safe-area-inset-bottom))' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{children}
|
{/* Handle */}
|
||||||
</div>
|
<div className="flex justify-center pt-3 pb-2">
|
||||||
</div>
|
<div className="w-9 h-1 rounded-full bg-dark-600" />
|
||||||
)
|
|
||||||
|
|
||||||
const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper
|
|
||||||
|
|
||||||
// Touch-friendly button classes
|
|
||||||
const touchButtonClass = "select-none"
|
|
||||||
const minTouchTarget = "min-h-[44px] min-w-[44px]" // Apple HIG minimum
|
|
||||||
|
|
||||||
const modalContent = (
|
|
||||||
<>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-5 pt-3 pb-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-12 h-12 rounded-2xl bg-gradient-to-br from-accent-500/20 to-accent-600/10 flex items-center justify-center text-accent-400 ${minTouchTarget}`}>
|
|
||||||
<WalletIcon />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="font-bold text-dark-100 text-lg leading-tight">{methodName}</h2>
|
|
||||||
<p className="text-dark-400 text-sm">{formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Header */}
|
||||||
onClick={handleClose}
|
<div className="px-5 pb-3 flex items-center justify-between">
|
||||||
className={`p-2.5 rounded-full bg-dark-800/80 hover:bg-dark-700 active:bg-dark-600 text-dark-400 hover:text-dark-200 transition-colors -mr-1 ${touchButtonClass} ${minTouchTarget}`}
|
<span className="font-bold text-dark-100 text-lg">{methodName}</span>
|
||||||
aria-label={t('common.close')}
|
<button onClick={handleClose} className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400">
|
||||||
>
|
<CloseIcon />
|
||||||
<CloseIcon />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
{/* Content */}
|
||||||
|
<div className="px-5 pb-4 space-y-4 overflow-y-auto">
|
||||||
{/* Content */}
|
{renderContent()}
|
||||||
<div className="px-5 pb-5 space-y-4 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
|
||||||
{/* Payment options */}
|
|
||||||
{hasOptions && method.options && (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{method.options.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedOption(opt.id)}
|
|
||||||
className={`${minTouchTarget} py-3 px-4 rounded-xl text-sm font-semibold transition-all ${touchButtonClass} ${
|
|
||||||
selectedOption === opt.id
|
|
||||||
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/20'
|
|
||||||
: 'bg-dark-800/70 text-dark-300 hover:bg-dark-800 active:bg-dark-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Amount input */}
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
enterKeyHint="done"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSubmit()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t('balance.enterAmount') || 'Введите сумму'}
|
|
||||||
className="w-full h-14 px-5 pr-14 text-xl font-bold bg-dark-800/50 border-2 border-dark-700/50 rounded-2xl text-dark-100 placeholder:text-dark-500 placeholder:font-normal focus:outline-none focus:border-accent-500/50 focus:bg-dark-800/70 transition-all"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
spellCheck="false"
|
|
||||||
style={{
|
|
||||||
fontSize: '20px', // Prevent iOS zoom on focus
|
|
||||||
WebkitTapHighlightColor: 'transparent'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="absolute right-5 top-1/2 -translate-y-1/2 text-dark-400 font-semibold text-lg pointer-events-none">
|
|
||||||
{currencySymbol}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick amounts */}
|
|
||||||
{quickAmounts.length > 0 && (
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{quickAmounts.map((a) => {
|
|
||||||
const val = getQuickValue(a)
|
|
||||||
const isSelected = amount === val
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={a}
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
|
||||||
className={`${minTouchTarget} py-3 rounded-xl text-sm font-semibold transition-all ${touchButtonClass} ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/30'
|
|
||||||
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-800 active:bg-dark-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatAmount(a, 0)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-error-500/10 border border-error-500/20" role="alert">
|
|
||||||
<svg className="w-5 h-5 text-error-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-error-400 text-sm font-medium">{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isPending || !amount}
|
|
||||||
className={`w-full h-14 rounded-2xl font-bold text-base transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:shadow-accent-500/40 active:scale-[0.98] active:shadow-accent-500/20 ${touchButtonClass}`}
|
|
||||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<span className="w-6 h-6 border-[3px] border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>{t('balance.topUp')}</span>
|
|
||||||
{amount && parseFloat(amount) > 0 && (
|
|
||||||
<span className="opacity-80">• {formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <Wrapper>{modalContent}</Wrapper>
|
// Desktop centered modal
|
||||||
|
const DesktopModal = () => (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 z-[60] flex items-start justify-center p-4 pt-[10vh]"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-modal-content
|
||||||
|
className="w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||||
|
<span className="font-semibold text-dark-100">{methodName}</span>
|
||||||
|
<button onClick={handleClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderContent = () => (
|
||||||
|
<>
|
||||||
|
{/* Payment options */}
|
||||||
|
{hasOptions && method.options && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{method.options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedOption(opt.id)}
|
||||||
|
className={`flex-1 py-2.5 px-3 rounded-xl text-sm font-semibold transition-all ${
|
||||||
|
selectedOption === opt.id
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount input */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
enterKeyHint="done"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
|
||||||
|
placeholder={`${formatAmount(minRubles, 0)} – ${formatAmount(maxRubles, 0)}`}
|
||||||
|
className="w-full h-12 px-4 pr-12 text-lg font-semibold bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||||
|
style={{ fontSize: '18px' }}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-500 font-medium pointer-events-none">
|
||||||
|
{currencySymbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick amounts */}
|
||||||
|
{quickAmounts.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{quickAmounts.map((a) => {
|
||||||
|
const val = getQuickValue(a)
|
||||||
|
const isSelected = amount === val
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
||||||
|
className={`flex-1 py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatAmount(a, 0)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-error-400 text-sm text-center py-1">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isPending || !amount}
|
||||||
|
className="btn-primary w-full h-12 text-base font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('balance.topUp')}
|
||||||
|
{amount && parseFloat(amount) > 0 && (
|
||||||
|
<span className="ml-2 opacity-80">{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = isMobileScreen ? <MobileModal /> : <DesktopModal />
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return createPortal(content, document.body)
|
||||||
|
}
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user