payment-kit 1.13.92 → 1.13.94
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/index.ts +2 -0
- package/api/src/libs/audit.ts +28 -34
- package/api/src/libs/payment.ts +2 -11
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/util.ts +8 -5
- package/api/src/routes/checkout-sessions.ts +41 -39
- package/api/src/routes/connect/collect.ts +12 -12
- package/api/src/routes/connect/setup.ts +8 -11
- package/api/src/routes/connect/shared.ts +81 -20
- package/api/src/routes/connect/subscribe.ts +8 -11
- package/api/src/routes/connect/update.ts +134 -0
- package/api/src/routes/pricing-table.ts +9 -121
- package/api/src/routes/subscriptions.ts +417 -142
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/pricing-table.ts +125 -1
- package/api/src/store/models/subscription.ts +4 -0
- package/api/src/store/models/types.ts +8 -0
- package/api/tests/libs/util.spec.ts +6 -6
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/app.tsx +12 -4
- package/src/components/checkout/form/address.tsx +41 -34
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/checkout/pricing-table.tsx +205 -0
- package/src/components/payment-link/product-select.tsx +13 -3
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/actions.tsx +153 -0
- package/src/components/portal/subscription/list.tsx +21 -150
- package/src/components/subscription/metrics.tsx +46 -0
- package/src/contexts/products.tsx +2 -1
- package/src/libs/util.ts +43 -0
- package/src/locales/en.tsx +15 -1
- package/src/locales/zh.tsx +16 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -34
- package/src/pages/checkout/pricing-table.tsx +9 -158
- package/src/pages/customer/subscription/{index.tsx → detail.tsx} +6 -36
- package/src/pages/customer/subscription/update.tsx +281 -0
|
@@ -2,32 +2,15 @@ import Center from '@arcblock/ux/lib/Center';
|
|
|
2
2
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
3
|
import Toast from '@arcblock/ux/lib/Toast';
|
|
4
4
|
import Header from '@blocklet/ui-react/lib/Header';
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
Alert,
|
|
10
|
-
Box,
|
|
11
|
-
Fade,
|
|
12
|
-
List,
|
|
13
|
-
ListItem,
|
|
14
|
-
ListItemIcon,
|
|
15
|
-
ListItemText,
|
|
16
|
-
Skeleton,
|
|
17
|
-
Stack,
|
|
18
|
-
ToggleButton,
|
|
19
|
-
ToggleButtonGroup,
|
|
20
|
-
Typography,
|
|
21
|
-
} from '@mui/material';
|
|
22
|
-
import { useLocalStorageState, useRequest, useSetState } from 'ahooks';
|
|
23
|
-
import { useEffect } from 'react';
|
|
5
|
+
import type { TPricingTableExpanded } from '@did-pay/types';
|
|
6
|
+
import { Alert, Box, Skeleton, Stack, Typography } from '@mui/material';
|
|
7
|
+
import { useLocalStorageState, useRequest } from 'ahooks';
|
|
24
8
|
import { useSearchParams } from 'react-router-dom';
|
|
25
9
|
|
|
26
|
-
import
|
|
10
|
+
import PricingTable from '../../components/checkout/pricing-table';
|
|
27
11
|
import Livemode from '../../components/livemode';
|
|
28
12
|
import ProductSkeleton from '../../components/pricing-table/product-skeleton';
|
|
29
13
|
import api from '../../libs/api';
|
|
30
|
-
import { formatPriceAmount, formatRecurring } from '../../libs/util';
|
|
31
14
|
|
|
32
15
|
type Props = {
|
|
33
16
|
id: string;
|
|
@@ -38,42 +21,11 @@ const fetchData = async (id: string): Promise<TPricingTableExpanded> => {
|
|
|
38
21
|
return data;
|
|
39
22
|
};
|
|
40
23
|
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const recurring: { [key: string]: PriceRecurring } = {};
|
|
44
|
-
|
|
45
|
-
items.forEach((x) => {
|
|
46
|
-
const key = [x.price.recurring?.interval, x.price.recurring?.interval_count].join('-');
|
|
47
|
-
recurring[key] = x.price.recurring as PriceRecurring;
|
|
48
|
-
|
|
49
|
-
if (!grouped[key]) {
|
|
50
|
-
grouped[key] = [];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// @ts-ignore
|
|
54
|
-
grouped[key].push(x);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
return { recurring, grouped };
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export default function PricingTable({ id }: Props) {
|
|
61
|
-
const { t, locale } = useLocaleContext();
|
|
24
|
+
export default function PricingTablePage({ id }: Props) {
|
|
25
|
+
const { t } = useLocaleContext();
|
|
62
26
|
const [params] = useSearchParams();
|
|
63
|
-
const { error, loading, data } = useRequest(() => fetchData(id));
|
|
64
|
-
const [state, setState] = useSetState({ interval: '', loading: '' });
|
|
65
27
|
const [livemode] = useLocalStorageState('livemode', { defaultValue: true });
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (data && !state.interval) {
|
|
69
|
-
const { recurring } = groupItemsByRecurring(data.items);
|
|
70
|
-
const keys = Object.keys(recurring);
|
|
71
|
-
if (keys[0]) {
|
|
72
|
-
setState({ interval: keys[0] });
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
-
}, [data]);
|
|
28
|
+
const { error, loading, data } = useRequest(() => fetchData(id));
|
|
77
29
|
|
|
78
30
|
if (error) {
|
|
79
31
|
return (
|
|
@@ -141,10 +93,7 @@ export default function PricingTable({ id }: Props) {
|
|
|
141
93
|
);
|
|
142
94
|
}
|
|
143
95
|
|
|
144
|
-
const { recurring, grouped } = groupItemsByRecurring(data.items);
|
|
145
|
-
|
|
146
96
|
const onStartCheckoutSession = (priceId: string) => {
|
|
147
|
-
setState({ loading: priceId });
|
|
148
97
|
api
|
|
149
98
|
.post(`/api/pricing-tables/${data.id}/checkout/${priceId}?${params.toString()}`)
|
|
150
99
|
.then((res) => {
|
|
@@ -153,7 +102,6 @@ export default function PricingTable({ id }: Props) {
|
|
|
153
102
|
.catch((err) => {
|
|
154
103
|
console.error(err);
|
|
155
104
|
Toast.error(err.message);
|
|
156
|
-
setState({ loading: '' });
|
|
157
105
|
});
|
|
158
106
|
};
|
|
159
107
|
|
|
@@ -171,109 +119,12 @@ export default function PricingTable({ id }: Props) {
|
|
|
171
119
|
}}>
|
|
172
120
|
<Header />
|
|
173
121
|
<Center relative="parent">
|
|
174
|
-
<Stack
|
|
175
|
-
direction="column"
|
|
176
|
-
alignItems="center"
|
|
177
|
-
sx={{
|
|
178
|
-
pt: {
|
|
179
|
-
xs: 4,
|
|
180
|
-
sm: 2,
|
|
181
|
-
},
|
|
182
|
-
gap: {
|
|
183
|
-
xs: 3,
|
|
184
|
-
sm: 5,
|
|
185
|
-
},
|
|
186
|
-
}}>
|
|
122
|
+
<Stack direction="column" alignItems="center" spacing={4}>
|
|
187
123
|
<Typography variant="h4" color="text.primary" fontWeight={600}>
|
|
188
124
|
{data.name}
|
|
189
125
|
{!livemode && <Livemode />}
|
|
190
126
|
</Typography>
|
|
191
|
-
{
|
|
192
|
-
<ToggleButtonGroup
|
|
193
|
-
value={state.interval}
|
|
194
|
-
onChange={(_, value) => {
|
|
195
|
-
if (value !== null) {
|
|
196
|
-
setState({ interval: value });
|
|
197
|
-
}
|
|
198
|
-
}}
|
|
199
|
-
exclusive>
|
|
200
|
-
{Object.keys(recurring).map((x) => (
|
|
201
|
-
<ToggleButton key={x} value={x} sx={{ textTransform: 'capitalize' }}>
|
|
202
|
-
{formatRecurring(recurring[x] as PriceRecurring, true, '', locale)}
|
|
203
|
-
</ToggleButton>
|
|
204
|
-
))}
|
|
205
|
-
</ToggleButtonGroup>
|
|
206
|
-
)}
|
|
207
|
-
<Stack flexWrap="wrap" direction="row" gap={{ xs: 3, sm: 5, md: 10 }} justifyContent="center">
|
|
208
|
-
{grouped[state.interval]?.map((x) => {
|
|
209
|
-
return (
|
|
210
|
-
<Fade in>
|
|
211
|
-
<Stack
|
|
212
|
-
key={x.price_id}
|
|
213
|
-
padding={4}
|
|
214
|
-
spacing={2}
|
|
215
|
-
direction="column"
|
|
216
|
-
alignItems="center"
|
|
217
|
-
sx={{
|
|
218
|
-
width: 320,
|
|
219
|
-
cursor: 'pointer',
|
|
220
|
-
border: '1px solid #eee',
|
|
221
|
-
borderRadius: 1,
|
|
222
|
-
transition: 'border-color 0.3s ease 0s, box-shadow 0.3s ease 0s',
|
|
223
|
-
boxShadow: '0 4px 8px rgba(0, 0, 0, 20%)',
|
|
224
|
-
'&:hover': {
|
|
225
|
-
borderColor: '#ddd',
|
|
226
|
-
boxShadow: '0 8px 16px rgba(0, 0, 0, 20%)',
|
|
227
|
-
},
|
|
228
|
-
}}>
|
|
229
|
-
<Box textAlign="center">
|
|
230
|
-
<Typography variant="h5" color="text.primary" fontWeight={600}>
|
|
231
|
-
{x.product.name}
|
|
232
|
-
</Typography>
|
|
233
|
-
<Typography color="text.secondary">{x.product.description}</Typography>
|
|
234
|
-
</Box>
|
|
235
|
-
<Stack direction="row" alignItems="center" spacing={1}>
|
|
236
|
-
<PaymentAmount amount={formatPriceAmount(x.price, data.currency, x.product.unit_label)} />
|
|
237
|
-
<Stack direction="column" alignItems="flex-start">
|
|
238
|
-
<Typography component="span" color="text.secondary" fontSize="0.8rem">
|
|
239
|
-
{t('checkout.per')}
|
|
240
|
-
</Typography>
|
|
241
|
-
<Typography component="span" color="text.secondary" fontSize="0.8rem">
|
|
242
|
-
{formatRecurring(x.price.recurring as PriceRecurring, false, '', locale)}
|
|
243
|
-
</Typography>
|
|
244
|
-
</Stack>
|
|
245
|
-
</Stack>
|
|
246
|
-
<LoadingButton
|
|
247
|
-
fullWidth
|
|
248
|
-
size="large"
|
|
249
|
-
loadingPosition="end"
|
|
250
|
-
variant={x.is_highlight ? 'contained' : 'outlined'}
|
|
251
|
-
color={x.is_highlight ? 'primary' : 'info'}
|
|
252
|
-
sx={{ fontSize: '1.2rem' }}
|
|
253
|
-
loading={state.loading === x.price_id}
|
|
254
|
-
onClick={() => onStartCheckoutSession(x.price_id)}>
|
|
255
|
-
{x.subscription_data?.trial_period_days ? t('checkout.try') : t('checkout.subscription')}
|
|
256
|
-
</LoadingButton>
|
|
257
|
-
{x.product.features.length > 0 && (
|
|
258
|
-
<Box>
|
|
259
|
-
<Typography>{t('checkout.include')}</Typography>
|
|
260
|
-
<List dense>
|
|
261
|
-
{x.product.features.map((f: any) => (
|
|
262
|
-
<ListItem key={f.name} disableGutters disablePadding>
|
|
263
|
-
<ListItemIcon sx={{ minWidth: 25 }}>
|
|
264
|
-
<CheckOutlined color="success" fontSize="small" />
|
|
265
|
-
</ListItemIcon>
|
|
266
|
-
<ListItemText primary={f.name} />
|
|
267
|
-
</ListItem>
|
|
268
|
-
))}
|
|
269
|
-
</List>
|
|
270
|
-
</Box>
|
|
271
|
-
)}
|
|
272
|
-
</Stack>
|
|
273
|
-
</Fade>
|
|
274
|
-
);
|
|
275
|
-
})}
|
|
276
|
-
</Stack>
|
|
127
|
+
<PricingTable table={data} onSelect={onStartCheckoutSession} />
|
|
277
128
|
</Stack>
|
|
278
129
|
</Center>
|
|
279
130
|
</Box>
|
|
@@ -9,11 +9,12 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
|
9
9
|
|
|
10
10
|
import TxLink from '../../../components/blockchain/tx';
|
|
11
11
|
import Currency from '../../../components/currency';
|
|
12
|
-
import InfoMetric from '../../../components/info-metric';
|
|
13
12
|
import InfoRow from '../../../components/info-row';
|
|
14
13
|
import InvoiceList from '../../../components/invoice/list';
|
|
14
|
+
import SubscriptionActions from '../../../components/portal/subscription/actions';
|
|
15
15
|
import SectionHeader from '../../../components/section/header';
|
|
16
16
|
import SubscriptionItemList from '../../../components/subscription/items';
|
|
17
|
+
import SubscriptionMetrics from '../../../components/subscription/metrics';
|
|
17
18
|
import SubscriptionStatus from '../../../components/subscription/status';
|
|
18
19
|
import api from '../../../libs/api';
|
|
19
20
|
import { formatSubscriptionProduct, formatTime } from '../../../libs/util';
|
|
@@ -22,10 +23,10 @@ const fetchData = (id: string | undefined): Promise<TSubscriptionExpanded> => {
|
|
|
22
23
|
return api.get(`/api/subscriptions/${id}`).then((res) => res.data);
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
export default function
|
|
26
|
+
export default function CustomerSubscriptionDetail() {
|
|
26
27
|
const { id } = useParams() as { id: string };
|
|
27
28
|
const { t } = useLocaleContext();
|
|
28
|
-
const { loading, error, data } = useRequest(() => fetchData(id));
|
|
29
|
+
const { loading, error, data, refresh } = useRequest(() => fetchData(id));
|
|
29
30
|
const navigate = useNavigate();
|
|
30
31
|
|
|
31
32
|
if (error) {
|
|
@@ -57,6 +58,7 @@ export default function CustomerSubscription() {
|
|
|
57
58
|
</Typography>
|
|
58
59
|
<SubscriptionStatus subscription={data} sx={{ ml: 1 }} />
|
|
59
60
|
</Stack>
|
|
61
|
+
<SubscriptionActions subscription={data} onChange={() => refresh()} showUpdate />
|
|
60
62
|
</Stack>
|
|
61
63
|
<Stack
|
|
62
64
|
className="section-body"
|
|
@@ -65,39 +67,7 @@ export default function CustomerSubscription() {
|
|
|
65
67
|
justifyContent="flex-start"
|
|
66
68
|
flexWrap="wrap"
|
|
67
69
|
sx={{ pt: 2, mt: 2, borderTop: '1px solid #eee' }}>
|
|
68
|
-
<
|
|
69
|
-
label={t('admin.subscription.startedAt')}
|
|
70
|
-
value={formatTime(data.start_date ? data.start_date * 1000 : data.created_at)}
|
|
71
|
-
divider
|
|
72
|
-
/>
|
|
73
|
-
{data.status === 'active' && !data.cancel_at && (
|
|
74
|
-
<InfoMetric
|
|
75
|
-
label={t('admin.subscription.nextInvoice')}
|
|
76
|
-
value={formatTime(data.current_period_end * 1000)}
|
|
77
|
-
divider
|
|
78
|
-
/>
|
|
79
|
-
)}
|
|
80
|
-
{['active', 'trailing'].includes(data.status) && data.cancel_at && (
|
|
81
|
-
<InfoMetric
|
|
82
|
-
label={t('admin.subscription.cancel.schedule')}
|
|
83
|
-
value={formatTime(data.cancel_at * 1000)}
|
|
84
|
-
divider
|
|
85
|
-
/>
|
|
86
|
-
)}
|
|
87
|
-
{data.status !== 'canceled' && data.cancel_at_period_end && (
|
|
88
|
-
<InfoMetric
|
|
89
|
-
label={t('admin.subscription.cancel.schedule')}
|
|
90
|
-
value={formatTime(data.current_period_end * 1000)}
|
|
91
|
-
divider
|
|
92
|
-
/>
|
|
93
|
-
)}
|
|
94
|
-
{data.status === 'canceled' && data.canceled_at && (
|
|
95
|
-
<InfoMetric
|
|
96
|
-
label={t('admin.subscription.cancel.done')}
|
|
97
|
-
value={formatTime(data.canceled_at * 1000)}
|
|
98
|
-
divider
|
|
99
|
-
/>
|
|
100
|
-
)}
|
|
70
|
+
<SubscriptionMetrics subscription={data} />
|
|
101
71
|
</Stack>
|
|
102
72
|
</Box>
|
|
103
73
|
</Box>
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/* eslint-disable react/no-unstable-nested-components */
|
|
2
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
4
|
+
import type { TLineItemExpanded, TPricingTableExpanded, TSubscriptionExpanded } from '@did-pay/types';
|
|
5
|
+
import { ArrowBackOutlined } from '@mui/icons-material';
|
|
6
|
+
import { LoadingButton } from '@mui/lab';
|
|
7
|
+
import { Alert, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
8
|
+
import { styled } from '@mui/system';
|
|
9
|
+
import { fromUnitToToken } from '@ocap/util';
|
|
10
|
+
import { useRequest, useSetState } from 'ahooks';
|
|
11
|
+
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
12
|
+
|
|
13
|
+
import PricingTable from '../../../components/checkout/pricing-table';
|
|
14
|
+
import InfoCard from '../../../components/info-card';
|
|
15
|
+
import SectionHeader from '../../../components/section/header';
|
|
16
|
+
import { useSessionContext } from '../../../contexts/session';
|
|
17
|
+
import api from '../../../libs/api';
|
|
18
|
+
import { formatError, formatPrice, formatSubscriptionProduct, formatTime } from '../../../libs/util';
|
|
19
|
+
|
|
20
|
+
const fetchData = async (
|
|
21
|
+
id: string
|
|
22
|
+
): Promise<{ subscription: TSubscriptionExpanded; table: TPricingTableExpanded }> => {
|
|
23
|
+
const results = await Promise.all([
|
|
24
|
+
api.get(`/api/subscriptions/${id}`).then((res) => res.data),
|
|
25
|
+
api.get(`/api/subscriptions/${id}/update`).then((res) => res.data),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
subscription: results[0],
|
|
30
|
+
table: results[1],
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type UpdateItem = {
|
|
35
|
+
id?: string;
|
|
36
|
+
deleted?: boolean;
|
|
37
|
+
clear_usage?: boolean;
|
|
38
|
+
price_id?: string;
|
|
39
|
+
quantity?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const simulateUpdate = ({ id, items }: { id: string; items: UpdateItem[] }) => {
|
|
43
|
+
return api.post(`/api/subscriptions/${id}/update`, { items }).then((res) => res.data);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default function CustomerSubscriptionUpdate() {
|
|
47
|
+
const navigate = useNavigate();
|
|
48
|
+
const { id } = useParams() as { id: string };
|
|
49
|
+
const { t, locale } = useLocaleContext();
|
|
50
|
+
const { connectApi } = useSessionContext();
|
|
51
|
+
|
|
52
|
+
const { loading, error, data } = useRequest(() => fetchData(id));
|
|
53
|
+
const [state, setState] = useSetState({
|
|
54
|
+
loading: false,
|
|
55
|
+
priceId: '',
|
|
56
|
+
total: '',
|
|
57
|
+
credit: '',
|
|
58
|
+
setup: null,
|
|
59
|
+
prorations: [],
|
|
60
|
+
items: [],
|
|
61
|
+
paying: false,
|
|
62
|
+
paid: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (error) {
|
|
66
|
+
return <Alert severity="error">{error.message}</Alert>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (loading || !data) {
|
|
70
|
+
return <CircularProgress />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!data.subscription) {
|
|
74
|
+
return <Alert severity="error">{t('customer.upgrade.subscriptionNotFound')}</Alert>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!data.table) {
|
|
78
|
+
return <Alert severity="error">{t('customer.upgrade.tableNotFound')}</Alert>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handleSelect = async (priceId: string) => {
|
|
82
|
+
try {
|
|
83
|
+
if (state.priceId === priceId) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const deleted = data.subscription.items.find((si) => data.table.items.some((ti) => ti.price_id === si.price_id));
|
|
88
|
+
if (deleted!.price_id === priceId) {
|
|
89
|
+
setState({ priceId: '', total: '', credit: '', setup: null, prorations: [], items: [] });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = await simulateUpdate({
|
|
94
|
+
id: data.subscription.id,
|
|
95
|
+
items: [
|
|
96
|
+
{
|
|
97
|
+
id: deleted!.id,
|
|
98
|
+
deleted: true,
|
|
99
|
+
clear_usage: false,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
price_id: priceId,
|
|
103
|
+
quantity: 1, // FIXME: support customized quantity
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
setState({ priceId, ...result });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
Toast.error(formatError(err));
|
|
111
|
+
setState({ priceId: '', total: '', credit: '', setup: null, prorations: [], items: [] });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleBack = () => {
|
|
116
|
+
navigate(`/customer/subscription/${data.subscription.id}`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleConfirm = async () => {
|
|
120
|
+
try {
|
|
121
|
+
setState({ loading: true });
|
|
122
|
+
const deleted = data.subscription.items.find((si) => data.table.items.some((ti) => ti.price_id === si.price_id));
|
|
123
|
+
const items = [
|
|
124
|
+
{
|
|
125
|
+
id: deleted!.id,
|
|
126
|
+
deleted: true,
|
|
127
|
+
clear_usage: false,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
price_id: state.priceId,
|
|
131
|
+
quantity: 1, // FIXME: support customized quantity
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// FIXME: support more proration_behavior
|
|
136
|
+
const result = await api.put(`/api/subscriptions/${id}`, { proration_behavior: 'create_prorations', items });
|
|
137
|
+
if (result.data.status === 'active') {
|
|
138
|
+
Toast.success(t('customer.upgrade.success'));
|
|
139
|
+
setState({ paid: true });
|
|
140
|
+
setTimeout(handleBack, 2000);
|
|
141
|
+
} else {
|
|
142
|
+
setState({ paying: true });
|
|
143
|
+
try {
|
|
144
|
+
setState({ paying: true });
|
|
145
|
+
connectApi.open({
|
|
146
|
+
action: result.data.connectAction,
|
|
147
|
+
timeout: 5 * 60 * 1000,
|
|
148
|
+
messages: {
|
|
149
|
+
title: t('customer.upgrade.pay'),
|
|
150
|
+
scan: t('customer.upgrade.scan'),
|
|
151
|
+
success: t('customer.upgrade.success'),
|
|
152
|
+
error: t('customer.upgrade.error'),
|
|
153
|
+
},
|
|
154
|
+
extraParams: { invoiceId: result.data.latest_invoice_id, subscriptionId: result.data.id },
|
|
155
|
+
onSuccess: () => {
|
|
156
|
+
setState({ paid: true, paying: false });
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
connectApi.close();
|
|
159
|
+
handleBack();
|
|
160
|
+
}, 2000);
|
|
161
|
+
},
|
|
162
|
+
onClose: () => {
|
|
163
|
+
connectApi.close();
|
|
164
|
+
setState({ paying: false });
|
|
165
|
+
},
|
|
166
|
+
onError: (err: any) => {
|
|
167
|
+
setState({ paying: false });
|
|
168
|
+
Toast.error(formatError(err));
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
Toast.error(formatError(err));
|
|
173
|
+
} finally {
|
|
174
|
+
setState({ paying: false });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(err);
|
|
179
|
+
Toast.error(formatError(err));
|
|
180
|
+
} finally {
|
|
181
|
+
setState({ loading: false });
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const table = { ...data.table, currency: data.subscription.paymentCurrency };
|
|
186
|
+
table.items.forEach((x: any) => {
|
|
187
|
+
x.is_selected = x.price_id === state.priceId;
|
|
188
|
+
if (data.subscription.items.find((y) => y.price_id === x.price_id)) {
|
|
189
|
+
x.is_highlight = true;
|
|
190
|
+
x.is_disabled = true;
|
|
191
|
+
x.highlight_text = t('customer.upgrade.current');
|
|
192
|
+
if (!state.priceId) {
|
|
193
|
+
x.is_selected = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const { recurring } = data.subscription.items.find((x) => x.price.type === 'recurring')?.price || {};
|
|
199
|
+
const interval = [recurring?.interval, recurring?.interval_count].join('-');
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Root direction="column" spacing={4} sx={{ mb: 4 }}>
|
|
203
|
+
<Stack className="page-header" direction="row" justifyContent="space-between" alignItems="center">
|
|
204
|
+
<Link to={`/customer/subscription/${data.subscription.id}`}>
|
|
205
|
+
<Stack direction="row" alignItems="center" sx={{ fontWeight: 'normal', mt: '16px' }}>
|
|
206
|
+
<ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
|
|
207
|
+
<Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
|
|
208
|
+
{formatSubscriptionProduct(data.subscription.items)}
|
|
209
|
+
</Typography>
|
|
210
|
+
</Stack>
|
|
211
|
+
</Link>
|
|
212
|
+
</Stack>
|
|
213
|
+
<Stack direction="column" sx={{ marginTop: 32 }}>
|
|
214
|
+
<SectionHeader title={t('customer.upgrade.config')} />
|
|
215
|
+
<PricingTable mode="select" alignItems="left" interval={interval} table={table} onSelect={handleSelect} />
|
|
216
|
+
</Stack>
|
|
217
|
+
{state.priceId && state.total && state.setup && (
|
|
218
|
+
<Stack direction="column" spacing={3} sx={{ maxWidth: 640 }}>
|
|
219
|
+
<SectionHeader title={t('customer.upgrade.confirm')} />
|
|
220
|
+
<Stack direction="column" spacing={2}>
|
|
221
|
+
<Typography variant="h6" sx={{ fontWeight: 'normal' }}>
|
|
222
|
+
{t('customer.upgrade.summary', {
|
|
223
|
+
// @ts-ignore
|
|
224
|
+
date: formatTime(state.setup.period.end * 1000, 'lll', locale),
|
|
225
|
+
})}
|
|
226
|
+
</Typography>
|
|
227
|
+
{state.items.map((x: TLineItemExpanded) => {
|
|
228
|
+
const { product } = x.price;
|
|
229
|
+
return (
|
|
230
|
+
<Stack key={x.price_id} direction="row" alignItems="center" justifyContent="space-between">
|
|
231
|
+
<InfoCard logo={product.images[0]} name={product.name} description={product.description} />
|
|
232
|
+
<Typography component="p" style={{ fontWeight: 'bold' }}>
|
|
233
|
+
{formatPrice(
|
|
234
|
+
x.price,
|
|
235
|
+
data.subscription.paymentCurrency,
|
|
236
|
+
x.price.product.unit_label,
|
|
237
|
+
1,
|
|
238
|
+
true,
|
|
239
|
+
locale
|
|
240
|
+
)}
|
|
241
|
+
</Typography>
|
|
242
|
+
</Stack>
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
245
|
+
</Stack>
|
|
246
|
+
<Divider />
|
|
247
|
+
<Stack direction="row" justifyContent="space-between">
|
|
248
|
+
<Typography variant="h6" sx={{ fontWeight: 'normal' }}>
|
|
249
|
+
{t('customer.upgrade.due')}
|
|
250
|
+
</Typography>
|
|
251
|
+
<Typography component="p" style={{ fontWeight: 'bold' }}>
|
|
252
|
+
{fromUnitToToken(state.total, data.subscription.paymentCurrency.decimal)}{' '}
|
|
253
|
+
{data.subscription.paymentCurrency.symbol}
|
|
254
|
+
</Typography>
|
|
255
|
+
</Stack>
|
|
256
|
+
<Divider />
|
|
257
|
+
<Stack direction="row" alignItems="center" justifyContent="flex-end" spacing={2}>
|
|
258
|
+
<LoadingButton
|
|
259
|
+
disabled={state.loading || state.paying}
|
|
260
|
+
onClick={() => setState({ priceId: '' })}
|
|
261
|
+
variant="text"
|
|
262
|
+
size="large">
|
|
263
|
+
{t('common.cancel')}
|
|
264
|
+
</LoadingButton>
|
|
265
|
+
<LoadingButton
|
|
266
|
+
onClick={handleConfirm}
|
|
267
|
+
variant="contained"
|
|
268
|
+
size="large"
|
|
269
|
+
sx={{ width: 150 }}
|
|
270
|
+
loading={state.loading || state.paying}
|
|
271
|
+
loadingPosition="end">
|
|
272
|
+
{t('common.confirm')}
|
|
273
|
+
</LoadingButton>
|
|
274
|
+
</Stack>
|
|
275
|
+
</Stack>
|
|
276
|
+
)}
|
|
277
|
+
</Root>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const Root = styled(Stack)``;
|