TuringToken/web/src/components/topup/RechargeCard.jsx

555 lines
20 KiB
JavaScript

import React, { useEffect, useState } from 'react';
import {
Typography,
Tag,
Button,
Banner,
Skeleton,
Spin,
Tooltip,
Input,
InputNumber,
} from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import {
CreditCard,
WalletCards,
Activity,
ChartColumn,
Receipt,
Gift,
DollarSign,
Ticket,
ChevronRight,
} from 'lucide-react';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { getCurrencyConfig } from '../../helpers/render';
import SubscriptionPlansCard from './SubscriptionPlansCard';
import './preset-amount-card.css';
const { Text } = Typography;
const RechargeCard = ({
t,
enableOnlineTopUp,
enableStripeTopUp,
enableCreemTopUp,
creemProducts,
creemPreTopUp,
presetAmounts,
selectedPreset,
selectPresetAmount,
onPresetCardClick,
formatLargeNumber,
priceRatio,
topUpCount,
minTopUp,
renderTopUpCount,
getAmount,
setTopUpCount,
setSelectedPreset,
renderAmount,
amountLoading,
payMethods,
preTopUp,
paymentLoading,
payWay,
redemptionCode,
setRedemptionCode,
topUp,
isSubmitting,
topUpLink,
openTopUpLink,
userState,
renderQuota,
statusLoading,
topupInfo,
onOpenHistory,
enableAlipayTopUp,
enableWechatPayTopUp,
enableWaffoTopUp,
waffoTopUp,
waffoPayMethods,
subscriptionLoading = false,
subscriptionPlans = [],
billingPreference,
onChangeBillingPreference,
activeSubscriptions = [],
allSubscriptions = [],
reloadSubscriptionSelf,
}) => {
const [activeTab, setActiveTab] = useState('topup');
const [customAmount, setCustomAmount] = useState(10);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
const statusStr = localStorage.getItem('status');
let usdRate = 7;
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) {}
const { symbol, rate, type: currencyType } = getCurrencyConfig();
const calcRmb = (usdAmount) => {
return usdAmount * (priceRatio || 1);
};
const getDiscountForAmount = (val) => {
const preset = presetAmounts.find((p) => p.value === val);
if (preset?.discount) return preset.discount;
if (topupInfo?.discount?.[val]) return topupInfo.discount[val];
return 1.0;
};
const handleCustomAmountChange = (val) => {
const num = Number(val);
if (!num || num <= 0) return;
setCustomAmount(num);
setTopUpCount(num);
setSelectedPreset(null);
getAmount(num);
};
const handlePresetClick = (preset) => {
const discount = getDiscountForAmount(preset.value);
const num = preset.value * discount;
setCustomAmount(preset.value);
if (onPresetCardClick) {
onPresetCardClick(preset);
} else {
selectPresetAmount(preset);
}
};
useEffect(() => {
if (subscriptionLoading) return;
if (shouldShowSubscription) {
setActiveTab('subscription');
}
}, [shouldShowSubscription, subscriptionLoading]);
const isAnyPayEnabled =
enableOnlineTopUp ||
enableStripeTopUp ||
enableCreemTopUp ||
enableAlipayTopUp ||
enableWechatPayTopUp ||
enableWaffoTopUp;
const tabs = [
{ key: 'topup', label: t('充值'), desc: t('灵活付费') },
{ key: 'subscription', label: t('订阅'), desc: t('享更低单价') },
];
// 统计卡片配置
const statCards = [
{
key: 'balance',
icon: WalletCards,
label: t('当前余额'),
value: userState?.user?.quota,
sublabel: t('剩余配额'),
highlight: true,
},
{
key: 'usage',
icon: ChartColumn,
label: t('总用量'),
value: userState?.user?.used_quota,
sublabel: t('总消耗额度'),
},
{
key: 'requests',
icon: Activity,
label: t('API 请求'),
value: userState?.user?.request_count || 0,
sublabel: t('总请求数'),
raw: true,
},
];
const topupContent = (
<div className='grid gap-5 lg:grid-cols-[1fr_380px] lg:items-start'>
{/* 左侧:充值卡片 */}
<div className='rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
{/* 卡片头部 */}
<div className='flex items-center gap-3 border-b border-slate-100 px-5 py-4 dark:border-white/5'>
<div className='flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'>
<DollarSign className='h-4 w-4' />
</div>
<div>
<div className='text-base font-semibold text-slate-900 dark:text-white'>
{t('账户充值')}
</div>
<div className='text-xs text-slate-400 dark:text-white/40'>
{t('选择金额和支付方式完成充值')}
</div>
</div>
</div>
<div className='space-y-5 p-5'>
{isAnyPayEnabled ? (
<>
{/* 预设额度 */}
<section>
<div className='mb-3 flex items-center justify-between'>
<span className='text-sm font-medium text-slate-700 dark:text-slate-300'>
{t('充值金额')}
</span>
<span className='text-xs text-slate-400 dark:text-white/40'>
USD
</span>
</div>
<div className='grid grid-cols-3 gap-2'>
{presetAmounts.map((preset, index) => {
const discount = getDiscountForAmount(preset.value);
const rmbAmount = calcRmb(preset.value * discount);
const isSelected = selectedPreset === preset.value;
return (
<button
key={index}
type='button'
onClick={() => handlePresetClick(preset)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-3 py-3 transition-all duration-200 ${
isSelected
? 'border-emerald-500 bg-emerald-50/50 dark:border-emerald-400 dark:bg-emerald-500/10'
: 'border-slate-100 bg-white hover:border-slate-300 hover:shadow-sm dark:border-white/5 dark:bg-transparent dark:hover:border-white/20'
}`}
>
{discount < 1.0 && (
<span className='absolute -top-2 right-2 rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white'>
{(discount * 10).toFixed(1)}{t('折')}
</span>
)}
<span className={`text-xl font-bold tracking-tight ${isSelected ? 'text-emerald-700 dark:text-emerald-400' : 'text-slate-900 dark:text-white'}`}>
${formatLargeNumber(preset.value)}
</span>
<span className='mt-1 text-xs text-slate-400 dark:text-white/40'>
¥{rmbAmount.toFixed(0)}
</span>
</button>
);
})}
</div>
</section>
{/* 自定义金额 */}
<section>
<div className='mb-3'>
<span className='text-sm font-medium text-slate-700 dark:text-slate-300'>
{t('自定义金额')}
</span>
</div>
<div className='flex items-center gap-3'>
<div className='relative flex-1'>
<span className='pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm font-medium text-slate-400'>
$
</span>
<InputNumber
min={minTopUp}
max={999999999}
value={customAmount}
onChange={handleCustomAmountChange}
className='!h-10 !rounded-xl !pl-7 !text-base'
style={{ width: '100%' }}
/>
</div>
<div className='flex h-10 items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 dark:border-white/10 dark:bg-white/5'>
<span className='text-xs text-slate-400'>{t('应付')}</span>
<span className='text-sm font-semibold text-slate-700 dark:text-white'>
¥{calcRmb(customAmount * getDiscountForAmount(customAmount)).toFixed(0)}
</span>
</div>
</div>
</section>
{/* 支付方式 */}
<section>
<div className='mb-3'>
<span className='text-sm font-medium text-slate-700 dark:text-slate-300'>
{t('支付方式')}
</span>
</div>
<div className='flex flex-wrap gap-2'>
{payMethods
.filter((m) => m.type !== 'waffo')
.map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const isAlipay = payMethod.type === 'alipay';
const isWechat = payMethod.type === 'wxpay';
const disabled =
(!enableOnlineTopUp && !isStripe && !isAlipay && !isWechat) ||
(!enableStripeTopUp && isStripe) ||
(!enableAlipayTopUp && isAlipay) ||
(!enableWechatPayTopUp && isWechat) ||
minTopupVal > Number(topUpCount || 0);
const getIcon = () => {
if (isAlipay)
return <SiAlipay size={18} color='#1677FF' />;
if (isWechat)
return <SiWechat size={18} color='#07C160' />;
if (isStripe)
return <SiStripe size={18} color='#635BFF' />;
return <CreditCard size={18} />;
};
const btn = (
<Button
key={payMethod.type}
theme='outline'
type='tertiary'
onClick={() => preTopUp(payMethod.type)}
disabled={disabled}
loading={paymentLoading && payWay === payMethod.type}
icon={getIcon()}
className='!h-10 !rounded-xl !px-5 !text-sm'
>
{payMethod.name}
</Button>
);
if (disabled && minTopupVal > Number(topUpCount || 0)) {
return (
<Tooltip
content={t('此支付方式最低充值金额为') + ' ' + minTopupVal}
key={payMethod.type}
>
{btn}
</Tooltip>
);
}
return <React.Fragment key={payMethod.type}>{btn}</React.Fragment>;
})}
{enableWaffoTopUp &&
waffoPayMethods &&
waffoPayMethods.length > 0 &&
waffoPayMethods.map((method, index) => (
<Button
key={`waffo-${index}`}
theme='outline'
type='tertiary'
onClick={() => waffoTopUp(index)}
loading={paymentLoading}
icon={
method.icon ? (
<img
src={method.icon}
alt={method.name}
style={{ width: 18, height: 18, objectFit: 'contain' }}
/>
) : (
<CreditCard size={18} />
)
}
className='!h-10 !rounded-xl !px-5 !text-sm'
>
{method.name}
</Button>
))}
{enableCreemTopUp &&
creemProducts.length > 0 &&
creemProducts.map((product, index) => (
<Button
key={`creem-${index}`}
theme='outline'
type='tertiary'
onClick={() => creemPreTopUp(product)}
className='!h-10 !rounded-xl !px-5 !text-sm'
>
{product.name} - {product.currency === 'EUR' ? '€' : '$'}
{product.price}
</Button>
))}
</div>
</section>
</>
) : (
<Banner
type='info'
description={t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
className='!rounded-xl'
closeIcon={null}
/>
)}
</div>
</div>
{/* 右侧:兑换码 + 账单入口 */}
<div className='space-y-4'>
<div className='rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
<div className='flex items-center gap-3 border-b border-slate-100 px-5 py-4 dark:border-white/5'>
<div className='flex h-9 w-9 items-center justify-center rounded-xl bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'>
<Gift className='h-4 w-4' />
</div>
<div>
<div className='text-base font-semibold text-slate-900 dark:text-white'>
{t('兑换码')}
</div>
<div className='text-xs text-slate-400 dark:text-white/40'>
{t('输入兑换码获取额度')}
</div>
</div>
</div>
<div className='space-y-3 p-5'>
<Input
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={setRedemptionCode}
className='!h-10 !rounded-xl'
/>
<Button
type='primary'
theme='solid'
onClick={topUp}
loading={isSubmitting}
disabled={!redemptionCode}
className='!h-10 !w-full !rounded-xl !bg-emerald-600 hover:!bg-emerald-700'
>
{t('立即兑换')}
</Button>
{topUpLink && (
<button
onClick={openTopUpLink}
className='flex w-full items-center justify-center gap-1 text-xs text-slate-400 transition-colors hover:text-slate-600 dark:text-white/40 dark:hover:text-white/70'
>
<Ticket className='h-3 w-3' />
{t('购买兑换码')}
<ChevronRight className='h-3 w-3' />
</button>
)}
</div>
</div>
{/* 账单入口 */}
<button
onClick={onOpenHistory}
className='flex w-full items-center justify-between rounded-2xl border border-slate-200 bg-white px-5 py-4 transition-all hover:border-slate-300 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.02] dark:hover:border-white/20'
>
<div className='flex items-center gap-3'>
<div className='flex h-9 w-9 items-center justify-center rounded-xl bg-slate-50 text-slate-500 dark:bg-white/5 dark:text-white/60'>
<Receipt className='h-4 w-4' />
</div>
<div className='text-left'>
<div className='text-sm font-medium text-slate-700 dark:text-slate-300'>
{t('充值记录')}
</div>
<div className='text-xs text-slate-400 dark:text-white/40'>
{t('查看历史账单明细')}
</div>
</div>
</div>
<ChevronRight className='h-4 w-4 text-slate-300 dark:text-white/20' />
</button>
</div>
</div>
);
return (
<div className='flex w-full flex-col gap-5'>
{/* 统计卡片 */}
<div className='grid grid-cols-3 gap-0 overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
{statCards.map((card, i) => (
<div
key={card.key}
className={`relative flex flex-col px-5 py-4 ${
i < 2 ? 'border-r border-slate-100 dark:border-white/5' : ''
}`}
>
<div className='flex items-center gap-2'>
<card.icon
className={`h-3.5 w-3.5 ${
card.highlight
? 'text-emerald-500 dark:text-emerald-400'
: 'text-slate-400 dark:text-white/40'
}`}
/>
<span className='text-xs font-medium text-slate-400 dark:text-white/40'>
{card.label}
</span>
</div>
<div
className={`mt-2 font-mono text-2xl font-bold tabular-nums ${
card.highlight
? 'text-emerald-600 dark:text-emerald-400'
: 'text-slate-900 dark:text-white'
}`}
>
{statusLoading ? (
<Skeleton.Title style={{ width: 60, height: 28, margin: 0 }} />
) : card.raw ? (
card.value
) : (
renderQuota(card.value)
)}
</div>
<div className='mt-1 text-xs text-slate-400 dark:text-white/40'>
{card.sublabel}
</div>
</div>
))}
</div>
{/* 标签切换 */}
{shouldShowSubscription && (
<div className='flex justify-start'>
<div className='inline-flex items-center rounded-xl bg-slate-100 p-1 dark:bg-white/5'>
{tabs.map((tab) => (
<button
key={tab.key}
type='button'
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all duration-200 ${
activeTab === tab.key
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
: 'text-slate-500 hover:text-slate-700 dark:text-white/40 dark:hover:text-white/70'
}`}
>
{tab.label}
<span className='text-xs font-normal text-slate-400 dark:text-white/40'>
{tab.desc}
</span>
</button>
))}
</div>
</div>
)}
{/* 主体内容 */}
{statusLoading ? (
<div className='flex items-center justify-center py-16'>
<Spin size='large' />
</div>
) : shouldShowSubscription && activeTab === 'subscription' ? (
<SubscriptionPlansCard
t={t}
loading={subscriptionLoading}
plans={subscriptionPlans}
payMethods={payMethods}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
billingPreference={billingPreference}
onChangeBillingPreference={onChangeBillingPreference}
activeSubscriptions={activeSubscriptions}
allSubscriptions={allSubscriptions}
reloadSubscriptionSelf={reloadSubscriptionSelf}
withCard={false}
/>
) : (
topupContent
)}
</div>
);
};
export default RechargeCard;