payment-kit 1.13.92 → 1.13.94

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 (37) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/libs/audit.ts +28 -34
  3. package/api/src/libs/payment.ts +2 -11
  4. package/api/src/libs/session.ts +1 -1
  5. package/api/src/libs/util.ts +8 -5
  6. package/api/src/routes/checkout-sessions.ts +41 -39
  7. package/api/src/routes/connect/collect.ts +12 -12
  8. package/api/src/routes/connect/setup.ts +8 -11
  9. package/api/src/routes/connect/shared.ts +81 -20
  10. package/api/src/routes/connect/subscribe.ts +8 -11
  11. package/api/src/routes/connect/update.ts +134 -0
  12. package/api/src/routes/pricing-table.ts +9 -121
  13. package/api/src/routes/subscriptions.ts +417 -142
  14. package/api/src/store/models/index.ts +3 -0
  15. package/api/src/store/models/pricing-table.ts +125 -1
  16. package/api/src/store/models/subscription.ts +4 -0
  17. package/api/src/store/models/types.ts +8 -0
  18. package/api/tests/libs/util.spec.ts +6 -6
  19. package/blocklet.yml +1 -1
  20. package/package.json +6 -6
  21. package/src/app.tsx +12 -4
  22. package/src/components/checkout/form/address.tsx +41 -34
  23. package/src/components/checkout/form/index.tsx +1 -1
  24. package/src/components/checkout/pricing-table.tsx +205 -0
  25. package/src/components/payment-link/product-select.tsx +13 -3
  26. package/src/components/portal/invoice/list.tsx +1 -1
  27. package/src/components/portal/subscription/actions.tsx +153 -0
  28. package/src/components/portal/subscription/list.tsx +21 -150
  29. package/src/components/subscription/metrics.tsx +46 -0
  30. package/src/contexts/products.tsx +2 -1
  31. package/src/libs/util.ts +43 -0
  32. package/src/locales/en.tsx +15 -1
  33. package/src/locales/zh.tsx +16 -2
  34. package/src/pages/admin/billing/subscriptions/detail.tsx +2 -34
  35. package/src/pages/checkout/pricing-table.tsx +9 -158
  36. package/src/pages/customer/subscription/{index.tsx → detail.tsx} +6 -36
  37. package/src/pages/customer/subscription/update.tsx +281 -0
@@ -99,6 +99,7 @@ export type TLineItemExpanded = LineItem & {
99
99
  price: TPriceExpanded;
100
100
  upsell_price: TPriceExpanded;
101
101
  metadata?: Record<string, any>;
102
+ [key: string]: any;
102
103
  };
103
104
 
104
105
  export type TProductExpanded = TProduct & {
@@ -214,6 +215,8 @@ export type TSetupIntentExpanded = TSetupIntent & {
214
215
  export type TPricingTableItem = PricingTableItem & {
215
216
  price: TPrice;
216
217
  product: TProduct;
218
+ is_selected?: boolean;
219
+ is_disabled?: boolean;
217
220
  };
218
221
 
219
222
  export type TPricingTableExpanded = TPricingTable & {
@@ -1,9 +1,11 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import pick from 'lodash/pick';
3
+ import uniq from 'lodash/uniq';
2
4
  import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
5
  import type { LiteralUnion } from 'type-fest';
4
6
 
5
7
  import { createEvent } from '../../libs/audit';
6
- import { createIdGenerator } from '../../libs/util';
8
+ import { createIdGenerator, formatMetadata } from '../../libs/util';
7
9
  import type { BrandSettings, PricingTableItem } from './types';
8
10
 
9
11
  const nextId = createIdGenerator('prctbl', 24);
@@ -102,6 +104,128 @@ export class PricingTable extends Model<InferAttributes<PricingTable>, InferCrea
102
104
  public static associate() {
103
105
  // Do nothing
104
106
  }
107
+
108
+ public static formatItem(payload: any) {
109
+ const item = Object.assign(
110
+ {
111
+ adjustable_quantity: {
112
+ enabled: false,
113
+ maximum: 1,
114
+ minimum: 0,
115
+ },
116
+ after_completion: {
117
+ type: 'hosted_confirmation',
118
+ hosted_confirmation: {
119
+ custom_message: '',
120
+ },
121
+ },
122
+ allow_promotion_codes: false,
123
+ customer_creation: 'always',
124
+ consent_collection: {
125
+ promotions: 'none',
126
+ terms_of_service: 'none',
127
+ },
128
+ invoice_creation: {
129
+ enabled: true,
130
+ },
131
+ phone_number_collection: {
132
+ enabled: false,
133
+ },
134
+ billing_address_collection: 'auto',
135
+ subscription_data: {
136
+ description: '',
137
+ trial_period_days: 0,
138
+ },
139
+ nft_mint_settings: {
140
+ enabled: false,
141
+ factory: '',
142
+ },
143
+ submit_type: 'auto',
144
+ cross_sell_behavior: 'auto',
145
+ },
146
+ pick(payload, [
147
+ 'adjustable_quantity',
148
+ 'after_completion',
149
+ 'allow_promotion_codes',
150
+ 'billing_address_collection',
151
+ 'consent_collection',
152
+ 'cross_sell_behavior',
153
+ 'custom_fields',
154
+ 'highlight_text',
155
+ 'is_highlight',
156
+ 'nft_mint_settings',
157
+ 'phone_number_collection',
158
+ 'price_id',
159
+ 'product_id',
160
+ 'submit_type',
161
+ 'subscription_data',
162
+ ])
163
+ );
164
+
165
+ if (item.adjustable_quantity?.enabled) {
166
+ item.adjustable_quantity.minimum = Number(item.adjustable_quantity?.minimum);
167
+ item.adjustable_quantity.maximum = Number(item.adjustable_quantity?.maximum);
168
+ }
169
+ if (item.after_completion?.type === 'hosted_confirmation') {
170
+ // @ts-ignore
171
+ item.after_completion.redirect = null;
172
+ }
173
+ if (item.after_completion?.type === 'redirect') {
174
+ // @ts-ignore
175
+ item.after_completion.hosted_confirmation = null;
176
+ }
177
+
178
+ return item;
179
+ }
180
+
181
+ public static format(payload: any) {
182
+ const raw: Partial<PricingTable> = Object.assign(
183
+ {
184
+ branding_settings: {
185
+ background_color: '#ffffff',
186
+ border_style: 'default',
187
+ button_color: '#0074d4',
188
+ font_family: 'default',
189
+ },
190
+ },
191
+ pick(payload, ['name', 'items', 'metadata', 'brand_settings'])
192
+ );
193
+
194
+ raw.items = raw.items?.map((x) => PricingTable.formatItem(x));
195
+
196
+ if (payload.highlight && payload.highlight_product_id) {
197
+ raw.items?.forEach((x) => {
198
+ if (x.product_id === payload.highlight_product_id) {
199
+ x.is_highlight = x.product_id === payload.highlight_product_id;
200
+ x.highlight_text = payload.highlight_text || 'popular';
201
+ } else {
202
+ x.is_highlight = false;
203
+ x.highlight_text = 'popular';
204
+ }
205
+ });
206
+ }
207
+
208
+ raw.metadata = formatMetadata(raw.metadata);
209
+
210
+ return raw;
211
+ }
212
+
213
+ public async expand() {
214
+ const { Price, Product } = this.sequelize.models;
215
+
216
+ const doc = this.toJSON();
217
+ const prices = await Price!.findAll({ where: { id: uniq(doc.items.map((x) => x.price_id)) } });
218
+ const products = await Product!.findAll({ where: { id: uniq(doc.items.map((x) => x.product_id)) } });
219
+
220
+ doc.items.forEach((i) => {
221
+ // @ts-ignore
222
+ i.price = prices.find((p) => p.id === i.price_id);
223
+ // @ts-ignore
224
+ i.product = products.find((p) => p.id === i.product_id);
225
+ });
226
+
227
+ return doc;
228
+ }
105
229
  }
106
230
 
107
231
  export type TPricingTable = InferAttributes<PricingTable>;
@@ -360,6 +360,10 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
360
360
  return ['active', 'trialing'].includes(this.status);
361
361
  }
362
362
 
363
+ public isScheduledToCancel() {
364
+ return this.isActive() && (!!this.cancel_at_period_end || !!this.cancel_at);
365
+ }
366
+
363
367
  public async start() {
364
368
  if (this.isActive()) {
365
369
  logger.warn(`subscription already started: ${this.id}`);
@@ -347,6 +347,14 @@ export type BrandSettings = {
347
347
  font_family: string;
348
348
  };
349
349
 
350
+ export type SubscriptionUpdateItem = {
351
+ id?: string;
352
+ deleted?: boolean;
353
+ clear_usage?: boolean;
354
+ price_id?: string;
355
+ quantity?: number;
356
+ };
357
+
350
358
  export type EventType = LiteralUnion<
351
359
  | 'account.application.authorized'
352
360
  | 'account.application.deauthorized'
@@ -3,7 +3,7 @@ import {
3
3
  createCodeGenerator,
4
4
  createIdGenerator,
5
5
  formatMetadata,
6
- getMetadataFromQuery,
6
+ getDataObjectFromQuery,
7
7
  getNextRetry,
8
8
  tryWithTimeout,
9
9
  } from '../../src/libs/util';
@@ -134,9 +134,9 @@ describe('getNextRetry', () => {
134
134
  });
135
135
  });
136
136
 
137
- describe('getMetadataFromQuery', () => {
137
+ describe('getDataObjectFromQuery', () => {
138
138
  it('should return an empty object when the query object is empty', () => {
139
- const result = getMetadataFromQuery({});
139
+ const result = getDataObjectFromQuery({});
140
140
  expect(result).toEqual({});
141
141
  });
142
142
 
@@ -145,7 +145,7 @@ describe('getMetadataFromQuery', () => {
145
145
  'metadata.key1': 'value1',
146
146
  'metadata.key2': 'value2',
147
147
  };
148
- const result = getMetadataFromQuery(query);
148
+ const result = getDataObjectFromQuery(query);
149
149
  expect(result).toEqual({
150
150
  key1: 'value1',
151
151
  key2: 'value2',
@@ -157,7 +157,7 @@ describe('getMetadataFromQuery', () => {
157
157
  'metadata.key1': 'value1',
158
158
  key2: 'value2',
159
159
  };
160
- const result = getMetadataFromQuery(query);
160
+ const result = getDataObjectFromQuery(query);
161
161
  expect(result).toEqual({
162
162
  key1: 'value1',
163
163
  });
@@ -169,7 +169,7 @@ describe('getMetadataFromQuery', () => {
169
169
  'metadata.key2': undefined,
170
170
  'metadata.key3': null,
171
171
  };
172
- const result = getMetadataFromQuery(query);
172
+ const result = getDataObjectFromQuery(query);
173
173
  expect(result).toEqual({
174
174
  key1: 'value1',
175
175
  });
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.92
17
+ version: 1.13.94
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.92",
3
+ "version": "1.13.94",
4
4
  "scripts": {
5
5
  "dev": "COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev",
6
6
  "eject": "vite eject",
@@ -45,13 +45,13 @@
45
45
  "@abtnode/cron": "1.16.21",
46
46
  "@arcblock/did": "^1.18.108",
47
47
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
48
- "@arcblock/did-connect": "^2.9.9",
48
+ "@arcblock/did-connect": "^2.9.13",
49
49
  "@arcblock/did-util": "^1.18.108",
50
50
  "@arcblock/jwt": "^1.18.108",
51
- "@arcblock/ux": "^2.9.9",
51
+ "@arcblock/ux": "^2.9.13",
52
52
  "@blocklet/logger": "1.16.21",
53
53
  "@blocklet/sdk": "1.16.21",
54
- "@blocklet/ui-react": "^2.9.9",
54
+ "@blocklet/ui-react": "^2.9.13",
55
55
  "@blocklet/uploader": "^0.0.64",
56
56
  "@mui/icons-material": "^5.14.19",
57
57
  "@mui/lab": "^5.0.0-alpha.155",
@@ -110,7 +110,7 @@
110
110
  "@abtnode/types": "1.16.21",
111
111
  "@arcblock/eslint-config": "^0.2.4",
112
112
  "@arcblock/eslint-config-ts": "^0.2.4",
113
- "@did-pay/types": "1.13.92",
113
+ "@did-pay/types": "1.13.94",
114
114
  "@types/cookie-parser": "^1.4.6",
115
115
  "@types/cors": "^2.8.17",
116
116
  "@types/dotenv-flow": "^3.3.3",
@@ -149,5 +149,5 @@
149
149
  "parser": "typescript"
150
150
  }
151
151
  },
152
- "gitHead": "1e2c450bea035a3c6410849f3fcac790b92da38c"
152
+ "gitHead": "3491fe2d96de870635ad6486e6e0972267d2fc64"
153
153
  }
package/src/app.tsx CHANGED
@@ -20,7 +20,8 @@ const CheckoutPage = React.lazy(() => import('./pages/checkout'));
20
20
  const AdminPage = React.lazy(() => import('./pages/admin'));
21
21
  const CustomerHome = React.lazy(() => import('./pages/customer/index'));
22
22
  const CustomerInvoice = React.lazy(() => import('./pages/customer/invoice'));
23
- const CustomerSubscription = React.lazy(() => import('./pages/customer/subscription'));
23
+ const CustomerSubscriptionDetail = React.lazy(() => import('./pages/customer/subscription/detail'));
24
+ const CustomerSubscriptionUpdate = React.lazy(() => import('./pages/customer/subscription/update'));
24
25
 
25
26
  const theme = createTheme({
26
27
  typography: {
@@ -58,13 +59,21 @@ function App() {
58
59
  </Layout>
59
60
  }
60
61
  />
61
- ,
62
62
  <Route
63
63
  key="customer-subscription"
64
64
  path="/customer/subscription/:id"
65
65
  element={
66
66
  <Layout>
67
- <CustomerSubscription />
67
+ <CustomerSubscriptionDetail />
68
+ </Layout>
69
+ }
70
+ />
71
+ <Route
72
+ key="customer-subscription"
73
+ path="/customer/subscription/:id/update"
74
+ element={
75
+ <Layout>
76
+ <CustomerSubscriptionUpdate />
68
77
  </Layout>
69
78
  }
70
79
  />
@@ -77,7 +86,6 @@ function App() {
77
86
  </Layout>
78
87
  }
79
88
  />
80
- ,
81
89
  <Route key="customer-fallback" path="/customer/*" element={<Navigate to="/customer" />} />,
82
90
  <Route path="*" element={<Navigate to="/" />} />
83
91
  </Routes>
@@ -7,9 +7,10 @@ import FormInput from '../../input';
7
7
 
8
8
  type Props = {
9
9
  mode: string;
10
+ stripe: boolean;
10
11
  };
11
12
 
12
- export default function AddressForm({ mode }: Props) {
13
+ export default function AddressForm({ mode, stripe }: Props) {
13
14
  const { t } = useLocaleContext();
14
15
  const { control, setValue } = useFormContext();
15
16
 
@@ -74,39 +75,45 @@ export default function AddressForm({ mode }: Props) {
74
75
  );
75
76
  }
76
77
 
77
- return (
78
- <Fade in>
79
- <Stack className="cko-payment-address cko-payment-form">
80
- <Typography sx={{ mb: 1, color: 'text.primary', fontWeight: 600 }}>{t(`checkout.billing.${mode}`)}</Typography>
81
- <Stack direction="column" className="cko-payment-form" spacing={0}>
82
- <Stack direction="row" spacing={0}>
83
- <Controller
84
- name="billing_address.country"
85
- control={control}
86
- render={({ field }) => (
87
- <CountrySelector
88
- selectedCountry={field.value}
89
- onSelect={({ iso2 }) => setValue(field.name, iso2)}
90
- buttonStyle={{
91
- width: '64px',
92
- height: '40px',
93
- border: '1px solid #ccc',
94
- marginLeft: -1,
95
- marginTop: -1,
96
- }}
97
- />
98
- )}
99
- />
100
- <FormInput
101
- name="billing_address.postal_code"
102
- rules={{ required: t('checkout.required') }}
103
- errorPosition="right"
104
- variant="outlined"
105
- placeholder={t('checkout.billing.postal_code')}
106
- />
78
+ if (stripe) {
79
+ return (
80
+ <Fade in>
81
+ <Stack className="cko-payment-address cko-payment-form">
82
+ <Typography sx={{ mb: 1, color: 'text.primary', fontWeight: 600 }}>
83
+ {t(`checkout.billing.${mode}`)}
84
+ </Typography>
85
+ <Stack direction="column" className="cko-payment-form" spacing={0}>
86
+ <Stack direction="row" spacing={0}>
87
+ <Controller
88
+ name="billing_address.country"
89
+ control={control}
90
+ render={({ field }) => (
91
+ <CountrySelector
92
+ selectedCountry={field.value}
93
+ onSelect={({ iso2 }) => setValue(field.name, iso2)}
94
+ buttonStyle={{
95
+ width: '64px',
96
+ height: '40px',
97
+ border: '1px solid #ccc',
98
+ marginLeft: -1,
99
+ marginTop: -1,
100
+ }}
101
+ />
102
+ )}
103
+ />
104
+ <FormInput
105
+ name="billing_address.postal_code"
106
+ rules={{ required: t('checkout.required') }}
107
+ errorPosition="right"
108
+ variant="outlined"
109
+ placeholder={t('checkout.billing.postal_code')}
110
+ />
111
+ </Stack>
107
112
  </Stack>
108
113
  </Stack>
109
- </Stack>
110
- </Fade>
111
- );
114
+ </Fade>
115
+ );
116
+ }
117
+
118
+ return null;
112
119
  }
@@ -317,7 +317,7 @@ export default function PaymentForm({
317
317
  </Stack>
318
318
  </Stack>
319
319
  </Fade>
320
- <AddressForm mode={checkoutSession.billing_address_collection as string} />
320
+ <AddressForm mode={checkoutSession.billing_address_collection as string} stripe={method.type === 'stripe'} />
321
321
  <Fade in>
322
322
  <Stack direction="column" className="cko-payment-methods">
323
323
  <Typography sx={{ mb: 2, color: 'text.primary', fontWeight: 600 }}>{t('checkout.method')}</Typography>
@@ -0,0 +1,205 @@
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 { PriceRecurring, TPricingTableExpanded, TPricingTableItem } from '@did-pay/types';
5
+ import { CheckOutlined } from '@mui/icons-material';
6
+ import { LoadingButton } from '@mui/lab';
7
+ import {
8
+ Box,
9
+ Chip,
10
+ Fade,
11
+ List,
12
+ ListItem,
13
+ ListItemIcon,
14
+ ListItemText,
15
+ Stack,
16
+ ToggleButton,
17
+ ToggleButtonGroup,
18
+ Typography,
19
+ } from '@mui/material';
20
+ import { useSetState } from 'ahooks';
21
+ import { useEffect } from 'react';
22
+
23
+ import { formatError, formatPriceAmount, formatRecurring } from '../../libs/util';
24
+ import PaymentAmount from './amount';
25
+
26
+ const groupItemsByRecurring = (items: TPricingTableItem[]) => {
27
+ const grouped: { [key: string]: TPricingTableItem[] } = {};
28
+ const recurring: { [key: string]: PriceRecurring } = {};
29
+
30
+ items.forEach((x) => {
31
+ const key = [x.price.recurring?.interval, x.price.recurring?.interval_count].join('-');
32
+ recurring[key] = x.price.recurring as PriceRecurring;
33
+
34
+ if (!grouped[key]) {
35
+ grouped[key] = [];
36
+ }
37
+
38
+ // @ts-ignore
39
+ grouped[key].push(x);
40
+ });
41
+
42
+ return { recurring, grouped };
43
+ };
44
+
45
+ type Props = {
46
+ table: TPricingTableExpanded;
47
+ onSelect: (priceId: string) => void;
48
+ alignItems?: 'center' | 'left';
49
+ mode?: 'checkout' | 'select';
50
+ interval?: string;
51
+ };
52
+
53
+ PricingTable.defaultProps = {
54
+ alignItems: 'center',
55
+ mode: 'checkout',
56
+ interval: '',
57
+ };
58
+
59
+ export default function PricingTable({ table, alignItems, interval, mode, onSelect }: Props) {
60
+ const { t, locale } = useLocaleContext();
61
+ const [state, setState] = useSetState({ interval, loading: '' });
62
+ const { recurring, grouped } = groupItemsByRecurring(table.items);
63
+
64
+ useEffect(() => {
65
+ if (table) {
66
+ if (!state.interval || !grouped[state.interval]) {
67
+ const keys = Object.keys(recurring);
68
+ if (keys[0]) {
69
+ setState({ interval: keys[0] });
70
+ }
71
+ }
72
+ }
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [table]);
75
+
76
+ const handleSelect = async (priceId: string) => {
77
+ try {
78
+ setState({ loading: priceId });
79
+ await onSelect(priceId);
80
+ } catch (err) {
81
+ console.error(err);
82
+ Toast.error(formatError(err));
83
+ } finally {
84
+ setState({ loading: '' });
85
+ }
86
+ };
87
+
88
+ return (
89
+ <Stack
90
+ direction="column"
91
+ alignItems={alignItems === 'center' ? 'center' : 'flex-start'}
92
+ sx={{
93
+ pt: {
94
+ xs: 4,
95
+ sm: 2,
96
+ },
97
+ gap: {
98
+ xs: 3,
99
+ sm: mode === 'select' ? 3 : 5,
100
+ },
101
+ }}>
102
+ {Object.keys(recurring).length > 1 && (
103
+ <ToggleButtonGroup
104
+ color="primary"
105
+ value={state.interval}
106
+ onChange={(_, value) => {
107
+ if (value !== null) {
108
+ setState({ interval: value });
109
+ }
110
+ }}
111
+ exclusive>
112
+ {Object.keys(recurring).map((x) => (
113
+ <ToggleButton key={x} value={x} sx={{ textTransform: 'capitalize' }}>
114
+ {formatRecurring(recurring[x] as PriceRecurring, true, '', locale)}
115
+ </ToggleButton>
116
+ ))}
117
+ </ToggleButtonGroup>
118
+ )}
119
+ <Stack
120
+ flexWrap="wrap"
121
+ direction="row"
122
+ gap={{ xs: 3, sm: 5, md: mode === 'checkout' ? 10 : 5 }}
123
+ justifyContent={alignItems === 'center' ? 'center' : 'flex-start'}>
124
+ {grouped[state.interval as string]?.map((x: TPricingTableItem) => {
125
+ let action = x.subscription_data?.trial_period_days ? t('checkout.try') : t('checkout.subscription');
126
+ if (mode === 'select') {
127
+ action = x.is_selected ? t('checkout.selected') : t('checkout.select');
128
+ }
129
+
130
+ return (
131
+ <Fade key={x.price_id} in>
132
+ <Stack
133
+ padding={4}
134
+ spacing={2}
135
+ direction="column"
136
+ alignItems="center"
137
+ sx={{
138
+ width: 320,
139
+ cursor: 'pointer',
140
+ borderWidth: '1px',
141
+ borderStyle: 'solid',
142
+ borderColor: mode === 'select' && x.is_selected ? 'primary.main' : '#eee',
143
+ borderRadius: 1,
144
+ transition: 'border-color 0.3s ease 0s, box-shadow 0.3s ease 0s',
145
+ boxShadow: '0 4px 8px rgba(0, 0, 0, 20%)',
146
+ '&:hover': {
147
+ borderColor: mode === 'select' && x.is_selected ? 'primary.main' : '#ddd',
148
+ boxShadow: '0 8px 16px rgba(0, 0, 0, 20%)',
149
+ },
150
+ }}>
151
+ <Box textAlign="center">
152
+ <Stack direction="row" alignItems="center" spacing={1}>
153
+ <Typography variant="h5" color="text.primary" fontWeight={600}>
154
+ {x.product.name}
155
+ </Typography>
156
+ {x.is_highlight && <Chip label={x.highlight_text} color="default" size="small" />}
157
+ </Stack>
158
+ <Typography color="text.secondary">{x.product.description}</Typography>
159
+ </Box>
160
+ <Stack direction="row" alignItems="center" spacing={1}>
161
+ <PaymentAmount amount={formatPriceAmount(x.price, table.currency, x.product.unit_label)} />
162
+ <Stack direction="column" alignItems="flex-start">
163
+ <Typography component="span" color="text.secondary" fontSize="0.8rem">
164
+ {t('checkout.per')}
165
+ </Typography>
166
+ <Typography component="span" color="text.secondary" fontSize="0.8rem">
167
+ {formatRecurring(x.price.recurring as PriceRecurring, false, '', locale)}
168
+ </Typography>
169
+ </Stack>
170
+ </Stack>
171
+ <LoadingButton
172
+ fullWidth
173
+ size="large"
174
+ loadingPosition="end"
175
+ variant={x.is_highlight || x.is_selected ? 'contained' : 'outlined'}
176
+ color={x.is_highlight || x.is_selected ? 'primary' : 'info'}
177
+ sx={{ fontSize: '1.2rem' }}
178
+ loading={state.loading === x.price_id}
179
+ disabled={x.is_disabled}
180
+ onClick={() => handleSelect(x.price_id)}>
181
+ {action}
182
+ </LoadingButton>
183
+ {x.product.features.length > 0 && (
184
+ <Box>
185
+ <Typography>{t('checkout.include')}</Typography>
186
+ <List dense>
187
+ {x.product.features.map((f: any) => (
188
+ <ListItem key={f.name} disableGutters disablePadding>
189
+ <ListItemIcon sx={{ minWidth: 25 }}>
190
+ <CheckOutlined color="success" fontSize="small" />
191
+ </ListItemIcon>
192
+ <ListItemText primary={f.name} />
193
+ </ListItem>
194
+ ))}
195
+ </List>
196
+ </Box>
197
+ )}
198
+ </Stack>
199
+ </Fade>
200
+ );
201
+ })}
202
+ </Stack>
203
+ </Stack>
204
+ );
205
+ }
@@ -1,7 +1,7 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import type { TProductExpanded } from '@did-pay/types';
3
3
  import { AddOutlined } from '@mui/icons-material';
4
- import { Box, ListSubheader, MenuItem, Select, Typography } from '@mui/material';
4
+ import { Avatar, Box, ListSubheader, MenuItem, Select, Stack, Typography } from '@mui/material';
5
5
  import cloneDeep from 'lodash/cloneDeep';
6
6
  import { useState } from 'react';
7
7
  import type { LiteralUnion } from 'type-fest';
@@ -38,6 +38,7 @@ export default function ProductSelect({ mode: initialMode, hasSelected, onSelect
38
38
  };
39
39
 
40
40
  if (mode === 'selecting') {
41
+ const size = { width: 16, height: 16 };
41
42
  return (
42
43
  <Select value="" fullWidth size="small" onChange={handleSelect} MenuProps={{ style: { maxHeight: 480 } }}>
43
44
  <MenuItem value="add">
@@ -46,10 +47,19 @@ export default function ProductSelect({ mode: initialMode, hasSelected, onSelect
46
47
  </MenuItem>
47
48
  {filterProducts(products, hasSelected).map((product) => [
48
49
  <ListSubheader key={product.id} sx={{ fontSize: '1rem', color: 'text.secondary', lineHeight: '2.5rem' }}>
49
- {product.name}
50
+ <Stack direction="row" alignItems="center" spacing={0.5}>
51
+ {product.images[0] ? (
52
+ <Avatar src={product.images[0]} alt={product.name} variant="square" sx={size} />
53
+ ) : (
54
+ <Avatar variant="square" sx={size}>
55
+ {product.name.slice(0, 1)}
56
+ </Avatar>
57
+ )}
58
+ <Typography component="span">{product.name}</Typography>
59
+ </Stack>
50
60
  </ListSubheader>,
51
61
  ...product.prices.map((price) => (
52
- <MenuItem key={price.id} sx={{ pl: 3 }} value={price.id}>
62
+ <MenuItem key={price.id} sx={{ pl: 4.5 }} value={price.id}>
53
63
  <Typography color="text.primary">{formatPrice(price, settings.baseCurrency)}</Typography>
54
64
  <Typography color="text.secondary" sx={{ ml: 2 }}>
55
65
  {getPriceCurrencyOptions(price).length > 1