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/api/src/libs/env.ts +0 -2
- package/api/src/libs/overdraft-protection.ts +4 -1
- package/api/src/libs/subscription.ts +1 -1
- package/api/src/locales/en.ts +2 -2
- package/api/src/locales/zh.ts +2 -2
- package/api/src/queues/notification.ts +1 -1
- package/api/src/queues/subscription.ts +1 -1
- package/api/src/routes/connect/overdraft-protection.ts +4 -1
- package/api/src/routes/connect/shared.ts +12 -7
- package/api/src/routes/invoices.ts +21 -5
- package/api/src/routes/subscriptions.ts +1 -1
- package/blocklet.yml +1 -1
- package/package.json +15 -15
- package/src/components/customer/overdraft-protection.tsx +132 -18
- package/src/components/subscription/metrics.tsx +83 -7
- package/src/components/subscription/portal/actions.tsx +188 -153
- package/src/components/subscription/portal/list.tsx +1 -0
- package/src/libs/dayjs.ts +132 -0
- package/src/locales/en.tsx +18 -11
- package/src/locales/zh.tsx +17 -10
- package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
- package/src/pages/customer/recharge.tsx +63 -27
- package/src/pages/customer/subscription/detail.tsx +153 -10
- package/src/pages/customer/subscription/embed.tsx +2 -1
- /package/api/src/libs/notification/template/{subscription.overdraft-protection.exhausted.ts → subscription-overdraft-protection-exhausted.ts} +0 -0
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
|
+
};
|
package/src/locales/en.tsx
CHANGED
|
@@ -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: '
|
|
652
|
-
setting: '
|
|
653
|
-
tip: 'To avoid service interruption due to unpaid invoices, you can enable
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
676
|
-
settingError: '
|
|
677
|
-
keepStake: '
|
|
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.',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -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: '
|
|
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
|
|
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: '您当前有未支付的账单,请及时付清。',
|
|
@@ -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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
<
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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' : '
|
|
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>
|