payment-kit 1.13.24 → 1.13.26

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.
Files changed (38) hide show
  1. package/README.md +4 -0
  2. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  3. package/api/src/integrations/stripe/handlers/payment-intent.ts +2 -2
  4. package/api/src/integrations/stripe/handlers/setup-intent.ts +1 -1
  5. package/api/src/integrations/stripe/handlers/subscription.ts +2 -2
  6. package/api/src/routes/checkout-sessions.ts +3 -3
  7. package/api/src/routes/index.ts +2 -0
  8. package/api/src/routes/payment-links.ts +0 -1
  9. package/api/src/routes/pricing-table.ts +342 -0
  10. package/api/src/store/migrations/20231017-pricing-table.ts +10 -0
  11. package/api/src/store/models/index.ts +14 -1
  12. package/api/src/store/models/pricing-table.ts +107 -0
  13. package/api/src/store/models/types.ts +53 -0
  14. package/blocklet.yml +1 -1
  15. package/package.json +5 -5
  16. package/src/app.tsx +1 -1
  17. package/src/components/payment-link/actions.tsx +1 -1
  18. package/src/components/payment-link/chrome.tsx +5 -3
  19. package/src/components/payment-link/preview.tsx +8 -5
  20. package/src/components/payment-link/rename.tsx +3 -3
  21. package/src/components/pricing-table/actions.tsx +126 -0
  22. package/src/components/pricing-table/customer-settings.tsx +17 -0
  23. package/src/components/pricing-table/payment-settings.tsx +179 -0
  24. package/src/components/pricing-table/preview.tsx +34 -0
  25. package/src/components/pricing-table/price-item.tsx +64 -0
  26. package/src/components/pricing-table/product-item.tsx +86 -0
  27. package/src/components/pricing-table/product-settings.tsx +195 -0
  28. package/src/components/pricing-table/rename.tsx +67 -0
  29. package/src/libs/util.ts +43 -0
  30. package/src/locales/en.tsx +26 -0
  31. package/src/pages/admin/payments/links/create.tsx +1 -1
  32. package/src/pages/admin/products/index.tsx +8 -13
  33. package/src/pages/admin/products/pricing-tables/create.tsx +140 -0
  34. package/src/pages/admin/products/pricing-tables/detail.tsx +237 -0
  35. package/src/pages/admin/products/pricing-tables/index.tsx +154 -0
  36. package/src/pages/checkout/index.tsx +2 -1
  37. package/src/pages/checkout/pricing-table.tsx +195 -0
  38. package/src/pages/admin/products/pricing-tables.tsx +0 -3
@@ -0,0 +1,195 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { Checkbox, FormControlLabel, MenuItem, Select, Stack, Typography } from '@mui/material';
3
+ import { useSetState } from 'ahooks';
4
+ import { useEffect } from 'react';
5
+ import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
6
+
7
+ import { useProductsContext } from '../../contexts/products';
8
+ import { getProductByPriceId, groupPricingTableItems, isPriceCurrencyAligned } from '../../libs/util';
9
+ import ProductSelect from '../payment-link/product-select';
10
+ import CreateProduct from '../product/create';
11
+ import ProductItem from './product-item';
12
+
13
+ export default function PricingTableProductSettings() {
14
+ const { t } = useLocaleContext();
15
+ const { products, refresh } = useProductsContext();
16
+ const { control, setValue, getValues } = useFormContext();
17
+ const items = useFieldArray({ control, name: 'items' });
18
+ const [state, setState] = useSetState({ creating: false });
19
+ const highlight = useWatch({ control, name: 'highlight' });
20
+
21
+ useEffect(() => {
22
+ if (items.fields.length) {
23
+ const selected: any[] = items.fields.map((x: any) => getProductByPriceId(products, x.price_id));
24
+ const name = selected.length > 1 ? `${selected[0].name} and ${selected.length - 1} more` : selected[0].name;
25
+ setValue('name', name);
26
+ } else {
27
+ setValue('name', '');
28
+ }
29
+ }, [items.fields, setValue, products]);
30
+
31
+ const onProductSelected = (priceId: string) => {
32
+ if (priceId === 'add') {
33
+ setState({ creating: true });
34
+ } else if (priceId) {
35
+ const product = getProductByPriceId(products, priceId);
36
+ if (product) {
37
+ items.append({
38
+ price_id: priceId,
39
+ product_id: product.id,
40
+ is_highlight: false,
41
+ highlight_text: 'popular',
42
+ adjustable_quantity: {
43
+ enabled: false,
44
+ maximum: 1,
45
+ minimum: 0,
46
+ },
47
+ after_completion: {
48
+ type: 'hosted_confirmation',
49
+ hosted_confirmation: {
50
+ custom_message: '',
51
+ },
52
+ redirect: {
53
+ url: '',
54
+ },
55
+ },
56
+ allow_promotion_codes: false,
57
+ customer_creation: 'always',
58
+ consent_collection: {
59
+ promotions: 'none',
60
+ terms_of_service: 'none',
61
+ },
62
+ phone_number_collection: {
63
+ enabled: false,
64
+ },
65
+ billing_address_collection: 'auto',
66
+ include_free_trial: false,
67
+ subscription_data: {
68
+ description: '',
69
+ trial_period_days: 0,
70
+ },
71
+ custom_fields: [],
72
+ submit_type: 'auto',
73
+ });
74
+ }
75
+ }
76
+ };
77
+
78
+ const onProductCreated = () => {
79
+ setState({ creating: false });
80
+ refresh();
81
+ };
82
+
83
+ const grouped = groupPricingTableItems(items.fields);
84
+
85
+ return (
86
+ <Stack spacing={2} alignItems="flex-start">
87
+ <Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
88
+ {t('admin.paymentLink.products')}
89
+ </Typography>
90
+ <Stack spacing={2} sx={{ width: '100%' }}>
91
+ {grouped.map((item) => {
92
+ const [productId, prices] = item;
93
+ // @ts-ignore
94
+ const product = products.find((x) => x.id === productId);
95
+ if (!product) {
96
+ return null;
97
+ }
98
+
99
+ return (
100
+ <ProductItem
101
+ key={productId}
102
+ // @ts-ignore
103
+ valid={prices.every((x) => isPriceCurrencyAligned(items.fields, products, x.index))}
104
+ product={product}
105
+ prices={prices}
106
+ onUpdate={refresh}
107
+ onRemove={(i: number) => items.remove(i)}
108
+ />
109
+ );
110
+ })}
111
+ {items.fields.some((_, index) => !isPriceCurrencyAligned(items.fields as any[], products, index)) && (
112
+ <Typography color="error" fontSize="small">
113
+ {t('admin.paymentLink.currencyNotAligned')}
114
+ </Typography>
115
+ )}
116
+ <ProductSelect
117
+ mode={items.fields.length ? 'waiting' : 'selecting'}
118
+ onSelect={onProductSelected}
119
+ hasSelected={(price) => price.type !== 'recurring' || items.fields.some((x: any) => x.price_id === price.id)}
120
+ />
121
+ {state.creating && <CreateProduct onCancel={() => setState({ creating: false })} onSave={onProductCreated} />}
122
+ </Stack>
123
+ <Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
124
+ {t('admin.pricingTable.display')}
125
+ </Typography>
126
+ {grouped.length > 0 && (
127
+ <Stack direction="column" spacing={2}>
128
+ <Controller
129
+ name="highlight"
130
+ control={control}
131
+ render={({ field }) => (
132
+ <FormControlLabel
133
+ label={t('admin.pricingTable.highlight')}
134
+ control={
135
+ <Checkbox
136
+ checked={getValues().highlight}
137
+ {...field}
138
+ onChange={(_, checked) => {
139
+ setValue(field.name, checked);
140
+ if (checked && !getValues().highlight_product_id && grouped[0]) {
141
+ setValue('highlight_product_id', grouped[0][0]);
142
+ }
143
+ }}
144
+ />
145
+ }
146
+ />
147
+ )}
148
+ />
149
+ {highlight && (
150
+ <Stack direction="row" alignItems="center" spacing={0.5}>
151
+ <Controller
152
+ name="highlight_product_id"
153
+ control={control}
154
+ render={({ field }) => (
155
+ <Select {...field} size="small">
156
+ {grouped.map(([productId]) => {
157
+ const product = products.find((x) => x.id === productId);
158
+ if (!product) {
159
+ return null;
160
+ }
161
+
162
+ return (
163
+ <MenuItem key={productId} value={productId}>
164
+ {product.name}
165
+ </MenuItem>
166
+ );
167
+ })}
168
+ </Select>
169
+ )}
170
+ />
171
+ <Typography>as</Typography>
172
+ <Controller
173
+ name="highlight_text"
174
+ control={control}
175
+ render={({ field }) => (
176
+ <Select {...field} size="small">
177
+ <MenuItem key="deal" value="deal">
178
+ Best deal
179
+ </MenuItem>
180
+ <MenuItem key="popular" value="popular">
181
+ Most popular
182
+ </MenuItem>
183
+ <MenuItem key="recommended" value="recommended">
184
+ Recommended
185
+ </MenuItem>
186
+ </Select>
187
+ )}
188
+ />
189
+ </Stack>
190
+ )}
191
+ </Stack>
192
+ )}
193
+ </Stack>
194
+ );
195
+ }
@@ -0,0 +1,67 @@
1
+ import Dialog from '@arcblock/ux/lib/Dialog';
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import type { TPricingTable } from '@did-pay/types';
4
+ import { Button, CircularProgress, Stack } from '@mui/material';
5
+ import type { EventHandler } from 'react';
6
+ import { FormProvider, useForm } from 'react-hook-form';
7
+
8
+ import TextInput from '../input';
9
+
10
+ export default function RenamePricingTable({
11
+ data,
12
+ loading,
13
+ onSave,
14
+ onCancel,
15
+ }: {
16
+ data: TPricingTable;
17
+ loading: boolean;
18
+ onSave: EventHandler<any>;
19
+ onCancel: EventHandler<any>;
20
+ }) {
21
+ const { t } = useLocaleContext();
22
+ const methods = useForm<TPricingTable>({
23
+ defaultValues: {
24
+ name: data.name,
25
+ },
26
+ });
27
+
28
+ const { handleSubmit, reset } = methods;
29
+ const onSubmit = () => {
30
+ handleSubmit(async (formData: any) => {
31
+ await onSave(formData);
32
+ reset();
33
+ onCancel(null);
34
+ })();
35
+ };
36
+
37
+ return (
38
+ <Dialog
39
+ open
40
+ disableEscapeKeyDown
41
+ fullWidth
42
+ maxWidth="sm"
43
+ onClose={() => onCancel(null)}
44
+ showCloseButton={false}
45
+ title={t('admin.product.edit')}
46
+ actions={
47
+ <Stack direction="row">
48
+ <Button size="small" sx={{ mr: 2 }} onClick={onCancel}>
49
+ {t('common.cancel')}
50
+ </Button>
51
+ <Button variant="contained" color="primary" size="small" disabled={loading} onClick={onSubmit}>
52
+ {loading && <CircularProgress size="small" />} {t('common.save')}
53
+ </Button>
54
+ </Stack>
55
+ }>
56
+ <FormProvider {...methods}>
57
+ <TextInput
58
+ name="name"
59
+ rules={{ required: true }}
60
+ label={t('admin.paymentLink.name.label')}
61
+ placeholder={t('admin.paymentLink.name.placeholder')}
62
+ autoFocus
63
+ />
64
+ </FormProvider>
65
+ </Dialog>
66
+ );
67
+ }
package/src/libs/util.ts CHANGED
@@ -159,6 +159,31 @@ export const formatPrice = (
159
159
  return `${amount} ${currency.symbol}`;
160
160
  };
161
161
 
162
+ export const formatPriceAmount = (
163
+ price: TPrice,
164
+ currency: TPaymentCurrency,
165
+ unit_label?: string,
166
+ quantity: number = 1,
167
+ bn: boolean = true
168
+ ) => {
169
+ const unit = getPriceUintAmountByCurrency(price, currency);
170
+ const amount = bn
171
+ ? fromUnitToToken(new BN(unit).mul(new BN(quantity)), currency.decimal).toString()
172
+ : +unit * quantity;
173
+ if (price?.type === 'recurring' && price.recurring) {
174
+ if (unit_label) {
175
+ return `${amount} ${currency.symbol} / ${unit_label}`;
176
+ }
177
+ if (price.recurring.usage_type === 'metered') {
178
+ return `${amount} ${currency.symbol} / unit`;
179
+ }
180
+
181
+ return `${amount} ${currency.symbol}`;
182
+ }
183
+
184
+ return `${amount} ${currency.symbol}`;
185
+ };
186
+
162
187
  export function getPricingModel(price: TPrice) {
163
188
  if (price.billing_scheme === 'tiered') {
164
189
  return price.tiers_mode;
@@ -179,6 +204,11 @@ export function getProductByPriceId(products: TProductExpanded[], priceId: strin
179
204
  return product;
180
205
  }
181
206
 
207
+ export function getPriceFromProducts(products: TProductExpanded[], priceId: string) {
208
+ const product = products.find((x) => x.prices.some((p) => p.id === priceId));
209
+ return product?.prices.find((x) => x.id === priceId);
210
+ }
211
+
182
212
  export function getStatementDescriptor(items: any[]) {
183
213
  for (const item of items) {
184
214
  if (item.price?.product?.statement_descriptor) {
@@ -588,3 +618,16 @@ export function stopEvent(e: React.SyntheticEvent<any>) {
588
618
  // Do nothing
589
619
  }
590
620
  }
621
+
622
+ export function groupPricingTableItems(items: any[]) {
623
+ const grouped: { [key: string]: any[] } = {};
624
+ items.forEach((x: any, index) => {
625
+ x.index = index;
626
+ if (!grouped[x.product_id]) {
627
+ grouped[x.product_id] = [];
628
+ }
629
+ grouped[x.product_id]?.push(x);
630
+ });
631
+
632
+ return Object.entries(grouped);
633
+ }
@@ -45,6 +45,8 @@ export default flat({
45
45
  loadingMore: 'Loading more {resource}...',
46
46
  noMore: 'No more {resource}',
47
47
  copied: 'Copied',
48
+ previous: 'Back',
49
+ continue: 'Continue',
48
50
  metadata: {
49
51
  label: 'Metadata',
50
52
  add: 'Add more metadata',
@@ -212,6 +214,28 @@ export default flat({
212
214
  placeholder: 'Not consumer facing',
213
215
  },
214
216
  },
217
+ pricingTable: {
218
+ view: 'View pricing table',
219
+ add: 'Create pricing table',
220
+ save: 'Create',
221
+ copyLink: 'Copy URL',
222
+ saved: 'Pricing table successfully saved',
223
+ edit: 'Edit pricing table',
224
+ rename: 'Change name',
225
+ archive: 'Archive pricing table',
226
+ archiveTip:
227
+ 'Archiving will hide this pricing table from new purchases. Are you sure you want to archive this pricing table?',
228
+ remove: 'Remove pricing table',
229
+ removeTip:
230
+ 'Removing will hide this pricing table from new purchases. Are you sure you want to remove this pricing table?',
231
+ name: {
232
+ label: 'Name',
233
+ placeholder: 'Not consumer facing',
234
+ },
235
+ display: 'Display Settings',
236
+ highlight: 'Highlight product',
237
+ customer: 'Customer portal',
238
+ },
215
239
  paymentIntent: {
216
240
  name: 'Payment',
217
241
  view: 'View payment detail',
@@ -398,6 +422,8 @@ export default flat({
398
422
  method: 'Payment method',
399
423
  processing: 'Processing',
400
424
  payment: 'Pay',
425
+ try: 'Try for free',
426
+ include: 'This includes:',
401
427
  subscription: 'Subscribe',
402
428
  setup: 'Subscribe',
403
429
  continue: 'Confirm {action}',
@@ -65,7 +65,7 @@ export default function CreatePaymentLink() {
65
65
  },
66
66
  metadata: [], // FIXME:
67
67
  custom_fields: [], // FIXME:
68
- submit_type: 'pay', // FIXME:
68
+ submit_type: 'auto', // FIXME:
69
69
  },
70
70
  });
71
71
 
@@ -1,7 +1,5 @@
1
- import Button from '@arcblock/ux/lib/Button';
2
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
2
  import Tabs from '@arcblock/ux/lib/Tabs';
4
- import { AddOutlined } from '@mui/icons-material';
5
3
  import { Stack, Typography } from '@mui/material';
6
4
  import React, { isValidElement } from 'react';
7
5
  import { useNavigate, useParams } from 'react-router-dom';
@@ -9,6 +7,8 @@ import { useNavigate, useParams } from 'react-router-dom';
9
7
  const ProductCreate = React.lazy(() => import('./products/create'));
10
8
  const ProductDetail = React.lazy(() => import('./products/detail'));
11
9
  const PriceDetail = React.lazy(() => import('./prices/detail'));
10
+ const PricingTableCreate = React.lazy(() => import('./pricing-tables/create'));
11
+ const PricingTableDetail = React.lazy(() => import('./pricing-tables/detail'));
12
12
 
13
13
  const pages = {
14
14
  products: React.lazy(() => import('./products')),
@@ -29,6 +29,10 @@ export default function Products() {
29
29
  return <PriceDetail id={page} />;
30
30
  }
31
31
 
32
+ if (page.startsWith('prctbl_')) {
33
+ return <PricingTableDetail id={page} />;
34
+ }
35
+
32
36
  // @ts-ignore
33
37
  const TabComponent = pages[page] || pages.products;
34
38
  const tabs = [
@@ -40,17 +44,8 @@ export default function Products() {
40
44
  let extra = null;
41
45
  if (page === 'products') {
42
46
  extra = <ProductCreate />;
43
- } else if (page === 'coupons') {
44
- extra = (
45
- <Button
46
- variant="contained"
47
- size="small"
48
- color="primary"
49
- onClick={() => navigate('/admin/products/coupons/create')}>
50
- <AddOutlined />
51
- {t('admin.coupon.create')}
52
- </Button>
53
- );
47
+ } else if (page === 'pricing-tables') {
48
+ extra = <PricingTableCreate />;
54
49
  }
55
50
 
56
51
  return (
@@ -0,0 +1,140 @@
1
+ /* eslint-disable no-nested-ternary */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import Toast from '@arcblock/ux/lib/Toast';
4
+ import type { TPricingTable } from '@did-pay/types';
5
+ import { AddOutlined } from '@mui/icons-material';
6
+ import { Box, Button, Stack, Typography } from '@mui/material';
7
+ import { useEffect, useState } from 'react';
8
+ import { FormProvider, useForm } from 'react-hook-form';
9
+ import { dispatch } from 'use-bus';
10
+
11
+ import DrawerForm from '../../../../components/drawer-form';
12
+ import PricingTableCustomerSettings from '../../../../components/pricing-table/customer-settings';
13
+ import PricingTablePaymentSettings from '../../../../components/pricing-table/payment-settings';
14
+ import PricingTablePreview from '../../../../components/pricing-table/preview';
15
+ import PricingTableProductSettings from '../../../../components/pricing-table/product-settings';
16
+ import { ProductsProvider } from '../../../../contexts/products';
17
+ import { useSessionContext } from '../../../../contexts/session';
18
+ import api from '../../../../libs/api';
19
+ import { formatError } from '../../../../libs/util';
20
+
21
+ export default function CreatePricingTable() {
22
+ const { t } = useLocaleContext();
23
+ const { session } = useSessionContext();
24
+ const [step, setStep] = useState(0); // ['products', 'payment', 'portal']
25
+ const [stashed, setStashed] = useState(0);
26
+
27
+ const methods = useForm<TPricingTable & any>({
28
+ shouldUnregister: false,
29
+ defaultValues: {
30
+ name: '',
31
+ branding_settings: {
32
+ background_color: '#ffffff',
33
+ border_style: 'default',
34
+ button_color: '#0074d4',
35
+ font_family: 'default',
36
+ },
37
+ highlight: false,
38
+ highlight_product_id: '',
39
+ highlight_text: 'popular',
40
+ items: [],
41
+ metadata: [], // FIXME:
42
+ },
43
+ });
44
+
45
+ const changes = methods.watch(['items', 'branding_settings']);
46
+
47
+ useEffect(() => {
48
+ api.post('/api/pricing-tables/stash', methods.getValues()).then(() => {
49
+ setStashed(stashed + 1);
50
+ });
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [JSON.stringify(changes)]);
53
+
54
+ const onSubmit = (data: TPricingTable) => {
55
+ if (data.items.length === 0) {
56
+ Toast.error(t('admin.paymentLink.noProducts'));
57
+ return;
58
+ }
59
+
60
+ api
61
+ .post('/api/pricing-tables', data)
62
+ .then(() => {
63
+ Toast.success(t('admin.pricingTable.saved'));
64
+ methods.reset();
65
+ dispatch('drawer.submitted');
66
+ dispatch('pricingTable.created');
67
+ })
68
+ .catch((err) => {
69
+ console.error(err);
70
+ Toast.error(formatError(err));
71
+ });
72
+ };
73
+
74
+ const onPrevious = () => {
75
+ if (step > 0) {
76
+ setStep(step - 1);
77
+ }
78
+ };
79
+
80
+ const onContinue = () => {
81
+ if (step < 2) {
82
+ setStep(step + 1);
83
+ } else {
84
+ methods.handleSubmit(async (formData: any) => {
85
+ await onSubmit(formData);
86
+ })();
87
+ }
88
+ };
89
+
90
+ return (
91
+ <DrawerForm
92
+ icon={<AddOutlined />}
93
+ text={t('admin.pricingTable.add')}
94
+ width={1280}
95
+ addons={
96
+ // @ts-ignore
97
+ <Button variant="contained" size="small" onClick={methods.handleSubmit(onSubmit)}>
98
+ {t('admin.pricingTable.save')}
99
+ </Button>
100
+ }>
101
+ <FormProvider {...methods}>
102
+ <ProductsProvider>
103
+ <Stack height="92vh" spacing={2} direction="row">
104
+ <Box flex={1} sx={{ borderRight: '1px solid #eee' }} position="relative">
105
+ <Stack height="100%" spacing={2}>
106
+ <Box overflow="auto" sx={{ pr: 2 }}>
107
+ {step === 0 && <PricingTableProductSettings />}
108
+ {step === 1 && <PricingTablePaymentSettings />}
109
+ {step === 2 && <PricingTableCustomerSettings />}
110
+ </Box>
111
+ <Stack
112
+ padding={2}
113
+ spacing={2}
114
+ width="100%"
115
+ direction="row"
116
+ alignItems="center"
117
+ justifyContent="flex-end"
118
+ position="absolute"
119
+ sx={{ borderTop: '1px solid #eee', left: 0, bottom: 0 }}>
120
+ <Button variant="text" color="inherit" disabled={step === 0} onClick={onPrevious}>
121
+ {t('common.previous')}
122
+ </Button>
123
+ <Button variant="contained" color="primary" onClick={onContinue}>
124
+ {step === 2 ? t('common.save') : t('common.continue')}
125
+ </Button>
126
+ </Stack>
127
+ </Stack>
128
+ </Box>
129
+ <Box flex={2}>
130
+ <Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
131
+ {t('common.preview')}
132
+ </Typography>
133
+ {stashed && <PricingTablePreview id={`prctbl_${session.user.did}`} version={stashed} />}
134
+ </Box>
135
+ </Stack>
136
+ </ProductsProvider>
137
+ </FormProvider>
138
+ </DrawerForm>
139
+ );
140
+ }