payment-kit 1.17.1 → 1.17.3

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.
package/src/libs/dayjs.ts CHANGED
@@ -1,3 +1,135 @@
1
1
  import { dayjs } from '@blocklet/payment-react';
2
2
 
3
3
  export default dayjs;
4
+
5
+ /**
6
+ * Time unit type definition
7
+ */
8
+ export type TimeUnit = 'hour' | 'day' | 'week' | 'month' | 'year';
9
+
10
+ /**
11
+ * Format options interface
12
+ */
13
+ interface FormatDurationOptions {
14
+ t: (key: string, options?: Record<string, any>) => string;
15
+ pluralSuffix?: string;
16
+ separator?: string;
17
+ }
18
+
19
+ /**
20
+ * Conversion rule type
21
+ */
22
+ export type ConversionRule = {
23
+ threshold: number;
24
+ convert: (value: number) => { main: number; remainder: number };
25
+ format: (main: number, remainder: number, value: number) => Array<[TimeUnit, number]>;
26
+ };
27
+ /**
28
+ * Smart duration formatter optimized for subscription periods
29
+ *
30
+ * Display Rules:
31
+ * 1. Hours:
32
+ * - < 24h: show hours (e.g., "5 hours")
33
+ * - 24-47h: show days and hours (e.g., "1 day 5 hours")
34
+ * - >= 48h: convert to days
35
+ *
36
+ * 2. Days:
37
+ * - < 7 days: show days (e.g., "5 days")
38
+ * - 7-13 days: show 1 week + days (e.g., "1 week 3 days")
39
+ * - 14+ days: show weeks if divisible, else days (e.g., "2 weeks" or "15 days")
40
+ * - 30+ days: show months if divisible, else weeks/days (e.g., "1 month" or "5 weeks")
41
+ *
42
+ * 3. Weeks:
43
+ * - < 4 weeks: show weeks (e.g., "3 weeks")
44
+ * - 4-7 weeks: show 1 month + weeks (e.g., "1 month 2 weeks")
45
+ * - 8+ weeks: show months if divisible, else weeks (e.g., "2 months" or "9 weeks")
46
+ *
47
+ * 4. Months:
48
+ * - < 12: show months (e.g., "6 months")
49
+ * - 12+: show years if divisible, else months (e.g., "1 year" or "15 months")
50
+ */
51
+ export const formatSmartDuration = (
52
+ value: number,
53
+ unit: TimeUnit,
54
+ { t, separator = ' ' }: FormatDurationOptions
55
+ ): string => {
56
+ // Format single unit
57
+ const formatUnit = (val: number, unitType: TimeUnit): string =>
58
+ `${val} ${t(`common.${unitType}${val > 1 ? 's' : ''}`).toLowerCase()}`;
59
+
60
+ // Convert to largest possible unit
61
+ const convertToLargest = (val: number, fromUnit: TimeUnit): [TimeUnit, number][] => {
62
+ switch (fromUnit) {
63
+ case 'hour': {
64
+ if (val < 24) return [['hour', val]];
65
+ if (val < 48)
66
+ return [
67
+ ['day', Math.floor(val / 24)],
68
+ ['hour', val % 24],
69
+ ];
70
+ const days = Math.floor(val / 24);
71
+ return convertToLargest(days, 'day');
72
+ }
73
+ case 'day': {
74
+ if (val < 7) return [['day', val]];
75
+ if (val < 14)
76
+ return [
77
+ ['week', Math.floor(val / 7)],
78
+ ['day', val % 7],
79
+ ];
80
+ if (val < 30) return val % 7 === 0 ? [['week', val / 7]] : [['day', val]];
81
+ const years = Math.floor(val / 365);
82
+ const remainingDays = val % 365;
83
+ const months = Math.floor(remainingDays / 30);
84
+ if (years > 0) {
85
+ return months > 0
86
+ ? [
87
+ ['year', years],
88
+ ['month', months],
89
+ ]
90
+ : [['year', years]];
91
+ }
92
+ return months > 0
93
+ ? [
94
+ ['month', months],
95
+ ['week', Math.floor((remainingDays % 30) / 7)],
96
+ ]
97
+ : [['week', Math.floor(val / 7)]];
98
+ }
99
+ case 'week': {
100
+ if (val < 4) return [['week', val]];
101
+ if (val < 8)
102
+ return [
103
+ ['month', Math.floor(val / 4)],
104
+ ['week', val % 4],
105
+ ];
106
+ const months = Math.floor(val / 4);
107
+ return convertToLargest(months, 'month');
108
+ }
109
+ case 'month': {
110
+ const years = Math.floor(val / 12);
111
+ const months = val % 12;
112
+ if (years > 0) {
113
+ if (months > 0) {
114
+ return [
115
+ ['year', years],
116
+ ['month', months],
117
+ ];
118
+ }
119
+ return [['year', years]];
120
+ }
121
+ return [['month', val]];
122
+ }
123
+ case 'year':
124
+ return [['year', val]];
125
+ default:
126
+ return [[fromUnit, val]];
127
+ }
128
+ };
129
+
130
+ // Get units and filter out zero values
131
+ const units = convertToLargest(value, unit).filter(([, val]) => val > 0);
132
+
133
+ // Format all units
134
+ return units.map(([u, val]) => formatUnit(val, u)).join(separator);
135
+ };
@@ -25,6 +25,7 @@ export default flat({
25
25
  rechargeTime: 'Recharge Time',
26
26
  submit: 'Submit',
27
27
  custom: 'Custom',
28
+ estimatedDuration: '{duration} est.',
28
29
  },
29
30
  admin: {
30
31
  balances: 'Balances',
@@ -456,6 +457,9 @@ export default flat({
456
457
  resume: 'Resume payment collection',
457
458
  resumeTip:
458
459
  'Are you sure you want to resume collecting payments? Any future invoices for this subscription will resume payment collection.',
460
+ paymentAddress: 'Payment Address',
461
+ currentBalance: 'Current Balance',
462
+ insufficientBalance: 'Insufficient Balance, please add funds',
459
463
  cancel: {
460
464
  schedule: 'Scheduled to cancel',
461
465
  title: 'Cancel subscription',
@@ -648,38 +652,41 @@ export default flat({
648
652
  error: 'Delegate failed',
649
653
  },
650
654
  overdraftProtection: {
651
- title: 'Overdraft Protection',
652
- setting: 'Overdraft Protection Setting',
653
- tip: 'To avoid service interruption due to unpaid invoices, you can enable overdraft protection by staking. Timely payment will not incur additional fees. Please settle your invoices promptly. If your available stake is insufficient or payment is overdue, we will deduct the amount from your stake and charge a service fee.',
655
+ title: 'SubGuard',
656
+ setting: 'Set SubGuard',
657
+ tip: 'To avoid service interruption due to unpaid invoices, you can enable SubGuard by staking. Timely payment will not incur additional fees. Please settle your invoices promptly. If your available stake is insufficient or payment is overdue, we will deduct the amount from your stake and charge a service fee.',
654
658
  enabled: 'Enabled',
655
659
  disabled: 'Disabled',
656
660
  returnRemaining: 'Return Remaining Stake',
657
661
  returnRemainingTip:
658
- 'Once the remaining stake is returned, the overdraft protection will be automatically disabled. Please confirm the action.',
662
+ 'Once the remaining stake is returned, the SubGuard will be automatically disabled. Please confirm the action.',
659
663
  applyRemainingSuccess: 'Stake return application successful',
660
664
  remaining:
661
665
  'Your current remaining stake: {amount} {symbol}, estimated required stake per cycle: {estimateAmount} {symbol}.',
662
666
  noRemaining:
663
- 'No remaining stake available. Please stake at least {estimateAmount} {symbol} as soon as possible to ensure overdraft protection is enabled.',
667
+ 'No remaining stake available. Please stake at least {estimateAmount} {symbol} as soon as possible to ensure SubGuard is enabled.',
664
668
  remainingNotEnough:
665
669
  'You have unpaid invoices totaling {due} {symbol}. If not paid, your remaining stake will be insufficient to cover the next invoice. Available stake: {unused} {symbol}. Please stake at least {min} {symbol}.',
666
670
  due: 'Please pay the outstanding amount first',
667
- insufficient: 'Insufficient Stake to cover the next invoice',
668
- insufficientTip: 'Insufficient Stake, please stake to ensure overdraft protection is enabled.',
671
+ insufficient: 'Insufficient Stake to cover the next invoice, please add stake',
672
+ insufficientTip: 'Insufficient Stake, please stake to ensure SubGuard is enabled.',
669
673
  intervals: 'cycles',
670
674
  estimatedDuration: '{duration} {unit} est.',
671
675
  rule: 'Rule: N * ( P + Fee )',
672
676
  ruleTip:
673
- 'N is the number of cycles, P is the subscription bill amount, Fee is the overdraft protection service fee, the single fee is {gas} {symbol}',
677
+ 'N is the number of cycles, P is the subscription bill amount, Fee is the SubGuard service fee, the single fee is {gas} {symbol}',
674
678
  min: 'The amount must be greater or equal to {min} {symbol}',
675
- settingSuccess: 'Overdraft protection setting successful',
676
- settingError: 'Overdraft protection setting failed',
677
- keepStake: 'Not Return',
679
+ settingSuccess: 'Set SubGuard Successful',
680
+ settingError: 'Set SubGuard Failed',
681
+ keepStake: 'Keep Remaining Stake For SubGuard',
678
682
  returnStake: 'Return Remaining Stake',
679
683
  stake: 'Stake',
680
684
  address: 'Staking Address',
681
685
  total: 'Total Stake: {total} {symbol}, ',
682
686
  disableConfirm: 'You currently have unpaid invoices, please settle your invoices first.',
687
+ open: 'Enable SubGuard',
688
+ payerAddress: 'Payer',
689
+ stakingAddress: 'Staking Address',
683
690
  },
684
691
  unpaidInvoicesWarning: 'You currently have unpaid invoices, please settle your invoices first.',
685
692
  unpaidInvoicesWarningTip: 'You currently have unpaid invoices, please settle your invoices promptly.',
@@ -24,6 +24,7 @@ export default flat({
24
24
  rechargeTime: '充值时间',
25
25
  submit: '提交',
26
26
  custom: '自定义',
27
+ estimatedDuration: '预计可用 {duration}',
27
28
  },
28
29
  admin: {
29
30
  balances: '余额',
@@ -445,6 +446,9 @@ export default flat({
445
446
  update: '更新订阅',
446
447
  resume: '恢复付款',
447
448
  resumeTip: '您确定要继续收款吗?此订阅的未来账单将继续付款。',
449
+ paymentAddress: '扣费地址',
450
+ currentBalance: '扣款账户余额',
451
+ insufficientBalance: '余额不足,立即充值',
448
452
  cancel: {
449
453
  schedule: '计划取消',
450
454
  title: '取消订阅',
@@ -634,34 +638,37 @@ export default flat({
634
638
  error: '授权失败',
635
639
  },
636
640
  overdraftProtection: {
637
- title: '透支保护',
638
- setting: '透支保护设置',
639
- tip: '为避免因扣费失败中断服务,您可以通过质押开启透支保护。按时付款不会收取额外费用,请及时付清账单,若可用质押不足或者超期未付,我们将从质押中扣除并收取服务费',
641
+ title: '订阅守护',
642
+ setting: '配置订阅守护',
643
+ tip: '为避免因扣费失败中断服务,您可以通过质押开启订阅守护。按时付款不会收取额外费用,请及时付清账单,若可用质押不足或者超期未付,我们将从质押中扣除并收取服务费',
640
644
  enabled: '已启用',
641
645
  disabled: '未启用',
642
646
  returnRemaining: '退还剩余质押',
643
- returnRemainingTip: '退还剩余质押后,透支保护将自动关闭,请确认操作。',
647
+ returnRemainingTip: '退还剩余质押后,订阅守护服务将自动关闭,请确认操作。',
644
648
  applyRemainingSuccess: '质押退还申请成功',
645
649
  remaining: '您当前剩余可用质押:{amount} {symbol}, 每周期预计需质押:{estimateAmount} {symbol}。',
646
- noRemaining: '当前无质押,为确保透支保护功能的正常使用,请至少质押 {estimateAmount} {symbol}。',
650
+ noRemaining: '当前无质押,为确保订阅守护服务的正常使用,请至少质押 {estimateAmount} {symbol}。',
647
651
  remainingNotEnough:
648
652
  '当前存在未支付的账单,总计 {due} {symbol},如果不支付,您当前剩余质押将无法覆盖下期账单,剩余可用质押:{unused} {symbol},请质押至少 {min} {symbol}。',
649
653
  due: '请先支付欠款',
650
- insufficient: '额度不足,下期账单将无法使用透支保护',
651
- insufficientTip: '透支保护额度不足,请尽快质押保证透支保护功能的正常使用。',
654
+ insufficient: '额度不足,下期账单将无法使用订阅守护服务, 请添加额度',
655
+ insufficientTip: '订阅守护服务额度不足,请尽快质押保证订阅守护服务的正常使用。',
652
656
  intervals: '个周期',
653
657
  estimatedDuration: '预计可用 {duration} {unit}',
654
658
  rule: '规则:N * ( P + Fee )',
655
- ruleTip: 'N 为周期数, P 为订阅账单费用, Fee 为透支保护服务费用,单次费用为 {gas} {symbol}',
659
+ ruleTip: 'N 为周期数, P 为订阅账单费用, Fee 为订阅守护服务费用,单次费用为 {gas} {symbol}',
656
660
  min: '质押金额不得小于 {min} {symbol}',
657
- settingSuccess: '透支保护设置成功',
658
- settingError: '透支保护设置失败',
661
+ settingSuccess: '订阅守护配置成功',
662
+ settingError: '订阅守护配置失败',
659
663
  keepStake: '不退还质押',
660
664
  returnStake: '退还剩余质押',
661
665
  stake: '质押',
662
666
  address: '质押账户',
663
667
  total: '总质押:{total} {symbol},',
664
668
  disableConfirm: '您当前有未支付的账单,请先付清账单。',
669
+ open: '开启订阅守护',
670
+ payerAddress: '付款账户',
671
+ stakingAddress: '质押地址',
665
672
  },
666
673
  unpaidInvoicesWarning: '您当前有未支付的账单,请先付清账单。',
667
674
  unpaidInvoicesWarningTip: '您当前有未支付的账单,请及时付清。',
@@ -156,7 +156,7 @@ export default function SubscriptionDetail(props: { id: string }) {
156
156
  md: 3,
157
157
  },
158
158
  }}>
159
- <SubscriptionMetrics subscription={data} />
159
+ <SubscriptionMetrics subscription={data} showBalance={false} />
160
160
  </Stack>
161
161
  </Box>
162
162
  <Divider />
@@ -38,6 +38,7 @@ import SubscriptionMetrics from '../../components/subscription/metrics';
38
38
  import { goBackOrFallback } from '../../libs/util';
39
39
  import CustomerLink from '../../components/customer/link';
40
40
  import { useSessionContext } from '../../contexts/session';
41
+ import { formatSmartDuration, TimeUnit } from '../../libs/dayjs';
41
42
 
42
43
  const Root = styled(Stack)(({ theme }) => ({
43
44
  marginBottom: theme.spacing(3),
@@ -73,6 +74,7 @@ export default function RechargePage() {
73
74
  const [customAmount, setCustomAmount] = useState(false);
74
75
  const [presetAmounts, setPresetAmounts] = useState<Array<{ amount: string; cycles: number }>>([]);
75
76
  const { session } = useSessionContext();
77
+ const [cycleAmount, setCycleAmount] = useState('0');
76
78
 
77
79
  const {
78
80
  paymentCurrency,
@@ -116,6 +118,11 @@ export default function RechargePage() {
116
118
  { amount: getCycleAmount(5), cycles: 5 },
117
119
  { amount: getCycleAmount(10), cycles: 10 },
118
120
  ]);
121
+
122
+ setCycleAmount(fromUnitToToken(upcomingRes.data.amount || '0', upcomingRes.data?.currency?.decimal) || '0');
123
+ if (!amount && !customAmount) {
124
+ handleSelect(getCycleAmount(10));
125
+ }
119
126
  } catch (err) {
120
127
  setError(formatError(err) || t('common.fetchError'));
121
128
  console.error(err);
@@ -124,19 +131,36 @@ export default function RechargePage() {
124
131
  }
125
132
  };
126
133
 
134
+ const rechargeRef = useRef<HTMLDivElement>(null);
135
+
127
136
  useEffect(() => {
128
137
  fetchData();
129
138
  }, [subscriptionId]);
130
139
 
140
+ useEffect(() => {
141
+ if (rechargeRef.current && subscription) {
142
+ setTimeout(() => {
143
+ // @ts-ignore
144
+ rechargeRef.current?.scrollIntoView({
145
+ behavior: 'smooth',
146
+ });
147
+ }, 200);
148
+ }
149
+ }, [subscription]);
150
+
131
151
  const handleRecharge = () => {
132
152
  if (!subscription) return;
133
153
 
154
+ if (Number.isNaN(Number(amount))) {
155
+ return;
156
+ }
157
+
134
158
  connect.open({
135
159
  containerEl: undefined as unknown as Element,
136
160
  saveConnect: false,
137
161
  action: 'recharge',
138
162
  prefix: joinURL(getPrefix(), '/api/did'),
139
- extraParams: { subscriptionId, amount },
163
+ extraParams: { subscriptionId, amount: Number(amount) },
140
164
  onSuccess: () => {
141
165
  connect.close();
142
166
  Toast.success(t('customer.recharge.success'));
@@ -154,7 +178,9 @@ export default function RechargePage() {
154
178
  const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
155
179
  const { value } = e.target;
156
180
  if (!subscription) return;
157
-
181
+ if (!/^\d*\.?\d*$/.test(value)) return;
182
+ // 不允许以小数点开头
183
+ if (value.startsWith('.')) return;
158
184
  const precision = subscription.paymentCurrency.maximum_precision || 6;
159
185
  const errorMessage = formatAmountPrecisionLimit(value, locale, precision);
160
186
  setAmountError(errorMessage || '');
@@ -198,13 +224,16 @@ export default function RechargePage() {
198
224
  const totalIntervals = cycles * intervalCount;
199
225
  const availableUnitKeys = ['hour', 'day', 'week', 'month', 'year'];
200
226
 
201
- const unitKey = availableUnitKeys.includes(interval)
202
- ? `common.${interval}${totalIntervals > 1 ? 's' : ''}`
203
- : 'customer.recharge.intervals';
204
-
205
- return t('customer.recharge.estimatedDuration', {
206
- duration: totalIntervals,
207
- unit: t(unitKey).toLowerCase(),
227
+ if (!availableUnitKeys.includes(interval)) {
228
+ return t('customer.recharge.estimatedDuration', {
229
+ duration: totalIntervals,
230
+ unit: t('customer.recharge.intervals').toLowerCase(),
231
+ });
232
+ }
233
+ return t('common.estimatedDuration', {
234
+ duration: formatSmartDuration(totalIntervals, interval as TimeUnit, {
235
+ t,
236
+ }),
208
237
  });
209
238
  };
210
239
 
@@ -248,7 +277,7 @@ export default function RechargePage() {
248
277
  alignItems: { xs: 'flex-start', sm: 'flex-start', md: 'center' },
249
278
  gap: { xs: 1, sm: 1, md: 3 },
250
279
  }}>
251
- <SubscriptionMetrics subscription={subscription} />
280
+ <SubscriptionMetrics subscription={subscription} showBalance={false} />
252
281
  </Stack>
253
282
  </Box>
254
283
 
@@ -292,7 +321,7 @@ export default function RechargePage() {
292
321
  </Box>
293
322
  <Divider />
294
323
 
295
- <Box sx={{ maxWidth: 600 }}>
324
+ <Box sx={{ maxWidth: 600 }} ref={rechargeRef}>
296
325
  <Typography variant="h2" gutterBottom>
297
326
  {t('customer.recharge.title')}
298
327
  </Typography>
@@ -389,22 +418,29 @@ export default function RechargePage() {
389
418
  </Paper>
390
419
 
391
420
  {customAmount && (
392
- <TextField
393
- fullWidth
394
- label={t('customer.recharge.amount')}
395
- variant="outlined"
396
- type="number"
397
- value={amount}
398
- error={!!amountError}
399
- helperText={amountError}
400
- onChange={handleAmountChange}
401
- InputProps={{
402
- endAdornment: <Typography>{subscription.paymentCurrency.symbol}</Typography>,
403
- autoComplete: 'off',
404
- }}
405
- sx={{ mt: 2, mb: 2 }}
406
- inputRef={customInputRef}
407
- />
421
+ <Box>
422
+ <TextField
423
+ fullWidth
424
+ label={t('customer.recharge.amount')}
425
+ variant="outlined"
426
+ type="text"
427
+ value={amount}
428
+ error={!!amountError}
429
+ helperText={amountError}
430
+ onChange={handleAmountChange}
431
+ InputProps={{
432
+ endAdornment: <Typography>{subscription.paymentCurrency.symbol}</Typography>,
433
+ autoComplete: 'off',
434
+ }}
435
+ sx={{ mt: 1 }}
436
+ inputRef={customInputRef}
437
+ />
438
+ {amount && Number(amount) > 0 && Number(cycleAmount) > 0 && !amountError && (
439
+ <Typography variant="body2" sx={{ color: 'text.lighter', mt: '8px !important' }} fontSize={12}>
440
+ {formatEstimatedDuration(Math.floor(Number(amount) / Number(cycleAmount)))}
441
+ </Typography>
442
+ )}
443
+ </Box>
408
444
  )}
409
445
 
410
446
  <Button
@@ -1,24 +1,34 @@
1
1
  /* eslint-disable react/no-unstable-nested-components */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { CustomerInvoiceList, TxLink, api, formatTime, hasDelegateTxHash, useMobile } from '@blocklet/payment-react';
4
- import type { TSubscriptionExpanded } from '@blocklet/payment-types';
5
- import { ArrowBackOutlined } from '@mui/icons-material';
6
- import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
3
+ import {
4
+ CustomerInvoiceList,
5
+ TxLink,
6
+ api,
7
+ formatBNStr,
8
+ formatTime,
9
+ hasDelegateTxHash,
10
+ useMobile,
11
+ } from '@blocklet/payment-react';
12
+ import type { TPaymentCurrency, TSubscriptionExpanded } from '@blocklet/payment-types';
13
+ import { ArrowBackOutlined, CheckCircle } from '@mui/icons-material';
14
+ import { Alert, Avatar, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
7
15
  import { useRequest } from 'ahooks';
8
16
  import { Link, useNavigate, useParams } from 'react-router-dom';
9
17
  import { styled } from '@mui/system';
10
- import { BN } from '@ocap/util';
18
+ import { BN, fromUnitToToken } from '@ocap/util';
19
+ import { useCallback, useRef } from 'react';
11
20
  import Currency from '../../../components/currency';
12
21
  import CustomerLink from '../../../components/customer/link';
13
22
  import InfoRow from '../../../components/info-row';
14
23
  import SubscriptionDescription from '../../../components/subscription/description';
15
24
  import SubscriptionItemList from '../../../components/subscription/items';
16
25
  import SubscriptionMetrics from '../../../components/subscription/metrics';
17
- import SubscriptionActions from '../../../components/subscription/portal/actions';
26
+ import SubscriptionActions, { ActionMethods } from '../../../components/subscription/portal/actions';
18
27
  import { canChangePaymentMethod } from '../../../libs/util';
19
28
  import { useSessionContext } from '../../../contexts/session';
20
29
  import InfoMetric from '../../../components/info-metric';
21
30
  import { useUnpaidInvoicesCheckForSubscription } from '../../../hooks/subscription';
31
+ import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
22
32
 
23
33
  const fetchData = (id: string | undefined): Promise<TSubscriptionExpanded> => {
24
34
  return api.get(`/api/subscriptions/${id}`).then((res) => res.data);
@@ -29,6 +39,14 @@ const fetchOverdraftProtection = (
29
39
  ): Promise<{ enabled: boolean; remaining: string; unused: string; upcoming: { amount: string }; gas: string }> => {
30
40
  return api.get(`/api/subscriptions/${id}/overdraft-protection`).then((res) => res.data);
31
41
  };
42
+
43
+ const fetchCycleAmount = (
44
+ subscriptionId: string,
45
+ params: { overdraftProtection: boolean }
46
+ ): Promise<{ amount: string; gas: string; currency: TPaymentCurrency }> => {
47
+ return api.get(`/api/subscriptions/${subscriptionId}/cycle-amount`, { params }).then((res) => res.data);
48
+ };
49
+
32
50
  const InfoDirection = 'column';
33
51
  const InfoAlignItems = 'flex-start';
34
52
 
@@ -48,8 +66,29 @@ export default function CustomerSubscriptionDetail() {
48
66
  ready: ['active', 'trialing', 'past_due'].includes(data?.status || ''),
49
67
  });
50
68
 
69
+ const {
70
+ data: cycleAmount = {
71
+ amount: '0',
72
+ gas: '0',
73
+ },
74
+ } = useRequest(
75
+ () =>
76
+ fetchCycleAmount(id, {
77
+ overdraftProtection: true,
78
+ }),
79
+ {
80
+ refreshDeps: [id],
81
+ }
82
+ );
83
+
84
+ const actionRef = useRef<ActionMethods>();
85
+
51
86
  const enableOverdraftProtection = !!overdraftProtection?.enabled;
52
87
 
88
+ const actionSetUp = useCallback((methods: ActionMethods) => {
89
+ actionRef.current = methods;
90
+ }, []);
91
+
53
92
  if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
54
93
  return <Alert severity="error">You do not have permission to access other customer data</Alert>;
55
94
  }
@@ -75,13 +114,116 @@ export default function CustomerSubscriptionDetail() {
75
114
  new BN(overdraftProtection.unused).lt(
76
115
  new BN(overdraftProtection.upcoming?.amount).add(new BN(overdraftProtection.gas))
77
116
  );
117
+ const estimateAmount = +fromUnitToToken(cycleAmount.amount, data.paymentCurrency?.decimal);
118
+ const remainingStake = +fromUnitToToken(overdraftProtection?.unused, data.paymentCurrency?.decimal);
119
+ const formatEstimatedDuration = (cycles: number) => {
120
+ if (!data?.pending_invoice_item_interval) return '';
121
+ const { interval, interval_count: intervalCount } = data.pending_invoice_item_interval;
122
+ const totalIntervals = cycles * intervalCount;
123
+ const availableUnitKeys = ['hour', 'day', 'week', 'month', 'year'];
124
+
125
+ if (!availableUnitKeys.includes(interval)) {
126
+ return t('customer.overdraftProtection.estimatedDuration', {
127
+ duration: totalIntervals,
128
+ unit: t('customer.overdraftProtection.intervals').toLowerCase(),
129
+ });
130
+ }
131
+ return t('common.estimatedDuration', {
132
+ duration: formatSmartDuration(totalIntervals, interval as TimeUnit, {
133
+ t,
134
+ }),
135
+ });
136
+ };
78
137
  if (!enabled) {
79
- return t('customer.overdraftProtection.disabled');
138
+ return (
139
+ <Stack direction="row" spacing={1} alignItems="center">
140
+ <Typography sx={{ color: 'text.lighter' }}>{t('customer.overdraftProtection.disabled')}</Typography>
141
+ <Button
142
+ size="small"
143
+ sx={{
144
+ fontSize: '13px',
145
+ color: 'text.link',
146
+ '&:hover': { backgroundColor: 'primary.lighter' },
147
+ }}
148
+ onClick={() =>
149
+ actionRef.current?.openOverdraftProtection({
150
+ enabled: true,
151
+ })
152
+ }>
153
+ {t('customer.overdraftProtection.open')}
154
+ </Button>
155
+ </Stack>
156
+ );
80
157
  }
158
+
81
159
  if (enabled && insufficient) {
82
- return <Typography color="error">{t('customer.overdraftProtection.insufficient')}</Typography>;
160
+ return (
161
+ <Button
162
+ size="small"
163
+ color="error"
164
+ sx={{
165
+ fontSize: '12px',
166
+ textAlign: 'left',
167
+ }}
168
+ onClick={() =>
169
+ actionRef.current?.openOverdraftProtection({
170
+ enabled: true,
171
+ })
172
+ }>
173
+ {t('customer.overdraftProtection.insufficient')}
174
+ </Button>
175
+ );
83
176
  }
84
- return t('customer.overdraftProtection.enabled');
177
+
178
+ return (
179
+ <Stack direction="row" spacing={1} alignItems="center">
180
+ <Stack direction="row" spacing={0.5} alignItems="center">
181
+ <CheckCircle
182
+ sx={{
183
+ fontSize: '16px',
184
+ color: 'success.main',
185
+ verticalAlign: 'middle',
186
+ }}
187
+ />
188
+ <Typography
189
+ sx={{
190
+ color: 'success.main',
191
+ fontWeight: 500,
192
+ }}>
193
+ {t('customer.overdraftProtection.enabled')}
194
+ </Typography>
195
+ </Stack>
196
+ <Divider
197
+ orientation="vertical"
198
+ flexItem
199
+ sx={{
200
+ mx: 1,
201
+ borderColor: 'divider',
202
+ }}
203
+ />
204
+ <Typography
205
+ sx={{
206
+ color: 'text.primary',
207
+ display: 'flex',
208
+ alignItems: 'center',
209
+ gap: 0.5,
210
+ fontWeight: 500,
211
+ }}>
212
+ <Avatar src={data.paymentCurrency?.logo} sx={{ width: 16, height: 16 }} alt={data.paymentCurrency?.name} />
213
+ <Box display="flex" alignItems="baseline">
214
+ {formatBNStr(overdraftProtection?.unused, data.paymentCurrency.decimal)}
215
+ <Typography
216
+ sx={{
217
+ color: 'text.secondary',
218
+ fontSize: '14px',
219
+ ml: 0.5,
220
+ }}>
221
+ {data.paymentCurrency.symbol}({formatEstimatedDuration(Math.ceil(remainingStake / estimateAmount))})
222
+ </Typography>
223
+ </Box>
224
+ </Typography>
225
+ </Stack>
226
+ );
85
227
  };
86
228
 
87
229
  return (
@@ -119,7 +261,7 @@ export default function CustomerSubscriptionDetail() {
119
261
  onChange: () => refreshOverdraftProtection(),
120
262
  }}
121
263
  showRecharge
122
- mode={isMobile ? 'menu' : 'btn'}
264
+ mode={isMobile ? 'menu-only' : 'primary-buttons'}
123
265
  actionProps={{
124
266
  cancel: {
125
267
  variant: 'outlined',
@@ -134,6 +276,7 @@ export default function CustomerSubscriptionDetail() {
134
276
  color: 'primary',
135
277
  },
136
278
  }}
279
+ setUp={actionSetUp}
137
280
  />
138
281
  </Stack>
139
282
  </Stack>