payment-kit 1.15.20 → 1.15.21

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.
@@ -0,0 +1,417 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
+ import Toast from '@arcblock/ux/lib/Toast';
4
+ import {
5
+ Box,
6
+ Typography,
7
+ TextField,
8
+ Button,
9
+ CircularProgress,
10
+ Alert,
11
+ Stack,
12
+ Divider,
13
+ Card,
14
+ CardActionArea,
15
+ Grid,
16
+ Paper,
17
+ } from '@mui/material';
18
+ import { styled } from '@mui/system';
19
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
20
+ import {
21
+ usePaymentContext,
22
+ getPrefix,
23
+ formatError,
24
+ formatAmountPrecisionLimit,
25
+ formatTime,
26
+ api,
27
+ formatBNStr,
28
+ } from '@blocklet/payment-react';
29
+ import { joinURL } from 'ufo';
30
+ import type { TSubscriptionExpanded } from '@blocklet/payment-types';
31
+ import { ArrowBackOutlined } from '@mui/icons-material';
32
+ import { BN, fromUnitToToken } from '@ocap/util';
33
+ import SubscriptionDescription from '../../components/subscription/description';
34
+ import InfoRow from '../../components/info-row';
35
+ import Currency from '../../components/currency';
36
+ import SubscriptionMetrics from '../../components/subscription/metrics';
37
+ import { goBackOrFallback } from '../../libs/util';
38
+ import CustomerLink from '../../components/customer/link';
39
+
40
+ const Root = styled(Stack)(({ theme }) => ({
41
+ marginBottom: theme.spacing(3),
42
+ gap: theme.spacing(3),
43
+ flexDirection: 'column',
44
+ margin: '0 auto',
45
+ padding: '20px 0',
46
+ }));
47
+
48
+ const BalanceCard = styled(Box)(({ theme }) => ({
49
+ padding: theme.spacing(2),
50
+ backgroundColor: theme.palette.background.paper,
51
+ border: `1px solid ${theme.palette.divider}`,
52
+ borderRadius: theme.shape.borderRadius,
53
+ marginBottom: theme.spacing(2),
54
+ }));
55
+
56
+ export default function RechargePage() {
57
+ const { t, locale } = useLocaleContext();
58
+ const { id: subscriptionId } = useParams<{ id: string }>();
59
+ const navigate = useNavigate();
60
+ const { connect } = usePaymentContext();
61
+ const [subscription, setSubscription] = useState<TSubscriptionExpanded | null>(null);
62
+ const [amount, setAmount] = useState('');
63
+ const [amountError, setAmountError] = useState('');
64
+ const [loading, setLoading] = useState(true);
65
+ const [error, setError] = useState('');
66
+ const customInputRef = useRef<HTMLInputElement>(null);
67
+ const [payerValue, setPayerValue] = useState<{
68
+ paymentAddress: string;
69
+ token: string;
70
+ } | null>(null);
71
+ const [customAmount, setCustomAmount] = useState(false);
72
+ const [presetAmounts, setPresetAmounts] = useState<Array<{ amount: string; cycles: number }>>([]);
73
+
74
+ const {
75
+ paymentCurrency,
76
+ payment_details: paymentDetails,
77
+ paymentMethod,
78
+ } = subscription || {
79
+ customer: null,
80
+ paymentCurrency: null,
81
+ payment_details: null,
82
+ paymentMethod: null,
83
+ };
84
+
85
+ // @ts-ignore
86
+ const receiveAddress = paymentDetails?.[paymentMethod?.type]?.payer;
87
+
88
+ useEffect(() => {
89
+ const fetchData = async () => {
90
+ try {
91
+ setLoading(true);
92
+ const [subscriptionRes, payerTokenRes, upcomingRes] = await Promise.all([
93
+ api.get(`/api/subscriptions/${subscriptionId}`),
94
+ api.get(`/api/subscriptions/${subscriptionId}/payer-token`),
95
+ api.get(`/api/subscriptions/${subscriptionId}/upcoming`),
96
+ ]);
97
+ setSubscription(subscriptionRes.data);
98
+ setPayerValue(payerTokenRes.data);
99
+
100
+ // Calculate preset amounts
101
+ const getCycleAmount = (cycles: number) =>
102
+ fromUnitToToken(
103
+ new BN(upcomingRes.data.amount).mul(new BN(cycles)).toString(),
104
+ upcomingRes.data?.currency?.decimal
105
+ );
106
+ setPresetAmounts([
107
+ { amount: getCycleAmount(1), cycles: 1 },
108
+ { amount: getCycleAmount(2), cycles: 2 },
109
+ { amount: getCycleAmount(3), cycles: 3 },
110
+ { amount: getCycleAmount(5), cycles: 5 },
111
+ { amount: getCycleAmount(10), cycles: 10 },
112
+ ]);
113
+ } catch (err) {
114
+ setError(err instanceof Error ? err.message : t('common.fetchError'));
115
+ console.error(err);
116
+ } finally {
117
+ setLoading(false);
118
+ }
119
+ };
120
+
121
+ fetchData();
122
+ }, [subscriptionId]);
123
+
124
+ const handleRecharge = () => {
125
+ if (!subscription) return;
126
+
127
+ connect.open({
128
+ containerEl: undefined as unknown as Element,
129
+ saveConnect: false,
130
+ action: 'recharge',
131
+ prefix: joinURL(getPrefix(), '/api/did'),
132
+ extraParams: { subscriptionId, amount },
133
+ onSuccess: () => {
134
+ connect.close();
135
+ Toast.success(t('customer.recharge.success'));
136
+ window.location.reload();
137
+ },
138
+ onClose: () => {
139
+ connect.close();
140
+ },
141
+ onError: (err: any) => {
142
+ Toast.error(formatError(err));
143
+ },
144
+ });
145
+ };
146
+
147
+ const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
148
+ const { value } = e.target;
149
+ if (!subscription) return;
150
+
151
+ const precision = subscription.paymentCurrency.maximum_precision || 6;
152
+ const errorMessage = formatAmountPrecisionLimit(value, locale, precision);
153
+ setAmountError(errorMessage || '');
154
+ setAmount(value);
155
+ };
156
+
157
+ const handleSelect = (selectedAmount: string) => {
158
+ setAmount(selectedAmount);
159
+ setCustomAmount(false);
160
+ };
161
+
162
+ const handleCustomSelect = () => {
163
+ setCustomAmount(true);
164
+ setAmount('');
165
+ setTimeout(() => {
166
+ customInputRef.current?.focus();
167
+ }, 0);
168
+ };
169
+
170
+ if (loading) {
171
+ return <CircularProgress />;
172
+ }
173
+
174
+ if (error) {
175
+ return <Alert severity="error">{error}</Alert>;
176
+ }
177
+
178
+ if (!subscription) {
179
+ return <Alert severity="info">{t('common.dataNotFound')}</Alert>;
180
+ }
181
+
182
+ const currentBalance = formatBNStr(payerValue?.token || '0', paymentCurrency?.decimal, 6, false);
183
+
184
+ const supportRecharge = ['arcblock', 'ethereum'].includes(paymentMethod?.type || '');
185
+
186
+ const formatEstimatedDuration = (cycles: number) => {
187
+ const { interval, interval_count: intervalCount } = subscription.pending_invoice_item_interval;
188
+ const totalIntervals = cycles * intervalCount;
189
+ const availableUnitKeys = ['hour', 'day', 'week', 'month', 'year'];
190
+
191
+ const unitKey = availableUnitKeys.includes(interval)
192
+ ? `common.${interval}${totalIntervals > 1 ? 's' : ''}`
193
+ : 'customer.recharge.intervals';
194
+
195
+ return t('customer.recharge.estimatedDuration', {
196
+ duration: totalIntervals,
197
+ unit: t(unitKey).toLowerCase(),
198
+ });
199
+ };
200
+
201
+ return (
202
+ <Root>
203
+ <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ position: 'relative' }}>
204
+ <Stack
205
+ direction="row"
206
+ onClick={() => goBackOrFallback('/customer')}
207
+ alignItems="center"
208
+ sx={{ fontWeight: 'normal', cursor: 'pointer' }}>
209
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
210
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
211
+ {t('common.previous')}
212
+ </Typography>
213
+ </Stack>
214
+ <Button variant="outlined" onClick={() => navigate(`/customer/subscription/${subscriptionId}`)}>
215
+ {t('customer.recharge.view')}
216
+ </Button>
217
+ </Stack>
218
+
219
+ <Box>
220
+ <Box
221
+ sx={{
222
+ display: 'flex',
223
+ gap: { xs: 2, sm: 2, md: 5 },
224
+ flexWrap: 'wrap',
225
+ flexDirection: { xs: 'column', sm: 'column', md: 'row' },
226
+ alignItems: { xs: 'flex-start', sm: 'flex-start', md: 'center' },
227
+ }}>
228
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
229
+ <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
230
+ <SubscriptionDescription subscription={subscription} variant="h1" />
231
+ </Stack>
232
+ </Stack>
233
+ <Stack
234
+ justifyContent="flex-start"
235
+ flexWrap="wrap"
236
+ sx={{
237
+ flexDirection: { xs: 'column', sm: 'column', md: 'row' },
238
+ alignItems: { xs: 'flex-start', sm: 'flex-start', md: 'center' },
239
+ gap: { xs: 1, sm: 1, md: 3 },
240
+ }}>
241
+ <SubscriptionMetrics subscription={subscription} />
242
+ </Stack>
243
+ </Box>
244
+
245
+ <Divider sx={{ my: 2 }} />
246
+
247
+ <Box
248
+ sx={{
249
+ display: 'grid',
250
+ gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(1, 1fr)', md: 'repeat(1, 1fr)' },
251
+ gap: { xs: 0, md: 2 },
252
+ }}>
253
+ <InfoRow
254
+ label={t('common.customer')}
255
+ value={<CustomerLink customer={subscription?.customer} linked={false} />}
256
+ direction="column"
257
+ alignItems="flex-start"
258
+ />
259
+ <InfoRow
260
+ label={t('admin.subscription.currentPeriod')}
261
+ value={`${formatTime(subscription.current_period_start * 1000)} ~ ${formatTime(subscription.current_period_end * 1000)}`}
262
+ direction="column"
263
+ alignItems="flex-start"
264
+ />
265
+ <InfoRow
266
+ label={t('admin.paymentMethod._name')}
267
+ value={<Currency logo={subscription.paymentMethod?.logo} name={subscription.paymentMethod?.name} />}
268
+ direction="column"
269
+ alignItems="flex-start"
270
+ />
271
+ <InfoRow
272
+ label={t('admin.paymentCurrency.name')}
273
+ value={
274
+ <Stack direction="row" spacing={2}>
275
+ <Currency logo={subscription.paymentCurrency.logo} name={subscription.paymentCurrency.symbol} />
276
+ </Stack>
277
+ }
278
+ direction="column"
279
+ alignItems="flex-start"
280
+ />
281
+ </Box>
282
+ </Box>
283
+ <Divider />
284
+
285
+ <Box sx={{ maxWidth: 600 }}>
286
+ <Typography variant="h2" gutterBottom>
287
+ {t('customer.recharge.title')}
288
+ </Typography>
289
+
290
+ {supportRecharge ? (
291
+ <>
292
+ <BalanceCard>
293
+ <Typography variant="subtitle1">{t('customer.recharge.receiveAddress')}</Typography>
294
+ <Typography variant="h4" sx={{ wordBreak: 'break-all' }}>
295
+ {payerValue?.paymentAddress || receiveAddress}
296
+ </Typography>
297
+ </BalanceCard>
298
+
299
+ <BalanceCard
300
+ sx={{
301
+ background: 'var(--tags-tag-orange-bg, #B7FEE3)',
302
+ color: 'var(--tags-tag-orange-text, #007C52)',
303
+ borderRadius: 'var(--radius-m, 8px)',
304
+ border: 'none',
305
+ }}>
306
+ <Typography variant="subtitle1">{t('admin.customer.summary.balance')}</Typography>
307
+ <Typography variant="h4">
308
+ {currentBalance} {subscription.paymentCurrency.symbol}
309
+ </Typography>
310
+ </BalanceCard>
311
+
312
+ <Paper elevation={0} sx={{ mb: 2, mt: 2, backgroundColor: 'background.default' }}>
313
+ <Grid container spacing={2}>
314
+ {presetAmounts.map(({ amount: presetAmount, cycles }) => (
315
+ <Grid item xs={6} sm={4} key={presetAmount}>
316
+ <Card
317
+ variant="outlined"
318
+ sx={{
319
+ height: '100%',
320
+ display: 'flex',
321
+ flexDirection: 'column',
322
+ justifyContent: 'center',
323
+ transition: 'all 0.3s',
324
+ cursor: 'pointer',
325
+ '&:hover': {
326
+ transform: 'translateY(-4px)',
327
+ boxShadow: 3,
328
+ },
329
+ ...(amount === presetAmount && !customAmount
330
+ ? { borderColor: 'primary.main', borderWidth: 2 }
331
+ : {}),
332
+ }}>
333
+ <CardActionArea onClick={() => handleSelect(presetAmount)} sx={{ height: '100%' }}>
334
+ <Stack direction="column" sx={{ p: 1 }} spacing={1} alignItems="center" justifyContent="center">
335
+ <Typography
336
+ variant="h6"
337
+ sx={{
338
+ fontWeight: 600,
339
+ color: amount === presetAmount && !customAmount ? 'primary.main' : 'text.primary',
340
+ }}>
341
+ {presetAmount} {subscription.paymentCurrency.symbol}
342
+ </Typography>
343
+ <Typography variant="caption" color="text.secondary">
344
+ {formatEstimatedDuration(cycles)}
345
+ </Typography>
346
+ </Stack>
347
+ </CardActionArea>
348
+ </Card>
349
+ </Grid>
350
+ ))}
351
+ <Grid item xs={6} sm={4}>
352
+ <Card
353
+ variant="outlined"
354
+ sx={{
355
+ height: '100%',
356
+ display: 'flex',
357
+ flexDirection: 'column',
358
+ justifyContent: 'center',
359
+ transition: 'all 0.3s',
360
+ cursor: 'pointer',
361
+ '&:hover': {
362
+ transform: 'translateY(-4px)',
363
+ boxShadow: 3,
364
+ },
365
+ ...(customAmount ? { borderColor: 'primary.main', borderWidth: 2 } : {}),
366
+ }}>
367
+ <CardActionArea onClick={handleCustomSelect} sx={{ height: '100%' }}>
368
+ <Stack direction="column" sx={{ p: 1 }} spacing={1} alignItems="center" justifyContent="center">
369
+ <Typography
370
+ variant="h6"
371
+ sx={{ fontWeight: 600, color: customAmount ? 'primary.main' : 'text.primary' }}>
372
+ {t('customer.recharge.custom')}
373
+ </Typography>
374
+ </Stack>
375
+ </CardActionArea>
376
+ </Card>
377
+ </Grid>
378
+ </Grid>
379
+ </Paper>
380
+
381
+ {customAmount && (
382
+ <TextField
383
+ fullWidth
384
+ label={t('customer.recharge.amount')}
385
+ variant="outlined"
386
+ type="number"
387
+ value={amount}
388
+ error={!!amountError}
389
+ helperText={amountError}
390
+ onChange={handleAmountChange}
391
+ InputProps={{
392
+ endAdornment: <Typography>{subscription.paymentCurrency.symbol}</Typography>,
393
+ autoComplete: 'off',
394
+ }}
395
+ sx={{ mt: 2, mb: 2 }}
396
+ inputRef={customInputRef}
397
+ />
398
+ )}
399
+
400
+ <Button
401
+ fullWidth
402
+ size="large"
403
+ variant="contained"
404
+ color="primary"
405
+ onClick={handleRecharge}
406
+ sx={{ mt: 2 }}
407
+ disabled={!amount || parseFloat(amount) <= 0 || !!amountError}>
408
+ {t('customer.recharge.submit')}
409
+ </Button>
410
+ </>
411
+ ) : (
412
+ <Alert severity="info">{t('customer.recharge.unsupported')}</Alert>
413
+ )}
414
+ </Box>
415
+ </Root>
416
+ );
417
+ }
@@ -5,7 +5,7 @@ import type { TSubscriptionExpanded } from '@blocklet/payment-types';
5
5
  import { ArrowBackOutlined } from '@mui/icons-material';
6
6
  import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
7
7
  import { useRequest } from 'ahooks';
8
- import { Link, useParams } from 'react-router-dom';
8
+ import { Link, useNavigate, useParams } from 'react-router-dom';
9
9
 
10
10
  import { styled } from '@mui/system';
11
11
  import Currency from '../../../components/currency';
@@ -24,8 +24,16 @@ const fetchData = (id: string | undefined): Promise<TSubscriptionExpanded> => {
24
24
  const InfoDirection = 'column';
25
25
  const InfoAlignItems = 'flex-start';
26
26
 
27
+ const supportRecharge = (subscription: TSubscriptionExpanded) => {
28
+ return (
29
+ ['active', 'trialing', 'past_due'].includes(subscription?.status) &&
30
+ ['arcblock', 'ethereum'].includes(subscription?.paymentMethod?.type)
31
+ );
32
+ };
33
+
27
34
  export default function CustomerSubscriptionDetail() {
28
35
  const { id } = useParams() as { id: string };
36
+ const navigate = useNavigate();
29
37
  const { t } = useLocaleContext();
30
38
  const { loading, error, data, refresh } = useRequest(() => fetchData(id));
31
39
 
@@ -56,25 +64,35 @@ export default function CustomerSubscriptionDetail() {
56
64
  {t('payment.customer.subscriptions.title')}
57
65
  </Typography>
58
66
  </Stack>
59
- <SubscriptionActions
60
- subscription={data}
61
- onChange={() => refresh()}
62
- showExtra
63
- actionProps={{
64
- cancel: {
65
- variant: 'outlined',
66
- color: 'primary',
67
- },
68
- recover: {
69
- variant: 'outlined',
70
- color: 'info',
71
- },
72
- pastDue: {
73
- variant: 'outlined',
74
- color: 'primary',
75
- },
76
- }}
77
- />
67
+ <Stack direction="row" gap={1}>
68
+ <SubscriptionActions
69
+ subscription={data}
70
+ onChange={() => refresh()}
71
+ showExtra
72
+ actionProps={{
73
+ cancel: {
74
+ variant: 'outlined',
75
+ color: 'primary',
76
+ },
77
+ recover: {
78
+ variant: 'outlined',
79
+ color: 'info',
80
+ },
81
+ pastDue: {
82
+ variant: 'outlined',
83
+ color: 'primary',
84
+ },
85
+ }}
86
+ />
87
+ {supportRecharge(data) && (
88
+ <Button
89
+ variant="outlined"
90
+ color="primary"
91
+ onClick={() => navigate(`/customer/subscription/${data.id}/recharge`)}>
92
+ {t('customer.recharge.title')}
93
+ </Button>
94
+ )}
95
+ </Stack>
78
96
  </Stack>
79
97
  <Box
80
98
  mt={4}