diff --git a/src/App.jsx b/src/App.jsx index 68b4afb..12f06da 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ const Dashboard = lazy(() => import('./pages/Dashboard')); const Tokens = lazy(() => import('./pages/Tokens')); const Packages = lazy(() => import('./pages/Packages')); const Pricing = lazy(() => import('./pages/Pricing')); +const Topup = lazy(() => import('./pages/Topup')); const Loading = () => (
@@ -34,6 +35,7 @@ function ThemedRoutes() { }> } /> } /> + } /> diff --git a/src/api.js b/src/api.js index 2f7372c..7da4ab4 100644 --- a/src/api.js +++ b/src/api.js @@ -67,6 +67,16 @@ export const deleteToken = (id) => api.delete(`/api/dist/token/${id}`); export const redeemCode = (key) => api.post('/api/dist/topup/redeem', { key }); // backend field is "key" export const subscribePackage = (packageId) => api.post('/api/dist/package/subscribe', { package_id: packageId }); +// ===== Online Topup ===== +export const getTopupInfo = () => api.get('/api/dist/topup/info'); +export const calculateAmount = (data) => api.post('/api/dist/topup/amount', data); +export const createEpayOrder = (data) => api.post('/api/dist/topup/pay', data); +export const createStripeOrder = (data) => api.post('/api/dist/topup/stripe/pay', data); +export const createCreemOrder = (data) => api.post('/api/dist/topup/creem/pay', data); +export const createCryptoOrder = (data) => api.post('/api/dist/topup/crypto/pay', data); +export const getCryptoOrderStatus = (tradeNo) => api.get(`/api/dist/topup/crypto/status?trade_no=${tradeNo}`); +export const getTopupHistory = (params) => api.get('/api/dist/topup/history', { params }); + // ===== Helpers ===== export const quotaToDollar = (quota) => (quota / Q).toFixed(4); export const quotaToDollar6 = (quota) => (quota / Q).toFixed(6); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7f3ff9a..669f864 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -154,6 +154,41 @@ "packages.subscribedSuccess": "Subscribed successfully!", "common.sessionExpired": "Session expired, please log in again", - "common.requestFailed": "Request failed" + "common.requestFailed": "Request failed", + + "nav.topup": "Top Up", + + "topup.title": "Top Up", + "topup.subtitle": "Recharge your account balance via online payment or redemption code", + "topup.onlineTopup": "Online Top Up", + "topup.selectAmount": "Select Amount", + "topup.customAmount": "Custom Amount", + "topup.amountPlaceholder": "Enter amount (min ${{min}})", + "topup.paymentMethod": "Payment Method", + "topup.processing": "Processing...", + "topup.calculating": "Calculating...", + "topup.payAmountLabel": "Amount to pay", + "topup.enterAmount": "Please enter a top-up amount", + "topup.paymentSuccess": "Payment successful! Balance updated.", + "topup.orderExpired": "Payment order expired", + "topup.viewHistory": "View History", + "topup.hideHistory": "Hide History", + "topup.history": "Top-Up History", + "topup.noHistory": "No top-up records yet", + "topup.statusSuccess": "Success", + "topup.statusPending": "Pending", + "topup.statusFailed": "Failed", + "topup.redeemTitle": "Redeem Code", + "topup.enterRedeemCode": "Enter redemption code", + "topup.redeeming": "Redeeming...", + "topup.redeem": "Redeem", + "topup.redeemCode": "Redeem Code", + "topup.redeemSuccess": "Code redeemed successfully!", + "topup.cryptoPayment": "Crypto Payment", + "topup.walletAddress": "Wallet Address", + "topup.amount": "Amount", + "topup.chain": "Chain", + "topup.waitingPayment": "Waiting for payment...", + "topup.close": "Close" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 379485a..72e09cd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -154,6 +154,41 @@ "packages.subscribedSuccess": "订阅成功!", "common.sessionExpired": "会话已过期,请重新登录", - "common.requestFailed": "请求失败" + "common.requestFailed": "请求失败", + + "nav.topup": "充值", + + "topup.title": "充值中心", + "topup.subtitle": "通过在线支付或兑换码为账户充值", + "topup.onlineTopup": "在线充值", + "topup.selectAmount": "选择金额", + "topup.customAmount": "自定义金额", + "topup.amountPlaceholder": "输入金额(最低 ${{min}})", + "topup.paymentMethod": "支付方式", + "topup.processing": "处理中...", + "topup.calculating": "计算中...", + "topup.payAmountLabel": "实付金额", + "topup.enterAmount": "请输入充值金额", + "topup.paymentSuccess": "支付成功!余额已更新。", + "topup.orderExpired": "支付订单已过期", + "topup.viewHistory": "查看记录", + "topup.hideHistory": "隐藏记录", + "topup.history": "充值记录", + "topup.noHistory": "暂无充值记录", + "topup.statusSuccess": "成功", + "topup.statusPending": "待支付", + "topup.statusFailed": "失败", + "topup.redeemTitle": "兑换码充值", + "topup.enterRedeemCode": "请输入兑换码", + "topup.redeeming": "兑换中...", + "topup.redeem": "兑换", + "topup.redeemCode": "兑换码", + "topup.redeemSuccess": "兑换码兑换成功!", + "topup.cryptoPayment": "加密货币支付", + "topup.walletAddress": "钱包地址", + "topup.amount": "金额", + "topup.chain": "链", + "topup.waitingPayment": "等待支付中...", + "topup.close": "关闭" } } diff --git a/src/pages/Topup.jsx b/src/pages/Topup.jsx new file mode 100644 index 0000000..36385f4 --- /dev/null +++ b/src/pages/Topup.jsx @@ -0,0 +1,427 @@ +import React, { useState, useEffect, useCallback } 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 SpotlightCard from '../components/bits/SpotlightCard'; +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 [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); + + // 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 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; + + // 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); + }; + + // Pay + const handlePay = async (method) => { + const payAmount = parseInt(amount); + if (!payAmount || payAmount <= 0) { + toast.error(t('topup.enterAmount')); + return; + } + setPaymentLoading(true); + setPayingMethod(method); + try { + let res; + const data = { amount: payAmount, payment_method: method }; + + if (method === 'stripe' || method === 'alipay' || method === 'wxpay') { + res = await createStripeOrder(data); + } else if (method === 'creem') { + res = await createCreemOrder(data); + } else { + // EPay methods + res = await createEpayOrder(data); + } + + if (res.data.message === 'success') { + // EPay returns url+data, Stripe returns url + if (res.data.url) { + window.location.href = res.data.url; + } else if (res.data.data?.checkout_url) { + window.location.href = res.data.data.checkout_url; + } + } + } catch (e) { /* interceptor */ } + setPaymentLoading(false); + setPayingMethod(''); + }; + + // Crypto pay + const handleCryptoPay = async () => { + const payAmount = parseInt(amount); + if (!payAmount || payAmount <= 0) { + toast.error(t('topup.enterAmount')); + return; + } + setPaymentLoading(true); + setPayingMethod('crypto'); + try { + const res = await createCryptoOrder({ amount: payAmount }); + if (res.data.message === 'success' && res.data.data) { + setCryptoOrder(res.data.data); + startCryptoPolling(res.data.data.trade_no); + } + } 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); + }; + + // 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); + }; + + 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; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

{t('topup.title')}

+

{t('topup.subtitle')}

+
+ + {/* Balance Stats */} +
+ +

{t('dashboard.balance')}

+
+ $ +
+

{t('dashboard.quotaUnits', { count: quota.toLocaleString() })}

+
+ + +

{t('dashboard.used')}

+
+ $ +
+
+ + +

{t('dashboard.totalRequests')}

+
+ +
+
+
+ + {/* Online Topup */} + {site?.enable_topup && hasAnyPayment && ( +
+
+

{t('topup.onlineTopup')}

+ +
+ + {/* Preset Amounts */} +
+ +
+ {presetAmounts.map((val) => ( + + ))} +
+
+ + {/* Custom Amount */} +
+ +
+ { + setAmount(e.target.value); + setSelectedPreset(null); + }} + onBlur={(e) => calcAmount(e.target.value)} + min={minTopup} + placeholder={t('topup.amountPlaceholder', { min: minTopup })} + className="input flex-1" + /> +
+ {amountLoading ? ( +

{t('topup.calculating')}

+ ) : payAmount ? ( +

+ {t('topup.payAmountLabel')}: ${payAmount} +

+ ) : null} +
+ + {/* Payment Methods */} +
+ +
+ {payMethods.map((method) => { + const isCurrentLoading = paymentLoading && payingMethod === method.type; + return ( + + ); + })} + {enableCrypto && ( + + )} +
+
+
+ )} + + {/* Crypto Payment Modal */} + {cryptoOrder && ( +
setCryptoOrder(null)}> +
e.stopPropagation()}> +

{t('topup.cryptoPayment')}

+
+
+

{t('topup.walletAddress')}

+

{cryptoOrder.wallet_address}

+
+
+
+

{t('topup.amount')}

+

{cryptoOrder.amount} {cryptoOrder.currency}

+
+
+

{t('topup.chain')}

+

{cryptoOrder.chain}

+
+
+ {cryptoPolling && ( +
+
+ {t('topup.waitingPayment')} +
+ )} + +
+
+
+ )} + + {/* History */} + {showHistory && ( +
+

{t('topup.history')}

+ {historyLoading ? ( +
+
+
+ ) : history.length === 0 ? ( +

{t('topup.noHistory')}

+ ) : ( +
+ {history.map((item, i) => ( +
+
+

${item.amount}

+

+ {new Date(item.create_time * 1000).toLocaleString()} · {item.payment_method || t('topup.redeemCode')} +

+
+ + {item.status === 'success' ? t('topup.statusSuccess') : item.status === 'pending' ? t('topup.statusPending') : t('topup.statusFailed')} + +
+ ))} +
+ )} +
+ )} + + {/* Redeem Code */} +
+

{t('topup.redeemTitle')}

+
+ setRedeemInput(e.target.value)} + className="input flex-1" + placeholder={t('topup.enterRedeemCode')} + /> + +
+
+
+ ); +} diff --git a/src/themes/dark/Layout.jsx b/src/themes/dark/Layout.jsx index ce0b330..c24dc42 100644 --- a/src/themes/dark/Layout.jsx +++ b/src/themes/dark/Layout.jsx @@ -21,6 +21,7 @@ export default function DarkLayout() { { to: '/packages', label: t('nav.packages'), auth: false }, { to: '/dashboard', label: t('nav.dashboard'), auth: true }, { to: '/tokens', label: t('nav.apiKeys'), auth: true }, + ...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []), ]; const visibleNavItems = navItems.filter((n) => !n.auth || user); diff --git a/src/themes/default/Layout.jsx b/src/themes/default/Layout.jsx index 6b48feb..406e93c 100644 --- a/src/themes/default/Layout.jsx +++ b/src/themes/default/Layout.jsx @@ -21,6 +21,7 @@ export default function DefaultLayout() { { to: '/packages', label: t('nav.packages'), auth: false }, { to: '/dashboard', label: t('nav.dashboard'), auth: true }, { to: '/tokens', label: t('nav.apiKeys'), auth: true }, + ...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []), ]; const visibleNavItems = navItems.filter((n) => !n.auth || user); diff --git a/src/themes/minimal/Layout.jsx b/src/themes/minimal/Layout.jsx index 67c94d6..fc4ef74 100644 --- a/src/themes/minimal/Layout.jsx +++ b/src/themes/minimal/Layout.jsx @@ -21,6 +21,7 @@ export default function MinimalLayout() { { to: '/packages', label: t('nav.packages'), auth: false }, { to: '/dashboard', label: t('nav.dashboard'), auth: true }, { to: '/tokens', label: t('nav.apiKeys'), auth: true }, + ...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []), ]; const visibleNavItems = navItems.filter((n) => !n.auth || user); diff --git a/src/themes/starter/Layout.jsx b/src/themes/starter/Layout.jsx index c402906..38bc262 100644 --- a/src/themes/starter/Layout.jsx +++ b/src/themes/starter/Layout.jsx @@ -21,6 +21,7 @@ export default function StarterLayout() { { to: '/packages', label: t('nav.packages'), auth: false }, { to: '/dashboard', label: t('nav.dashboard'), auth: true }, { to: '/tokens', label: t('nav.apiKeys'), auth: true }, + ...(site?.enable_topup ? [{ to: '/topup', label: t('nav.topup'), auth: true }] : []), ]; const visibleNavItems = navItems.filter((n) => !n.auth || user);