payment-kit 1.17.6 → 1.17.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import { Stack, Typography } from '@mui/material';
5
5
  import { useFormContext, useWatch } from 'react-hook-form';
6
6
 
7
7
  import Uploader from '../uploader';
8
+ import EvmRpcInput from './evm-rpc-input';
8
9
 
9
10
  export default function BaseMethodForm() {
10
11
  const { t } = useLocaleContext();
@@ -38,11 +39,8 @@ export default function BaseMethodForm() {
38
39
  label={t('admin.paymentMethod.description.label')}
39
40
  placeholder={t('admin.paymentMethod.description.tip')}
40
41
  />
41
- <FormInput
42
- key="api_host"
42
+ <EvmRpcInput
43
43
  name="settings.base.api_host"
44
- type="text"
45
- rules={{ required: true }}
46
44
  label={t('admin.paymentMethod.base.api_host.label')}
47
45
  placeholder={t('admin.paymentMethod.base.api_host.tip')}
48
46
  />
@@ -5,6 +5,7 @@ import { Stack, Typography } from '@mui/material';
5
5
  import { useFormContext, useWatch } from 'react-hook-form';
6
6
 
7
7
  import Uploader from '../uploader';
8
+ import EvmRpcInput from './evm-rpc-input';
8
9
 
9
10
  export default function EthereumMethodForm() {
10
11
  const { t } = useLocaleContext();
@@ -38,11 +39,8 @@ export default function EthereumMethodForm() {
38
39
  label={t('admin.paymentMethod.description.label')}
39
40
  placeholder={t('admin.paymentMethod.description.tip')}
40
41
  />
41
- <FormInput
42
- key="api_host"
42
+ <EvmRpcInput
43
43
  name="settings.ethereum.api_host"
44
- type="text"
45
- rules={{ required: true }}
46
44
  label={t('admin.paymentMethod.ethereum.api_host.label')}
47
45
  placeholder={t('admin.paymentMethod.ethereum.api_host.tip')}
48
46
  />
@@ -0,0 +1,66 @@
1
+ /* eslint-disable no-nested-ternary */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import { FormInput } from '@blocklet/payment-react';
4
+ import { Box, Typography, CircularProgress } from '@mui/material';
5
+ import { useWatch, useFormContext } from 'react-hook-form';
6
+ import { CheckCircleOutline, ErrorOutline } from '@mui/icons-material';
7
+ import { useRpcStatus } from '../../hooks/rpc-status';
8
+
9
+ interface Props {
10
+ name: string; // 表单字段名
11
+ label: string;
12
+ placeholder: string;
13
+ }
14
+
15
+ export default function EvmRpcInput({ name, label, placeholder }: Props) {
16
+ const { t } = useLocaleContext();
17
+ const { control } = useFormContext();
18
+ const apiHost = useWatch({
19
+ control,
20
+ name,
21
+ });
22
+
23
+ const { status } = useRpcStatus(apiHost, { debounce: 500, pollInterval: 2000 });
24
+
25
+ return (
26
+ <FormInput
27
+ key="api_host"
28
+ name={name}
29
+ type="text"
30
+ rules={{ required: true }}
31
+ label={
32
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
33
+ {label}
34
+ {apiHost && (
35
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
36
+ {status.loading ? (
37
+ <>
38
+ <CircularProgress size={14} />
39
+ <Typography variant="caption" color="text.secondary">
40
+ {t('admin.paymentMethod.evm.checking')}
41
+ </Typography>
42
+ </>
43
+ ) : status.connected ? (
44
+ <>
45
+ <CheckCircleOutline sx={{ fontSize: 14 }} color="success" />
46
+ <Typography variant="caption" color="text.secondary">
47
+ {t('admin.paymentMethod.evm.connected')} | {t('admin.paymentMethod.evm.blockHeight')}:{' '}
48
+ {status.blockNumber?.toLocaleString()}
49
+ </Typography>
50
+ </>
51
+ ) : status.error ? (
52
+ <>
53
+ <ErrorOutline sx={{ fontSize: 14 }} color="error" />
54
+ <Typography variant="caption" color="error">
55
+ {t('admin.paymentMethod.evm.connectionError')}
56
+ </Typography>
57
+ </>
58
+ ) : null}
59
+ </Box>
60
+ )}
61
+ </Box>
62
+ }
63
+ placeholder={placeholder}
64
+ />
65
+ );
66
+ }
@@ -0,0 +1,124 @@
1
+ import { useCallback, useEffect, useState, useMemo, useRef } from 'react';
2
+ import debounce from 'lodash/debounce';
3
+
4
+ interface NetworkStatus {
5
+ loading: boolean;
6
+ connected: boolean;
7
+ blockNumber?: bigint;
8
+ error?: string;
9
+ }
10
+
11
+ let cachedWeb3Promise: Promise<typeof import('web3').default> | null = null;
12
+
13
+ const loadWeb3 = () => {
14
+ if (!cachedWeb3Promise) {
15
+ cachedWeb3Promise = import('web3')
16
+ .then((module) => module.default)
17
+ .catch((error) => {
18
+ cachedWeb3Promise = null;
19
+ throw error;
20
+ });
21
+ }
22
+ return cachedWeb3Promise;
23
+ };
24
+
25
+ /**
26
+ * Manages the network status of a Web3 connection with optional debouncing.
27
+ *
28
+ * @param apiHost - Optional URL of the Ethereum RPC endpoint
29
+ * @param options - Configuration options for connection checking
30
+ * @param options.debounce - Delay in milliseconds between connection checks (default: 500ms)
31
+ * @param options.pollInterval - Interval in milliseconds between connection checks (default: 2000ms)
32
+ *
33
+ * @returns An object containing the current network status and a manual connection check function
34
+ *
35
+ * @remarks
36
+ * This hook provides real-time monitoring of Web3 network connectivity, including:
37
+ * - Loading state tracking
38
+ * - Connection status detection
39
+ * - Block number retrieval
40
+ * - Error handling for connection attempts
41
+ *
42
+ * @example
43
+ * const { status, checkConnection } = useRpcStatus('https://mainnet.infura.io/v3/YOUR-PROJECT-ID');
44
+ * // status will contain { loading, connected, blockNumber?, error? }
45
+ */
46
+ export function useRpcStatus(apiHost?: string, options = { debounce: 500, pollInterval: 2000 }) {
47
+ const [status, setStatus] = useState<NetworkStatus>({
48
+ loading: false,
49
+ connected: false,
50
+ });
51
+
52
+ const initializedRef = useRef(false);
53
+ const checkingRef = useRef(false);
54
+ const checkConnection = useCallback(
55
+ async (isPolling = false) => {
56
+ if (!apiHost) {
57
+ setStatus({ loading: false, connected: false });
58
+ return;
59
+ }
60
+
61
+ if (checkingRef.current) {
62
+ return;
63
+ }
64
+ checkingRef.current = true;
65
+
66
+ if (!isPolling && !initializedRef.current) {
67
+ setStatus((prev) => ({ ...prev, loading: true }));
68
+ }
69
+ try {
70
+ const Web3 = await loadWeb3();
71
+ const web3 = new Web3(apiHost);
72
+
73
+ const timeoutPromise = new Promise<never>((_, reject) => {
74
+ setTimeout(() => reject(new Error('Connection timeout')), 5000);
75
+ });
76
+ const blockNumber = await Promise.race([web3.eth.getBlockNumber(), timeoutPromise]);
77
+
78
+ setStatus({
79
+ loading: false,
80
+ connected: true,
81
+ blockNumber,
82
+ });
83
+ initializedRef.current = true;
84
+ } catch (error) {
85
+ setStatus({
86
+ loading: false,
87
+ connected: false,
88
+ error: error.message,
89
+ });
90
+ } finally {
91
+ checkingRef.current = false;
92
+ }
93
+ },
94
+ [apiHost]
95
+ );
96
+
97
+ const debouncedCheck = useMemo(
98
+ () => debounce(checkConnection, options.debounce),
99
+ [checkConnection, options.debounce]
100
+ );
101
+
102
+ useEffect(() => {
103
+ const check = options.debounce > 0 ? debouncedCheck : checkConnection;
104
+ initializedRef.current = false;
105
+ check();
106
+
107
+ let pollTimer: NodeJS.Timeout | null = null;
108
+ if (options.pollInterval > 0) {
109
+ pollTimer = setInterval(() => checkConnection(true), options.pollInterval);
110
+ }
111
+
112
+ return () => {
113
+ if (options.debounce > 0) {
114
+ debouncedCheck.cancel();
115
+ }
116
+ if (pollTimer) {
117
+ clearInterval(pollTimer);
118
+ }
119
+ checkingRef.current = false;
120
+ };
121
+ }, [apiHost, options.debounce, options.pollInterval, checkConnection, debouncedCheck]);
122
+
123
+ return { status, checkConnection };
124
+ }
@@ -398,6 +398,13 @@ export default flat({
398
398
  tip: 'How many blocks since transaction execution',
399
399
  },
400
400
  },
401
+ evm: {
402
+ checking: 'Checking connection...',
403
+ connected: 'Connected',
404
+ connectionError: 'Connection failed',
405
+ blockHeight: 'Block Height',
406
+ rpcStatus: 'RPC Status',
407
+ },
401
408
  base: {
402
409
  chain_id: {
403
410
  label: 'Chain ID',
@@ -430,6 +437,9 @@ export default flat({
430
437
  delete: 'Delete payment currency',
431
438
  deleteConfirm: 'Are you sure you want to delete this payment currency? Once deleted, it cannot be recovered',
432
439
  deleted: 'Payment currency successfully deleted',
440
+ quickAdd: 'Quick add supported currency',
441
+ searchToken: 'Search currency',
442
+ orManualInput: 'Or manually fill in currency information below',
433
443
  logo: {
434
444
  label: 'Logo',
435
445
  tip: 'Displayed on payment page',
@@ -387,6 +387,13 @@ export default flat({
387
387
  tip: '交易标记为确认需要的区块数',
388
388
  },
389
389
  },
390
+ evm: {
391
+ checking: '正在检查连接...',
392
+ connected: '连接成功',
393
+ connectionError: '连接失败',
394
+ blockHeight: '区块高度',
395
+ rpcStatus: 'RPC 状态',
396
+ },
390
397
  base: {
391
398
  chain_id: {
392
399
  label: '链 ID',
@@ -419,6 +426,9 @@ export default flat({
419
426
  delete: '删除货币',
420
427
  deleteConfirm: '确定要删除此货币吗?一旦删除,将无法恢复',
421
428
  deleted: '货币已成功删除',
429
+ quickAdd: '快速添加已支持的货币',
430
+ searchToken: '搜索货币',
431
+ orManualInput: '或在下方手动填写货币信息',
422
432
  logo: {
423
433
  label: 'Logo',
424
434
  tip: '在支付页面显示',
@@ -77,9 +77,9 @@ export default function PaymentMethodCreate() {
77
77
  icon={<AddOutlined />}
78
78
  text={t('admin.paymentMethod.add')}
79
79
  width={640}
80
- addons={
81
- <Button variant="contained" size="small" onClick={handleSubmit(onSubmit)}>
82
- {t('admin.paymentMethod.save')}
80
+ footer={
81
+ <Button variant="contained" size="large" onClick={handleSubmit(onSubmit)} sx={{ width: '100%' }}>
82
+ {t('common.save')}
83
83
  </Button>
84
84
  }>
85
85
  <FormProvider {...methods}>
@@ -9,6 +9,8 @@ import {
9
9
  EditOutlined,
10
10
  InfoOutlined,
11
11
  QrCodeOutlined,
12
+ CheckCircleOutline,
13
+ ErrorOutline,
12
14
  } from '@mui/icons-material';
13
15
  import {
14
16
  Alert,
@@ -26,7 +28,7 @@ import {
26
28
  Tooltip,
27
29
  Typography,
28
30
  } from '@mui/material';
29
- import { useRequest, useSetState } from 'ahooks';
31
+ import { useRequest, useSessionStorageState, useSetState } from 'ahooks';
30
32
  import useBus from 'use-bus';
31
33
 
32
34
  import { useState } from 'react';
@@ -37,6 +39,7 @@ import InfoCard from '../../../../components/info-card';
37
39
  import InfoRow from '../../../../components/info-row';
38
40
  import PaymentCurrencyAdd from '../../../../components/payment-currency/add';
39
41
  import PaymentCurrencyEdit from '../../../../components/payment-currency/edit';
42
+ import { useRpcStatus } from '../../../../hooks/rpc-status';
40
43
 
41
44
  const getMethods = (
42
45
  params: Record<string, any> = {}
@@ -143,8 +146,49 @@ const groupByType = (methods: TPaymentMethodExpanded[]) => {
143
146
  return groups;
144
147
  };
145
148
 
149
+ function RpcStatus({ method }: { method: TPaymentMethodExpanded }) {
150
+ const { t } = useLocaleContext();
151
+ // @ts-ignore
152
+ const apiHost = method.settings?.[method.type]?.api_host;
153
+ const { status } = useRpcStatus(apiHost, { debounce: 0, pollInterval: 2000 });
154
+
155
+ const renderStatus = () => {
156
+ if (status.loading) {
157
+ return (
158
+ <Typography variant="caption" color="text.secondary">
159
+ {t('admin.paymentMethod.evm.checking')}
160
+ </Typography>
161
+ );
162
+ }
163
+ if (status.connected) {
164
+ return (
165
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
166
+ <CheckCircleOutline sx={{ fontSize: 14 }} color="success" />
167
+ <Typography color="text.secondary">{t('admin.paymentMethod.evm.connected')}</Typography>
168
+ </Box>
169
+ );
170
+ }
171
+ return (
172
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
173
+ <ErrorOutline sx={{ fontSize: 14 }} color="error" />
174
+ <Typography color="error">{t('admin.paymentMethod.evm.connectionError')}</Typography>
175
+ </Box>
176
+ );
177
+ };
178
+ return (
179
+ <>
180
+ <InfoRow label={t(`admin.paymentMethod.${method.type}.api_host.label`)} value={apiHost} />
181
+ <InfoRow label={t('admin.paymentMethod.evm.rpcStatus')} value={renderStatus()} />
182
+ <InfoRow label={t('admin.paymentMethod.evm.blockHeight')} value={status.blockNumber?.toLocaleString()} />
183
+ </>
184
+ );
185
+ }
186
+
146
187
  export default function PaymentMethods() {
147
188
  const { t } = useLocaleContext();
189
+ const [expandedId, setExpandedId] = useSessionStorageState('payment-method-expanded-id', {
190
+ defaultValue: '',
191
+ });
148
192
  const {
149
193
  loading,
150
194
  error,
@@ -155,6 +199,9 @@ export default function PaymentMethods() {
155
199
  addresses: true,
156
200
  })
157
201
  );
202
+ const handleExpand = (methodId: string, expanded: boolean) => {
203
+ setExpandedId(expanded ? methodId : '');
204
+ };
158
205
  const [didDialog, setDidDialog] = useSetState({ open: false, chainId: '', did: '' });
159
206
  const { refresh } = usePaymentContext();
160
207
  const [currencyDialog, setCurrencyDialog] = useSetState({ action: '', value: null, method: '' });
@@ -224,7 +271,7 @@ export default function PaymentMethods() {
224
271
  <>
225
272
  {Object.keys(groups).map((x) => (
226
273
  <Box key={x} mt={3}>
227
- <Stack direction="row" alignItems="center" mb={1}>
274
+ <Stack direction="row" alignItems="center" mb={1} flexWrap="wrap" gap={1}>
228
275
  <Typography variant="h6" sx={{ textTransform: 'uppercase' }}>
229
276
  {x}
230
277
  </Typography>
@@ -234,7 +281,6 @@ export default function PaymentMethods() {
234
281
  alignItems="center"
235
282
  spacing={0.5}
236
283
  sx={{
237
- ml: 1,
238
284
  px: 0.5,
239
285
  color: 'text.secondary',
240
286
  }}>
@@ -273,11 +319,16 @@ export default function PaymentMethods() {
273
319
  borderTop: '1px solid #eee',
274
320
  borderBottom: '1px solid #eee',
275
321
  '& :hover': { color: 'text.primary' },
276
- }}>
322
+ }}
323
+ value={method.id}
324
+ expanded={expandedId === method.id}
325
+ onChange={(value, expanded) => handleExpand(value, expanded)}
326
+ lazy={false}>
277
327
  <Grid container spacing={2} mt={0}>
278
328
  <Grid item xs={12} md={6}>
279
329
  <InfoRow label={t('admin.paymentMethod.props.type')} value={method.type} />
280
330
  {method.type === 'arcblock' && <EditApiHost method={method} />}
331
+ {['ethereum', 'base'].includes(method.type) && <RpcStatus method={method} />}
281
332
  <InfoRow label={t('admin.paymentMethod.props.confirmation')} value={method.confirmation.type} />
282
333
  <InfoRow
283
334
  label={t('admin.paymentMethod.props.recurring')}