payment-kit 1.13.39 → 1.13.41

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 (32) hide show
  1. package/api/src/routes/checkout-sessions.ts +183 -47
  2. package/api/src/routes/payment-links.ts +2 -0
  3. package/api/src/routes/pricing-table.ts +23 -6
  4. package/api/src/routes/products.ts +4 -40
  5. package/api/src/routes/redirect.ts +4 -2
  6. package/api/src/store/migrate.ts +17 -0
  7. package/api/src/store/migrations/20231021-nft.ts +18 -9
  8. package/api/src/store/migrations/20231023-upsell.ts +5 -2
  9. package/api/src/store/migrations/20231030-crosssell.ts +22 -0
  10. package/api/src/store/models/checkout-session.ts +11 -5
  11. package/api/src/store/models/index.ts +9 -1
  12. package/api/src/store/models/payment-link.ts +6 -0
  13. package/api/src/store/models/price.ts +23 -18
  14. package/api/src/store/models/product.ts +73 -14
  15. package/api/src/store/models/types.ts +3 -0
  16. package/blocklet.yml +1 -1
  17. package/package.json +10 -10
  18. package/src/components/checkout/pay.tsx +23 -0
  19. package/src/components/checkout/product-item.tsx +30 -20
  20. package/src/components/checkout/summary.tsx +112 -4
  21. package/src/components/payment-link/before-pay.tsx +16 -0
  22. package/src/components/price/upsell-select.tsx +7 -2
  23. package/src/components/pricing-table/payment-settings.tsx +16 -0
  24. package/src/components/pricing-table/product-settings.tsx +1 -0
  25. package/src/components/product/cross-sell-select.tsx +51 -0
  26. package/src/components/product/cross-sell.tsx +83 -0
  27. package/src/locales/en.tsx +11 -0
  28. package/src/locales/zh.tsx +11 -0
  29. package/src/pages/admin/payments/links/create.tsx +1 -0
  30. package/src/pages/admin/products/products/detail.tsx +7 -0
  31. package/src/pages/checkout/pay.tsx +3 -5
  32. package/src/pages/checkout/pricing-table.tsx +1 -1
@@ -160,10 +160,6 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
160
160
  type: DataTypes.JSON,
161
161
  defaultValue: [],
162
162
  },
163
- upsell: {
164
- type: DataTypes.JSON,
165
- allowNull: true,
166
- },
167
163
  created_at: {
168
164
  type: DataTypes.DATE,
169
165
  defaultValue: DataTypes.NOW,
@@ -181,21 +177,30 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
181
177
 
182
178
  // eslint-disable-next-line @typescript-eslint/no-shadow
183
179
  public static initialize(sequelize: any) {
184
- this.init(Price.GENESIS_ATTRIBUTES, {
185
- sequelize,
186
- modelName: 'Price',
187
- tableName: 'prices',
188
- createdAt: 'created_at',
189
- updatedAt: 'updated_at',
190
- hooks: {
191
- afterCreate: (model: Price, options) =>
192
- createEvent('Price', 'price.created', model, options).catch(console.error),
193
- afterUpdate: (model: Price, options) =>
194
- createEvent('Price', 'price.updated', model, options).catch(console.error),
195
- afterDestroy: (model: Price, options) =>
196
- createEvent('Price', 'price.deleted', model, options).catch(console.error),
180
+ this.init(
181
+ {
182
+ ...Price.GENESIS_ATTRIBUTES,
183
+ upsell: {
184
+ type: DataTypes.JSON,
185
+ allowNull: true,
186
+ },
197
187
  },
198
- });
188
+ {
189
+ sequelize,
190
+ modelName: 'Price',
191
+ tableName: 'prices',
192
+ createdAt: 'created_at',
193
+ updatedAt: 'updated_at',
194
+ hooks: {
195
+ afterCreate: (model: Price, options) =>
196
+ createEvent('Price', 'price.created', model, options).catch(console.error),
197
+ afterUpdate: (model: Price, options) =>
198
+ createEvent('Price', 'price.updated', model, options).catch(console.error),
199
+ afterDestroy: (model: Price, options) =>
200
+ createEvent('Price', 'price.deleted', model, options).catch(console.error),
201
+ },
202
+ }
203
+ );
199
204
  }
200
205
 
201
206
  public static associate(models: any) {
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import pick from 'lodash/pick';
2
3
  import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
4
  import type { LiteralUnion } from 'type-fest';
4
5
 
@@ -46,6 +47,10 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
46
47
  // If set, we will mint an nft to consumer on purchase
47
48
  declare nft_factory?: string;
48
49
 
50
+ declare cross_sell?: {
51
+ cross_sells_to_id: string;
52
+ };
53
+
49
54
  // TODO: goods related props are not supported
50
55
  // declare package_dimensions?: any;
51
56
  // declare shippable?: boolean;
@@ -126,21 +131,30 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
126
131
  };
127
132
 
128
133
  public static initialize(sequelize: any) {
129
- this.init(Product.GENESIS_ATTRIBUTES, {
130
- sequelize,
131
- modelName: 'Product',
132
- tableName: 'products',
133
- createdAt: 'created_at',
134
- updatedAt: 'updated_at',
135
- hooks: {
136
- afterCreate: (model: Product, options) =>
137
- createEvent('Product', 'product.created', model, options).catch(console.error),
138
- afterUpdate: (model: Product, options) =>
139
- createEvent('Product', 'product.updated', model, options).catch(console.error),
140
- afterDestroy: (model: Product, options) =>
141
- createEvent('Product', 'product.deleted', model, options).catch(console.error),
134
+ this.init(
135
+ {
136
+ ...Product.GENESIS_ATTRIBUTES,
137
+ cross_sell: {
138
+ type: DataTypes.JSON,
139
+ allowNull: true,
140
+ },
142
141
  },
143
- });
142
+ {
143
+ sequelize,
144
+ modelName: 'Product',
145
+ tableName: 'products',
146
+ createdAt: 'created_at',
147
+ updatedAt: 'updated_at',
148
+ hooks: {
149
+ afterCreate: (model: Product, options) =>
150
+ createEvent('Product', 'product.created', model, options).catch(console.error),
151
+ afterUpdate: (model: Product, options) =>
152
+ createEvent('Product', 'product.updated', model, options).catch(console.error),
153
+ afterDestroy: (model: Product, options) =>
154
+ createEvent('Product', 'product.deleted', model, options).catch(console.error),
155
+ },
156
+ }
157
+ );
144
158
  }
145
159
 
146
160
  public static associate(models: any) {
@@ -154,6 +168,51 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
154
168
  as: 'default_price',
155
169
  });
156
170
  }
171
+
172
+ public static async expand(id: string) {
173
+ // @ts-ignore
174
+ const { Price, PaymentCurrency } = this.sequelize.models;
175
+ const product = await Product.findOne({
176
+ where: { id },
177
+ include: [
178
+ { model: Price, as: 'prices', include: [{ model: PaymentCurrency, as: 'currency' }] },
179
+ { model: Price, as: 'default_price' },
180
+ ],
181
+ });
182
+
183
+ if (product) {
184
+ const doc = product.toJSON();
185
+ // @ts-ignore
186
+ const currencies = await PaymentCurrency.findAll();
187
+ // @ts-ignore
188
+ for (const price of doc.prices) {
189
+ if (Array.isArray(price.currency_options)) {
190
+ price.currency_options.forEach((x: any) => {
191
+ x.currency = currencies.find((c: any) => c.id === x.currency_id);
192
+ });
193
+ const exist = price.currency_options.find((x: any) => x.currency_id === price.currency_id);
194
+ if (!exist) {
195
+ price.currency_options.unshift(
196
+ pick(price, ['currency_id', 'unit_amount', 'tiers', 'custom_unit_amount', 'currency'])
197
+ );
198
+ }
199
+ } else {
200
+ price.currency_options = [
201
+ pick(price, ['currency_id', 'unit_amount', 'tiers', 'custom_unit_amount', 'currency']),
202
+ ];
203
+ }
204
+ }
205
+
206
+ if (doc.cross_sell?.cross_sells_to_id) {
207
+ // @ts-ignore
208
+ doc.cross_sell.cross_sells_to = await Product.expand(doc.cross_sell.cross_sells_to_id);
209
+ }
210
+
211
+ return doc;
212
+ }
213
+
214
+ return null;
215
+ }
157
216
  }
158
217
 
159
218
  export type TProduct = InferAttributes<Product>;
@@ -147,6 +147,7 @@ export type LineItem = {
147
147
  minimum: number;
148
148
  };
149
149
  upsell_price_id?: string;
150
+ cross_sell?: boolean;
150
151
  // TODO: following are not supported
151
152
  // price_data?: any;
152
153
  // dynamic_tax_rates?: any;
@@ -337,6 +338,8 @@ export type PricingTableItem = {
337
338
  };
338
339
 
339
340
  nft_mint_settings?: NftMintSettings;
341
+
342
+ cross_sell_behavior?: LiteralUnion<'auto' | 'required', string>;
340
343
  };
341
344
 
342
345
  export type BrandSettings = {
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.13.39
17
+ version: 1.13.41
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.39",
3
+ "version": "1.13.41",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev",
6
6
  "eject": "vite eject",
@@ -42,13 +42,13 @@
42
42
  "dependencies": {
43
43
  "@arcblock/did": "^1.18.93",
44
44
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
45
- "@arcblock/did-connect": "^2.8.5",
45
+ "@arcblock/did-connect": "^2.8.6",
46
46
  "@arcblock/did-util": "^1.18.93",
47
- "@arcblock/ux": "^2.8.5",
48
- "@blocklet/logger": "1.16.17-beta-952ef53d",
49
- "@blocklet/sdk": "1.16.17-beta-952ef53d",
50
- "@blocklet/ui-react": "^2.8.5",
51
- "@blocklet/uploader": "^0.0.32",
47
+ "@arcblock/ux": "^2.8.6",
48
+ "@blocklet/logger": "1.16.17",
49
+ "@blocklet/sdk": "1.16.17",
50
+ "@blocklet/ui-react": "^2.8.6",
51
+ "@blocklet/uploader": "^0.0.33",
52
52
  "@mui/icons-material": "^5.14.15",
53
53
  "@mui/lab": "^5.0.0-alpha.150",
54
54
  "@mui/material": "^5.14.15",
@@ -100,10 +100,10 @@
100
100
  "validator": "^13.11.0"
101
101
  },
102
102
  "devDependencies": {
103
- "@abtnode/types": "1.16.17-beta-952ef53d",
103
+ "@abtnode/types": "1.16.17",
104
104
  "@arcblock/eslint-config": "^0.2.4",
105
105
  "@arcblock/eslint-config-ts": "^0.2.4",
106
- "@did-pay/types": "1.13.39",
106
+ "@did-pay/types": "1.13.41",
107
107
  "@types/cookie-parser": "^1.4.5",
108
108
  "@types/cors": "^2.8.15",
109
109
  "@types/dotenv-flow": "^3.3.2",
@@ -140,5 +140,5 @@
140
140
  "parser": "typescript"
141
141
  }
142
142
  },
143
- "gitHead": "d50fe71988ab503ca4f0adf0eef4293008a3ae4f"
143
+ "gitHead": "5f150269dbc497d19cc4d8422b03c67d29793cf2"
144
144
  }
@@ -200,6 +200,26 @@ export function CheckoutPayMain({
200
200
  }
201
201
  };
202
202
 
203
+ const onApplyCrossSell = async (to: string) => {
204
+ try {
205
+ const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`, { to });
206
+ setState({ checkoutSession: data });
207
+ } catch (err) {
208
+ console.error(err);
209
+ Toast.error(formatError(err));
210
+ }
211
+ };
212
+
213
+ const onCancelCrossSell = async () => {
214
+ try {
215
+ const { data } = await api.delete(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`);
216
+ setState({ checkoutSession: data });
217
+ } catch (err) {
218
+ console.error(err);
219
+ Toast.error(formatError(err));
220
+ }
221
+ };
222
+
203
223
  return (
204
224
  <FormProvider {...methods}>
205
225
  <PaymentRoot>
@@ -212,6 +232,8 @@ export function CheckoutPayMain({
212
232
  currency={currency}
213
233
  onUpsell={onUpsell}
214
234
  onDownsell={onDownsell}
235
+ onApplyCrossSell={onApplyCrossSell}
236
+ onCancelCrossSell={onCancelCrossSell}
215
237
  />
216
238
  </Stack>
217
239
  </Fade>
@@ -276,6 +298,7 @@ export const PaymentRoot = styled(Box)`
276
298
 
277
299
  .cko-overview {
278
300
  width: 380px;
301
+ min-height: 540px;
279
302
  }
280
303
 
281
304
  .cko-payment {
@@ -13,35 +13,45 @@ type Props = {
13
13
  currency: TPaymentCurrency;
14
14
  onUpsell: Function;
15
15
  onDownsell: Function;
16
+ mode?: 'normal' | 'cross-sell';
17
+ children?: React.ReactNode;
16
18
  };
17
19
 
18
- export default function ProductItem({ item, session, currency, onUpsell, onDownsell }: Props) {
20
+ ProductItem.defaultProps = {
21
+ mode: 'normal',
22
+ children: null,
23
+ };
24
+
25
+ export default function ProductItem({ item, session, currency, mode, children, onUpsell, onDownsell }: Props) {
19
26
  const { t } = useLocaleContext();
20
27
  const pricing = formatLineItemPricing(item, currency, session.subscription_data?.trial_period_days || 0);
21
28
  const saving = formatUpsellSaving(session, currency);
22
29
  const metered = item.price?.recurring?.usage_type === 'metered' ? ' based on usage' : '';
23
- const canUpsell = session.line_items.length === 1;
30
+ const canUpsell = mode === 'normal' && session.line_items.length === 1;
24
31
  return (
25
32
  <Stack direction="column" alignItems="flex-start" spacing={1} sx={{ width: '100%' }}>
26
- <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
27
- <ProductCard
28
- logo={item.price.product?.images[0]}
29
- name={item.price.product?.name}
30
- description={item.price.product?.description}
31
- extra={
32
- item.price.type === 'recurring' && item.price.recurring
33
- ? [pricing.quantity, `billed ${formatRecurring(item.upsell_price?.recurring || item.price.recurring)} ${metered}`].filter(Boolean).join(', ') // prettier-ignore
34
- : pricing.quantity
35
- }
36
- />
37
- <Stack direction="column" alignItems="flex-end" flex={1}>
38
- <Typography sx={{ color: 'text.primary', fontWeight: 500 }} gutterBottom>
39
- {pricing.primary}
40
- </Typography>
41
- {pricing.secondary && (
42
- <Typography sx={{ fontSize: '0.85rem', color: 'text.secondary' }}>{pricing.secondary}</Typography>
43
- )}
33
+ <Stack direction="column" alignItems="flex-end" sx={{ width: '100%' }}>
34
+ <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
35
+ <ProductCard
36
+ logo={item.price.product?.images[0]}
37
+ name={item.price.product?.name}
38
+ description={item.price.product?.description}
39
+ extra={
40
+ item.price.type === 'recurring' && item.price.recurring
41
+ ? [pricing.quantity, `billed ${formatRecurring(item.upsell_price?.recurring || item.price.recurring)} ${metered}`].filter(Boolean).join(', ') // prettier-ignore
42
+ : pricing.quantity
43
+ }
44
+ />
45
+ <Stack direction="column" alignItems="flex-end" flex={1}>
46
+ <Typography sx={{ color: 'text.primary', fontWeight: 500 }} gutterBottom>
47
+ {pricing.primary}
48
+ </Typography>
49
+ {pricing.secondary && (
50
+ <Typography sx={{ fontSize: '0.85rem', color: 'text.secondary' }}>{pricing.secondary}</Typography>
51
+ )}
52
+ </Stack>
44
53
  </Stack>
54
+ {children}
45
55
  </Stack>
46
56
  {canUpsell && !item.upsell_price_id && item.price.upsell?.upsells_to && (
47
57
  <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
@@ -1,7 +1,13 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
1
2
  import type { TCheckoutSessionExpanded, TLineItemExpanded, TPaymentCurrency } from '@did-pay/types';
3
+ import { LoadingButton } from '@mui/lab';
2
4
  import { Fade, Stack, Typography } from '@mui/material';
5
+ import { useRequest, useSetState } from 'ahooks';
6
+ import noop from 'lodash/noop';
3
7
 
8
+ import api from '../../libs/api';
4
9
  import { formatCheckoutHeadlines } from '../../libs/util';
10
+ import Status from '../status';
5
11
  import PaymentAmount from './amount';
6
12
  import ProductItem from './product-item';
7
13
 
@@ -10,10 +16,70 @@ type Props = {
10
16
  currency: TPaymentCurrency;
11
17
  onUpsell: Function;
12
18
  onDownsell: Function;
19
+ onApplyCrossSell: Function;
20
+ onCancelCrossSell: Function;
13
21
  };
14
22
 
15
- export default function PaymentSummary({ checkoutSession, currency, onUpsell, onDownsell }: Props) {
23
+ async function fetchCrossSell(id: string) {
24
+ try {
25
+ const { data } = await api.get(`/api/checkout-sessions/${id}/cross-sell`);
26
+ if (!data.error) {
27
+ return data;
28
+ }
29
+
30
+ return null;
31
+ } catch (err) {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export default function PaymentSummary({
37
+ checkoutSession,
38
+ currency,
39
+ onUpsell,
40
+ onDownsell,
41
+ onApplyCrossSell,
42
+ onCancelCrossSell,
43
+ }: Props) {
44
+ const { t } = useLocaleContext();
45
+ const [state, setState] = useSetState({ loading: false });
46
+ const { data, runAsync } = useRequest(() => fetchCrossSell(checkoutSession.id));
16
47
  const headlines = formatCheckoutHeadlines(checkoutSession, currency);
48
+
49
+ const handleUpsell = async (from: string, to: string) => {
50
+ await onUpsell(from, to);
51
+ runAsync();
52
+ };
53
+
54
+ const handleDownsell = async (from: string) => {
55
+ await onDownsell(from);
56
+ runAsync();
57
+ };
58
+
59
+ const handleApplyCrossSell = async () => {
60
+ if (data) {
61
+ try {
62
+ setState({ loading: true });
63
+ await onApplyCrossSell(data.id);
64
+ } catch (err) {
65
+ console.error(err);
66
+ } finally {
67
+ setState({ loading: false });
68
+ }
69
+ }
70
+ };
71
+
72
+ const handleCancelCrossSell = async () => {
73
+ try {
74
+ setState({ loading: true });
75
+ await onCancelCrossSell();
76
+ } catch (err) {
77
+ console.error(err);
78
+ } finally {
79
+ setState({ loading: false });
80
+ }
81
+ };
82
+
17
83
  return (
18
84
  <Fade in>
19
85
  <Stack className="cko-product" direction="column" sx={{ mt: 4 }}>
@@ -33,11 +99,53 @@ export default function PaymentSummary({ checkoutSession, currency, onUpsell, on
33
99
  item={x}
34
100
  session={checkoutSession}
35
101
  currency={currency}
36
- onUpsell={onUpsell}
37
- onDownsell={onDownsell}
38
- />
102
+ onUpsell={handleUpsell}
103
+ onDownsell={handleDownsell}>
104
+ {x.cross_sell && (
105
+ <LoadingButton
106
+ size="small"
107
+ loadingPosition="end"
108
+ color="error"
109
+ variant="text"
110
+ loading={state.loading}
111
+ onClick={handleCancelCrossSell}>
112
+ {t('checkout.cross_sell.remove')}
113
+ </LoadingButton>
114
+ )}
115
+ </ProductItem>
39
116
  ))}
40
117
  </Stack>
118
+ {data && checkoutSession.line_items.some((x) => x.price_id === data.id) === false && (
119
+ <Stack
120
+ direction="column"
121
+ alignItems="flex-end"
122
+ spacing={0.5}
123
+ sx={{ border: '1px solid #eee', borderRadius: 1, padding: 1, mt: 8, mb: 4 }}>
124
+ <ProductItem
125
+ item={{ quantity: 1, price: data, price_id: data.id, cross_sell: true } as TLineItemExpanded}
126
+ session={checkoutSession}
127
+ currency={currency}
128
+ onUpsell={noop}
129
+ onDownsell={noop}
130
+ />
131
+ <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: 1 }}>
132
+ <Typography>
133
+ {checkoutSession.cross_sell_behavior === 'required' && (
134
+ <Status label={t('checkout.required')} color="info" variant="outlined" sx={{ mr: 1 }} />
135
+ )}
136
+ </Typography>
137
+ <LoadingButton
138
+ size="small"
139
+ loadingPosition="end"
140
+ color={checkoutSession.cross_sell_behavior === 'required' ? 'info' : 'info'}
141
+ variant={checkoutSession.cross_sell_behavior === 'required' ? 'text' : 'text'}
142
+ loading={state.loading}
143
+ onClick={handleApplyCrossSell}>
144
+ {t('checkout.cross_sell.add')}
145
+ </LoadingButton>
146
+ </Stack>
147
+ </Stack>
148
+ )}
41
149
  </Stack>
42
150
  </Fade>
43
151
  );
@@ -156,6 +156,22 @@ export default function BeforePay() {
156
156
  />
157
157
  )}
158
158
  />
159
+ <Controller
160
+ name="cross_sell_behavior"
161
+ control={control}
162
+ render={({ field }) => (
163
+ <FormControlLabel
164
+ control={
165
+ <Checkbox
166
+ checked={getValues().cross_sell_behavior === 'required'}
167
+ {...field}
168
+ onChange={(_, checked) => setValue(field.name, checked ? 'required' : 'auto')}
169
+ />
170
+ }
171
+ label={t('admin.paymentLink.requireCrossSell')}
172
+ />
173
+ )}
174
+ />
159
175
  <Controller
160
176
  name="include_free_trial"
161
177
  control={control}
@@ -34,7 +34,7 @@ export default function UpsellSelect({ price, onSelect, onAdd }: Props) {
34
34
  }
35
35
 
36
36
  const onSelectPrice = (e: any) => {
37
- if (e.target.value) {
37
+ if (e.target.value && e.target.value !== 'empty') {
38
38
  if (e.target.value === 'add') {
39
39
  setState({ action: 'add' });
40
40
  } else {
@@ -59,7 +59,12 @@ export default function UpsellSelect({ price, onSelect, onAdd }: Props) {
59
59
 
60
60
  return (
61
61
  <>
62
- <Select value="empty" sx={{ width: 300 }} size="small" onChange={onSelectPrice}>
62
+ <Select
63
+ value="empty"
64
+ sx={{ width: 300 }}
65
+ size="small"
66
+ onChange={onSelectPrice}
67
+ MenuProps={{ style: { maxHeight: 480 } }}>
63
68
  <MenuItem value="empty">
64
69
  <Stack direction="row" alignItems="center">
65
70
  <Typography>{t('admin.price.find')}</Typography>
@@ -71,6 +71,22 @@ export function PricePaymentSettings({ index }: { index: number }) {
71
71
  />
72
72
  )}
73
73
  />
74
+ <Controller
75
+ name={getFieldName('cross_sell_behavior')}
76
+ control={control}
77
+ render={({ field }) => (
78
+ <FormControlLabel
79
+ control={
80
+ <Checkbox
81
+ checked={get(values, getFieldName('cross_sell_behavior')) === 'required'}
82
+ {...field}
83
+ onChange={(_, checked) => setValue(field.name, checked ? 'required' : 'auto')}
84
+ />
85
+ }
86
+ label={t('admin.paymentLink.requireCrossSell')}
87
+ />
88
+ )}
89
+ />
74
90
  <Controller
75
91
  name={getFieldName('after_completion.type')}
76
92
  control={control}
@@ -76,6 +76,7 @@ export default function PricingTableProductSettings() {
76
76
  },
77
77
  custom_fields: [],
78
78
  submit_type: 'auto',
79
+ cross_sell_behavior: 'auto',
79
80
  });
80
81
  }
81
82
  }
@@ -0,0 +1,51 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import type { TProductExpanded } from '@did-pay/types';
3
+ import { MenuItem, Select, Stack, Typography } from '@mui/material';
4
+
5
+ import { useProductsContext } from '../../contexts/products';
6
+ import { useSettingsContext } from '../../contexts/settings';
7
+ import { formatProductPrice } from '../../libs/util';
8
+ import InfoCard from '../info-card';
9
+
10
+ type Props = {
11
+ data: TProductExpanded;
12
+ onSelect: Function;
13
+ };
14
+
15
+ export default function CrossSellSelect({ data, onSelect }: Props) {
16
+ const { t } = useLocaleContext();
17
+ const { products } = useProductsContext();
18
+ const { settings } = useSettingsContext();
19
+
20
+ const onSelectPrice = (e: any) => {
21
+ if (e.target.value && e.target.value !== 'empty') {
22
+ onSelect(e.target.value);
23
+ }
24
+ };
25
+
26
+ return (
27
+ <Select
28
+ value="empty"
29
+ sx={{ width: 300 }}
30
+ size="small"
31
+ onChange={onSelectPrice}
32
+ MenuProps={{ style: { maxHeight: 480 } }}>
33
+ <MenuItem value="empty">
34
+ <Stack direction="row" alignItems="center">
35
+ <Typography>{t('admin.product.find')}</Typography>
36
+ </Stack>
37
+ </MenuItem>
38
+ {products
39
+ .filter((x) => x.id !== data.id)
40
+ .map((x) => (
41
+ <MenuItem key={x.id} value={x.id}>
42
+ <InfoCard
43
+ name={x.name}
44
+ description={formatProductPrice(x as any, settings.baseCurrency)}
45
+ logo={x.images[0]}
46
+ />
47
+ </MenuItem>
48
+ ))}
49
+ </Select>
50
+ );
51
+ }