import React, { useMemo, useState } from 'react'; import { Box, Typography, Stack, Button, FormControlLabel, Alert, CircularProgress, InputAdornment, MenuItem, Avatar, Select, TextField, } from '@mui/material'; import { AccountBalanceWalletOutlined, AddOutlined, CreditCard, SwapHoriz } from '@mui/icons-material'; import { useForm, FormProvider, Controller } from 'react-hook-form'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import Toast from '@arcblock/ux/lib/Toast'; import Dialog from '@arcblock/ux/lib/Dialog'; import { useRequest, useSetState } from 'ahooks'; // eslint-disable-next-line import/no-extraneous-dependencies import { useNavigate } from 'react-router-dom'; import { joinURL } from 'ufo'; import pWaitFor from 'p-wait-for'; import type { AutoRechargeConfig, TCustomer, TPaymentCurrency, TPaymentMethod } from '@blocklet/payment-types'; import DidAddress from '@arcblock/ux/lib/DID'; import Switch from '../switch-button'; import api from '../../libs/api'; import { formatError, flattenPaymentMethods, getPrefix, formatBNStr, formatCreditAmount } from '../../libs/util'; import { usePaymentContext } from '../../contexts/payment'; import { createLink, handleNavigation } from '../../libs/navigation'; import Collapse from '../collapse'; import FormInput from '../input'; import StripeCheckout from '../../payment/form/stripe'; import AutoTopupProductCard from './product-card'; import FormLabel from '../label'; import type { SlippageConfigValue } from '../slippage-config'; export interface AutoTopupFormData { enabled: boolean; threshold: string; quantity: number; payment_method_id: string; recharge_currency_id: string; price_id: string; change_payment_method?: boolean; customer_name?: string; customer_email?: string; daily_limits: { max_attempts: number; max_amount: number; }; billing_address?: { country?: string; state?: string; line1?: string; line2?: string; city?: string; postal_code?: string; }; slippage_config?: SlippageConfigValue | null; } export interface AutoTopupModalProps { open: boolean; onClose: () => void; customerId?: string; currencyId: string; onSuccess?: (config: AutoRechargeConfig) => void; onError?: (error: any) => void; defaultEnabled?: boolean; // 默认是否启用 } const fetchConfig = async (customerId: string, currencyId: string) => { const { data } = await api.get(`/api/auto-recharge-configs/customer/${customerId}`, { params: { currency_id: currencyId }, }); return data; }; const fetchCurrencyBalance = async (currencyId: string, payerAddress: string) => { const { data } = await api.get('/api/customers/payer-token', { params: { currencyId, payerAddress }, }); return data; }; const DEFAULT_VALUES = { enabled: false, threshold: '100', quantity: 1, payment_method_id: '', recharge_currency_id: '', price_id: '', daily_max_amount: 0, daily_max_attempts: 0, slippage_config: null as SlippageConfigValue | null, }; const fetchExchangeRate = async (currencyId: string) => { const { data } = await api.post('/api/exchange-rates/validate', { currency: currencyId }); return data; }; export const waitForAutoRechargeComplete = async (configId: string) => { let result: any; await pWaitFor( async () => { const { data } = await api.get(`/api/auto-recharge-configs/retrieve/${configId}`); result = data; return !!result.payment_settings?.payment_method_options?.[result.paymentMethod.type]?.payer; }, { interval: 2000, timeout: 3 * 60 * 1000 } ); // @ts-ignore return result; }; // 支付方式显示组件 function PaymentMethodDisplay({ config, onChangePaymentMethod, paymentMethod, currency, }: { config: any; paymentMethod: TPaymentMethod; onChangePaymentMethod: (change: boolean) => void; currency: TPaymentCurrency; }) { const { t } = useLocaleContext(); const [changePaymentMethod, setChangePaymentMethod] = useState(false); const navigate = useNavigate(); const paymentInfo = config?.payment_settings?.payment_method_options?.[paymentMethod.type || '']; const { data: balanceInfo, loading: balanceLoading } = useRequest( async () => { if (paymentMethod.type === 'stripe') { return null; } const result = await fetchCurrencyBalance(currency.id, paymentInfo?.payer as string); return result; }, { refreshDeps: [currency.id, paymentInfo?.payer], ready: !!currency.id && !!paymentInfo?.payer, } ); const handleChangeToggle = () => { const newChange = !changePaymentMethod; setChangePaymentMethod(newChange); onChangePaymentMethod(newChange); }; if (!paymentInfo) { return null; } const handleRecharge = (e: React.MouseEvent) => { const url = joinURL(getPrefix(), `/customer/recharge/${currency.id}?rechargeAddress=${paymentInfo?.payer}`); const link = createLink(url, true); handleNavigation(e, link, navigate); }; const renderPaymentMethodInfo = () => { if (paymentMethod.type === 'stripe') { return ( **** **** **** {paymentInfo?.card_last4 || '****'} {paymentInfo?.card_brand || 'CARD'} {paymentInfo?.exp_time && ( {paymentInfo?.exp_time} )} ); } return ( (theme.palette.mode === 'dark' ? 'grey.100' : 'grey.50'), p: 2, }}> {(balanceInfo || balanceLoading) && ( {balanceLoading ? ( ) : ( {formatBNStr(balanceInfo?.token || '0', currency?.decimal)} {currency?.symbol || ''} )} )} ); }; return ( {t('payment.autoTopup.currentPaymentMethod')} {changePaymentMethod ? ( {t('payment.autoTopup.changePaymentMethodTip')} ) : ( renderPaymentMethodInfo() )} ); } export default function AutoTopup({ open, onClose, currencyId, onSuccess = () => {}, onError = () => {}, defaultEnabled = undefined, }: AutoTopupModalProps) { const { t, locale } = useLocaleContext(); const { session, connect, settings } = usePaymentContext(); const [changePaymentMethod, setChangePaymentMethod] = useState(false); const [slippagePercent, setSlippagePercent] = useState(0.5); const [slippageConfig, setSlippageConfig] = useState(null); const [state, setState] = useSetState({ loading: false, submitting: false, authorizationRequired: false, stripeContext: { client_secret: '', intent_type: '', status: '', public_key: '', customer: {} as TCustomer, }, }); const currencies = flattenPaymentMethods(settings.paymentMethods); const methods = useForm({ defaultValues: { enabled: defaultEnabled || DEFAULT_VALUES.enabled, threshold: DEFAULT_VALUES.threshold, quantity: DEFAULT_VALUES.quantity, payment_method_id: DEFAULT_VALUES.payment_method_id, recharge_currency_id: DEFAULT_VALUES.recharge_currency_id, price_id: DEFAULT_VALUES.price_id, daily_limits: { max_attempts: DEFAULT_VALUES.daily_max_attempts, max_amount: DEFAULT_VALUES.daily_max_amount, }, }, }); const { handleSubmit, setValue, watch } = methods; const enabled = watch('enabled'); const quantity = watch('quantity') as number; const rechargeCurrencyId = watch('recharge_currency_id'); // Determine payment method type early for exchange rate fetching logic const selectedMethod = settings.paymentMethods.find((method) => { return method.payment_currencies.find((c) => c.id === rechargeCurrencyId); }); const isStripePayment = selectedMethod?.type === 'stripe'; const handleClose = () => { setState({ loading: false, submitting: false, authorizationRequired: false, }); onClose(); }; const { data: config } = useRequest(() => fetchConfig(session?.user?.did, currencyId), { refreshDeps: [session?.user?.did, currencyId], ready: !!session?.user?.did && !!currencyId, onError: (error: any) => { Toast.error(formatError(error)); }, onSuccess: (data) => { setValue('enabled', defaultEnabled || data.enabled); setValue('threshold', data.threshold); setValue('quantity', data.quantity); setValue('payment_method_id', data.payment_method_id); setValue('recharge_currency_id', data.recharge_currency_id || data.price?.currency_id); setValue('price_id', data.price_id); setValue('daily_limits', { max_amount: data.daily_limits?.max_amount || 0, max_attempts: data.daily_limits?.max_attempts || 0, }); // Set slippage config from existing data if (data.slippage_config) { setSlippageConfig(data.slippage_config); setSlippagePercent(data.slippage_config.percent ?? 0.5); } }, }); // Check if price is dynamic pricing const isDynamicPricing = config?.price?.pricing_type === 'dynamic'; // Fetch exchange rate for dynamic pricing with auto-refresh every 30 seconds // Skip for Stripe payments as they use USD directly const { data: exchangeRateData } = useRequest(() => fetchExchangeRate(rechargeCurrencyId), { refreshDeps: [rechargeCurrencyId], ready: !!rechargeCurrencyId && isDynamicPricing && enabled && !isStripePayment, pollingInterval: 30000, // Refresh every 30 seconds pollingWhenHidden: false, // Stop polling when tab is hidden onError: (error: any) => { // Silently handle error - exchange rate is optional for display console.warn('Failed to fetch exchange rate:', error.message); }, }); const filterCurrencies = useMemo(() => { return currencies.filter((c) => config?.price?.currency_options?.find((o: any) => o.currency_id === c.id)); }, [currencies, config]); const handleConnected = async () => { try { const result = await waitForAutoRechargeComplete(config?.id); if (result) { setState({ submitting: false, authorizationRequired: false }); onSuccess?.(config); handleClose(); Toast.success(t('payment.autoTopup.saveSuccess')); } } catch (err) { Toast.error(formatError(err)); } finally { setState({ submitting: false, authorizationRequired: false }); } }; const handleDisable = async () => { try { const submitData = { ...config, enabled: false, }; if (!config?.enabled) { return; } const { data } = await api.post('/api/auto-recharge-configs/submit', submitData); onSuccess?.(data); Toast.success(t('payment.autoTopup.disableSuccess')); } catch (error) { Toast.error(formatError(error)); onError?.(error); } }; const handleEnableChange = async (checked: boolean) => { setValue('enabled', checked); if (!checked) { await handleDisable(); } }; const handleAuthorizationRequired = (authData: any) => { setState({ authorizationRequired: true }); if (authData.stripeContext) { // 处理Stripe授权 setState({ stripeContext: { client_secret: authData.stripeContext.client_secret, intent_type: authData.stripeContext.intent_type, status: authData.stripeContext.status, public_key: authData.paymentMethod.settings.stripe.publishable_key, customer: authData.customer, }, }); } else if (authData.delegation) { // 处理DID Connect授权 handleDidConnect(); } }; const handleDidConnect = () => { try { setState({ submitting: true }); connect.open({ containerEl: undefined as unknown as Element, saveConnect: false, locale: locale as 'en' | 'zh', action: 'auto-recharge-auth', prefix: joinURL(getPrefix(), '/api/did'), extraParams: { autoRechargeConfigId: config?.id, }, messages: { scan: t('payment.autoTopup.authTip'), title: t('payment.autoTopup.authTitle'), confirm: t('common.connect.confirm'), } as any, onSuccess: async () => { connect.close(); await handleConnected(); }, onClose: () => { connect.close(); setState({ submitting: false, authorizationRequired: false }); }, onError: (err: any) => { setState({ submitting: false, authorizationRequired: false }); Toast.error(formatError(err)); }, }); } catch (error) { setState({ submitting: false, authorizationRequired: false }); Toast.error(formatError(error)); } }; const handleFormSubmit = async (formData: AutoTopupFormData) => { setState({ submitting: true }); try { const submitData: Record = { customer_id: session?.user?.did, enabled: formData.enabled, threshold: formData.threshold, currency_id: currencyId, recharge_currency_id: formData.recharge_currency_id, price_id: formData.price_id, quantity: formData.quantity, daily_limits: { max_attempts: formData.daily_limits.max_attempts || 0, max_amount: formData.daily_limits.max_amount || '0', }, change_payment_method: changePaymentMethod, }; // Include slippage_config for dynamic pricing if (isDynamicPricing && slippageConfig) { submitData.slippage_config = { ...slippageConfig, updated_at_ms: Date.now(), }; } const { data } = await api.post('/api/auto-recharge-configs/submit', submitData); if (data.balanceResult && !data.balanceResult.sufficient) { await handleAuthorizationRequired({ ...data.balanceResult, paymentMethod: data.paymentMethod, customer: data.customer, }); return; } setState({ submitting: false, authorizationRequired: false, }); onSuccess?.(data); handleClose(); Toast.success(t('payment.autoTopup.saveSuccess')); } catch (error) { setState({ submitting: false, authorizationRequired: false }); Toast.error(formatError(error)); onError?.(error); } }; const onSubmit = (formData: AutoTopupFormData) => { handleFormSubmit(formData); }; const rechargeCurrency = filterCurrencies.find((c) => c.id === rechargeCurrencyId); const showStripeForm = state.authorizationRequired && isStripePayment; const onStripeConfirm = async () => { await handleConnected(); }; const onStripeCancel = () => { setState({ submitting: false, authorizationRequired: false }); }; return ( ) : null }> {t('payment.autoTopup.tip')} {/* 启用开关 */} {t('payment.autoTopup.enableLabel')} handleEnableChange(e.target.checked)} />} label="" /> {enabled && ( <> {t('payment.autoTopup.triggerThreshold')} ( {formatCreditAmount('', config?.currency?.symbol)} ), }, htmlInput: { min: 0, step: 0.01, }, }} /> )} /> {t('payment.autoTopup.purchaseBelow')} ( )} /> {config?.price?.product && ( c.id === rechargeCurrencyId) || filterCurrencies[0]} quantity={quantity} onQuantityChange={(newQuantity) => setValue('quantity', newQuantity)} maxQuantity={9999} minQuantity={1} exchangeRate={exchangeRateData?.rate} isDynamicPricing={isDynamicPricing && !isStripePayment} exchangeRateData={exchangeRateData} slippageConfig={slippageConfig} slippagePercent={slippagePercent} onSlippageChange={(newSlippageConfig) => { setSlippageConfig(newSlippageConfig); if (newSlippageConfig.percent !== undefined) { setSlippagePercent(newSlippageConfig.percent); } }} disabled={state.submitting} /> )} {config && rechargeCurrency && ( )} {filterCurrencies.find((c) => c.id === rechargeCurrencyId)?.symbol || ''} ), }, }} sx={{ maxWidth: { xs: '100%', sm: '220px', }, }} layout="horizontal" /> {/* Stripe 表单 */} {showStripeForm && ( {state.stripeContext && ( )} )} )} ); }