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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.24",
3
+ "version": "1.13.26",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev",
6
6
  "eject": "vite eject",
@@ -45,8 +45,8 @@
45
45
  "@arcblock/did-connect": "^2.7.28",
46
46
  "@arcblock/did-util": "^1.18.92",
47
47
  "@arcblock/ux": "^2.7.28",
48
- "@blocklet/logger": "^1.16.16",
49
- "@blocklet/sdk": "^1.16.16",
48
+ "@blocklet/logger": "1.16.17-beta-ce49fe0e",
49
+ "@blocklet/sdk": "1.16.17-beta-ce49fe0e",
50
50
  "@blocklet/ui-react": "^2.7.28",
51
51
  "@blocklet/uploader": "^0.0.26",
52
52
  "@mui/icons-material": "^5.14.13",
@@ -101,7 +101,7 @@
101
101
  "devDependencies": {
102
102
  "@arcblock/eslint-config": "^0.2.4",
103
103
  "@arcblock/eslint-config-ts": "^0.2.4",
104
- "@did-pay/types": "1.13.24",
104
+ "@did-pay/types": "1.13.26",
105
105
  "@types/cookie-parser": "^1.4.4",
106
106
  "@types/cors": "^2.8.14",
107
107
  "@types/dotenv-flow": "^3.3.1",
@@ -138,5 +138,5 @@
138
138
  "parser": "typescript"
139
139
  }
140
140
  },
141
- "gitHead": "abe08b8cb109399d3b53b985f628d9688b561c0c"
141
+ "gitHead": "d58aed96307bf2c0e3fdcfcd6d98992436b290fd"
142
142
  }
package/src/app.tsx CHANGED
@@ -36,7 +36,7 @@ function App() {
36
36
  <ErrorBoundary FallbackComponent={ErrorFallback} onReset={window.location.reload}>
37
37
  <Suspense
38
38
  fallback={
39
- <Center relative="parent">
39
+ <Center>
40
40
  <CircularProgress />
41
41
  </Center>
42
42
  }>
@@ -97,7 +97,7 @@ export default function PaymentLinkActions({ data, variant, onChange }: Props) {
97
97
  />
98
98
  {state.action === 'rename' && (
99
99
  <RenamePaymentLink
100
- paymentLink={data}
100
+ data={data}
101
101
  loading={state.loading}
102
102
  onSave={onUpdate}
103
103
  onCancel={() => setState({ action: '' })}
@@ -1,7 +1,11 @@
1
1
  import { Box } from '@mui/material';
2
2
  import { styled } from '@mui/system';
3
3
 
4
- const Chrome = styled(Box)`
4
+ export default function Chrome({ children, ...props }: any) {
5
+ return <Root {...props}>{children}</Root>;
6
+ }
7
+
8
+ const Root = styled(Box)`
5
9
  background-color: #fcfeff;
6
10
  border-radius: 8px;
7
11
  margin-top: 40px;
@@ -9,5 +13,3 @@ const Chrome = styled(Box)`
9
13
  overflow: hidden;
10
14
  box-shadow: 0 20px 44px #32325d1f, 0 -1px 32px #32325d0f, 0 3px 12px #00000014;
11
15
  `;
12
-
13
- export default Chrome;
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unsafe-optional-chaining */
1
2
  import { Box } from '@mui/material';
2
3
  import { useSize } from 'ahooks';
3
4
  import IframeResizer from 'iframe-resizer-react';
@@ -13,15 +14,17 @@ export default function PaymentLinkPreview({ id, version }: { id: string; versio
13
14
  const ref = useRef(null);
14
15
  const size = useSize(ref);
15
16
  return (
16
- <Chrome ref={ref}>
17
- <Box sx={{ width: '100%' }}>&nbsp;</Box>
17
+ <Chrome>
18
+ <Box ref={ref} sx={{ width: '100%' }}>
19
+ &nbsp;
20
+ </Box>
18
21
  <IframeResizer
19
22
  style={{
20
- width: '1px',
21
- minWidth: size?.width,
23
+ // @ts-ignore
24
+ minWidth: size?.width / 0.8,
22
25
  minHeight: '64vh',
23
26
  transform: 'scale(0.8)',
24
- transformOrigin: 'center',
27
+ transformOrigin: 'top left',
25
28
  border: 'none',
26
29
  }}
27
30
  src={`${window.blocklet.prefix}checkout/pay/${id}?preview=1&version=${version}`}
@@ -8,12 +8,12 @@ import { FormProvider, useForm } from 'react-hook-form';
8
8
  import TextInput from '../input';
9
9
 
10
10
  export default function RenamePaymentLink({
11
- paymentLink,
11
+ data,
12
12
  loading,
13
13
  onSave,
14
14
  onCancel,
15
15
  }: {
16
- paymentLink: TPaymentLink;
16
+ data: TPaymentLink;
17
17
  loading: boolean;
18
18
  onSave: EventHandler<any>;
19
19
  onCancel: EventHandler<any>;
@@ -21,7 +21,7 @@ export default function RenamePaymentLink({
21
21
  const { t } = useLocaleContext();
22
22
  const methods = useForm<TPaymentLink>({
23
23
  defaultValues: {
24
- name: paymentLink.name,
24
+ name: data.name,
25
25
  },
26
26
  });
27
27
 
@@ -0,0 +1,126 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import type { TPricingTableExpanded } from '@did-pay/types';
4
+ import { useSetState } from 'ahooks';
5
+ import Copy from 'copy-to-clipboard';
6
+ import type { LiteralUnion } from 'type-fest';
7
+ import { joinURL } from 'ufo';
8
+
9
+ import api from '../../libs/api';
10
+ import { formatError } from '../../libs/util';
11
+ import Actions from '../actions';
12
+ import ClickBoundary from '../click-boundary';
13
+ import ConfirmDialog from '../confirm';
14
+ import RenamePricingTable from './rename';
15
+
16
+ type Props = {
17
+ data: TPricingTableExpanded;
18
+ onChange: (action: string) => void;
19
+ variant?: LiteralUnion<'compact' | 'normal', string>;
20
+ };
21
+
22
+ PricingTableActions.defaultProps = {
23
+ variant: 'compact',
24
+ };
25
+
26
+ export default function PricingTableActions({ data, variant, onChange }: Props) {
27
+ const { t } = useLocaleContext();
28
+ const [state, setState] = useSetState({
29
+ action: '',
30
+ loading: false,
31
+ });
32
+
33
+ const onUpdate = async (updates: TPricingTableExpanded) => {
34
+ try {
35
+ setState({ loading: true });
36
+ await api.put(`/api/pricing-tables/${data.id}`, updates).then((res) => res.data);
37
+ Toast.success(t('common.saved'));
38
+ onChange(state.action);
39
+ } catch (err) {
40
+ console.error(err);
41
+ Toast.error(formatError(err));
42
+ } finally {
43
+ setState({ loading: false, action: '' });
44
+ }
45
+ };
46
+ const onArchive = async () => {
47
+ try {
48
+ setState({ loading: true });
49
+ await api.put(`/api/pricing-tables/${data.id}/archive`).then((res) => res.data);
50
+ Toast.success(t('common.saved'));
51
+ onChange(state.action);
52
+ } catch (err) {
53
+ console.error(err);
54
+ Toast.error(formatError(err));
55
+ } finally {
56
+ setState({ loading: false, action: '' });
57
+ }
58
+ };
59
+ const onRemove = async () => {
60
+ try {
61
+ setState({ loading: true });
62
+ await api.delete(`/api/pricing-tables/${data.id}`).then((res) => res.data);
63
+ Toast.success(t('common.removed'));
64
+ onChange(state.action);
65
+ } catch (err) {
66
+ console.error(err);
67
+ Toast.error(formatError(err));
68
+ } finally {
69
+ setState({ loading: false, action: '' });
70
+ }
71
+ };
72
+ const onCopyLink = () => {
73
+ Copy(joinURL(window.blocklet.appUrl, window.blocklet.prefix, `/checkout/pricing-table/${data.id}`));
74
+ Toast.success(t('common.copied'));
75
+ };
76
+
77
+ return (
78
+ <ClickBoundary>
79
+ <Actions
80
+ variant={variant}
81
+ actions={[
82
+ {
83
+ label: t('admin.pricingTable.edit'),
84
+ handler: () => setState({ action: 'edit' }),
85
+ color: 'primary',
86
+ disabled: true,
87
+ },
88
+ {
89
+ label: t('admin.pricingTable.copyLink'),
90
+ handler: onCopyLink,
91
+ color: 'primary',
92
+ },
93
+ { label: t('admin.pricingTable.rename'), handler: () => setState({ action: 'rename' }), color: 'primary' },
94
+ { label: t('admin.pricingTable.archive'), handler: () => setState({ action: 'archive' }), color: 'primary' },
95
+ { label: t('admin.pricingTable.remove'), handler: () => setState({ action: 'remove' }), color: 'error' },
96
+ ]}
97
+ />
98
+ {state.action === 'rename' && (
99
+ <RenamePricingTable
100
+ data={data}
101
+ loading={state.loading}
102
+ onSave={onUpdate}
103
+ onCancel={() => setState({ action: '' })}
104
+ />
105
+ )}
106
+ {state.action === 'archive' && (
107
+ <ConfirmDialog
108
+ onConfirm={onArchive}
109
+ onCancel={() => setState({ action: '' })}
110
+ title={t('admin.pricingTable.archive')}
111
+ message={t('admin.pricingTable.archiveTip')}
112
+ loading={state.loading}
113
+ />
114
+ )}
115
+ {state.action === 'remove' && (
116
+ <ConfirmDialog
117
+ onConfirm={onRemove}
118
+ onCancel={() => setState({ action: '' })}
119
+ title={t('admin.pricingTable.remove')}
120
+ message={t('admin.pricingTable.removeTip')}
121
+ loading={state.loading}
122
+ />
123
+ )}
124
+ </ClickBoundary>
125
+ );
126
+ }
@@ -0,0 +1,17 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { Stack, Typography } from '@mui/material';
3
+
4
+ export default function PricingTableCustomerSettings() {
5
+ const { t } = useLocaleContext();
6
+
7
+ return (
8
+ <Stack spacing={2} alignItems="flex-start">
9
+ <Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
10
+ {t('admin.pricingTable.customer')}
11
+ </Typography>
12
+ <Stack spacing={2} sx={{ width: '100%' }}>
13
+ FIXME
14
+ </Stack>
15
+ </Stack>
16
+ );
17
+ }
@@ -0,0 +1,179 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Tabs from '@arcblock/ux/lib/Tabs';
3
+ import type { TPrice, TProduct } from '@did-pay/types';
4
+ import { Box, Checkbox, FormControlLabel, Stack, TextField, Typography } from '@mui/material';
5
+ import get from 'lodash/get';
6
+ import { useState } from 'react';
7
+ import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
8
+
9
+ import { useProductsContext } from '../../contexts/products';
10
+ import { useSettingsContext } from '../../contexts/settings';
11
+ import { formatPrice, getPriceFromProducts, groupPricingTableItems } from '../../libs/util';
12
+ import IconCollapse from '../collapse';
13
+
14
+ export function PricePaymentSettings({ index }: { index: number }) {
15
+ const getFieldName = (name: string) => `items.${index}.${name}`;
16
+
17
+ const { t } = useLocaleContext();
18
+ const { control, setValue, getValues } = useFormContext();
19
+ const type = useWatch({ control, name: getFieldName('after_completion.type') });
20
+
21
+ const values = getValues();
22
+
23
+ return (
24
+ <Stack spacing={2} mb={2}>
25
+ <Controller
26
+ name={getFieldName('billing_address_collection')}
27
+ control={control}
28
+ render={({ field }) => (
29
+ <FormControlLabel
30
+ control={
31
+ <Checkbox
32
+ checked={get(values, getFieldName('billing_address_collection')) === 'required'}
33
+ {...field}
34
+ onChange={(_, checked) => setValue(field.name, checked ? 'required' : 'auto')}
35
+ />
36
+ }
37
+ label={t('admin.paymentLink.requireBillingAddress')}
38
+ />
39
+ )}
40
+ />
41
+ <Controller
42
+ name={getFieldName('phone_number_collection.enabled')}
43
+ control={control}
44
+ render={({ field }) => (
45
+ <FormControlLabel
46
+ control={
47
+ <Checkbox
48
+ checked={get(values, getFieldName('phone_number_collection.enabled'))}
49
+ {...field}
50
+ onChange={(_, checked) => setValue(field.name, checked)}
51
+ />
52
+ }
53
+ label={t('admin.paymentLink.requirePhoneNumber')}
54
+ />
55
+ )}
56
+ />
57
+ <Controller
58
+ name={getFieldName('allow_promotion_codes')}
59
+ control={control}
60
+ render={({ field }) => (
61
+ <FormControlLabel
62
+ control={
63
+ <Checkbox
64
+ checked={get(values, getFieldName('allow_promotion_codes'))}
65
+ {...field}
66
+ onChange={(_, checked) => setValue(field.name, checked)}
67
+ />
68
+ }
69
+ label={t('admin.paymentLink.allowPromotionCodes')}
70
+ />
71
+ )}
72
+ />
73
+ <Controller
74
+ name={getFieldName('after_completion.type')}
75
+ control={control}
76
+ render={({ field }) => (
77
+ <FormControlLabel
78
+ control={
79
+ <Checkbox
80
+ checked={get(values, getFieldName('after_completion.type')) === 'hosted_confirmation'}
81
+ {...field}
82
+ onChange={(_, checked) => setValue(field.name, checked ? 'hosted_confirmation' : 'redirect')}
83
+ />
84
+ }
85
+ label={t('admin.paymentLink.showConfirmPage')}
86
+ />
87
+ )}
88
+ />
89
+ {type === 'hosted_confirmation' && (
90
+ <Controller
91
+ name={getFieldName('after_completion.hosted_confirmation.custom_message')}
92
+ control={control}
93
+ render={({ field }) => (
94
+ <TextField {...field} placeholder="Replace default success message" fullWidth size="small" />
95
+ )}
96
+ />
97
+ )}
98
+ <Controller
99
+ name={getFieldName('after_completion.type')}
100
+ control={control}
101
+ render={({ field }) => (
102
+ <FormControlLabel
103
+ control={
104
+ <Checkbox
105
+ checked={get(values, getFieldName('after_completion.type')) === 'redirect'}
106
+ {...field}
107
+ onChange={(_, checked) => setValue(field.name, checked ? 'redirect' : 'hosted_confirmation')}
108
+ />
109
+ }
110
+ label={t('admin.paymentLink.noConfirmPage')}
111
+ />
112
+ )}
113
+ />
114
+ {type === 'redirect' && (
115
+ <Controller
116
+ name={getFieldName('after_completion.redirect.url')}
117
+ control={control}
118
+ render={({ field }) => (
119
+ <TextField placeholder="Redirect customers to your site" {...field} fullWidth size="small" />
120
+ )}
121
+ />
122
+ )}
123
+ </Stack>
124
+ );
125
+ }
126
+
127
+ export function ProductPaymentSettings({ product, prices }: { product: TProduct; prices: TPrice[] }) {
128
+ const { settings } = useSettingsContext();
129
+ const { products } = useProductsContext();
130
+ const [current, setCurrent] = useState(prices[0]?.id);
131
+ const tabs = prices.map((x: any) => ({
132
+ value: x.id,
133
+ label: formatPrice(getPriceFromProducts(products, x.price_id) as TPrice, settings.baseCurrency),
134
+ component: <PricePaymentSettings index={x.index} />,
135
+ }));
136
+
137
+ return (
138
+ <Box sx={{ px: 2, py: 0, border: '1px solid #eee', borderRadius: 2 }}>
139
+ <IconCollapse
140
+ key={product.id}
141
+ expanded
142
+ trigger={<Typography variant="h6">{product.name}</Typography>}
143
+ style={{ py: 1 }}>
144
+ <Tabs
145
+ tabs={tabs}
146
+ current={current}
147
+ onChange={(v: string) => setCurrent(v)}
148
+ style={{ width: '100%' }}
149
+ scrollButtons="auto"
150
+ />
151
+ {tabs.find((x) => x.value === current)?.component}
152
+ </IconCollapse>
153
+ </Box>
154
+ );
155
+ }
156
+
157
+ export default function PricingTablePaymentSettings() {
158
+ const { t } = useLocaleContext();
159
+ const { products } = useProductsContext();
160
+ const { control } = useFormContext();
161
+ const items = useFieldArray({ control, name: 'items' });
162
+
163
+ const grouped = groupPricingTableItems(items.fields);
164
+
165
+ return (
166
+ <Stack spacing={2} alignItems="flex-start">
167
+ <Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
168
+ {t('admin.paymentLink.products')}
169
+ </Typography>
170
+ <Stack spacing={2} sx={{ width: '100%' }}>
171
+ {grouped.map((item) => {
172
+ const [productId, prices] = item;
173
+ const product = products.find((x) => x.id === productId);
174
+ return product ? <ProductPaymentSettings key={productId} product={product} prices={prices} /> : null;
175
+ })}
176
+ </Stack>
177
+ </Stack>
178
+ );
179
+ }
@@ -0,0 +1,34 @@
1
+ import { Box } from '@mui/material';
2
+ import { useSize } from 'ahooks';
3
+ import IframeResizer from 'iframe-resizer-react';
4
+ import { useRef } from 'react';
5
+
6
+ import Chrome from '../payment-link/chrome';
7
+
8
+ PricingTablePreview.defaultProps = {
9
+ version: 1,
10
+ };
11
+
12
+ export default function PricingTablePreview({ id, version }: { id: string; version?: number }) {
13
+ const ref = useRef(null);
14
+ const size = useSize(ref);
15
+ return (
16
+ <Chrome>
17
+ <Box ref={ref} sx={{ width: '100%' }}>
18
+ &nbsp;
19
+ </Box>
20
+ <IframeResizer
21
+ style={{
22
+ // @ts-ignore
23
+ // eslint-disable-next-line no-unsafe-optional-chaining
24
+ minWidth: size?.width / 0.8,
25
+ minHeight: '64vh',
26
+ transform: 'scale(0.8)',
27
+ transformOrigin: 'top left',
28
+ border: 'none',
29
+ }}
30
+ src={`${window.blocklet.prefix}checkout/pricing-table/${id}?preview=1&version=${version}`}
31
+ />
32
+ </Chrome>
33
+ );
34
+ }
@@ -0,0 +1,64 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import type { TPrice } from '@did-pay/types';
3
+ import { DeleteOutlineOutlined } from '@mui/icons-material';
4
+ import { Box, Checkbox, FormControlLabel, IconButton, InputAdornment, Stack, Typography } from '@mui/material';
5
+ import { Controller, useFormContext, useWatch } from 'react-hook-form';
6
+
7
+ import { useSettingsContext } from '../../contexts/settings';
8
+ import { formatPrice } from '../../libs/util';
9
+ import FormInput from '../input';
10
+
11
+ type Props = {
12
+ prefix: string;
13
+ price: TPrice;
14
+ onRemove: () => void;
15
+ };
16
+
17
+ export default function PriceItem({ prefix, price, onRemove }: Props) {
18
+ const { t } = useLocaleContext();
19
+ const getFieldName = (name: string) => (prefix ? `${prefix}.${name}` : name);
20
+ const { settings } = useSettingsContext();
21
+ const { control, setValue } = useFormContext();
22
+ const includeFreeTrail = useWatch({ control, name: getFieldName('include_free_trial') });
23
+
24
+ return (
25
+ <Box sx={{ width: '100%' }}>
26
+ <Stack direction="row" alignItems="center" justifyContent="space-between">
27
+ <Typography>{formatPrice(price, settings.baseCurrency)}</Typography>
28
+ <IconButton size="small" onClick={onRemove}>
29
+ <DeleteOutlineOutlined color="inherit" sx={{ opacity: 0.75 }} />
30
+ </IconButton>
31
+ </Stack>
32
+ <Controller
33
+ name={getFieldName('include_free_trial')}
34
+ control={control}
35
+ render={({ field }) => (
36
+ <FormControlLabel
37
+ label={t('admin.paymentLink.includeFreeTrail')}
38
+ control={
39
+ <Checkbox
40
+ sx={{ ml: 1 }}
41
+ {...field}
42
+ onChange={(_, checked) => {
43
+ setValue(field.name, checked);
44
+ }}
45
+ />
46
+ }
47
+ />
48
+ )}
49
+ />
50
+ {includeFreeTrail && (
51
+ <FormInput
52
+ name={getFieldName('subscription_data.trial_period_days')}
53
+ rules={{ required: t('checkout.required') }}
54
+ errorPosition="right"
55
+ sx={{ mt: 0.5 }}
56
+ fullWidth={false}
57
+ InputProps={{
58
+ endAdornment: <InputAdornment position="end">days</InputAdornment>,
59
+ }}
60
+ />
61
+ )}
62
+ </Box>
63
+ );
64
+ }
@@ -0,0 +1,86 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import type { PricingTableItem, TProduct, TProductExpanded } from '@did-pay/types';
4
+ import { Box, Stack } from '@mui/material';
5
+ import { useSetState } from 'ahooks';
6
+
7
+ import { useProductsContext } from '../../contexts/products';
8
+ import api from '../../libs/api';
9
+ import { formatError, getPriceFromProducts } from '../../libs/util';
10
+ import Actions from '../actions';
11
+ import ClickBoundary from '../click-boundary';
12
+ import InfoCard from '../info-card';
13
+ import EditProduct from '../product/edit';
14
+ import PriceItem from './price-item';
15
+
16
+ type Props = {
17
+ product: TProductExpanded;
18
+ prices: PricingTableItem[];
19
+ valid: boolean;
20
+ onUpdate: () => void;
21
+ onRemove: (i: number) => void;
22
+ };
23
+
24
+ export default function ProductItem({ product, prices, valid, onUpdate, onRemove }: Props) {
25
+ const { t } = useLocaleContext();
26
+ const { products } = useProductsContext();
27
+ const [state, setState] = useSetState({ editing: false, loading: false });
28
+
29
+ const onSave = async (updates: TProduct) => {
30
+ try {
31
+ setState({ loading: true });
32
+ await api.put(`/api/products/${product.id}`, updates).then((res) => res.data);
33
+ Toast.success(t('common.saved'));
34
+ onUpdate();
35
+ } catch (err) {
36
+ console.error(err);
37
+ Toast.error(formatError(err));
38
+ } finally {
39
+ setState({ loading: false });
40
+ }
41
+ };
42
+
43
+ return (
44
+ <Box
45
+ sx={{
46
+ p: 2,
47
+ borderWidth: valid ? 1 : 2,
48
+ borderStyle: 'solid',
49
+ borderColor: valid ? '#eee' : 'error.main',
50
+ borderRadius: 2,
51
+ position: 'relative',
52
+ }}>
53
+ <ClickBoundary>
54
+ <Actions
55
+ sx={{ position: 'absolute', top: 8, right: 16 }}
56
+ actions={[
57
+ {
58
+ label: t('admin.product.edit'),
59
+ handler: () => setState({ editing: true }),
60
+ color: 'primary',
61
+ },
62
+ ]}
63
+ />
64
+ </ClickBoundary>
65
+ <InfoCard logo={product.images[0]} name={product.name} description={product.description} />
66
+ <Stack direction="column" spacing={2} alignItems="flex-start">
67
+ {prices.map((x: any) => (
68
+ <PriceItem
69
+ key={x.index}
70
+ prefix={`items.${x.index}`}
71
+ price={getPriceFromProducts(products, x.price_id) as any}
72
+ onRemove={() => onRemove(x.index)}
73
+ />
74
+ ))}
75
+ </Stack>
76
+ {state.editing && (
77
+ <EditProduct
78
+ product={product}
79
+ loading={state.loading}
80
+ onSave={onSave}
81
+ onCancel={() => setState({ editing: false })}
82
+ />
83
+ )}
84
+ </Box>
85
+ );
86
+ }