payment-kit 1.13.23 → 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/jobs/event.ts +10 -4
- package/api/src/jobs/webhook.ts +17 -8
- package/api/src/libs/audit.ts +3 -3
- package/api/src/libs/event.ts +3 -0
- package/api/src/libs/util.ts +5 -0
- package/api/src/routes/checkout-sessions.ts +3 -3
- package/api/src/routes/connect/pay.ts +1 -1
- 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/routes/subscriptions.ts +15 -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 +2 -2
- package/package.json +4 -3
- package/src/app.tsx +1 -1
- package/src/components/blockchain/tx.tsx +8 -0
- package/src/components/payment-link/actions.tsx +20 -9
- 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/price/form.tsx +4 -1
- 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 +54 -5
- package/src/locales/en.tsx +28 -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/admin/products/products/create.tsx +8 -4
- 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,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
|
+
}
|
|
@@ -34,7 +34,7 @@ export default function ProductsCreate() {
|
|
|
34
34
|
metadata: [],
|
|
35
35
|
},
|
|
36
36
|
});
|
|
37
|
-
const { control, handleSubmit } = methods;
|
|
37
|
+
const { control, handleSubmit, getValues } = methods;
|
|
38
38
|
|
|
39
39
|
const prices = useFieldArray({ control, name: 'prices' });
|
|
40
40
|
const getPrice = (index: number) => methods.getValues().prices[index];
|
|
@@ -79,10 +79,14 @@ export default function ProductsCreate() {
|
|
|
79
79
|
expanded
|
|
80
80
|
style={{ fontWeight: 'bold', width: '50%' }}
|
|
81
81
|
addons={<PriceActions onDuplicate={() => prices.append(price)} onRemove={() => prices.remove(index)} />}
|
|
82
|
-
trigger={(expanded: boolean) =>
|
|
82
|
+
trigger={(expanded: boolean) => {
|
|
83
|
+
if (expanded) {
|
|
84
|
+
return t('admin.price.detail');
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
// @ts-ignore
|
|
84
|
-
|
|
85
|
-
}>
|
|
88
|
+
return formatPrice(getPrice(index), settings.baseCurrency, getValues().unit_label, 1, false);
|
|
89
|
+
}}>
|
|
86
90
|
<PriceForm prefix={`prices.${index}`} />
|
|
87
91
|
</Collapse>
|
|
88
92
|
<Divider sx={{ mt: 2, mb: 4 }} />
|
|
@@ -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
|
+
}
|