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')}
+
+
+
+ );
+}
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);