Refactor ConnectionModal and Referral components: introduce ModalContent and CopyLinkButton helper components for improved structure and reusability. Update referral link handling in Referral component to utilize a dynamic bot username, enhancing link generation and sharing functionality.

This commit is contained in:
PEDZEO
2026-01-20 02:38:02 +03:00
parent 6183f7b3e6
commit 5a7fda6e3a
2 changed files with 407 additions and 248 deletions

View File

@@ -93,6 +93,82 @@ const platformIconComponents: Record<string, React.FC> = {
appleTV: TvIcon, appleTV: TvIcon,
} }
// Helper components
interface ModalContentProps {
title: string
subtitle?: string
onClose: () => void
onBack?: () => void
icon?: string
children: React.ReactNode
}
const ModalContent = ({ title, subtitle, onClose, onBack, icon, children }: ModalContentProps) => (
<>
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-3">
{onBack && (
<button onClick={onBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<BackIcon />
</button>
)}
{icon && !onBack && (
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center">
<span className="text-xl">{icon}</span>
</div>
)}
<div>
<h2 className="font-semibold text-dark-100">{title}</h2>
{subtitle && <p className="text-xs text-dark-500">{subtitle}</p>}
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{children}
</div>
</>
)
interface CopyLinkButtonProps {
copied: boolean
onClick: () => void
t: (key: string) => string
compact?: boolean
}
const CopyLinkButton = ({ copied, onClick, t, compact }: CopyLinkButtonProps) => (
<button
onClick={onClick}
className={`w-full p-3 rounded-2xl border-2 border-dashed transition-all flex items-center justify-center gap-3 ${
copied
? 'border-success-500/50 bg-success-500/10'
: 'border-dark-700 hover:border-accent-500/50 hover:bg-dark-800/50'
}`}
>
{copied ? (
<>
<div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center text-success-400">
<CheckIcon />
</div>
<span className="font-medium text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center text-dark-400">
<CopyIcon />
</div>
<div className="text-left">
<p className="font-medium text-dark-200">{t('subscription.connection.copyLink')}</p>
{!compact && <p className="text-xs text-dark-500">Для ручного подключения</p>}
</div>
</>
)}
</button>
)
// Platform order for display // Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
@@ -272,29 +348,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
// Step 1: Select platform // Step 1: Select platform
if (!selectedPlatform) { if (!selectedPlatform) {
return ( return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center p-0 sm:p-4" onClick={onClose}> <div className="fixed inset-0 bg-black/70 z-50" onClick={onClose}>
{/* Desktop: centered modal */}
<div className="hidden sm:flex items-center justify-center min-h-screen p-4">
<div <div
className="w-full sm:max-w-md bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 max-h-[85vh] sm:max-h-[90vh] flex flex-col animate-slide-up" className="w-full max-w-md bg-dark-900 rounded-2xl border border-dark-700/50 max-h-[90vh] flex flex-col animate-scale-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} <ModalContent
<div className="flex items-center justify-between p-4 border-b border-dark-800"> title={t('subscription.connection.title')}
<div className="flex items-center gap-3"> subtitle={t('subscription.connection.selectDevice')}
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center"> onClose={onClose}
<span className="text-xl">📱</span> icon="📱"
</div> >
<div>
<h2 className="font-semibold text-dark-100">{t('subscription.connection.title')}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectDevice')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{availablePlatforms.map((platform) => { {availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform] const IconComponent = platformIconComponents[platform]
@@ -324,37 +390,78 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
) )
})} })}
</div> </div>
</ModalContent>
<div className="p-4 border-t border-dark-800">
<CopyLinkButton copied={copied} onClick={copySubscriptionLink} t={t} />
</div>
</div>
</div>
{/* Mobile: bottom sheet */}
<div
className="sm:hidden fixed bottom-0 left-0 right-0 bg-dark-900 rounded-t-3xl border-t border-dark-700/50 animate-slide-up"
style={{ maxHeight: '80vh' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col h-full" style={{ maxHeight: '80vh' }}>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-dark-700" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center">
<span className="text-xl">📱</span>
</div>
<div>
<h2 className="font-semibold text-dark-100">{t('subscription.connection.title')}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectDevice')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<div className="grid grid-cols-2 gap-3">
{availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform]
const isDetected = platform === detectedPlatform
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`relative p-4 rounded-2xl border-2 transition-all text-left ${
isDetected
? 'bg-accent-500/10 border-accent-500/50'
: 'bg-dark-800/50 border-dark-700/50'
}`}
>
{isDetected && (
<div className="absolute -top-2 -right-2 px-2 py-0.5 rounded-full text-[10px] font-medium bg-accent-500 text-white">
{t('subscription.connection.yourDevice')}
</div>
)}
<div className={`mb-2 ${isDetected ? 'text-accent-400' : 'text-dark-400'}`}>
{IconComponent && <IconComponent />}
</div>
<p className={`font-medium text-sm ${isDetected ? 'text-accent-300' : 'text-dark-200'}`}>
{getPlatformName(platform)}
</p>
</button>
)
})}
</div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="p-4 border-t border-dark-800 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4"> <div className="p-4 border-t border-dark-800" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' }}>
<button <CopyLinkButton copied={copied} onClick={copySubscriptionLink} t={t} />
onClick={copySubscriptionLink}
className={`w-full p-4 rounded-2xl border-2 border-dashed transition-all flex items-center justify-center gap-3 ${
copied
? 'border-success-500/50 bg-success-500/10'
: 'border-dark-700 hover:border-accent-500/50 hover:bg-dark-800/50'
}`}
>
{copied ? (
<>
<div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center">
<CheckIcon />
</div> </div>
<span className="font-medium text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center text-dark-400">
<CopyIcon />
</div>
<div className="text-left">
<p className="font-medium text-dark-200">{t('subscription.connection.copyLink')}</p>
<p className="text-xs text-dark-500">Для ручного подключения</p>
</div>
</>
)}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -363,31 +470,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
// Step 2: Select app // Step 2: Select app
if (!selectedApp) { if (!selectedApp) {
return ( const appListContent = platformApps.length === 0 ? (
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center p-0 sm:p-4" onClick={onClose}>
<div
className="w-full sm:max-w-md bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 max-h-[85vh] sm:max-h-[90vh] flex flex-col animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedPlatform(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<BackIcon />
</button>
<div>
<h2 className="font-semibold text-dark-100">{getPlatformName(selectedPlatform)}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectApp')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4">
{platformApps.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<span className="text-3xl">📭</span> <span className="text-3xl">📭</span>
@@ -423,38 +506,50 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</button> </button>
))} ))}
</div> </div>
)} )
return (
<div className="fixed inset-0 bg-black/70 z-50" onClick={onClose}>
{/* Desktop */}
<div className="hidden sm:flex items-center justify-center min-h-screen p-4">
<div className="w-full max-w-md bg-dark-900 rounded-2xl border border-dark-700/50 max-h-[90vh] flex flex-col animate-scale-in" onClick={(e) => e.stopPropagation()}>
<ModalContent title={getPlatformName(selectedPlatform)} subtitle={t('subscription.connection.selectApp')} onClose={onClose} onBack={() => setSelectedPlatform(null)}>
{appListContent}
</ModalContent>
</div>
</div>
{/* Mobile */}
<div className="sm:hidden fixed bottom-0 left-0 right-0 bg-dark-900 rounded-t-3xl border-t border-dark-700/50 animate-slide-up" style={{ maxHeight: '80vh' }} onClick={(e) => e.stopPropagation()}>
<div className="flex flex-col" style={{ maxHeight: '80vh' }}>
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-dark-700" />
</div>
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedPlatform(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400">
<BackIcon />
</button>
<div>
<h2 className="font-semibold text-dark-100">{getPlatformName(selectedPlatform)}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectApp')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' }}>
{appListContent}
</div>
</div> </div>
</div> </div>
</div> </div>
) )
} }
// Step 3: App instructions // Step 3: App instructions - shared content
return ( const instructionsContent = (
<div className="fixed inset-0 bg-black/70 z-50 flex items-end sm:items-center sm:justify-center p-0 sm:p-4" onClick={onClose}>
<div
className="w-full sm:max-w-md bg-dark-900 sm:rounded-2xl rounded-t-3xl border-t sm:border border-dark-700/50 max-h-[85vh] sm:max-h-[90vh] flex flex-col animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedApp(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<BackIcon />
</button>
<div>
<h2 className="font-semibold text-dark-100">{selectedApp.name}</h2>
<p className="text-xs text-dark-500">Инструкция по подключению</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400 hover:text-dark-200 transition-colors">
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4"> <div className="space-y-4">
{/* Step 1: Install */} {/* Step 1: Install */}
{selectedApp.installationStep && ( {selectedApp.installationStep && (
@@ -539,15 +634,73 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div> </div>
)} )}
</div> </div>
</div> )
{/* Footer */} return (
<div className="p-4 border-t border-dark-800 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-4"> <div className="fixed inset-0 bg-black/70 z-50" onClick={onClose}>
{/* Desktop: centered modal */}
<div className="hidden sm:flex items-center justify-center min-h-screen p-4">
<div
className="w-full max-w-md bg-dark-900 rounded-2xl border border-dark-700/50 max-h-[90vh] flex flex-col animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
<ModalContent
title={selectedApp.name}
subtitle={t('subscription.connection.instructions')}
onClose={onClose}
onBack={() => setSelectedApp(null)}
>
{instructionsContent}
</ModalContent>
<div className="p-4 border-t border-dark-800">
<button onClick={onClose} className="btn-secondary w-full"> <button onClick={onClose} className="btn-secondary w-full">
{t('common.close')} {t('common.close')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Mobile: bottom sheet */}
<div
className="sm:hidden fixed bottom-0 left-0 right-0 bg-dark-900 rounded-t-3xl border-t border-dark-700/50 animate-slide-up"
style={{ maxHeight: '80vh' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col" style={{ maxHeight: '80vh' }}>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-dark-700" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 pb-3">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedApp(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400">
<BackIcon />
</button>
<div>
<h2 className="font-semibold text-dark-100">{selectedApp.name}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.instructions')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
{instructionsContent}
</div>
{/* Footer */}
<div className="p-4 border-t border-dark-800" style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' }}>
<button onClick={onClose} className="btn-secondary w-full">
{t('common.close')}
</button>
</div>
</div>
</div>
</div>
) )
} }

View File

@@ -68,6 +68,12 @@ export default function Referral() {
queryFn: referralApi.getReferralInfo, queryFn: referralApi.getReferralInfo,
}) })
// Build referral link using frontend env variable for correct bot username
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME
const referralLink = info?.referral_code && botUsername
? `https://t.me/${botUsername}?start=${info.referral_code}`
: info?.referral_link || ''
const { data: terms } = useQuery({ const { data: terms } = useQuery({
queryKey: ['referral-terms'], queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms, queryFn: referralApi.getReferralTerms,
@@ -90,15 +96,15 @@ export default function Referral() {
}) })
const copyLink = () => { const copyLink = () => {
if (info?.referral_link) { if (referralLink) {
navigator.clipboard.writeText(info.referral_link) navigator.clipboard.writeText(referralLink)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
} }
const shareLink = () => { const shareLink = () => {
if (!info?.referral_link) return if (!referralLink) return
const shareText = t('referral.shareMessage', { const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0, percent: info?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet', botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
@@ -109,7 +115,7 @@ export default function Referral() {
.share({ .share({
title: t('referral.title'), title: t('referral.title'),
text: shareText, text: shareText,
url: info.referral_link, url: referralLink,
}) })
.catch(() => { .catch(() => {
// ignore cancellation errors // ignore cancellation errors
@@ -118,7 +124,7 @@ export default function Referral() {
} }
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent( const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link referralLink
)}&text=${encodeURIComponent(shareText)}` )}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer') window.open(telegramUrl, '_blank', 'noopener,noreferrer')
} }
@@ -176,16 +182,16 @@ export default function Referral() {
<input <input
type='text' type='text'
readOnly readOnly
value={info?.referral_link || ''} value={referralLink}
className='input flex-1' className='input flex-1'
/> />
<div className='flex gap-2'> <div className='flex gap-2'>
<button <button
onClick={copyLink} onClick={copyLink}
disabled={!info?.referral_link} disabled={!referralLink}
className={`btn-primary px-5 ${ className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : '' copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`} } ${!referralLink ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{copied ? <CheckIcon /> : <CopyIcon />} {copied ? <CheckIcon /> : <CopyIcon />}
<span className='ml-2'> <span className='ml-2'>
@@ -194,9 +200,9 @@ export default function Referral() {
</button> </button>
<button <button
onClick={shareLink} onClick={shareLink}
disabled={!info?.referral_link} disabled={!referralLink}
className={`btn-secondary px-5 flex items-center ${ className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : '' !referralLink ? 'opacity-50 cursor-not-allowed' : ''
}`} }`}
> >
<ShareIcon /> <ShareIcon />