659 lines
27 KiB
React
659 lines
27 KiB
React
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,
|
|
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 [displayAmount, setDisplayAmount] = 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);
|
|
|
|
const enableTopup = site?.enable_topup && topupInfo;
|
|
const topupConfig = site?.topup_config;
|
|
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;
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [usageRes, topupRes] = await Promise.all([
|
|
getUserUsage(),
|
|
site?.enable_topup ? getTopupInfo().catch(() => null) : Promise.resolve(null),
|
|
]);
|
|
if (usageRes.data.success) setUsage(usageRes.data.data);
|
|
if (topupRes?.data?.data) setTopupInfo(topupRes.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;
|
|
|
|
const formatCurrencyAmount = useCallback((value) => {
|
|
if (value === '' || value == null || Number.isNaN(Number(value))) return '';
|
|
return Number(value).toFixed(2).replace(/\.?0+$/, '');
|
|
}, []);
|
|
|
|
const toDisplayAmount = useCallback((quotaAmount) => {
|
|
if (quotaAmount === '' || quotaAmount == null) return '';
|
|
return formatCurrencyAmount(Number(quotaAmount) * rate);
|
|
}, [formatCurrencyAmount, rate]);
|
|
|
|
const toQuotaAmount = useCallback((currencyAmount) => {
|
|
const numeric = Number.parseFloat(currencyAmount);
|
|
if (!Number.isFinite(numeric) || numeric <= 0) return '';
|
|
return Math.max(minTopup, Math.round(numeric / rate));
|
|
}, [minTopup, 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(String(val));
|
|
setDisplayAmount(toDisplayAmount(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);
|
|
};
|
|
|
|
// 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 (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto px-6 py-10">
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-heading font-bold text-page mb-1">{t('topup.title')}</h1>
|
|
<p className="text-sm text-page-secondary">{t('topup.subtitle')}</p>
|
|
</div>
|
|
|
|
{/* Balance Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
|
<div className="glass rounded-2xl p-6">
|
|
<p className="text-sm text-page-secondary mb-2">{t('dashboard.balance')}</p>
|
|
<div className="text-3xl font-bold text-page">
|
|
{symbol}<CountUp from={0} to={balanceDollars} duration={1.5} />
|
|
</div>
|
|
<p className="text-xs text-page-muted mt-1">{t('dashboard.quotaUnits', { count: quota.toLocaleString() })}</p>
|
|
</div>
|
|
|
|
<div className="glass rounded-2xl p-6">
|
|
<p className="text-sm text-page-secondary mb-2">{t('dashboard.used')}</p>
|
|
<div className="text-3xl font-bold text-page">
|
|
{symbol}<CountUp from={0} to={usedQuota / Q * rate} duration={1.5} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="glass rounded-2xl p-6">
|
|
<p className="text-sm text-page-secondary mb-2">{t('dashboard.totalRequests')}</p>
|
|
<div className="text-3xl font-bold text-page">
|
|
<CountUp from={0} to={requestCount} duration={1.5} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Online Topup - EPay & Stripe & Crypto */}
|
|
{site?.enable_topup && (enableOnline || enableStripe || enableCrypto) && (epayAndStripeMethods.length > 0 || enableCrypto) && (
|
|
<div className="glass rounded-2xl p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-lg font-semibold text-page">{t('topup.onlineTopup')}</h2>
|
|
<button
|
|
onClick={() => { setShowHistory(!showHistory); if (!showHistory) loadHistory(); }}
|
|
className="text-sm text-page-secondary hover:text-page transition-colors"
|
|
>
|
|
{showHistory ? t('topup.hideHistory') : t('topup.viewHistory')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Preset Amounts */}
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-page-label mb-3">{t('topup.selectAmount')}</label>
|
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
|
{presetAmounts.map((val) => (
|
|
<button
|
|
key={val}
|
|
onClick={() => handlePreset(val)}
|
|
className={`py-2.5 px-3 rounded-xl text-sm font-medium transition-all ${
|
|
selectedPreset === val
|
|
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
|
|
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
|
|
}`}
|
|
>
|
|
{symbol}{formatCurrencyAmount(val * rate)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Amount */}
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-page-label mb-2">{t('topup.customAmount')}</label>
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="number"
|
|
value={displayAmount}
|
|
onChange={(e) => {
|
|
const currentValue = e.target.value;
|
|
setDisplayAmount(currentValue);
|
|
setSelectedPreset(null);
|
|
const quotaAmount = toQuotaAmount(currentValue);
|
|
setAmount(quotaAmount === '' ? '' : String(quotaAmount));
|
|
calcAmount(quotaAmount);
|
|
}}
|
|
onBlur={(e) => {
|
|
const quotaAmount = toQuotaAmount(e.target.value);
|
|
if (quotaAmount === '') {
|
|
setDisplayAmount('');
|
|
setAmount('');
|
|
setPayAmount(null);
|
|
return;
|
|
}
|
|
setAmount(String(quotaAmount));
|
|
setDisplayAmount(toDisplayAmount(quotaAmount));
|
|
calcAmount(quotaAmount);
|
|
}}
|
|
min={minTopup * rate}
|
|
step="0.01"
|
|
placeholder={t('topup.amountPlaceholder', { min: formatCurrencyAmount(minTopup * rate) })}
|
|
className="input flex-1"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-page-muted mt-2">
|
|
{t('topup.customAmountHint')}
|
|
</p>
|
|
{amountLoading ? (
|
|
<p className="text-xs text-page-muted mt-2">{t('topup.calculating')}</p>
|
|
) : payAmount ? (
|
|
<div className="mt-2 space-y-1 text-xs text-page-secondary">
|
|
<p>
|
|
{t('topup.rechargeAmountLabel')}: <span className="text-page font-medium">{symbol}{displayAmount || toDisplayAmount(amount)}</span>
|
|
</p>
|
|
<p>
|
|
{t('topup.payAmountLabel')}: <span className="text-page-success font-medium">{symbol}{payAmount}</span>
|
|
</p>
|
|
</div>
|
|
) : amount ? (
|
|
<p className="text-xs text-page-muted mt-2">
|
|
{t('topup.rechargeAmountLabel')}: {symbol}{displayAmount || toDisplayAmount(amount)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Payment Methods (EPay + Stripe) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-page-label mb-3">{t('topup.paymentMethod')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{epayAndStripeMethods.map((method) => {
|
|
const isCurrentLoading = paymentLoading && payingMethod === method.type;
|
|
return (
|
|
<button
|
|
key={method.type}
|
|
onClick={() => handlePay(method.type)}
|
|
disabled={paymentLoading || !amount}
|
|
className="px-4 py-2.5 rounded-xl text-sm font-medium glass-sm text-page-label hover:text-page hover:bg-page-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
>
|
|
{isCurrentLoading ? t('topup.processing') : method.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Crypto Payment (inline) */}
|
|
{enableCrypto && availableChains.length > 0 && (
|
|
<div className="mt-6 pt-6 border-t border-page-divider">
|
|
<label className="block text-sm font-medium text-page-label mb-3">{t('topup.cryptoPayment')}</label>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Chain */}
|
|
<div className="flex gap-1.5">
|
|
{availableChains.map((chain) => (
|
|
<button
|
|
key={chain.key}
|
|
onClick={() => setSelectedChain(chain.key)}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
|
selectedChain === chain.key
|
|
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
|
|
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
|
|
}`}
|
|
>
|
|
{chain.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Token */}
|
|
<div className="flex gap-1.5">
|
|
{['usdt', 'usdc'].map((token) => (
|
|
<button
|
|
key={token}
|
|
onClick={() => setSelectedToken(token)}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
|
selectedToken === token
|
|
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
|
|
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
|
|
}`}
|
|
>
|
|
{token.toUpperCase()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Pay */}
|
|
<button
|
|
onClick={handleCryptoPay}
|
|
disabled={paymentLoading || !amount}
|
|
className="px-4 py-2.5 rounded-xl text-sm font-medium glass-sm text-page-label hover:text-page hover:bg-page-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
>
|
|
{paymentLoading && payingMethod === 'crypto'
|
|
? t('topup.processing')
|
|
: `${selectedToken.toUpperCase()} (${availableChains.find(c => c.key === selectedChain)?.label || selectedChain})`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Creem Products Section */}
|
|
{site?.enable_topup && enableCreem && creemProducts.length > 0 && (
|
|
<div className="glass rounded-2xl p-6 mb-6">
|
|
<h2 className="text-lg font-semibold text-page mb-4">{t('topup.creemPayment') || 'Creem Payment'}</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{creemProducts.map((product) => (
|
|
<div
|
|
key={product.productId}
|
|
className={`glass-sm rounded-xl p-4 cursor-pointer transition-all ${
|
|
selectedCreemProduct?.productId === product.productId
|
|
? 'ring-2 ring-brand-500 bg-brand-500/10'
|
|
: 'hover:bg-page-surface'
|
|
}`}
|
|
onClick={() => setSelectedCreemProduct(product)}
|
|
>
|
|
<h3 className="text-sm font-semibold text-page mb-1">{product.name}</h3>
|
|
<p className="text-xl font-bold text-page-success mb-1">
|
|
${product.price} <span className="text-xs text-page-muted font-normal">{product.currency || 'USD'}</span>
|
|
</p>
|
|
{product.quota && (
|
|
<p className="text-xs text-page-secondary">{t('topup.quotaIncluded') || `${product.quota} quota`}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{selectedCreemProduct && (
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
onClick={() => handleCreemPay(selectedCreemProduct)}
|
|
disabled={paymentLoading}
|
|
className="btn-primary px-6"
|
|
>
|
|
{paymentLoading && payingMethod === 'creem'
|
|
? t('topup.processing')
|
|
: `${t('topup.payNow') || 'Pay'} $${selectedCreemProduct.price}`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Crypto Payment Modal */}
|
|
{cryptoOrder && (
|
|
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setCryptoOrder(null)}>
|
|
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
|
|
<h3 className="text-lg font-semibold text-page mb-4">{t('topup.cryptoPayment')}</h3>
|
|
<div className="space-y-4">
|
|
<div className="glass-sm rounded-xl p-4">
|
|
<p className="text-xs text-page-secondary mb-1">{t('topup.walletAddress')}</p>
|
|
<p className="text-sm text-page font-mono break-all">{cryptoOrder.wallet}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="glass-sm rounded-xl p-3">
|
|
<p className="text-xs text-page-secondary mb-1">{t('topup.amount')}</p>
|
|
<p className="text-sm text-page font-medium">{cryptoOrder.amount} {cryptoOrder.token}</p>
|
|
</div>
|
|
<div className="glass-sm rounded-xl p-3">
|
|
<p className="text-xs text-page-secondary mb-1">{t('topup.chain')}</p>
|
|
<p className="text-sm text-page font-medium">{cryptoOrder.chain}</p>
|
|
</div>
|
|
</div>
|
|
{cryptoPolling && (
|
|
<div className="flex items-center gap-2 text-sm text-page-secondary">
|
|
<div className="w-4 h-4 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
|
{t('topup.waitingPayment')}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => { setCryptoOrder(null); setCryptoPolling(false); }}
|
|
className="w-full py-2 rounded-xl text-sm glass-sm text-page-secondary hover:text-page transition-colors"
|
|
>
|
|
{t('topup.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* History */}
|
|
{showHistory && (
|
|
<div className="glass rounded-2xl p-6 mb-6">
|
|
<h2 className="text-lg font-semibold text-page mb-4">{t('topup.history')}</h2>
|
|
{historyLoading ? (
|
|
<div className="flex justify-center py-8">
|
|
<div className="w-6 h-6 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
|
</div>
|
|
) : history.length === 0 ? (
|
|
<p className="text-sm text-page-muted text-center py-8">{t('topup.noHistory')}</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{history.map((item, i) => (
|
|
<div key={i} className="flex items-center justify-between glass-sm rounded-xl px-4 py-3">
|
|
<div>
|
|
<p className="text-sm text-page">{symbol}{(Number(item.amount) * rate).toFixed(2)}</p>
|
|
<p className="text-xs text-page-muted">
|
|
{new Date(item.create_time * 1000).toLocaleString()} · {item.payment_method || t('topup.redeemCode')}
|
|
</p>
|
|
</div>
|
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
|
item.status === 'success'
|
|
? 'bg-green-500/10 text-page-success'
|
|
: item.status === 'pending'
|
|
? 'bg-yellow-500/10 text-page-warning'
|
|
: 'bg-red-500/10 text-page-danger'
|
|
}`}>
|
|
{item.status === 'success' ? t('topup.statusSuccess') : item.status === 'pending' ? t('topup.statusPending') : t('topup.statusFailed')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Redeem Code */}
|
|
<div className="glass rounded-2xl p-6">
|
|
<h2 className="text-lg font-semibold text-page mb-4">{t('topup.redeemTitle')}</h2>
|
|
<form onSubmit={handleRedeem} className="flex gap-3">
|
|
<input
|
|
type="text"
|
|
value={redeemInput}
|
|
onChange={(e) => setRedeemInput(e.target.value)}
|
|
className="input flex-1"
|
|
placeholder={t('topup.enterRedeemCode')}
|
|
/>
|
|
<button type="submit" disabled={redeeming} className="btn-primary whitespace-nowrap">
|
|
{redeeming ? t('topup.redeeming') : t('topup.redeem')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|