payment-kit 1.13.24 → 1.13.25
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/README.md +4 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/payment-intent.ts +2 -2
- package/api/src/integrations/stripe/handlers/setup-intent.ts +1 -1
- package/api/src/integrations/stripe/handlers/subscription.ts +2 -2
- package/api/src/routes/checkout-sessions.ts +3 -3
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/payment-links.ts +0 -1
- package/api/src/routes/pricing-table.ts +342 -0
- package/api/src/store/migrations/20231017-pricing-table.ts +10 -0
- package/api/src/store/models/index.ts +14 -1
- package/api/src/store/models/pricing-table.ts +107 -0
- package/api/src/store/models/types.ts +53 -0
- package/blocklet.yml +1 -1
- package/package.json +3 -3
- package/src/app.tsx +1 -1
- package/src/components/payment-link/actions.tsx +1 -1
- package/src/components/payment-link/chrome.tsx +5 -3
- package/src/components/payment-link/preview.tsx +8 -5
- package/src/components/payment-link/rename.tsx +3 -3
- package/src/components/pricing-table/actions.tsx +126 -0
- package/src/components/pricing-table/customer-settings.tsx +17 -0
- package/src/components/pricing-table/payment-settings.tsx +179 -0
- package/src/components/pricing-table/preview.tsx +34 -0
- package/src/components/pricing-table/price-item.tsx +64 -0
- package/src/components/pricing-table/product-item.tsx +86 -0
- package/src/components/pricing-table/product-settings.tsx +195 -0
- package/src/components/pricing-table/rename.tsx +67 -0
- package/src/libs/util.ts +43 -0
- package/src/locales/en.tsx +26 -0
- package/src/pages/admin/payments/links/create.tsx +1 -1
- package/src/pages/admin/products/index.tsx +8 -13
- package/src/pages/admin/products/pricing-tables/create.tsx +140 -0
- package/src/pages/admin/products/pricing-tables/detail.tsx +237 -0
- package/src/pages/admin/products/pricing-tables/index.tsx +154 -0
- package/src/pages/checkout/index.tsx +2 -1
- package/src/pages/checkout/pricing-table.tsx +195 -0
- package/src/pages/admin/products/pricing-tables.tsx +0 -3
|
@@ -0,0 +1,237 @@
|
|
|
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 { TPricingTableExpanded, TProduct } from '@did-pay/types';
|
|
5
|
+
import { ArrowBackOutlined, Edit } from '@mui/icons-material';
|
|
6
|
+
import { Alert, Box, Button, CircularProgress, Grid, Stack, Typography } from '@mui/material';
|
|
7
|
+
import { styled } from '@mui/system';
|
|
8
|
+
import { useRequest, useSetState } from 'ahooks';
|
|
9
|
+
import { isEmpty } from 'lodash';
|
|
10
|
+
import { Link, useNavigate } from 'react-router-dom';
|
|
11
|
+
import { joinURL } from 'ufo';
|
|
12
|
+
|
|
13
|
+
import Copyable from '../../../../components/copyable';
|
|
14
|
+
import EventList from '../../../../components/event/list';
|
|
15
|
+
import InfoCard from '../../../../components/info-card';
|
|
16
|
+
import InfoRow from '../../../../components/info-row';
|
|
17
|
+
import MetadataEditor from '../../../../components/metadata/editor';
|
|
18
|
+
import PricingTableActions from '../../../../components/pricing-table/actions';
|
|
19
|
+
import PricingTablePreview from '../../../../components/pricing-table/preview';
|
|
20
|
+
import SectionHeader from '../../../../components/section/header';
|
|
21
|
+
import Table from '../../../../components/table';
|
|
22
|
+
import { useSettingsContext } from '../../../../contexts/settings';
|
|
23
|
+
import api from '../../../../libs/api';
|
|
24
|
+
import { formatError, formatProductPrice, formatTime } from '../../../../libs/util';
|
|
25
|
+
|
|
26
|
+
const fetchData = (id: string): Promise<TPricingTableExpanded> => {
|
|
27
|
+
return api.get(`/api/pricing-tables/${id}`).then((res) => res.data);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default function PricingTableDetail(props: { id: string }) {
|
|
31
|
+
const { t, locale } = useLocaleContext();
|
|
32
|
+
const navigate = useNavigate();
|
|
33
|
+
const { settings } = useSettingsContext();
|
|
34
|
+
const [state, setState] = useSetState({
|
|
35
|
+
adding: {
|
|
36
|
+
price: false,
|
|
37
|
+
},
|
|
38
|
+
editing: {
|
|
39
|
+
metadata: false,
|
|
40
|
+
product: false,
|
|
41
|
+
},
|
|
42
|
+
loading: {
|
|
43
|
+
metadata: false,
|
|
44
|
+
price: false,
|
|
45
|
+
product: false,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const { loading, error, data, runAsync } = useRequest(() => fetchData(props.id));
|
|
50
|
+
|
|
51
|
+
if (error) {
|
|
52
|
+
return <Alert severity="error">{error.message}</Alert>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (loading || !data) {
|
|
56
|
+
return <CircularProgress />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const createUpdater = (key: string) => async (updates: TProduct) => {
|
|
60
|
+
try {
|
|
61
|
+
setState((prev) => ({ loading: { ...prev.loading, [key]: true } }));
|
|
62
|
+
await api.put(`/api/payment-links/${props.id}`, updates).then((res) => res.data);
|
|
63
|
+
Toast.success(t('common.saved'));
|
|
64
|
+
runAsync();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(err);
|
|
67
|
+
Toast.error(formatError(err));
|
|
68
|
+
} finally {
|
|
69
|
+
setState((prev) => ({ loading: { ...prev.loading, [key]: false } }));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onUpdateMetadata = createUpdater('metadata');
|
|
74
|
+
const onChange = (action: string) => {
|
|
75
|
+
if (action === 'remove') {
|
|
76
|
+
navigate('/admin/products/pricing-tables');
|
|
77
|
+
} else {
|
|
78
|
+
runAsync();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Grid container spacing={4} sx={{ mb: 4 }}>
|
|
84
|
+
<Grid item md={12}>
|
|
85
|
+
<Stack className="page-header" direction="row" justifyContent="space-between" alignItems="center">
|
|
86
|
+
<Link to="/admin/products/pricing-tables">
|
|
87
|
+
<Stack direction="row" alignItems="center" sx={{ fontWeight: 'normal' }}>
|
|
88
|
+
<ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
|
|
89
|
+
<Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
|
|
90
|
+
{t('admin.pricingTables')}
|
|
91
|
+
</Typography>
|
|
92
|
+
</Stack>
|
|
93
|
+
</Link>
|
|
94
|
+
<Copyable text={props.id} style={{ marginLeft: 4 }} />
|
|
95
|
+
</Stack>
|
|
96
|
+
<Box mt={2}>
|
|
97
|
+
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
98
|
+
<Stack direction="column" alignItems="flex-start">
|
|
99
|
+
<Stack direction="row" alignItems="center" mb={1}>
|
|
100
|
+
<Typography variant="h5" sx={{ color: 'text.primary', fontWeight: 600 }}>
|
|
101
|
+
{data.name}
|
|
102
|
+
</Typography>
|
|
103
|
+
</Stack>
|
|
104
|
+
<Copyable
|
|
105
|
+
text={joinURL(window.blocklet.appUrl, window.blocklet.prefix, `/checkout/pricing-table/${data.id}`)}>
|
|
106
|
+
<Typography variant="h6" sx={{ color: 'text.secondary', mr: 1 }}>
|
|
107
|
+
{joinURL(window.blocklet.prefix, `/checkout/pricing-table/${data.id}`)}
|
|
108
|
+
</Typography>
|
|
109
|
+
</Copyable>
|
|
110
|
+
</Stack>
|
|
111
|
+
<PricingTableActions data={data} onChange={onChange} variant="normal" />
|
|
112
|
+
</Stack>
|
|
113
|
+
</Box>
|
|
114
|
+
</Grid>
|
|
115
|
+
<Grid item xs={12} md={4}>
|
|
116
|
+
<Div direction="column" spacing={4}>
|
|
117
|
+
<Box className="section">
|
|
118
|
+
<SectionHeader title={t('admin.products')} />
|
|
119
|
+
<Box className="section-body">
|
|
120
|
+
<Table
|
|
121
|
+
className="link-products-table"
|
|
122
|
+
toolbar={false}
|
|
123
|
+
footer={false}
|
|
124
|
+
locale={locale}
|
|
125
|
+
data={data.items}
|
|
126
|
+
columns={[
|
|
127
|
+
{
|
|
128
|
+
label: t('common.name'),
|
|
129
|
+
name: 'product_id',
|
|
130
|
+
options: {
|
|
131
|
+
sort: false,
|
|
132
|
+
customBodyRenderLite: (_: any, index: number) => {
|
|
133
|
+
const item = data.items[index] as any;
|
|
134
|
+
return (
|
|
135
|
+
<Link to={`/admin/products/${item.product_id}`}>
|
|
136
|
+
<InfoCard
|
|
137
|
+
name={item.product.name}
|
|
138
|
+
description={formatProductPrice(
|
|
139
|
+
// @ts-ignore
|
|
140
|
+
{ ...item.product, prices: [item.price] },
|
|
141
|
+
settings.baseCurrency
|
|
142
|
+
)}
|
|
143
|
+
logo={item.product.images[0]}
|
|
144
|
+
/>
|
|
145
|
+
</Link>
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
label: t('common.actions'),
|
|
152
|
+
name: 'quantity',
|
|
153
|
+
},
|
|
154
|
+
]}
|
|
155
|
+
options={{
|
|
156
|
+
count: data.items.length,
|
|
157
|
+
page: 0,
|
|
158
|
+
rowsPerPage: 20,
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
</Box>
|
|
162
|
+
</Box>
|
|
163
|
+
<Box className="section">
|
|
164
|
+
<SectionHeader title={t('admin.details')}>
|
|
165
|
+
<Button
|
|
166
|
+
variant="outlined"
|
|
167
|
+
color="inherit"
|
|
168
|
+
size="small"
|
|
169
|
+
onClick={() => setState((prev) => ({ editing: { ...prev.editing, product: true } }))}>
|
|
170
|
+
<Edit fontSize="small" sx={{ mr: 0.5 }} />
|
|
171
|
+
{t('common.edit')}
|
|
172
|
+
</Button>
|
|
173
|
+
</SectionHeader>
|
|
174
|
+
<Stack>
|
|
175
|
+
<InfoRow sizes={[1, 1]} label={t('common.createdAt')} value={formatTime(data.created_at)} />
|
|
176
|
+
<InfoRow sizes={[1, 1]} label={t('common.updatedAt')} value={formatTime(data.updated_at)} />
|
|
177
|
+
</Stack>
|
|
178
|
+
</Box>
|
|
179
|
+
<Box className="section">
|
|
180
|
+
<SectionHeader title={t('common.metadata.label')}>
|
|
181
|
+
<Button
|
|
182
|
+
variant="outlined"
|
|
183
|
+
color="inherit"
|
|
184
|
+
size="small"
|
|
185
|
+
disabled={state.editing.metadata}
|
|
186
|
+
onClick={() => setState((prev) => ({ editing: { ...prev.editing, metadata: true } }))}>
|
|
187
|
+
<Edit fontSize="small" sx={{ mr: 0.5 }} />
|
|
188
|
+
{t('common.metadata.edit')}
|
|
189
|
+
</Button>
|
|
190
|
+
</SectionHeader>
|
|
191
|
+
<Box className="section-body">
|
|
192
|
+
{!state.editing.metadata &&
|
|
193
|
+
(isEmpty(data.metadata) ? (
|
|
194
|
+
<Typography color="text.secondary">{t('common.metadata.empty')}</Typography>
|
|
195
|
+
) : (
|
|
196
|
+
<Grid container>
|
|
197
|
+
<Grid item xs={12} md={6}>
|
|
198
|
+
{Object.keys(data.metadata || {}).map((key) => (
|
|
199
|
+
// @ts-ignore
|
|
200
|
+
<InfoRow key={key} label={key} value={data.metadata[key]} />
|
|
201
|
+
))}
|
|
202
|
+
</Grid>
|
|
203
|
+
</Grid>
|
|
204
|
+
))}
|
|
205
|
+
{state.editing.metadata && (
|
|
206
|
+
<MetadataEditor
|
|
207
|
+
data={data}
|
|
208
|
+
loading={state.loading.metadata}
|
|
209
|
+
onSave={onUpdateMetadata}
|
|
210
|
+
onCancel={() => setState((prev) => ({ editing: { ...prev.editing, metadata: false } }))}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
</Box>
|
|
214
|
+
</Box>
|
|
215
|
+
<Box className="section">
|
|
216
|
+
<SectionHeader title={t('admin.events')} />
|
|
217
|
+
<Box className="section-body">
|
|
218
|
+
<EventList features={{ toolbar: false }} object_id={data.id} />
|
|
219
|
+
</Box>
|
|
220
|
+
</Box>
|
|
221
|
+
</Div>
|
|
222
|
+
</Grid>
|
|
223
|
+
<Grid item xs={12} md={8}>
|
|
224
|
+
<Div>
|
|
225
|
+
<Box className="section">
|
|
226
|
+
<SectionHeader title={t('common.preview')} />
|
|
227
|
+
<Box className="section-body">
|
|
228
|
+
<PricingTablePreview id={data.id} />
|
|
229
|
+
</Box>
|
|
230
|
+
</Box>
|
|
231
|
+
</Div>
|
|
232
|
+
</Grid>
|
|
233
|
+
</Grid>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const Div = styled(Stack)``;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/* eslint-disable react/no-unstable-nested-components */
|
|
2
|
+
import { getDurableData } from '@arcblock/ux/lib/Datatable';
|
|
3
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
4
|
+
import type { TPricingTableExpanded } from '@did-pay/types';
|
|
5
|
+
import { Alert, CircularProgress, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
|
|
6
|
+
import { useRequest } from 'ahooks';
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { useNavigate } from 'react-router-dom';
|
|
9
|
+
import { joinURL } from 'ufo';
|
|
10
|
+
import useBus from 'use-bus';
|
|
11
|
+
|
|
12
|
+
import Copyable from '../../../../components/copyable';
|
|
13
|
+
import PricingTableActions from '../../../../components/pricing-table/actions';
|
|
14
|
+
import Status from '../../../../components/status';
|
|
15
|
+
import Table from '../../../../components/table';
|
|
16
|
+
import { ProductsProvider } from '../../../../contexts/products';
|
|
17
|
+
import api from '../../../../libs/api';
|
|
18
|
+
import { formatTime } from '../../../../libs/util';
|
|
19
|
+
|
|
20
|
+
const fetchData = (params: Record<string, any> = {}): Promise<{ list: TPricingTableExpanded[]; count: number }> => {
|
|
21
|
+
const search = new URLSearchParams();
|
|
22
|
+
Object.keys(params).forEach((key) => {
|
|
23
|
+
search.set(key, String(params[key]));
|
|
24
|
+
});
|
|
25
|
+
return api.get(`/api/pricing-tables?${search.toString()}`).then((res) => res.data);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function PricingTables() {
|
|
29
|
+
const listKey = 'pricing-tables';
|
|
30
|
+
const { t } = useLocaleContext();
|
|
31
|
+
const navigate = useNavigate();
|
|
32
|
+
|
|
33
|
+
const persisted = getDurableData(listKey);
|
|
34
|
+
const [search, setSearch] = useState<{ active: string; pageSize: number; page: number }>({
|
|
35
|
+
active: '',
|
|
36
|
+
pageSize: persisted.rowsPerPage || 20,
|
|
37
|
+
page: persisted.page ? persisted.page + 1 : 1,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const { loading, error, data, refresh } = useRequest(() => fetchData(search));
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
refresh();
|
|
43
|
+
}, [search, refresh]);
|
|
44
|
+
|
|
45
|
+
useBus('pricingTable.created', () => refresh(), []);
|
|
46
|
+
|
|
47
|
+
if (error) {
|
|
48
|
+
return <Alert severity="error">{error.message}</Alert>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (loading || !data) {
|
|
52
|
+
return <CircularProgress />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const columns = [
|
|
56
|
+
{
|
|
57
|
+
label: t('common.url'),
|
|
58
|
+
name: 'id',
|
|
59
|
+
options: {
|
|
60
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
61
|
+
const item = data.list[index] as TPricingTableExpanded;
|
|
62
|
+
return (
|
|
63
|
+
<Copyable
|
|
64
|
+
text={joinURL(window.blocklet.appUrl, window.blocklet.prefix, `/checkout/pricing-table/${item.id}`)}>
|
|
65
|
+
<Typography sx={{ color: 'text.primary', mr: 1 }}>{item.id}</Typography>
|
|
66
|
+
</Copyable>
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: t('common.status'),
|
|
73
|
+
name: 'active',
|
|
74
|
+
options: {
|
|
75
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
76
|
+
const item = data.list[index];
|
|
77
|
+
return <Status label={item?.active ? 'Active' : 'Archived'} color={item?.active ? 'success' : 'default'} />;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
label: t('common.name'),
|
|
83
|
+
name: 'name',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
label: t('common.createdAt'),
|
|
87
|
+
name: 'created_at',
|
|
88
|
+
options: {
|
|
89
|
+
customBodyRender: (e: string) => {
|
|
90
|
+
return formatTime(e);
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: t('common.actions'),
|
|
96
|
+
name: 'id',
|
|
97
|
+
options: {
|
|
98
|
+
sort: false,
|
|
99
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
100
|
+
const doc = data.list[index] as TPricingTableExpanded;
|
|
101
|
+
return <PricingTableActions data={doc} onChange={refresh} />;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const onTableChange = ({ page, rowsPerPage }: any) => {
|
|
108
|
+
if (search.pageSize !== rowsPerPage) {
|
|
109
|
+
setSearch((x) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
|
|
110
|
+
} else if (search.page !== page + 1) {
|
|
111
|
+
setSearch((x) => ({ ...x, page: page + 1 }));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Table
|
|
117
|
+
durable={listKey}
|
|
118
|
+
durableKeys={['page', 'rowsPerPage']}
|
|
119
|
+
title={
|
|
120
|
+
<div className="table-toolbar-left">
|
|
121
|
+
<ToggleButtonGroup
|
|
122
|
+
value={search.active}
|
|
123
|
+
onChange={(_, value) => setSearch((x) => ({ ...x, active: value }))}
|
|
124
|
+
exclusive>
|
|
125
|
+
<ToggleButton value="">All</ToggleButton>
|
|
126
|
+
<ToggleButton value="true">Active</ToggleButton>
|
|
127
|
+
<ToggleButton value="false">Archived</ToggleButton>
|
|
128
|
+
</ToggleButtonGroup>
|
|
129
|
+
</div>
|
|
130
|
+
}
|
|
131
|
+
data={data.list}
|
|
132
|
+
columns={columns}
|
|
133
|
+
options={{
|
|
134
|
+
count: data.count,
|
|
135
|
+
page: search.page - 1,
|
|
136
|
+
rowsPerPage: search.pageSize,
|
|
137
|
+
onRowClick: (_: any, { dataIndex }: any) => {
|
|
138
|
+
const item = data.list[dataIndex] as TPricingTableExpanded;
|
|
139
|
+
navigate(`/admin/products/${item.id}`);
|
|
140
|
+
},
|
|
141
|
+
}}
|
|
142
|
+
loading={loading}
|
|
143
|
+
onChange={onTableChange}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default function WrappedPricingTables() {
|
|
149
|
+
return (
|
|
150
|
+
<ProductsProvider>
|
|
151
|
+
<PricingTables />
|
|
152
|
+
</ProductsProvider>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -6,10 +6,11 @@ import { SettingsProvider } from '../../contexts/settings';
|
|
|
6
6
|
|
|
7
7
|
const pages = {
|
|
8
8
|
pay: React.lazy(() => import('./pay')),
|
|
9
|
+
'pricing-table': React.lazy(() => import('./pricing-table')),
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
function Checkout() {
|
|
12
|
-
const { action, id } = useParams<{ action:
|
|
13
|
+
const { action, id } = useParams<{ action: string; id: string }>();
|
|
13
14
|
|
|
14
15
|
// @ts-ignore
|
|
15
16
|
const TabComponent = pages[action];
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import Center from '@arcblock/ux/lib/Center';
|
|
2
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
4
|
+
import type { PriceRecurring, TPricingTableExpanded, TPricingTableItem } from '@did-pay/types';
|
|
5
|
+
import { CheckOutlined } from '@mui/icons-material';
|
|
6
|
+
import { LoadingButton } from '@mui/lab';
|
|
7
|
+
import {
|
|
8
|
+
Alert,
|
|
9
|
+
Box,
|
|
10
|
+
CircularProgress,
|
|
11
|
+
Fade,
|
|
12
|
+
List,
|
|
13
|
+
ListItem,
|
|
14
|
+
ListItemIcon,
|
|
15
|
+
ListItemText,
|
|
16
|
+
Stack,
|
|
17
|
+
ToggleButton,
|
|
18
|
+
ToggleButtonGroup,
|
|
19
|
+
Typography,
|
|
20
|
+
} from '@mui/material';
|
|
21
|
+
import { useLocalStorageState, useRequest, useSetState } from 'ahooks';
|
|
22
|
+
import { useEffect } from 'react';
|
|
23
|
+
|
|
24
|
+
import PaymentAmount from '../../components/checkout/amount';
|
|
25
|
+
import Livemode from '../../components/livemode';
|
|
26
|
+
import api from '../../libs/api';
|
|
27
|
+
import { formatPriceAmount, formatRecurring } from '../../libs/util';
|
|
28
|
+
|
|
29
|
+
type Props = {
|
|
30
|
+
id: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const fetchData = async (id: string): Promise<TPricingTableExpanded> => {
|
|
34
|
+
const { data } = await api.get(`/api/pricing-tables/${id}`);
|
|
35
|
+
return data;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const groupItemsByRecurring = (items: TPricingTableItem[]) => {
|
|
39
|
+
const grouped: { [key: string]: TPricingTableItem[] } = {};
|
|
40
|
+
const recurring: { [key: string]: PriceRecurring } = {};
|
|
41
|
+
|
|
42
|
+
items.forEach((x) => {
|
|
43
|
+
const key = [x.price.recurring?.interval, x.price.recurring?.interval_count].join('-');
|
|
44
|
+
recurring[key] = x.price.recurring as PriceRecurring;
|
|
45
|
+
|
|
46
|
+
if (!grouped[key]) {
|
|
47
|
+
grouped[key] = [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// @ts-ignore
|
|
51
|
+
grouped[key].push(x);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { recurring, grouped };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export default function PricingTable({ id }: Props) {
|
|
58
|
+
const { t } = useLocaleContext();
|
|
59
|
+
const { error, loading, data } = useRequest(() => fetchData(id));
|
|
60
|
+
const [state, setState] = useSetState({ interval: '', loading: '' });
|
|
61
|
+
const [livemode] = useLocalStorageState('livemode', { defaultValue: true });
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (data && !state.interval) {
|
|
65
|
+
const { recurring } = groupItemsByRecurring(data.items);
|
|
66
|
+
const keys = Object.keys(recurring);
|
|
67
|
+
if (keys[0]) {
|
|
68
|
+
setState({ interval: keys[0] });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
}, [data]);
|
|
73
|
+
|
|
74
|
+
if (error) {
|
|
75
|
+
return (
|
|
76
|
+
<Center>
|
|
77
|
+
<Alert severity="error">{error.message}</Alert>
|
|
78
|
+
</Center>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (loading || !data) {
|
|
83
|
+
return (
|
|
84
|
+
<Center>
|
|
85
|
+
<CircularProgress />
|
|
86
|
+
</Center>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { recurring, grouped } = groupItemsByRecurring(data.items);
|
|
91
|
+
|
|
92
|
+
const onStartCheckoutSession = (priceId: string) => {
|
|
93
|
+
setState({ loading: priceId });
|
|
94
|
+
api
|
|
95
|
+
.post(`/api/pricing-tables/${data.id}/checkout/${priceId}`)
|
|
96
|
+
.then((res) => {
|
|
97
|
+
window.location.href = res.data.url;
|
|
98
|
+
})
|
|
99
|
+
.catch((err) => {
|
|
100
|
+
console.error(err);
|
|
101
|
+
Toast.error(err.message);
|
|
102
|
+
setState({ loading: '' });
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Center>
|
|
108
|
+
<Stack direction="column" alignItems="center" spacing={4}>
|
|
109
|
+
<Typography variant="h4" color="text.primary" fontWeight={600}>
|
|
110
|
+
{data.name}
|
|
111
|
+
{!livemode && <Livemode />}
|
|
112
|
+
</Typography>
|
|
113
|
+
{Object.keys(recurring).length > 1 && (
|
|
114
|
+
<ToggleButtonGroup value={state.interval} onChange={(_, value) => setState({ interval: value })} exclusive>
|
|
115
|
+
{Object.keys(recurring).map((x) => (
|
|
116
|
+
<ToggleButton key={x} value={x} sx={{ textTransform: 'capitalize' }}>
|
|
117
|
+
{formatRecurring(recurring[x] as PriceRecurring)}
|
|
118
|
+
</ToggleButton>
|
|
119
|
+
))}
|
|
120
|
+
</ToggleButtonGroup>
|
|
121
|
+
)}
|
|
122
|
+
<Stack direction="row" flexWrap="wrap" spacing={5}>
|
|
123
|
+
{grouped[state.interval]?.map((x) => {
|
|
124
|
+
return (
|
|
125
|
+
<Fade in>
|
|
126
|
+
<Stack
|
|
127
|
+
key={x.price_id}
|
|
128
|
+
padding={4}
|
|
129
|
+
spacing={2}
|
|
130
|
+
direction="column"
|
|
131
|
+
alignItems="center"
|
|
132
|
+
sx={{
|
|
133
|
+
width: 320,
|
|
134
|
+
cursor: 'pointer',
|
|
135
|
+
border: '1px solid #eee',
|
|
136
|
+
borderRadius: 1,
|
|
137
|
+
transition: 'border-color 0.3s ease 0s, box-shadow 0.3s ease 0s',
|
|
138
|
+
boxShadow: '0 4px 8px rgba(0, 0, 0, 20%)',
|
|
139
|
+
'&:hover': {
|
|
140
|
+
borderColor: '#ddd',
|
|
141
|
+
boxShadow: '0 8px 16px rgba(0, 0, 0, 20%)',
|
|
142
|
+
},
|
|
143
|
+
}}>
|
|
144
|
+
<Box textAlign="center">
|
|
145
|
+
<Typography variant="h5" color="text.primary" fontWeight={600}>
|
|
146
|
+
{x.product.name}
|
|
147
|
+
</Typography>
|
|
148
|
+
<Typography color="text.secondary">{x.product.description}</Typography>
|
|
149
|
+
</Box>
|
|
150
|
+
<Stack direction="row" alignItems="center" spacing={1}>
|
|
151
|
+
<PaymentAmount amount={formatPriceAmount(x.price, data.currency, x.product.unit_label)} />
|
|
152
|
+
<Stack direction="column" alignItems="flex-start">
|
|
153
|
+
<Typography component="span" color="text.secondary" fontSize="0.8rem">
|
|
154
|
+
per
|
|
155
|
+
</Typography>
|
|
156
|
+
<Typography component="span" color="text.secondary" fontSize="0.8rem">
|
|
157
|
+
{formatRecurring(x.price.recurring as PriceRecurring, false, '')}
|
|
158
|
+
</Typography>
|
|
159
|
+
</Stack>
|
|
160
|
+
</Stack>
|
|
161
|
+
<LoadingButton
|
|
162
|
+
fullWidth
|
|
163
|
+
size="large"
|
|
164
|
+
loadingPosition="end"
|
|
165
|
+
variant={x.is_highlight ? 'contained' : 'outlined'}
|
|
166
|
+
color={x.is_highlight ? 'primary' : 'info'}
|
|
167
|
+
sx={{ fontSize: '1.2rem' }}
|
|
168
|
+
loading={state.loading === x.price_id}
|
|
169
|
+
onClick={() => onStartCheckoutSession(x.price_id)}>
|
|
170
|
+
{x.subscription_data?.trial_period_days ? t('checkout.try') : t('checkout.subscription')}
|
|
171
|
+
</LoadingButton>
|
|
172
|
+
{x.product.features.length > 0 && (
|
|
173
|
+
<Box>
|
|
174
|
+
<Typography>{t('checkout.include')}</Typography>
|
|
175
|
+
<List dense>
|
|
176
|
+
{x.product.features.map((f) => (
|
|
177
|
+
<ListItem key={f.name} disableGutters disablePadding>
|
|
178
|
+
<ListItemIcon sx={{ minWidth: 25 }}>
|
|
179
|
+
<CheckOutlined color="success" fontSize="small" />
|
|
180
|
+
</ListItemIcon>
|
|
181
|
+
<ListItemText primary={f.name} />
|
|
182
|
+
</ListItem>
|
|
183
|
+
))}
|
|
184
|
+
</List>
|
|
185
|
+
</Box>
|
|
186
|
+
)}
|
|
187
|
+
</Stack>
|
|
188
|
+
</Fade>
|
|
189
|
+
);
|
|
190
|
+
})}
|
|
191
|
+
</Stack>
|
|
192
|
+
</Stack>
|
|
193
|
+
</Center>
|
|
194
|
+
);
|
|
195
|
+
}
|