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.
- package/api/src/index.ts +2 -0
- package/api/src/libs/audit.ts +28 -34
- package/api/src/libs/payment.ts +2 -11
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/util.ts +8 -5
- package/api/src/routes/checkout-sessions.ts +41 -39
- package/api/src/routes/connect/collect.ts +12 -12
- package/api/src/routes/connect/setup.ts +8 -11
- package/api/src/routes/connect/shared.ts +81 -20
- package/api/src/routes/connect/subscribe.ts +8 -11
- package/api/src/routes/connect/update.ts +134 -0
- package/api/src/routes/pricing-table.ts +9 -121
- package/api/src/routes/subscriptions.ts +417 -142
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/pricing-table.ts +125 -1
- package/api/src/store/models/subscription.ts +4 -0
- package/api/src/store/models/types.ts +8 -0
- package/api/tests/libs/util.spec.ts +6 -6
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/app.tsx +12 -4
- package/src/components/checkout/form/address.tsx +41 -34
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/checkout/pricing-table.tsx +205 -0
- package/src/components/payment-link/product-select.tsx +13 -3
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/actions.tsx +153 -0
- package/src/components/portal/subscription/list.tsx +21 -150
- package/src/components/subscription/metrics.tsx +46 -0
- package/src/contexts/products.tsx +2 -1
- package/src/libs/util.ts +43 -0
- package/src/locales/en.tsx +15 -1
- package/src/locales/zh.tsx +16 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -34
- package/src/pages/checkout/pricing-table.tsx +9 -158
- package/src/pages/customer/subscription/{index.tsx → detail.tsx} +6 -36
- 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
|
-
|
|
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('
|
|
137
|
+
describe('getDataObjectFromQuery', () => {
|
|
138
138
|
it('should return an empty object when the query object is empty', () => {
|
|
139
|
-
const result =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
172
|
+
const result = getDataObjectFromQuery(query);
|
|
173
173
|
expect(result).toEqual({
|
|
174
174
|
key1: 'value1',
|
|
175
175
|
});
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
</
|
|
110
|
-
|
|
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
|
-
{
|
|
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:
|
|
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
|