Enhance ConnectionModal and TopUpModal with improved mobile support, new icons, and keyboard accessibility. Update localization files to include 'enterAmount' key for multiple languages.

This commit is contained in:
PEDZEO
2026-01-20 16:52:01 +03:00
parent 9b0f5060dd
commit 236c36f1ab
6 changed files with 603 additions and 305 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -10,6 +10,7 @@ interface ConnectionModalProps {
onClose: () => void onClose: () => void
} }
// 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-6 h-6" 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" />
@@ -34,9 +35,9 @@ const LinkIcon = () => (
</svg> </svg>
) )
const ChevronIcon = () => ( const ChevronIcon = ({ className = '' }: { className?: string }) => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className={`w-5 h-5 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg> </svg>
) )
@@ -46,6 +47,13 @@ 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
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">
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z"/> <path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z"/>
@@ -87,6 +95,26 @@ 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()
@@ -113,7 +141,6 @@ function detectPlatform(): string | null {
} }
function useIsMobile() { function useIsMobile() {
// Initialize synchronously to avoid flash between desktop/mobile layouts
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 < 768
@@ -126,6 +153,10 @@ 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)
@@ -133,13 +164,10 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const [detectedPlatform, setDetectedPlatform] = useState<string | null>(null) const [detectedPlatform, setDetectedPlatform] = useState<string | null>(null)
const [showAppSelector, setShowAppSelector] = useState(false) const [showAppSelector, setShowAppSelector] = useState(false)
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
const isMobileScreen = useIsMobile() const isMobileScreen = useIsMobile()
// Use mobile layout only on small screens, even in Telegram Desktop
const isMobile = isMobileScreen
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
// In fullscreen mode, add +45px for Telegram native controls (close/menu buttons in corners)
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
const { data: appConfig, isLoading, error } = useQuery<AppConfig>({ const { data: appConfig, isLoading, error } = useQuery<AppConfig>({
@@ -147,6 +175,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
queryFn: () => subscriptionApi.getAppConfig(), queryFn: () => subscriptionApi.getAppConfig(),
}) })
// Handle close with memoization
const handleClose = useCallback(() => {
onClose()
}, [onClose])
// Handle back (for app selector)
const handleBack = useCallback(() => {
setShowAppSelector(false)
}, [])
useEffect(() => { useEffect(() => {
setDetectedPlatform(detectPlatform()) setDetectedPlatform(detectPlatform())
}, []) }, [])
@@ -160,9 +198,57 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
if (app) setSelectedApp(app) if (app) setSelectedApp(app)
}, [appConfig, detectedPlatform, selectedApp]) }, [appConfig, detectedPlatform, selectedApp])
// Keyboard support (Escape to close) - PC
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
if (showAppSelector) {
handleBack()
} else {
handleClose()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose, handleBack, showAppSelector])
// Telegram back button support - Android
useEffect(() => {
if (!webApp) return
const backHandler = showAppSelector ? handleBack : handleClose
if (webApp.BackButton) {
webApp.BackButton.show()
webApp.BackButton.onClick(backHandler)
}
return () => {
if (webApp.BackButton) {
webApp.BackButton.offClick(backHandler)
webApp.BackButton.hide()
}
}
}, [webApp, handleClose, handleBack, showAppSelector])
// Scroll lock - iOS/Android
useEffect(() => {
const scrollY = window.scrollY
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' } // iOS specific
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 => {
@@ -212,51 +298,26 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
window.location.href = redirectUrl window.location.href = redirectUrl
} }
// Desktop modal wrapper - compact centered modal with max height // Mobile fullscreen modal
const DesktopWrapper = ({ children }: { children: React.ReactNode }) => (
<div
className="fixed inset-0 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-fade-in"
onClick={onClose}
>
<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 animate-scale-in"
onClick={e => e.stopPropagation()}
>
{/* Desktop close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 p-2 rounded-xl bg-dark-800/80 hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors"
>
<CloseIcon />
</button>
{children}
</div>
</div>
)
// Mobile fullscreen wrapper - like React Native Modal with animationType="slide"
// Use portal to render directly in body, avoiding transform/filter issues with fixed positioning
const MobileWrapper = ({ children }: { children: React.ReactNode }) => { const MobileWrapper = ({ children }: { children: React.ReactNode }) => {
const content = ( const content = (
<> <>
{/* Backdrop */} {/* Backdrop */}
<div className="fixed inset-0 z-[9998] bg-black/50 animate-fade-in" onClick={onClose} /> <div
{/* Modal - fullscreen overlay */} className="fixed inset-0 z-[9998] bg-black/60 animate-fade-in"
onClick={handleClose}
style={{ WebkitTapHighlightColor: 'transparent' }}
/>
{/* Modal */}
<div <div
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col animate-slide-up" 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'
}} }}
> >
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 z-10 p-2.5 rounded-full bg-dark-800/80 text-dark-200 active:bg-dark-700"
style={{ top: safeTop ? `${safeTop + 16}px` : 'calc(env(safe-area-inset-top, 0px) + 16px)' }}
>
<CloseIcon />
</button>
{children} {children}
</div> </div>
</> </>
@@ -268,70 +329,104 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return content return content
} }
const Wrapper = isMobile ? MobileWrapper : DesktopWrapper // 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
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>
)
// Loading const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper
// Loading state
if (isLoading) { if (isLoading) {
return ( return (
<Wrapper> <Wrapper>
<div className={`${isMobile ? 'flex-1' : ''} flex items-center justify-center p-12`}> <div className="flex-1 flex items-center justify-center p-12">
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="w-12 h-12 border-[3px] border-accent-500/30 border-t-accent-500 rounded-full animate-spin" />
</div> </div>
</Wrapper> </Wrapper>
) )
} }
// Error // Error state
if (error || !appConfig) { if (error || !appConfig) {
return ( return (
<Wrapper> <Wrapper>
<div className={`${isMobile ? '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="text-5xl mb-4">😕</div> <div className="w-16 h-16 rounded-full bg-error-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">😕</span>
</div>
<p className="text-dark-300 text-lg mb-6">{t('common.error')}</p> <p className="text-dark-300 text-lg mb-6">{t('common.error')}</p>
<button onClick={onClose} className="btn-primary px-8 py-3 text-base">{t('common.close')}</button> <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 // No subscription state
if (!appConfig.hasSubscription) { if (!appConfig.hasSubscription) {
return ( return (
<Wrapper> <Wrapper>
<div className={`${isMobile ? '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="text-5xl mb-4">📱</div> <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-6">{t('subscription.connection.noSubscription')}</p>
<button onClick={onClose} className="btn-primary px-8 py-3 text-base">{t('common.close')}</button> <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 // App selector view
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 p-4 border-b border-dark-800"> <div className="flex items-center gap-3 px-4 py-4 border-b border-dark-800/50">
<button onClick={() => setShowAppSelector(false)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300"> <button
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">{t('subscription.connection.selectApp')}</h2> <h2 className="font-bold text-dark-100 text-lg flex-1">{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 grouped by platform */} {/* Apps list */}
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-5`}> <div
className="flex-1 overflow-y-auto overscroll-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="p-4 space-y-5">
{availablePlatforms.map(platform => { {availablePlatforms.map(platform => {
const apps = appConfig.platforms[platform] const apps = appConfig.platforms[platform]
if (!apps?.length) return null if (!apps?.length) return null
@@ -340,90 +435,125 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return ( return (
<div key={platform}> <div key={platform}>
{/* Platform header */} {/* Platform header */}
<div className="flex items-center gap-2 mb-2 px-1"> <div className="flex items-center gap-2 mb-3 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> </span>
{isCurrentPlatform && ( {isCurrentPlatform && (
<span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full"> <span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full font-medium">
{t('subscription.connection.yourDevice')} {t('subscription.connection.yourDevice')}
</span> </span>
)} )}
</div> </div>
{/* Apps for this platform */} {/* Apps grid */}
<div className="space-y-2"> <div className="space-y-2">
{apps.map(app => ( {apps.map(app => {
const isSelected = selectedApp?.id === app.id
return (
<button <button
key={app.id} key={app.id}
onClick={() => { setSelectedApp(app); setShowAppSelector(false) }} onClick={() => { setSelectedApp(app); setShowAppSelector(false) }}
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all ${ className={`w-full p-4 rounded-2xl flex items-center gap-4 transition-all ${touchButtonClass} ${
selectedApp?.id === app.id isSelected
? 'bg-accent-500/15 ring-2 ring-accent-500/50' ? 'bg-accent-500/10 ring-2 ring-accent-500/40'
: 'bg-dark-800/40 hover:bg-dark-800/70 active:bg-dark-800' : 'bg-dark-800/40 hover:bg-dark-800/70 active:bg-dark-800'
}`} }`}
style={{ WebkitTapHighlightColor: 'transparent' }}
> >
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center text-dark-200"> <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)} {getAppIcon(app.name)}
</div> </div>
<span className="font-medium text-dark-100 flex-1 text-left">{app.name}</span> <div className="flex-1 text-left">
<span className="font-semibold text-dark-100 block">{app.name}</span>
{app.isFeatured && ( {app.isFeatured && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-accent-500/20 text-accent-400"> <span className="text-xs text-accent-400 font-medium">
{t('subscription.connection.featured')} {t('subscription.connection.featured')}
</span> </span>
)} )}
</div>
<ChevronIcon className="text-dark-500" />
</button> </button>
))} )
})}
</div> </div>
</div> </div>
) )
})} })}
</div> </div>
</div>
</Wrapper> </Wrapper>
) )
} }
// Main view // Main connection view
return ( return (
<Wrapper> <Wrapper>
{/* Header - app selector */} {/* Header with app selector */}
<div className="p-4 border-b border-dark-800"> <div className="px-4 pt-4 pb-3 border-b border-dark-800/50">
<div className="flex items-center justify-between mb-3">
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
<button <button
onClick={() => setShowAppSelector(true)} onClick={handleClose}
className="w-full flex items-center gap-4 p-3 rounded-2xl bg-dark-800/50 hover:bg-dark-800 transition-colors" 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')}
> >
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-500/30 to-accent-600/10 flex items-center justify-center text-accent-400"> <CloseIcon />
{selectedApp && getAppIcon(selectedApp.name)}
</div>
<div className="flex-1 text-left">
<div className="font-bold text-dark-100 text-lg">{selectedApp?.name}</div>
<div className="text-sm text-accent-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
</div>
<ChevronIcon />
</button> </button>
</div> </div>
{/* Content */} {/* App selector button */}
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-4`}> <button
{/* Step 1 */} onClick={() => setShowAppSelector(true)}
{selectedApp?.installationStep && ( 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}`}
<div className="p-4 rounded-2xl bg-dark-800/30"> style={{ WebkitTapHighlightColor: 'transparent' }}
<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> <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}`}>
<h3 className="font-semibold text-dark-100">{t('subscription.connection.installApp')}</h3> {selectedApp && getAppIcon(selectedApp.name)}
</div> </div>
<p className="text-dark-300 mb-3 leading-relaxed">{getLocalizedText(selectedApp.installationStep.description)}</p> <div className="flex-1 text-left min-w-0">
<div className="font-bold text-dark-100 text-base truncate">{selectedApp?.name}</div>
<div className="text-sm text-accent-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
</div>
<ChevronIcon className="text-dark-500 flex-shrink-0" />
</button>
</div>
{/* Steps content */}
<div
className="flex-1 overflow-y-auto overscroll-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="p-4 space-y-4">
{/* Step 1: Install */}
{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 && ( {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 ml-11">
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( {selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => (
<a <a
key={idx} key={idx}
href={btn.buttonLink} href={btn.buttonLink}
target="_blank" target="_blank"
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 font-medium hover:bg-dark-600 transition-colors" 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' }}
> >
<LinkIcon /> <DownloadIcon />
{getLocalizedText(btn.buttonText)} {getLocalizedText(btn.buttonText)}
</a> </a>
))} ))}
@@ -432,32 +562,41 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div> </div>
)} )}
{/* Step 2 */} {/* Step 2: Add subscription */}
{selectedApp?.addSubscriptionStep && ( {selectedApp?.addSubscriptionStep && (
<div className="p-4 rounded-2xl bg-dark-800/30"> <div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-start gap-3 mb-4">
<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> <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">
<h3 className="font-semibold text-dark-100">{t('subscription.connection.addSubscription')}</h3> 2
</div>
<div className="flex-1 min-w-0">
<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>
<p className="text-dark-300 mb-4 leading-relaxed">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
<div className="space-y-3"> <div className="space-y-3 ml-11">
{/* Connect button */}
{selectedApp.deepLink && ( {selectedApp.deepLink && (
<button <button
onClick={() => handleConnect(selectedApp)} onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-3 text-base font-semibold flex items-center justify-center gap-2" 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 /> <LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })} {t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button> </button>
)} )}
{/* Copy link button */}
<button <button
onClick={copySubscriptionLink} onClick={copySubscriptionLink}
className={`w-full py-3 rounded-xl border-2 transition-all flex items-center justify-center gap-2 text-base font-medium ${ className={`w-full h-12 rounded-xl border-2 transition-all flex items-center justify-center gap-2 text-sm font-semibold ${touchButtonClass} ${minTouchTarget} ${
copied copied
? 'border-success-500 bg-success-500/10 text-success-400' ? 'border-success-500 bg-success-500/10 text-success-400'
: 'border-dark-600 hover:border-dark-500 text-dark-300 hover:text-dark-200' : 'border-dark-600 hover:border-dark-500 text-dark-300 hover:text-dark-200 active:bg-dark-800'
}`} }`}
style={{ WebkitTapHighlightColor: 'transparent' }}
> >
{copied ? <CheckIcon /> : <CopyIcon />} {copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')} {copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
@@ -466,18 +605,22 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div> </div>
)} )}
{/* Step 3 */} {/* Step 3: Connect */}
{selectedApp?.connectAndUseStep && ( {selectedApp?.connectAndUseStep && (
<div className="p-4 rounded-2xl bg-success-500/5 border border-success-500/20"> <div className="p-4 rounded-2xl bg-success-500/5 border border-success-500/20">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-start gap-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> <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">
<h3 className="font-semibold text-dark-100">{t('subscription.connection.connectVpn')}</h3> 3
</div>
<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>
</div>
</div> </div>
<p className="text-dark-300 leading-relaxed">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p>
</div> </div>
)} )}
</div> </div>
</div>
</Wrapper> </Wrapper>
) )
} }

View File

@@ -1,9 +1,10 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance' import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency' import { useCurrency } from '../hooks/useCurrency'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit' import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { PaymentMethod } from '../types' import type { PaymentMethod } from '../types'
@@ -18,7 +19,6 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) }
} }
if (webApp?.openLink) { if (webApp?.openLink) {
// try_browser: true - открывает диалог для перехода во внешний браузер (важно для мобильных)
try { webApp.openLink(url, { try_instant_view: false, try_browser: true }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } try { webApp.openLink(url, { try_instant_view: false, try_browser: true }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) }
} }
if (reservedWindow && !reservedWindow.closed) { if (reservedWindow && !reservedWindow.closed) {
@@ -30,16 +30,46 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
window.location.href = url window.location.href = url
} }
// Icons
const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</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
initialAmountRubles?: number initialAmountRubles?: number
} }
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false
return window.innerWidth < 768
})
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
return isMobile
}
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const isMobileScreen = useIsMobile()
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
const getInitialAmount = (): string => { const getInitialAmount = (): string => {
if (!initialAmountRubles || initialAmountRubles <= 0) return '' if (!initialAmountRubles || initialAmountRubles <= 0) return ''
@@ -56,31 +86,69 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
) )
const popupRef = useRef<Window | null>(null) const popupRef = useRef<Window | null>(null)
// Scroll lock when modal is open // Handle close with memoization
const handleClose = useCallback(() => {
onClose()
}, [onClose])
// Keyboard support (Escape to close) - PC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
handleClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose])
// Telegram back button support - Android
useEffect(() => {
if (!webApp) return
// Show back button in Telegram
if (webApp.BackButton) {
webApp.BackButton.show()
webApp.BackButton.onClick(handleClose)
}
return () => {
if (webApp.BackButton) {
webApp.BackButton.offClick(handleClose)
webApp.BackButton.hide()
}
}
}, [webApp, handleClose])
// Scroll lock - iOS/Android rubber band prevention
useEffect(() => { useEffect(() => {
const scrollY = window.scrollY const scrollY = window.scrollY
// Prevent all touch/wheel scroll on backdrop
const preventScroll = (e: TouchEvent) => { const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return if (target.closest('[data-modal-content]')) return
e.preventDefault() e.preventDefault()
} }
const preventWheel = (e: WheelEvent) => { const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return if (target.closest('[data-modal-content]')) return
e.preventDefault() e.preventDefault()
} }
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)
} }
}, []) }, [])
@@ -101,7 +169,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); onClose() } if (status === 'paid') { setError(null); handleClose() }
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)) }
@@ -121,7 +189,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
onClose() handleClose()
}, },
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 {}
@@ -162,50 +230,114 @@ 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 // Auto-focus input on mount (delayed for animation)
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() inputRef.current?.focus()
}, 100) }
}, 300)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, []) }, [isMobileScreen])
const modalContent = ( // Mobile bottom sheet modal
const MobileWrapper = ({ children }: { children: React.ReactNode }) => {
const content = (
<>
{/* Backdrop */}
<div <div
className="fixed inset-0 bg-black/70 z-[60] flex items-start justify-center p-4 pt-4 overflow-hidden" 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={{ style={{
paddingBottom: `max(1rem, env(safe-area-inset-bottom, 0px))`, paddingBottom: safeBottom ? `${safeBottom + 16}px` : 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation'
}} }}
onClick={onClose} 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-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden animate-scale-in" className="w-full max-w-[400px] bg-dark-900 rounded-[24px] border border-dark-700/30 shadow-2xl overflow-hidden animate-scale-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{children}
</div>
</div>
)
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 */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50"> <div className="px-5 pt-3 pb-3 flex items-center justify-between">
<span className="font-semibold text-dark-100">{methodName}</span> <div className="flex items-center gap-3">
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400"> <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}`}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <WalletIcon />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </div>
</svg> <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>
<button
onClick={handleClose}
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}`}
aria-label={t('common.close')}
>
<CloseIcon />
</button> </button>
</div> </div>
<div className="p-4 space-y-3"> {/* Content */}
<div className="px-5 pb-5 space-y-4 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
{/* Payment options */} {/* Payment options */}
{hasOptions && method.options && ( {hasOptions && method.options && (
<div className="flex gap-2"> <div className="grid grid-cols-2 gap-2">
{method.options.map((opt) => ( {method.options.map((opt) => (
<button <button
key={opt.id} key={opt.id}
type="button" type="button"
onClick={() => setSelectedOption(opt.id)} onClick={() => setSelectedOption(opt.id)}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${ className={`${minTouchTarget} py-3 px-4 rounded-xl text-sm font-semibold transition-all ${touchButtonClass} ${
selectedOption === opt.id selectedOption === opt.id
? 'bg-accent-500 text-white' ? 'bg-accent-500 text-white shadow-lg shadow-accent-500/20'
: 'bg-dark-800 text-dark-300' : 'bg-dark-800/70 text-dark-300 hover:bg-dark-800 active:bg-dark-700'
}`} }`}
> >
{opt.name} {opt.name}
@@ -220,29 +352,46 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
ref={inputRef} ref={inputRef}
type="number" type="number"
inputMode="decimal" inputMode="decimal"
enterKeyHint="done"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
placeholder={`${formatAmount(minRubles, 0)} ${formatAmount(maxRubles, 0)}`} onKeyDown={(e) => {
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" 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" autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
style={{
fontSize: '20px', // Prevent iOS zoom on focus
WebkitTapHighlightColor: 'transparent'
}}
/> />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-500 font-medium"> <span className="absolute right-5 top-1/2 -translate-y-1/2 text-dark-400 font-semibold text-lg pointer-events-none">
{currencySymbol} {currencySymbol}
</span> </span>
</div> </div>
{/* Quick amounts */} {/* Quick amounts */}
{quickAmounts.length > 0 && ( {quickAmounts.length > 0 && (
<div className="flex gap-2"> <div className="grid grid-cols-4 gap-2">
{quickAmounts.map((a) => { {quickAmounts.map((a) => {
const val = getQuickValue(a) const val = getQuickValue(a)
const isSelected = amount === val
return ( return (
<button <button
key={a} key={a}
type="button" type="button"
onClick={() => { setAmount(val); inputRef.current?.blur() }} onClick={() => { setAmount(val); inputRef.current?.blur() }}
className={`flex-1 py-2 rounded-lg text-sm font-medium ${ className={`${minTouchTarget} py-3 rounded-xl text-sm font-semibold transition-all ${touchButtonClass} ${
amount === val ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300' 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)} {formatAmount(a, 0)}
@@ -254,7 +403,12 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="text-error-400 text-sm text-center py-1">{error}</div> <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 */} {/* Submit */}
@@ -262,26 +416,23 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isPending || !amount} disabled={isPending || !amount}
className="btn-primary w-full h-11 text-base font-semibold" 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 ? ( {isPending ? (
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <span className="w-6 h-6 border-[3px] border-white/30 border-t-white rounded-full animate-spin" />
) : ( ) : (
<> <>
{t('balance.topUp')} <span>{t('balance.topUp')}</span>
{amount && parseFloat(amount) > 0 && ( {amount && parseFloat(amount) > 0 && (
<span className="ml-2 opacity-80">{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span> <span className="opacity-80"> {formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
)} )}
</> </>
)} )}
</button> </button>
</div> </div>
</div> </>
</div>
) )
if (typeof document !== 'undefined') { return <Wrapper>{modalContent}</Wrapper>
return createPortal(modalContent, document.body)
}
return modalContent
} }

View File

@@ -253,6 +253,7 @@
"currentBalance": "Current Balance", "currentBalance": "Current Balance",
"topUp": "Top Up", "topUp": "Top Up",
"topUpBalance": "Top Up Balance", "topUpBalance": "Top Up Balance",
"enterAmount": "Enter amount",
"paymentMethods": "Payment Methods", "paymentMethods": "Payment Methods",
"transactionHistory": "Transaction History", "transactionHistory": "Transaction History",
"noTransactions": "No transactions", "noTransactions": "No transactions",

View File

@@ -183,6 +183,7 @@
"currentBalance": "موجودی فعلی", "currentBalance": "موجودی فعلی",
"topUp": "شارژ", "topUp": "شارژ",
"topUpBalance": "شارژ موجودی", "topUpBalance": "شارژ موجودی",
"enterAmount": "مقدار را وارد کنید",
"paymentMethods": { "paymentMethods": {
"yookassa": { "yookassa": {
"name": "YooKassa", "name": "YooKassa",

View File

@@ -253,6 +253,7 @@
"currentBalance": "Текущий баланс", "currentBalance": "Текущий баланс",
"topUp": "Пополнить", "topUp": "Пополнить",
"topUpBalance": "Пополнение баланса", "topUpBalance": "Пополнение баланса",
"enterAmount": "Введите сумму",
"paymentMethods": "Способы оплаты", "paymentMethods": "Способы оплаты",
"transactionHistory": "История операций", "transactionHistory": "История операций",
"noTransactions": "Нет операций", "noTransactions": "Нет операций",

View File

@@ -184,6 +184,7 @@
"currentBalance": "当前余额", "currentBalance": "当前余额",
"topUp": "充值", "topUp": "充值",
"topUpBalance": "充值余额", "topUpBalance": "充值余额",
"enterAmount": "输入金额",
"paymentMethods": { "paymentMethods": {
"yookassa": { "yookassa": {
"name": "YooKassa", "name": "YooKassa",