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')}
}
onClick={handleChangeToggle}
variant="text"
sx={{
color: 'primary.main',
whiteSpace: 'nowrap',
alignSelf: { xs: 'flex-end', sm: 'center' },
}}>
{changePaymentMethod ? t('payment.autoTopup.keepCurrent') : t('payment.autoTopup.changePaymentMethod')}
{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 (
);
}