mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -9,28 +9,6 @@ import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils
|
|||||||
import type { PaymentMethod } from '../types'
|
import type { PaymentMethod } from '../types'
|
||||||
import BentoCard from './ui/BentoCard'
|
import BentoCard from './ui/BentoCard'
|
||||||
|
|
||||||
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
|
|
||||||
const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url)
|
|
||||||
|
|
||||||
const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
|
|
||||||
if (typeof window === 'undefined' || !url) return
|
|
||||||
const webApp = window.Telegram?.WebApp
|
|
||||||
|
|
||||||
if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) {
|
|
||||||
try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) }
|
|
||||||
}
|
|
||||||
if (webApp?.openLink) {
|
|
||||||
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) {
|
|
||||||
try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const w2 = window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
if (w2) { w2.opener = null; return }
|
|
||||||
window.location.href = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
<svg className="w-5 h-5" 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}>
|
||||||
@@ -68,6 +46,24 @@ const SparklesIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ExternalLinkIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CopyIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
method: PaymentMethod
|
method: PaymentMethod
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -117,7 +113,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
method.options && method.options.length > 0 ? method.options[0].id : null
|
||||||
)
|
)
|
||||||
const popupRef = useRef<Window | null>(null)
|
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false)
|
const [isInputFocused, setIsInputFocused] = useState(false)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -177,7 +174,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
||||||
const isStarsMethod = methodKey.includes('stars')
|
const isStarsMethod = methodKey.includes('stars')
|
||||||
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
|
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
|
||||||
const isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData)
|
|
||||||
|
|
||||||
const starsPaymentMutation = useMutation({
|
const starsPaymentMutation = useMutation({
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||||
@@ -205,13 +201,23 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
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) {
|
||||||
popupRef.current = null
|
// In Telegram Mini App, try to open directly
|
||||||
onClose()
|
const webApp = window.Telegram?.WebApp
|
||||||
|
if (webApp?.openLink) {
|
||||||
|
try {
|
||||||
|
webApp.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[TopUpModal] webApp.openLink failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise show the link for user to click
|
||||||
|
setPaymentUrl(redirectUrl)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {}
|
|
||||||
popupRef.current = null
|
|
||||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
|
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
|
||||||
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error')))
|
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error')))
|
||||||
},
|
},
|
||||||
@@ -219,6 +225,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setPaymentUrl(null)
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
|
|
||||||
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
||||||
@@ -234,9 +241,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
}
|
}
|
||||||
|
|
||||||
const amountKopeks = Math.round(amountRubles * 100)
|
const amountKopeks = Math.round(amountRubles * 100)
|
||||||
if (!isTelegramMiniApp) {
|
|
||||||
try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null }
|
|
||||||
}
|
|
||||||
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) }
|
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) }
|
||||||
else { topUpMutation.mutate(amountKopeks) }
|
else { topUpMutation.mutate(amountKopeks) }
|
||||||
}
|
}
|
||||||
@@ -248,6 +252,22 @@ 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
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
if (!paymentUrl) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(paymentUrl)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to copy:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenUrl = () => {
|
||||||
|
if (!paymentUrl) return
|
||||||
|
window.open(paymentUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-focus input - works on mobile in Telegram WebApp
|
// Auto-focus input - works on mobile in Telegram WebApp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -261,9 +281,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Calculate display amount for preview
|
|
||||||
const displayAmount = amount && parseFloat(amount) > 0 ? parseFloat(amount) : 0
|
|
||||||
|
|
||||||
// Content JSX - shared between mobile and desktop
|
// Content JSX - shared between mobile and desktop
|
||||||
const contentJSX = (
|
const contentJSX = (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -314,33 +331,55 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Amount input - modern design */}
|
{/* Amount input + Submit button - inline */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
||||||
<div className={`relative rounded-2xl transition-all duration-200 ${
|
<div className="flex gap-2">
|
||||||
isInputFocused
|
<div className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
||||||
? 'ring-2 ring-accent-500/50 bg-dark-800'
|
isInputFocused
|
||||||
: 'bg-dark-800/70 border border-dark-700/50'
|
? 'ring-2 ring-accent-500/50 bg-dark-800'
|
||||||
}`}>
|
: 'bg-dark-800/70 border border-dark-700/50'
|
||||||
<input
|
}`}>
|
||||||
ref={inputRef}
|
<input
|
||||||
type="number"
|
ref={inputRef}
|
||||||
inputMode="decimal"
|
type="number"
|
||||||
enterKeyHint="done"
|
inputMode="decimal"
|
||||||
value={amount}
|
enterKeyHint="done"
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
value={amount}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
placeholder="0"
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
|
||||||
className="w-full h-16 px-5 pr-16 text-2xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
placeholder="0"
|
||||||
style={{ fontSize: '24px' }}
|
className="w-full h-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-5 top-1/2 -translate-y-1/2 text-lg font-semibold text-dark-500">
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-base font-semibold text-dark-500">
|
||||||
{currencySymbol}
|
{currencySymbol}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isPending || !amount || parseFloat(amount) <= 0}
|
||||||
|
className={`shrink-0 h-14 px-6 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden flex items-center justify-center gap-2 ${
|
||||||
|
isPending || !amount || parseFloat(amount) <= 0
|
||||||
|
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
|
||||||
|
: isStarsMethod
|
||||||
|
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
|
||||||
|
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SparklesIcon />
|
||||||
|
<span>{t('balance.topUp')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -386,36 +425,53 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Payment link display - shown when URL is received */}
|
||||||
<button
|
{paymentUrl && (
|
||||||
type="button"
|
<div className="space-y-3 p-4 rounded-2xl bg-success-500/10 border border-success-500/20">
|
||||||
onClick={handleSubmit}
|
<div className="flex items-center gap-2 text-success-400">
|
||||||
disabled={isPending || !amount || parseFloat(amount) <= 0}
|
<CheckIcon />
|
||||||
className={`relative w-full h-14 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden ${
|
<span className="font-semibold">{t('balance.paymentReady', 'Ссылка на оплату готова')}</span>
|
||||||
isPending || !amount || parseFloat(amount) <= 0
|
|
||||||
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
|
|
||||||
: isStarsMethod
|
|
||||||
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
|
|
||||||
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
<span>{t('common.loading')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
<p className="text-sm text-dark-400">
|
||||||
<SparklesIcon />
|
{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')}
|
||||||
<span>{t('balance.topUp')}</span>
|
</p>
|
||||||
{displayAmount > 0 && (
|
|
||||||
<span className="opacity-90">
|
{/* Main open button */}
|
||||||
{formatAmount(displayAmount, currencyDecimals)} {currencySymbol}
|
<a
|
||||||
</span>
|
href={paymentUrl}
|
||||||
)}
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleOpenUrl()
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon />
|
||||||
|
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Copy and link display */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0 px-3 py-2 rounded-lg bg-dark-800/70 border border-dark-700/50">
|
||||||
|
<p className="text-xs text-dark-500 truncate">{paymentUrl}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className={`shrink-0 p-2.5 rounded-lg transition-colors ${
|
||||||
|
copied
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
title={t('common.copy', 'Копировать')}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user