import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useSite } from '../context/SiteContext'; import { getUserUsage, redeemCode, getTopupInfo, calculateAmount, createEpayOrder, createStripeOrder, createCreemOrder, createCryptoOrder, getCryptoOrderStatus, getTopupHistory, getAffCode, transferAffQuota, getAffEarnings, Q, quotaToDollar, } from '../api'; import { useCurrency } from '../context/SiteContext'; import CountUp from '../components/bits/CountUp'; import toast from 'react-hot-toast'; export default function Topup() { const { t } = useTranslation(); const { user, refreshUser } = useAuth(); const { site } = useSite(); const { symbol, rate } = useCurrency(); const [usage, setUsage] = useState(null); const [topupInfo, setTopupInfo] = useState(null); const [loading, setLoading] = useState(true); // Redeem code const [redeemInput, setRedeemInput] = useState(''); const [redeeming, setRedeeming] = useState(false); // Online topup const [amount, setAmount] = useState(''); const [selectedPreset, setSelectedPreset] = useState(null); const [payAmount, setPayAmount] = useState(null); const [amountLoading, setAmountLoading] = useState(false); const [paymentLoading, setPaymentLoading] = useState(false); const [payingMethod, setPayingMethod] = useState(''); // Crypto modal const [cryptoOrder, setCryptoOrder] = useState(null); const [cryptoPolling, setCryptoPolling] = useState(false); const [selectedChain, setSelectedChain] = useState(''); const [selectedToken, setSelectedToken] = useState('usdt'); // Creem const [selectedCreemProduct, setSelectedCreemProduct] = useState(null); // History const [showHistory, setShowHistory] = useState(false); const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); // Invitation / Aff const [affLink, setAffLink] = useState(''); const [affEarnings, setAffEarnings] = useState([]); const [showAffEarnings, setShowAffEarnings] = useState(false); const [affEarningsLoading, setAffEarningsLoading] = useState(false); const [transferAmount, setTransferAmount] = useState(''); const [transferring, setTransferring] = useState(false); const enableTopup = site?.enable_topup && topupInfo; const topupConfig = site?.topup_config; const loadData = useCallback(async () => { setLoading(true); try { const [usageRes, topupRes, affRes] = await Promise.all([ getUserUsage(), site?.enable_topup ? getTopupInfo().catch(() => null) : Promise.resolve(null), getAffCode().catch(() => null), ]); if (usageRes.data.success) setUsage(usageRes.data.data); if (topupRes?.data?.data) setTopupInfo(topupRes.data.data); if (affRes?.data?.success && affRes.data.data) { setAffLink(`${window.location.origin}/register?aff=${affRes.data.data}`); } } catch (e) { /* interceptor */ } setLoading(false); }, [site?.enable_topup]); useEffect(() => { loadData(); }, [loadData]); const quota = usage?.quota ?? user?.quota ?? 0; const usedQuota = usage?.used_quota ?? user?.used_quota ?? 0; const requestCount = usage?.request_count ?? user?.request_count ?? 0; const balanceDollars = quota / Q * rate; // Redeem const handleRedeem = async (e) => { e.preventDefault(); if (!redeemInput.trim()) return; setRedeeming(true); try { const res = await redeemCode(redeemInput.trim()); if (res.data.success) { toast.success(t('topup.redeemSuccess')); setRedeemInput(''); await Promise.all([loadData(), refreshUser()]); } } catch (err) { /* interceptor */ } setRedeeming(false); }; // Calculate amount const calcAmount = async (val) => { if (!val || val <= 0) { setPayAmount(null); return; } setAmountLoading(true); try { const res = await calculateAmount({ amount: parseInt(val) }); if (res.data.message === 'success') { setPayAmount(res.data.data); } } catch (e) { /* interceptor */ } setAmountLoading(false); }; // Select preset const handlePreset = (val) => { setSelectedPreset(val); setAmount(val); calcAmount(val); }; // Determine if a payment method is Stripe-based const isStripePayment = (method) => ['stripe', 'alipay', 'wxpay'].includes(method) && !method.startsWith('epay_'); // Pay handler for EPay and Stripe methods const handlePay = async (method) => { const payAmount = parseInt(amount); if (!payAmount || payAmount <= 0) { toast.error(t('topup.enterAmount')); return; } setPaymentLoading(true); setPayingMethod(method); try { const returnUrl = window.location.origin + '/topup'; const data = { amount: payAmount, payment_method: method, return_url: returnUrl }; if (isStripePayment(method)) { // Stripe payment const res = await createStripeOrder(data); if (res.data.message === 'success' && res.data.data?.pay_link) { window.open(res.data.data.pay_link, '_blank'); } else if (res.data.message !== 'success') { const errMsg = typeof res.data.data === 'string' ? res.data.data : res.data.message; toast.error(errMsg || t('common.requestFailed')); } } else { // EPay payment - submit via hidden form (same as main site) const res = await createEpayOrder(data); if (res.data.message === 'success') { const params = res.data.data; // EPay form params const url = res.data.url; // EPay gateway URL if (url && params) { const form = document.createElement('form'); form.action = url; form.method = 'POST'; // Open in new tab (except Safari) const isSafari = navigator.userAgent.indexOf('Safari') > -1 && navigator.userAgent.indexOf('Chrome') < 1; if (!isSafari) { form.target = '_blank'; } for (const key in params) { const input = document.createElement('input'); input.type = 'hidden'; input.name = key; input.value = params[key]; form.appendChild(input); } document.body.appendChild(form); form.submit(); document.body.removeChild(form); } } else { const errMsg = typeof res.data.data === 'string' ? res.data.data : res.data.message; toast.error(errMsg || t('common.requestFailed')); } } } catch (e) { /* interceptor */ } setPaymentLoading(false); setPayingMethod(''); }; // Creem pay - product based const handleCreemPay = async (product) => { setPaymentLoading(true); setPayingMethod('creem'); try { const res = await createCreemOrder({ product_id: product.productId, payment_method: 'creem', }); if (res.data.message === 'success' && res.data.data?.checkout_url) { window.open(res.data.data.checkout_url, '_blank'); } else if (res.data.message !== 'success') { const errMsg = typeof res.data.data === 'string' ? res.data.data : res.data.message; toast.error(errMsg || t('common.requestFailed')); } } catch (e) { /* interceptor */ } setPaymentLoading(false); setPayingMethod(''); }; // Crypto pay - needs chain + token const handleCryptoPay = async () => { const payAmountVal = parseInt(amount); if (!payAmountVal || payAmountVal <= 0) { toast.error(t('topup.enterAmount')); return; } if (!selectedChain) { toast.error(t('topup.selectChain')); return; } if (!selectedToken) { toast.error(t('topup.selectToken')); return; } setPaymentLoading(true); setPayingMethod('crypto'); try { const res = await createCryptoOrder({ amount: payAmountVal, chain: selectedChain, token: selectedToken, }); if (res.data.message === 'success' && res.data.data) { setCryptoOrder(res.data.data); startCryptoPolling(res.data.data.trade_no); } else if (res.data.message !== 'success') { const errMsg = typeof res.data.data === 'string' ? res.data.data : res.data.message; toast.error(errMsg || t('common.requestFailed')); } } catch (e) { /* interceptor */ } setPaymentLoading(false); setPayingMethod(''); }; const startCryptoPolling = (tradeNo) => { setCryptoPolling(true); const interval = setInterval(async () => { try { const res = await getCryptoOrderStatus(tradeNo); if (res.data.data?.status === 'success') { clearInterval(interval); setCryptoPolling(false); setCryptoOrder(null); toast.success(t('topup.paymentSuccess')); await Promise.all([loadData(), refreshUser()]); } else if (res.data.data?.status === 'expired') { clearInterval(interval); setCryptoPolling(false); toast.error(t('topup.orderExpired')); } } catch (e) { clearInterval(interval); setCryptoPolling(false); } }, 5000); // Auto-stop after expiry time const expiryMs = (topupInfo?.crypto_expiry_minutes || 30) * 60 * 1000; setTimeout(() => { clearInterval(interval); setCryptoPolling(false); }, expiryMs); }; // Available crypto chains from config const cryptoWallets = topupInfo?.crypto_wallets || {}; const availableChains = useMemo(() => { const chains = []; if (cryptoWallets.tron) chains.push({ key: 'tron', label: 'TRON (TRC20)' }); if (cryptoWallets.eth) chains.push({ key: 'eth', label: 'Ethereum (ERC20)' }); if (cryptoWallets.bsc) chains.push({ key: 'bsc', label: 'BSC (BEP20)' }); return chains; }, [cryptoWallets.tron, cryptoWallets.eth, cryptoWallets.bsc]); // Set default chain when available useEffect(() => { if (availableChains.length > 0 && !selectedChain) { setSelectedChain(availableChains[0].key); } }, [availableChains, selectedChain]); // Parse Creem products const creemProducts = useMemo(() => { if (!topupInfo?.creem_products) return []; try { const parsed = typeof topupInfo.creem_products === 'string' ? JSON.parse(topupInfo.creem_products) : topupInfo.creem_products; return Array.isArray(parsed) ? parsed : []; } catch { return []; } }, [topupInfo?.creem_products]); // History const loadHistory = async () => { setHistoryLoading(true); try { const res = await getTopupHistory({ page: 1, page_size: 20 }); if (res.data.data?.items) { setHistory(res.data.data.items); } } catch (e) { /* interceptor */ } setHistoryLoading(false); }; // Aff earnings const loadAffEarnings = async () => { setAffEarningsLoading(true); try { const res = await getAffEarnings({ page: 1, page_size: 20 }); if (res.data.success && res.data.data) { setAffEarnings(res.data.data); } } catch (e) { /* interceptor */ } setAffEarningsLoading(false); }; const handleCopyAffLink = () => { if (!affLink) return; navigator.clipboard.writeText(affLink).then(() => { toast.success(t('topup.copied') || 'Copied!'); }).catch(() => { toast.error('Copy failed'); }); }; const handleTransfer = async () => { const val = parseInt(transferAmount); if (!val || val <= 0) { toast.error(t('topup.enterAmount')); return; } setTransferring(true); try { const res = await transferAffQuota({ quota: val }); if (res.data.success) { toast.success(res.data.message || t('topup.transferSuccess') || 'Transfer successful'); setTransferAmount(''); await Promise.all([loadData(), refreshUser()]); } } catch (e) { /* interceptor */ } setTransferring(false); }; const presetAmounts = topupInfo?.amount_options || [1, 5, 10, 20, 50, 100]; const minTopup = topupInfo?.min_topup || 1; const payMethods = topupInfo?.pay_methods || []; const enableOnline = topupInfo?.enable_online_topup; const enableStripe = topupInfo?.enable_stripe_topup; const enableCreem = topupInfo?.enable_creem_topup; const enableCrypto = topupInfo?.enable_crypto_topup; const hasAnyPayment = enableOnline || enableStripe || enableCreem || enableCrypto; // Filter pay methods: EPay methods + Stripe-based methods go in the main payment buttons // Creem and Crypto get their own sections const epayAndStripeMethods = payMethods.filter( (m) => m.type !== 'creem' && m.type !== 'crypto' ); if (loading) { return (
{t('topup.subtitle')}
{t('dashboard.balance')}
{t('dashboard.quotaUnits', { count: quota.toLocaleString() })}
{t('dashboard.used')}
{t('dashboard.totalRequests')}
{t('topup.calculating')}
) : payAmount ? ({t('topup.payAmountLabel')}: ¥{payAmount}
) : null}${product.price} {product.currency || 'USD'}
{product.quota && ({t('topup.quotaIncluded') || `${product.quota} quota`}
)}{t('topup.walletAddress')}
{cryptoOrder.wallet}
{t('topup.amount')}
{cryptoOrder.amount} {cryptoOrder.token}
{t('topup.chain')}
{cryptoOrder.chain}
{t('topup.noHistory')}
) : ({symbol}{(Number(item.amount) * rate).toFixed(2)}
{new Date(item.create_time * 1000).toLocaleString()} · {item.payment_method || t('topup.redeemCode')}
{t('topup.inviteSubtitle') || '邀请好友注册,持续获得消费佣金'}
{/* Stats */}{t('topup.affAvailable') || '可用佣金'}
{symbol}{((user?.aff_quota || 0) / Q * rate).toFixed(2)}
{t('topup.affTotal') || '累计佣金'}
{symbol}{((user?.aff_history_quota || 0) / Q * rate).toFixed(2)}
{t('topup.affCount') || '邀请人数'}
{user?.aff_count || 0}
{t('topup.noEarnings') || '暂无收入记录'}
) : ({item.model_name}
{new Date(item.created_time * 1000).toLocaleString()} · {(item.commission_rate * 100).toFixed(1)}%