payment-kit 1.17.2 → 1.17.4

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.
@@ -16,6 +16,8 @@ import {
16
16
  FormControl,
17
17
  RadioGroup,
18
18
  Radio,
19
+ Link,
20
+ Skeleton,
19
21
  } from '@mui/material';
20
22
  import Dialog from '@arcblock/ux/lib/Dialog';
21
23
  import { EventHandler, useState } from 'react';
@@ -23,7 +25,10 @@ import { api, formatAmountPrecisionLimit, Switch, useMobile } from '@blocklet/pa
23
25
  import { useRequest } from 'ahooks';
24
26
  import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
25
27
  import type { TPaymentCurrency, TSubscriptionExpanded } from '@blocklet/payment-types';
28
+ import { joinURL } from 'ufo';
29
+ import { OpenInNewOutlined } from '@mui/icons-material';
26
30
  import Currency from '../currency';
31
+ import { formatSmartDuration, TimeUnit } from '../../libs/dayjs';
27
32
 
28
33
  const fetchCycleAmount = (
29
34
  subscriptionId: string,
@@ -58,7 +63,8 @@ type OverdraftProtectionDialogProps = {
58
63
  onSave: (data: { enabled: boolean; additionalCount?: number; returnRemaining?: boolean }) => Promise<void>;
59
64
  onCancel: EventHandler<any>;
60
65
  open: boolean;
61
- paymentAddress?: string;
66
+ stakingAddress?: string;
67
+ payerAddress?: string;
62
68
  currency: {
63
69
  symbol: string;
64
70
  logo: string;
@@ -66,10 +72,16 @@ type OverdraftProtectionDialogProps = {
66
72
  maximum_precision?: number;
67
73
  };
68
74
  subscription: TSubscriptionExpanded;
75
+ initValues?: {
76
+ enabled: boolean;
77
+ return_stake: boolean;
78
+ } | null;
69
79
  };
70
80
 
71
81
  OverdraftProtectionDialog.defaultProps = {
72
- paymentAddress: '',
82
+ payerAddress: '',
83
+ stakingAddress: '',
84
+ initValues: null,
73
85
  };
74
86
 
75
87
  export default function OverdraftProtectionDialog({
@@ -78,20 +90,23 @@ export default function OverdraftProtectionDialog({
78
90
  onSave,
79
91
  onCancel,
80
92
  open,
81
- paymentAddress,
93
+ stakingAddress,
94
+ payerAddress,
82
95
  currency,
83
96
  subscription,
97
+ initValues,
84
98
  }: OverdraftProtectionDialogProps) {
85
99
  const { t, locale } = useLocaleContext();
86
100
  const { isMobile } = useMobile();
87
101
  const [customAmount, setCustomAmount] = useState(false);
88
- const [presetAmounts, setPresetAmounts] = useState<{ amount: string; cycles: number }[]>([]);
102
+ const [presetAmounts, setPresetAmounts] = useState<{ amount: string; cycles: number; label: string }[]>([]);
89
103
 
90
104
  const {
91
105
  data: cycleAmount = {
92
106
  amount: '0',
93
107
  gas: '0',
94
108
  },
109
+ loading: cycleAmountLoading,
95
110
  } = useRequest(
96
111
  () =>
97
112
  fetchCycleAmount(subscription.id, {
@@ -100,10 +115,47 @@ export default function OverdraftProtectionDialog({
100
115
  {
101
116
  refreshDeps: [subscription.id],
102
117
  onSuccess: (data) => {
103
- const presets = [1, 2, 5, 10];
118
+ const presets = (() => {
119
+ const { interval, interval_count: intervalCount } = subscription.pending_invoice_item_interval;
120
+
121
+ switch (interval) {
122
+ case 'hour':
123
+ return [
124
+ { cycles: Math.ceil(24 / intervalCount), label: '1 day' },
125
+ { cycles: Math.ceil((24 * 7) / intervalCount), label: '1 week' },
126
+ { cycles: Math.ceil((24 * 30) / intervalCount), label: '1 month' },
127
+ { cycles: Math.ceil((24 * 90) / intervalCount), label: '3 months' },
128
+ { cycles: Math.ceil((24 * 180) / intervalCount), label: '6 months' },
129
+ ];
130
+ case 'day':
131
+ return [
132
+ { cycles: Math.ceil(7 / intervalCount), label: '1 week' },
133
+ { cycles: Math.ceil(30 / intervalCount), label: '1 month' },
134
+ { cycles: Math.ceil(90 / intervalCount), label: '3 months' },
135
+ { cycles: Math.ceil(180 / intervalCount), label: '6 months' },
136
+ { cycles: Math.ceil(365 / intervalCount), label: '1 year' },
137
+ ];
138
+ default:
139
+ return [
140
+ { cycles: 1, label: '1x' },
141
+ { cycles: 2, label: '2x' },
142
+ { cycles: 3, label: '6x' },
143
+ { cycles: 6, label: '6x' },
144
+ { cycles: 12, label: '12x' },
145
+ ];
146
+ }
147
+ })();
148
+
104
149
  const getCycleAmount = (cycles: number) =>
105
150
  fromUnitToToken(new BN(data.amount).mul(new BN(cycles)).toString(), data?.currency?.decimal);
106
- setPresetAmounts(presets.map((cycles) => ({ amount: getCycleAmount(cycles), cycles })));
151
+
152
+ setPresetAmounts(
153
+ presets.map(({ cycles, label }) => ({
154
+ amount: getCycleAmount(cycles),
155
+ cycles,
156
+ label,
157
+ }))
158
+ );
107
159
  },
108
160
  }
109
161
  );
@@ -117,6 +169,7 @@ export default function OverdraftProtectionDialog({
117
169
  enabled: !!subscription.overdraft_protection?.enabled,
118
170
  return_stake: false,
119
171
  amount: '0',
172
+ ...(initValues || {}),
120
173
  },
121
174
  mode: 'onChange',
122
175
  });
@@ -146,16 +199,28 @@ export default function OverdraftProtectionDialog({
146
199
  const totalIntervals = cycles * intervalCount;
147
200
  const availableUnitKeys = ['hour', 'day', 'week', 'month', 'year'];
148
201
 
149
- const unitKey = availableUnitKeys.includes(interval)
150
- ? `common.${interval}${totalIntervals > 1 ? 's' : ''}`
151
- : 'customer.overdraftProtection.intervals';
152
-
153
- return t('customer.overdraftProtection.estimatedDuration', {
154
- duration: totalIntervals,
155
- unit: t(unitKey).toLowerCase(),
202
+ if (!availableUnitKeys.includes(interval)) {
203
+ return t('customer.overdraftProtection.estimatedDuration', {
204
+ duration: totalIntervals,
205
+ unit: t('customer.overdraftProtection.intervals').toLowerCase(),
206
+ });
207
+ }
208
+ return t('common.estimatedDuration', {
209
+ duration: formatSmartDuration(totalIntervals, interval as TimeUnit, {
210
+ t,
211
+ }),
156
212
  });
157
213
  };
158
214
 
215
+ const getStakingAddressURL = () => {
216
+ return joinURL(
217
+ subscription.paymentMethod?.settings.arcblock?.explorer_host as string,
218
+ '/stakes',
219
+ stakingAddress as string,
220
+ '/tokens'
221
+ );
222
+ };
223
+
159
224
  return (
160
225
  <Dialog
161
226
  open={open}
@@ -207,16 +272,67 @@ export default function OverdraftProtectionDialog({
207
272
 
208
273
  {isEnabled ? (
209
274
  <Stack gap={1} sx={{ mt: '-8px' }}>
210
- {paymentAddress && (
211
- <Stack direction="row" alignItems="center" gap={isMobile ? 1 : 2} flexWrap="wrap">
275
+ {payerAddress && (
276
+ <Stack
277
+ sx={
278
+ isMobile
279
+ ? {
280
+ flexDirection: 'column',
281
+ gap: 1,
282
+ }
283
+ : {
284
+ flexDirection: 'row',
285
+ alignItems: 'center',
286
+ gap: 2,
287
+ }
288
+ }>
212
289
  <Typography variant="subtitle2" color="text.secondary">
213
- {t('customer.overdraftProtection.address')}
290
+ {t('customer.overdraftProtection.payerAddress')}
214
291
  </Typography>
215
292
  <Typography variant="body2" sx={{ wordBreak: 'break-all', fontFamily: 'monospace', fontWeight: '700' }}>
216
- {paymentAddress}
293
+ {payerAddress}
294
+ </Typography>
295
+ </Stack>
296
+ )}
297
+
298
+ {stakingAddress && (
299
+ <Stack
300
+ sx={
301
+ isMobile
302
+ ? {
303
+ flexDirection: 'column',
304
+ gap: 1,
305
+ }
306
+ : {
307
+ flexDirection: 'row',
308
+ alignItems: 'center',
309
+ gap: 2,
310
+ }
311
+ }>
312
+ <Typography variant="subtitle2" color="text.secondary">
313
+ {t('customer.overdraftProtection.stakingAddress')}
217
314
  </Typography>
315
+ <Link
316
+ href={getStakingAddressURL()}
317
+ target="_blank"
318
+ rel="noopener noreferrer"
319
+ sx={{
320
+ wordBreak: 'break-all',
321
+ fontFamily: 'monospace',
322
+ fontWeight: '700',
323
+ textDecoration: 'none',
324
+ color: 'text.link',
325
+ display: 'flex',
326
+ flexWrap: 'wrap',
327
+ alignItems: 'center',
328
+ gap: 1,
329
+ }}>
330
+ {stakingAddress}
331
+ {!isMobile && <OpenInNewOutlined fontSize="small" />}
332
+ </Link>
218
333
  </Stack>
219
334
  )}
335
+
220
336
  <Typography variant="body2" color="text.secondary">
221
337
  {(() => {
222
338
  if (Number(dueAmount) > 0 && !value.enabled) {
@@ -242,62 +358,91 @@ export default function OverdraftProtectionDialog({
242
358
  </Typography>
243
359
 
244
360
  <Grid container spacing={2} ml={-2} sx={{ mt: -1 }}>
245
- {presetAmounts.map(({ amount: presetAmount, cycles }) => (
246
- <Grid item xs={6} sm={4} key={presetAmount}>
247
- <Card
248
- variant="outlined"
249
- sx={{
250
- height: '100%',
251
- transition: 'all 0.3s',
252
- cursor: 'pointer',
253
- '&:hover': {
254
- transform: 'translateY(-4px)',
255
- boxShadow: 3,
256
- },
257
- ...(amount === presetAmount && !customAmount
258
- ? { borderColor: 'primary.main', borderWidth: 2 }
259
- : {}),
260
- }}>
261
- <CardActionArea
262
- onClick={() => {
263
- methods.setValue('amount', presetAmount);
264
- setCustomAmount(false);
265
- }}
266
- sx={{ height: '100%', p: 1 }}>
267
- <Stack spacing={1} alignItems="center">
268
- <Typography variant="h6" sx={{ fontWeight: 600 }}>
269
- {presetAmount} {currency.symbol}
270
- </Typography>
271
- <Typography variant="caption" color="text.secondary">
272
- {formatEstimatedDuration(cycles)}
273
- </Typography>
274
- </Stack>
275
- </CardActionArea>
276
- </Card>
277
- </Grid>
278
- ))}
279
- <Grid item xs={6} sm={4}>
280
- <Card
281
- variant="outlined"
282
- sx={{
283
- height: '100%',
284
- transition: 'all 0.3s',
285
- cursor: 'pointer',
286
- '&:hover': {
287
- transform: 'translateY(-4px)',
288
- boxShadow: 3,
289
- },
290
- ...(customAmount ? { borderColor: 'primary.main', borderWidth: 2 } : {}),
291
- }}>
292
- <CardActionArea onClick={handleCustomSelect} sx={{ height: '100%', p: 2 }}>
293
- <Stack spacing={1} alignItems="center">
294
- <Typography variant="h6" sx={{ fontWeight: 600 }}>
295
- {t('common.custom')}
296
- </Typography>
297
- </Stack>
298
- </CardActionArea>
299
- </Card>
300
- </Grid>
361
+ {cycleAmountLoading ? (
362
+ // 加载状态的占位
363
+ <>
364
+ {[1, 2, 3, 4, 5].map((key) => (
365
+ <Grid item xs={6} sm={4} key={key}>
366
+ <Card variant="outlined" sx={{ height: '100%' }}>
367
+ <CardActionArea sx={{ height: '100%', p: 1 }}>
368
+ <Stack spacing={1} alignItems="center">
369
+ <Skeleton variant="rectangular" width={80} height={32} />
370
+ <Skeleton width={100} />
371
+ </Stack>
372
+ </CardActionArea>
373
+ </Card>
374
+ </Grid>
375
+ ))}
376
+ <Grid item xs={6} sm={4}>
377
+ <Card variant="outlined" sx={{ height: '100%' }}>
378
+ <CardActionArea sx={{ height: '100%', p: 2 }}>
379
+ <Stack spacing={1} alignItems="center">
380
+ <Skeleton variant="rectangular" width={80} height={24} />
381
+ </Stack>
382
+ </CardActionArea>
383
+ </Card>
384
+ </Grid>
385
+ </>
386
+ ) : (
387
+ <>
388
+ {presetAmounts.map(({ amount: presetAmount, cycles }) => (
389
+ <Grid item xs={6} sm={4} key={presetAmount}>
390
+ <Card
391
+ variant="outlined"
392
+ sx={{
393
+ height: '100%',
394
+ transition: 'all 0.3s',
395
+ cursor: 'pointer',
396
+ '&:hover': {
397
+ transform: 'translateY(-4px)',
398
+ boxShadow: 3,
399
+ },
400
+ ...(amount === presetAmount && !customAmount
401
+ ? { borderColor: 'primary.main', borderWidth: 2 }
402
+ : {}),
403
+ }}>
404
+ <CardActionArea
405
+ onClick={() => {
406
+ methods.setValue('amount', presetAmount);
407
+ setCustomAmount(false);
408
+ }}
409
+ sx={{ height: '100%', p: 1 }}>
410
+ <Stack spacing={1} alignItems="center">
411
+ <Typography variant="h6" sx={{ fontWeight: 600 }}>
412
+ {presetAmount} {currency.symbol}
413
+ </Typography>
414
+ <Typography variant="caption" color="text.secondary">
415
+ {formatEstimatedDuration(cycles)}
416
+ </Typography>
417
+ </Stack>
418
+ </CardActionArea>
419
+ </Card>
420
+ </Grid>
421
+ ))}
422
+ <Grid item xs={6} sm={4}>
423
+ <Card
424
+ variant="outlined"
425
+ sx={{
426
+ height: '100%',
427
+ transition: 'all 0.3s',
428
+ cursor: 'pointer',
429
+ '&:hover': {
430
+ transform: 'translateY(-4px)',
431
+ boxShadow: 3,
432
+ },
433
+ ...(customAmount ? { borderColor: 'primary.main', borderWidth: 2 } : {}),
434
+ }}>
435
+ <CardActionArea onClick={handleCustomSelect} sx={{ height: '100%', p: 2 }}>
436
+ <Stack spacing={1} alignItems="center">
437
+ <Typography variant="h6" sx={{ fontWeight: 600 }}>
438
+ {t('common.custom')}
439
+ </Typography>
440
+ </Stack>
441
+ </CardActionArea>
442
+ </Card>
443
+ </Grid>
444
+ </>
445
+ )}
301
446
  </Grid>
302
447
 
303
448
  {customAmount && (
@@ -346,7 +491,7 @@ export default function OverdraftProtectionDialog({
346
491
  </FormControl>
347
492
  </Stack>
348
493
  )}
349
- {amount && Number(amount) > 0 && !methods.formState.errors.amount && (
494
+ {amount && Number(amount) > 0 && Number(estimateAmount) > 0 && !methods.formState.errors.amount && (
350
495
  <Typography variant="body2" sx={{ color: 'text.lighter', mt: '8px !important' }} fontSize={12}>
351
496
  {t('customer.overdraftProtection.total', {
352
497
  total: safeAdd(currency, amount, availableAmount),
@@ -3,21 +3,36 @@ import { api, formatBNStr, formatTime } from '@blocklet/payment-react';
3
3
  import type { TSubscriptionExpanded } from '@blocklet/payment-types';
4
4
  import { useRequest } from 'ahooks';
5
5
 
6
+ import { Button, Stack, Typography, Tooltip, Avatar, Box, CircularProgress, Skeleton } from '@mui/material';
7
+ import { BN } from '@ocap/util';
8
+ import { useNavigate } from 'react-router-dom';
9
+ import { ArrowForward, InfoOutlined } from '@mui/icons-material';
6
10
  import InfoMetric from '../info-metric';
7
11
  import SubscriptionStatus from './status';
8
12
 
9
13
  type Props = {
10
14
  subscription: TSubscriptionExpanded;
15
+ showBalance?: boolean;
11
16
  };
12
17
 
13
18
  const fetchUpcoming = (id: string): Promise<{ amount: string }> => {
14
19
  return api.get(`/api/subscriptions/${id}/upcoming`).then((res) => res.data);
15
20
  };
16
21
 
17
- export default function SubscriptionMetrics({ subscription }: Props) {
22
+ const fetchPayer = (id: string): Promise<{ token: string; paymentAddress: string }> => {
23
+ return api.get(`/api/subscriptions/${id}/payer-token`).then((res) => res.data);
24
+ };
25
+
26
+ export default function SubscriptionMetrics({ subscription, showBalance = true }: Props) {
18
27
  const { t } = useLocaleContext();
19
- const { data: upcoming } = useRequest(() => fetchUpcoming(subscription.id));
28
+ const { data: upcoming, loading: upcomingLoading } = useRequest(() => fetchUpcoming(subscription.id));
29
+ const navigate = useNavigate();
30
+
31
+ const { data: payerValue, loading: payerLoading } = useRequest(() => fetchPayer(subscription.id), {
32
+ ready: showBalance,
33
+ });
20
34
 
35
+ const supportShowBalance = showBalance && ['arcblock', 'ethereum'].includes(subscription.paymentMethod.type);
21
36
  // let scheduleToCancelTime = 0;
22
37
  // if (['active', 'trialing', 'past_due'].includes(subscription.status) && subscription.cancel_at) {
23
38
  // scheduleToCancelTime = subscription.cancel_at * 1000;
@@ -25,14 +40,43 @@ export default function SubscriptionMetrics({ subscription }: Props) {
25
40
  // scheduleToCancelTime = subscription.current_period_end * 1000;
26
41
  // }
27
42
 
43
+ const isInsufficientBalance = new BN(payerValue?.token || '0').lt(new BN(upcoming?.amount || '0'));
44
+ const renderBalanceValue = () => {
45
+ if (upcomingLoading || payerLoading) {
46
+ return <CircularProgress size={16} />;
47
+ }
48
+
49
+ if (isInsufficientBalance) {
50
+ return (
51
+ <Button
52
+ component="a"
53
+ color="error"
54
+ onClick={() => navigate(`/customer/subscription/${subscription.id}/recharge`)}>
55
+ {t('admin.subscription.insufficientBalance')}
56
+ <ArrowForward sx={{ fontSize: '14px' }} />
57
+ </Button>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <Stack flexDirection="row" alignItems="center" gap={0.5} sx={{ fontSize: '16px', fontWeight: 500 }}>
63
+ <Avatar
64
+ src={subscription.paymentCurrency?.logo}
65
+ sx={{ width: 16, height: 16 }}
66
+ alt={subscription.paymentCurrency?.name}
67
+ />
68
+ <Box display="flex" alignItems="baseline">
69
+ {formatBNStr(payerValue?.token, subscription.paymentCurrency.decimal)}
70
+ <Typography sx={{ fontSize: '14px', color: 'text.secondary', ml: 0.5 }}>
71
+ {subscription.paymentCurrency.symbol}
72
+ </Typography>
73
+ </Box>
74
+ </Stack>
75
+ );
76
+ };
28
77
  return (
29
78
  <>
30
79
  <InfoMetric label={t('common.status')} value={<SubscriptionStatus subscription={subscription} />} divider />
31
- <InfoMetric
32
- label={t('admin.subscription.startedAt')}
33
- value={formatTime(subscription.start_date ? subscription.start_date * 1000 : subscription.created_at)}
34
- divider
35
- />
36
80
  {subscription.status === 'active' && !subscription.cancel_at && (
37
81
  <InfoMetric
38
82
  label={t('admin.subscription.nextInvoice')}
@@ -49,6 +93,34 @@ export default function SubscriptionMetrics({ subscription }: Props) {
49
93
  divider
50
94
  />
51
95
  )}
96
+ {supportShowBalance && ['active', 'trialing'].includes(subscription.status) && (
97
+ <InfoMetric
98
+ label={
99
+ <Stack direction="row" spacing={1} alignItems="center">
100
+ <Typography>{t('admin.subscription.currentBalance')}</Typography>
101
+ <Tooltip
102
+ title={
103
+ <Typography sx={{ fontFamily: 'monospace', fontSize: '13px' }}>
104
+ {t('admin.subscription.paymentAddress')}:
105
+ {payerLoading ? <Skeleton width={120} /> : payerValue?.paymentAddress}
106
+ </Typography>
107
+ }
108
+ arrow>
109
+ <InfoOutlined
110
+ sx={{
111
+ fontSize: '16px',
112
+ color: 'text.secondary',
113
+ cursor: 'pointer',
114
+ '&:hover': { color: 'primary.main' },
115
+ }}
116
+ />
117
+ </Tooltip>
118
+ </Stack>
119
+ }
120
+ value={renderBalanceValue()}
121
+ divider
122
+ />
123
+ )}
52
124
  {/* {scheduleToCancelTime > 0 && (
53
125
  <InfoMetric label={t('admin.subscription.cancel.schedule')} value={formatTime(scheduleToCancelTime)} divider />
54
126
  )} */}
@@ -62,3 +134,7 @@ export default function SubscriptionMetrics({ subscription }: Props) {
62
134
  </>
63
135
  );
64
136
  }
137
+
138
+ SubscriptionMetrics.defaultProps = {
139
+ showBalance: true,
140
+ };