payment-kit 1.13.17 → 1.13.19
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/README.md +14 -0
- package/api/src/index.ts +17 -6
- package/api/src/integrations/stripe/handlers/index.ts +53 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +252 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +172 -0
- package/api/src/integrations/stripe/handlers/setup-intent.ts +42 -0
- package/api/src/integrations/stripe/handlers/subscription.ts +61 -0
- package/api/src/integrations/stripe/resource.ts +317 -0
- package/api/src/integrations/stripe/setup.ts +50 -0
- package/api/src/jobs/invoice.ts +11 -0
- package/api/src/jobs/payment.ts +15 -7
- package/api/src/jobs/subscription.ts +18 -2
- package/api/src/libs/session.ts +104 -8
- package/api/src/libs/util.ts +47 -1
- package/api/src/routes/checkout-sessions.ts +134 -27
- package/api/src/routes/connect/collect.ts +12 -4
- package/api/src/routes/connect/pay.ts +30 -20
- package/api/src/routes/connect/setup.ts +12 -4
- package/api/src/routes/connect/shared.ts +28 -4
- package/api/src/routes/connect/subscribe.ts +12 -5
- package/api/src/routes/customers.ts +5 -5
- package/api/src/routes/events.ts +9 -6
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/integrations/stripe.ts +64 -0
- package/api/src/routes/invoices.ts +19 -9
- package/api/src/routes/payment-intents.ts +19 -9
- package/api/src/routes/payment-links.ts +57 -15
- package/api/src/routes/payment-methods.ts +98 -1
- package/api/src/routes/prices.ts +71 -14
- package/api/src/routes/products.ts +79 -22
- package/api/src/routes/settings.ts +10 -11
- package/api/src/routes/subscription-items.ts +5 -5
- package/api/src/routes/subscriptions.ts +61 -10
- package/api/src/routes/usage-records.ts +52 -18
- package/api/src/routes/webhook-attempts.ts +5 -5
- package/api/src/routes/webhook-endpoints.ts +5 -5
- package/api/src/store/migrations/20230905-genesis.ts +2 -2
- package/api/src/store/migrations/20230911-seeding.ts +4 -3
- package/api/src/store/models/checkout-session.ts +15 -7
- package/api/src/store/models/index.ts +31 -7
- package/api/src/store/models/invoice.ts +1 -1
- package/api/src/store/models/payment-intent.ts +2 -5
- package/api/src/store/models/payment-link.ts +1 -1
- package/api/src/store/models/payment-method.ts +54 -33
- package/api/src/store/models/price.ts +52 -17
- package/api/src/store/models/product.ts +0 -3
- package/api/src/store/models/subscription.ts +3 -5
- package/api/src/store/models/types.ts +56 -2
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +36 -29
- package/public/currencies/dai.png +0 -0
- package/public/currencies/dollar.png +0 -0
- package/public/currencies/usdc.png +0 -0
- package/public/currencies/usdt.png +0 -0
- package/public/methods/arcblock.png +0 -0
- package/public/methods/binance.png +0 -0
- package/public/methods/coinbase.png +0 -0
- package/public/methods/ethereum.jpg +0 -0
- package/public/methods/stripe.png +0 -0
- package/src/components/checkout/form/address.tsx +86 -10
- package/src/components/checkout/form/index.tsx +169 -83
- package/src/components/checkout/form/phone.tsx +96 -0
- package/src/components/checkout/form/stripe.tsx +195 -0
- package/src/components/checkout/pay.tsx +115 -34
- package/src/components/checkout/product-item.tsx +4 -3
- package/src/components/checkout/summary.tsx +5 -4
- package/src/components/drawer-form.tsx +4 -4
- package/src/components/input.tsx +22 -4
- package/src/components/invoice/table.tsx +8 -3
- package/src/components/payment-link/before-pay.tsx +11 -6
- package/src/components/payment-link/chrome.tsx +13 -0
- package/src/components/payment-link/preview.tsx +31 -0
- package/src/components/payment-link/product-select.tsx +8 -3
- package/src/components/payment-method/arcblock.tsx +53 -0
- package/src/components/payment-method/bitcoin.tsx +53 -0
- package/src/components/payment-method/ethereum.tsx +53 -0
- package/src/components/payment-method/form.tsx +54 -0
- package/src/components/payment-method/stripe.tsx +45 -0
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/list.tsx +1 -1
- package/src/components/price/currency-select.tsx +53 -0
- package/src/components/price/form.tsx +118 -24
- package/src/components/product/add-price.tsx +1 -1
- package/src/components/product/edit-price.tsx +6 -2
- package/src/components/subscription/items/index.tsx +7 -6
- package/src/components/subscription/items/usage-records.tsx +98 -0
- package/src/components/subscription/list.tsx +3 -2
- package/src/components/subscription/status.tsx +68 -0
- package/src/contexts/settings.tsx +2 -2
- package/src/env.d.ts +2 -0
- package/src/libs/util.ts +116 -21
- package/src/locales/en.tsx +71 -3
- package/src/pages/admin/billing/invoices/detail.tsx +5 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +6 -6
- package/src/pages/admin/customers/customers/detail.tsx +13 -1
- package/src/pages/admin/payments/intents/detail.tsx +8 -3
- package/src/pages/admin/payments/links/create.tsx +23 -3
- package/src/pages/admin/payments/links/detail.tsx +13 -26
- package/src/pages/admin/products/prices/detail.tsx +55 -11
- package/src/pages/admin/products/prices/list.tsx +7 -1
- package/src/pages/admin/products/products/create.tsx +1 -1
- package/src/pages/admin/products/products/detail.tsx +14 -7
- package/src/pages/admin/settings/index.tsx +16 -6
- package/src/pages/admin/settings/payment-methods/create.tsx +81 -0
- package/src/pages/admin/settings/{payment-methods.tsx → payment-methods/index.tsx} +9 -6
- package/src/pages/checkout/pay.tsx +3 -1
- package/src/pages/customer/index.tsx +12 -1
- package/public/.gitkeep +0 -0
|
@@ -1,21 +1,28 @@
|
|
|
1
|
+
import 'react-international-phone/style.css';
|
|
2
|
+
|
|
1
3
|
import SessionManager from '@arcblock/did-connect/lib/SessionManager';
|
|
2
4
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
5
|
import LocaleSelector from '@arcblock/ux/lib/Locale/selector';
|
|
4
6
|
import Toast from '@arcblock/ux/lib/Toast';
|
|
5
7
|
import type { TCheckoutSessionExpanded, TCustomer, TPaymentIntent, TPaymentMethodExpanded } from '@did-pay/types';
|
|
6
|
-
import { InfoOutlined } from '@mui/icons-material';
|
|
7
8
|
import { LoadingButton } from '@mui/lab';
|
|
8
9
|
import { Avatar, Fade, InputAdornment, MenuItem, Select, Stack, Tooltip, Typography } from '@mui/material';
|
|
9
10
|
import { useSetState } from 'ahooks';
|
|
11
|
+
import { PhoneNumberUtil } from 'google-libphonenumber';
|
|
10
12
|
import pWaitFor from 'p-wait-for';
|
|
11
13
|
import { useEffect } from 'react';
|
|
12
|
-
import { Controller,
|
|
14
|
+
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
|
15
|
+
import isEmail from 'validator/es/lib/isEmail';
|
|
13
16
|
|
|
14
17
|
import { useSessionContext } from '../../../contexts/session';
|
|
15
18
|
import api from '../../../libs/api';
|
|
16
19
|
import { formatError, getStatementDescriptor } from '../../../libs/util';
|
|
17
20
|
import FormInput from '../../input';
|
|
18
21
|
import AddressForm from './address';
|
|
22
|
+
import PhoneInput from './phone';
|
|
23
|
+
import StripeCheckout from './stripe';
|
|
24
|
+
|
|
25
|
+
const phoneUtil = PhoneNumberUtil.getInstance();
|
|
19
26
|
|
|
20
27
|
const waitForCheckoutComplete = (sessionId: string) => {
|
|
21
28
|
return pWaitFor(
|
|
@@ -39,17 +46,24 @@ type PageData = {
|
|
|
39
46
|
customer?: TCustomer;
|
|
40
47
|
};
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
PaymentForm.defaultProps = {
|
|
43
50
|
paymentIntent: null,
|
|
44
51
|
customer: null,
|
|
45
52
|
};
|
|
46
53
|
|
|
47
54
|
// FIXME: https://stripe.com/docs/elements/address-element
|
|
48
|
-
// FIXME: https://github.com/goveo/react-international-phone | https://catamphetamine.gitlab.io/react-phone-number-input/
|
|
49
|
-
// TODO: https://github.com/rocktimsaikia/react-country-dropdown
|
|
50
55
|
// TODO: https://country-regions.github.io/react-country-region-selector/
|
|
51
|
-
//
|
|
52
|
-
|
|
56
|
+
// https://www.npmjs.com/package/postal-codes-js
|
|
57
|
+
// https://www.npmjs.com/package/val-zip
|
|
58
|
+
// https://npm.runkit.com/zips
|
|
59
|
+
export default function PaymentForm({
|
|
60
|
+
checkoutSession,
|
|
61
|
+
paymentMethods,
|
|
62
|
+
paymentIntent,
|
|
63
|
+
customer,
|
|
64
|
+
onPaid,
|
|
65
|
+
onError,
|
|
66
|
+
}: PageData) {
|
|
53
67
|
const { t } = useLocaleContext();
|
|
54
68
|
const { session, connectApi } = useSessionContext();
|
|
55
69
|
const { control, getValues, setValue, handleSubmit } = useFormContext();
|
|
@@ -58,11 +72,17 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
58
72
|
paying: boolean;
|
|
59
73
|
paid: boolean;
|
|
60
74
|
paymentIntent?: TPaymentIntent;
|
|
75
|
+
stripeContext?: any;
|
|
76
|
+
customer?: TCustomer;
|
|
77
|
+
stripePaying: boolean;
|
|
61
78
|
}>({
|
|
62
79
|
submitting: false,
|
|
63
80
|
paying: false,
|
|
64
81
|
paid: false,
|
|
65
82
|
paymentIntent,
|
|
83
|
+
stripeContext: null,
|
|
84
|
+
customer,
|
|
85
|
+
stripePaying: false,
|
|
66
86
|
});
|
|
67
87
|
|
|
68
88
|
useEffect(() => {
|
|
@@ -80,12 +100,25 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
80
100
|
}
|
|
81
101
|
}, [session.user, getValues, setValue]);
|
|
82
102
|
|
|
83
|
-
const
|
|
103
|
+
const paymentMethod = useWatch({ control, name: 'payment_method' });
|
|
104
|
+
const paymentCurrency = useWatch({ control, name: 'payment_currency' });
|
|
105
|
+
const paymentCurrencies = paymentMethods.find((x) => x.id === paymentMethod)?.payment_currencies || [];
|
|
106
|
+
|
|
84
107
|
const payee = getStatementDescriptor(checkoutSession.line_items);
|
|
85
108
|
const buttonText = session.user
|
|
86
109
|
? t(`checkout.${checkoutSession.mode}`)
|
|
87
110
|
: t('checkout.connect', { action: t(`checkout.${checkoutSession.mode}`) });
|
|
88
111
|
|
|
112
|
+
const method = paymentMethods.find((x) => x.id === paymentMethod) as TPaymentMethodExpanded;
|
|
113
|
+
|
|
114
|
+
const handleMethodChange = (e: any) => {
|
|
115
|
+
setValue('payment_method', e.target.value);
|
|
116
|
+
const currencies = paymentMethods.find((x) => x.id === e.target.value)?.payment_currencies || [];
|
|
117
|
+
if (currencies.some((x) => x.id === paymentCurrency) === false) {
|
|
118
|
+
setValue('payment_currency', currencies[0]?.id);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
89
122
|
const handleConnected = async () => {
|
|
90
123
|
try {
|
|
91
124
|
await waitForCheckoutComplete(checkoutSession.id);
|
|
@@ -98,31 +131,82 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
98
131
|
}
|
|
99
132
|
};
|
|
100
133
|
|
|
134
|
+
const onUserLoggedIn = async () => {
|
|
135
|
+
const { data: profile } = await api.get('/api/customers/me');
|
|
136
|
+
if (profile) {
|
|
137
|
+
const values = getValues();
|
|
138
|
+
if (!values.customer_name) {
|
|
139
|
+
setValue('customer_name', profile.name);
|
|
140
|
+
}
|
|
141
|
+
if (!values.customer_email) {
|
|
142
|
+
setValue('customer_email', profile.email);
|
|
143
|
+
}
|
|
144
|
+
if (!values.customer_phone) {
|
|
145
|
+
setValue('customer_phone', profile.phone);
|
|
146
|
+
}
|
|
147
|
+
if (profile.address?.country) {
|
|
148
|
+
setValue('billing_address.country', profile.address.country);
|
|
149
|
+
}
|
|
150
|
+
if (profile.address?.state) {
|
|
151
|
+
setValue('billing_address.state', profile.address.state);
|
|
152
|
+
}
|
|
153
|
+
if (profile.address?.line1) {
|
|
154
|
+
setValue('billing_address.line1', profile.address.line1);
|
|
155
|
+
}
|
|
156
|
+
if (profile.address?.line2) {
|
|
157
|
+
setValue('billing_address.line2', profile.address.line2);
|
|
158
|
+
}
|
|
159
|
+
if (profile.address?.city) {
|
|
160
|
+
setValue('billing_address.city', profile.address.city);
|
|
161
|
+
}
|
|
162
|
+
if (profile.address?.postal_code) {
|
|
163
|
+
setValue('billing_address.postal_code', profile.address.postal_code);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
101
168
|
const onSubmit = async (data: any) => {
|
|
102
169
|
setState({ submitting: true });
|
|
103
170
|
try {
|
|
104
171
|
const result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, data);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
172
|
+
|
|
173
|
+
setState({
|
|
174
|
+
paymentIntent: result.data.paymentIntent,
|
|
175
|
+
stripeContext: result.data.stripeContext,
|
|
176
|
+
customer: result.data.customer,
|
|
177
|
+
submitting: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (['arcblock', 'ethereum'].includes(method.type)) {
|
|
181
|
+
setState({ paying: true });
|
|
182
|
+
if (result.data.delegation.sufficient) {
|
|
183
|
+
await handleConnected();
|
|
184
|
+
} else {
|
|
185
|
+
connectApi.open({
|
|
186
|
+
action: checkoutSession.mode,
|
|
187
|
+
timeout: 5 * 60 * 1000,
|
|
188
|
+
extraParams: { checkoutSessionId: checkoutSession.id },
|
|
189
|
+
onSuccess: async () => {
|
|
190
|
+
connectApi.close();
|
|
191
|
+
await handleConnected();
|
|
192
|
+
},
|
|
193
|
+
onClose: () => {
|
|
194
|
+
connectApi.close();
|
|
195
|
+
setState({ submitting: false, paying: false });
|
|
196
|
+
},
|
|
197
|
+
onError: (err: any) => {
|
|
198
|
+
setState({ submitting: false, paying: false });
|
|
199
|
+
onError(err);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (['stripe'].includes(method.type)) {
|
|
205
|
+
if (result.data.stripeContext?.status === 'succeeded') {
|
|
206
|
+
setState({ paying: true });
|
|
207
|
+
} else {
|
|
208
|
+
setState({ stripePaying: true });
|
|
209
|
+
}
|
|
126
210
|
}
|
|
127
211
|
} catch (err) {
|
|
128
212
|
Toast.error(formatError(err));
|
|
@@ -136,12 +220,21 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
136
220
|
await handleSubmit(onSubmit)();
|
|
137
221
|
} else {
|
|
138
222
|
session.login({
|
|
139
|
-
onSuccess:
|
|
223
|
+
onSuccess: onUserLoggedIn,
|
|
140
224
|
extraParams: {},
|
|
141
225
|
});
|
|
142
226
|
}
|
|
143
227
|
};
|
|
144
228
|
|
|
229
|
+
const onStripeConfirm = async () => {
|
|
230
|
+
setState({ stripePaying: false, paying: true });
|
|
231
|
+
await handleConnected();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const onStripeCancel = () => {
|
|
235
|
+
setState({ stripePaying: false });
|
|
236
|
+
};
|
|
237
|
+
|
|
145
238
|
return (
|
|
146
239
|
<>
|
|
147
240
|
<Fade in>
|
|
@@ -150,13 +243,23 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
150
243
|
<Typography sx={{ color: 'text.primary', fontWeight: 600 }}>{t('checkout.contact')}</Typography>
|
|
151
244
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
|
152
245
|
<LocaleSelector showText={false} />
|
|
153
|
-
|
|
246
|
+
{session.user ? (
|
|
247
|
+
<SessionManager session={session} />
|
|
248
|
+
) : (
|
|
249
|
+
<Tooltip title={t('checkout.login')} arrow>
|
|
250
|
+
<SessionManager session={session} />
|
|
251
|
+
</Tooltip>
|
|
252
|
+
)}
|
|
154
253
|
</Stack>
|
|
155
254
|
</Stack>
|
|
156
255
|
<Stack direction="column" className="cko-payment-form" spacing={0}>
|
|
157
256
|
<FormInput
|
|
158
257
|
name="customer_name"
|
|
159
258
|
variant="outlined"
|
|
259
|
+
errorPosition="right"
|
|
260
|
+
rules={{
|
|
261
|
+
required: t('checkout.required'),
|
|
262
|
+
}}
|
|
160
263
|
InputProps={{
|
|
161
264
|
startAdornment: <InputAdornment position="start">{t('checkout.customer.name')}</InputAdornment>,
|
|
162
265
|
}}
|
|
@@ -164,23 +267,31 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
164
267
|
<FormInput
|
|
165
268
|
name="customer_email"
|
|
166
269
|
variant="outlined"
|
|
270
|
+
errorPosition="right"
|
|
271
|
+
rules={{
|
|
272
|
+
required: t('checkout.required'),
|
|
273
|
+
validate: (x) => (isEmail(x) ? true : t('checkout.customer.emailInvalid')),
|
|
274
|
+
}}
|
|
167
275
|
InputProps={{
|
|
168
276
|
startAdornment: <InputAdornment position="start">{t('checkout.customer.email')}</InputAdornment>,
|
|
169
277
|
}}
|
|
170
278
|
/>
|
|
171
279
|
{checkoutSession.phone_number_collection?.enabled && (
|
|
172
|
-
<
|
|
280
|
+
<PhoneInput
|
|
173
281
|
name="customer_phone"
|
|
174
282
|
variant="outlined"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
283
|
+
errorPosition="right"
|
|
284
|
+
placeholder="Phone number"
|
|
285
|
+
rules={{
|
|
286
|
+
required: t('checkout.required'),
|
|
287
|
+
validate: (x: string) => {
|
|
288
|
+
try {
|
|
289
|
+
const parsed = phoneUtil.parseAndKeepRawInput(x);
|
|
290
|
+
return phoneUtil.isValidNumber(parsed) ? true : t('checkout.invalid');
|
|
291
|
+
} catch {
|
|
292
|
+
return t('checkout.invalid');
|
|
293
|
+
}
|
|
294
|
+
},
|
|
184
295
|
}}
|
|
185
296
|
/>
|
|
186
297
|
)}
|
|
@@ -189,16 +300,16 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
189
300
|
</Fade>
|
|
190
301
|
<AddressForm mode={checkoutSession.billing_address_collection as string} />
|
|
191
302
|
<Fade in>
|
|
192
|
-
<Stack className="cko-payment-methods">
|
|
303
|
+
<Stack direction="column" className="cko-payment-methods">
|
|
193
304
|
<Typography sx={{ mb: 2, color: 'text.primary', fontWeight: 600 }}>{t('checkout.method')}</Typography>
|
|
194
305
|
<Stack direction="row" spacing={1}>
|
|
195
306
|
<Controller
|
|
196
307
|
name="payment_method"
|
|
197
308
|
control={control}
|
|
198
309
|
render={({ field }) => (
|
|
199
|
-
<Select {...field} sx={{ flex: 1 }} size="small">
|
|
310
|
+
<Select {...field} onChange={handleMethodChange} sx={{ flex: 1 }} size="small">
|
|
200
311
|
{paymentMethods.map((x) => {
|
|
201
|
-
const selected = x.id ===
|
|
312
|
+
const selected = x.id === paymentMethod;
|
|
202
313
|
return (
|
|
203
314
|
<MenuItem key={x.id} value={x.id}>
|
|
204
315
|
<Stack direction="row" spacing={1}>
|
|
@@ -216,8 +327,8 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
216
327
|
control={control}
|
|
217
328
|
render={({ field }) => (
|
|
218
329
|
<Select {...field} sx={{ flex: 1 }} size="small">
|
|
219
|
-
{
|
|
220
|
-
const selected = x.id ===
|
|
330
|
+
{paymentCurrencies.map((x) => {
|
|
331
|
+
const selected = x.id === paymentCurrency;
|
|
221
332
|
return (
|
|
222
333
|
<MenuItem key={x.id} value={x.id}>
|
|
223
334
|
<Stack direction="row" spacing={1}>
|
|
@@ -231,6 +342,17 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
231
342
|
)}
|
|
232
343
|
/>
|
|
233
344
|
</Stack>
|
|
345
|
+
{state.stripePaying && state.stripeContext && (
|
|
346
|
+
<StripeCheckout
|
|
347
|
+
clientSecret={state.stripeContext.client_secret}
|
|
348
|
+
intentType={state.stripeContext.intent_type}
|
|
349
|
+
publicKey={method.settings.stripe?.publishable_key as string}
|
|
350
|
+
customer={state.customer as TCustomer}
|
|
351
|
+
mode={checkoutSession.mode}
|
|
352
|
+
onConfirm={onStripeConfirm}
|
|
353
|
+
onCancel={onStripeCancel}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
234
356
|
</Stack>
|
|
235
357
|
</Fade>
|
|
236
358
|
<Fade in>
|
|
@@ -242,6 +364,7 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
242
364
|
onClick={onAction}
|
|
243
365
|
fullWidth
|
|
244
366
|
loadingPosition="end"
|
|
367
|
+
disabled={state.submitting || state.paying || state.stripePaying}
|
|
245
368
|
loading={state.submitting || state.paying}>
|
|
246
369
|
{state.submitting || state.paying ? t('checkout.processing') : buttonText}
|
|
247
370
|
</LoadingButton>
|
|
@@ -256,40 +379,3 @@ export function PaymentFormInner({ checkoutSession, paymentMethods, paymentInten
|
|
|
256
379
|
</>
|
|
257
380
|
);
|
|
258
381
|
}
|
|
259
|
-
|
|
260
|
-
export default function PaymentForm(props: PageData) {
|
|
261
|
-
const { session } = useSessionContext();
|
|
262
|
-
const { customer, checkoutSession, paymentMethods } = props;
|
|
263
|
-
|
|
264
|
-
const methods = useForm({
|
|
265
|
-
defaultValues: {
|
|
266
|
-
customer_name: customer?.name || session.user?.fullName || '',
|
|
267
|
-
customer_email: customer?.email || session.user?.email || '',
|
|
268
|
-
customer_phone: customer?.phone || session.user?.phone || '',
|
|
269
|
-
payment_method: paymentMethods[0]?.id || '', // FIXME: use default payment method
|
|
270
|
-
payment_currency: checkoutSession.currency_id || '',
|
|
271
|
-
billing_address: Object.assign(
|
|
272
|
-
{
|
|
273
|
-
country: '',
|
|
274
|
-
state: '',
|
|
275
|
-
city: '',
|
|
276
|
-
line1: '',
|
|
277
|
-
line2: '',
|
|
278
|
-
postal_code: '',
|
|
279
|
-
},
|
|
280
|
-
customer?.address || {}
|
|
281
|
-
),
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
return (
|
|
286
|
-
<FormProvider {...methods}>
|
|
287
|
-
<PaymentFormInner {...props} />
|
|
288
|
-
</FormProvider>
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
PaymentForm.defaultProps = {
|
|
293
|
-
paymentIntent: null,
|
|
294
|
-
customer: null,
|
|
295
|
-
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { InputAdornment, MenuItem, Select, Typography } from '@mui/material';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { useFormContext, useWatch } from 'react-hook-form';
|
|
4
|
+
import { CountryIso2, FlagEmoji, defaultCountries, parseCountry, usePhoneInput } from 'react-international-phone';
|
|
5
|
+
|
|
6
|
+
import FormInput from '../../input';
|
|
7
|
+
|
|
8
|
+
export default function PhoneInput({ ...props }) {
|
|
9
|
+
const { control, getValues, setValue } = useFormContext();
|
|
10
|
+
const { phone, handlePhoneValueChange, inputRef, country, setCountry } = usePhoneInput({
|
|
11
|
+
defaultCountry: 'us',
|
|
12
|
+
value: getValues().customer_phone || '',
|
|
13
|
+
countries: defaultCountries,
|
|
14
|
+
onChange: (data) => {
|
|
15
|
+
setValue('customer_phone', data.phone);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const userCountry = useWatch({ control, name: 'billing_address.country' });
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (userCountry !== country) {
|
|
23
|
+
setCountry(userCountry);
|
|
24
|
+
}
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
26
|
+
}, [userCountry]);
|
|
27
|
+
|
|
28
|
+
const onCountryChange = (e: any) => {
|
|
29
|
+
setCountry(e.target.value as CountryIso2);
|
|
30
|
+
setValue('billing_address.country', e.target.value);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
// @ts-ignore
|
|
35
|
+
<FormInput
|
|
36
|
+
value={phone}
|
|
37
|
+
onChange={handlePhoneValueChange}
|
|
38
|
+
type="tel"
|
|
39
|
+
inputRef={inputRef}
|
|
40
|
+
InputProps={{
|
|
41
|
+
startAdornment: (
|
|
42
|
+
<InputAdornment position="start" style={{ marginRight: '2px', marginLeft: '-8px' }}>
|
|
43
|
+
<Select
|
|
44
|
+
MenuProps={{
|
|
45
|
+
style: {
|
|
46
|
+
height: '300px',
|
|
47
|
+
width: '360px',
|
|
48
|
+
top: '10px',
|
|
49
|
+
left: '-34px',
|
|
50
|
+
},
|
|
51
|
+
transformOrigin: {
|
|
52
|
+
vertical: 'top',
|
|
53
|
+
horizontal: 'left',
|
|
54
|
+
},
|
|
55
|
+
}}
|
|
56
|
+
sx={{
|
|
57
|
+
width: 'max-content',
|
|
58
|
+
// Remove default outline (display only on focus)
|
|
59
|
+
fieldset: {
|
|
60
|
+
display: 'none',
|
|
61
|
+
},
|
|
62
|
+
'&.Mui-focused:has(div[aria-expanded="false"])': {
|
|
63
|
+
fieldset: {
|
|
64
|
+
display: 'block',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
// Update default spacing
|
|
68
|
+
'.MuiSelect-select': {
|
|
69
|
+
padding: '8px',
|
|
70
|
+
paddingRight: '24px !important',
|
|
71
|
+
},
|
|
72
|
+
svg: {
|
|
73
|
+
right: 0,
|
|
74
|
+
},
|
|
75
|
+
}}
|
|
76
|
+
value={country}
|
|
77
|
+
onChange={onCountryChange}
|
|
78
|
+
renderValue={(code) => <FlagEmoji iso2={code} style={{ display: 'flex' }} />}>
|
|
79
|
+
{defaultCountries.map((c) => {
|
|
80
|
+
const parsed = parseCountry(c);
|
|
81
|
+
return (
|
|
82
|
+
<MenuItem key={parsed.iso2} value={parsed.iso2}>
|
|
83
|
+
<FlagEmoji iso2={parsed.iso2} style={{ marginRight: '8px' }} />
|
|
84
|
+
<Typography marginRight="8px">{parsed.name}</Typography>
|
|
85
|
+
<Typography color="gray">+{parsed.dialCode}</Typography>
|
|
86
|
+
</MenuItem>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</Select>
|
|
90
|
+
</InputAdornment>
|
|
91
|
+
),
|
|
92
|
+
}}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import Center from '@arcblock/ux/lib/Center';
|
|
2
|
+
import Dialog from '@arcblock/ux/lib/Dialog';
|
|
3
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
4
|
+
import type { TCustomer } from '@did-pay/types';
|
|
5
|
+
import { LoadingButton } from '@mui/lab';
|
|
6
|
+
import { CircularProgress, Typography } from '@mui/material';
|
|
7
|
+
import { styled } from '@mui/system';
|
|
8
|
+
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
|
|
9
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
10
|
+
import { useSetState } from 'ahooks';
|
|
11
|
+
import { useEffect } from 'react';
|
|
12
|
+
|
|
13
|
+
type StripeCheckoutFormProps = {
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
intentType: string;
|
|
16
|
+
customer: TCustomer;
|
|
17
|
+
mode: string;
|
|
18
|
+
onConfirm: Function;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// @doc https://stripe.com/docs/js/elements_object/create_payment_element
|
|
22
|
+
function StripeCheckoutForm({ clientSecret, intentType, customer, mode, onConfirm }: StripeCheckoutFormProps) {
|
|
23
|
+
const stripe = useStripe();
|
|
24
|
+
const elements = useElements();
|
|
25
|
+
const { t } = useLocaleContext();
|
|
26
|
+
|
|
27
|
+
const [state, setState] = useSetState({
|
|
28
|
+
message: '',
|
|
29
|
+
confirming: false,
|
|
30
|
+
loaded: false,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!stripe) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!clientSecret) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const method = intentType === 'payment_intent' ? 'retrievePaymentIntent' : 'retrieveSetupIntent';
|
|
43
|
+
stripe[method](clientSecret).then(({ paymentIntent, setupIntent }: any) => {
|
|
44
|
+
const intent = paymentIntent || setupIntent;
|
|
45
|
+
switch (intent?.status) {
|
|
46
|
+
case 'succeeded':
|
|
47
|
+
setState({ message: t('paymentCredit.preparePayMessage.succeeded') });
|
|
48
|
+
break;
|
|
49
|
+
case 'processing':
|
|
50
|
+
setState({ message: t('paymentCredit.preparePayMessage.processing') });
|
|
51
|
+
break;
|
|
52
|
+
case 'requires_payment_method': // 忽略该状态
|
|
53
|
+
default:
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, [stripe, clientSecret]);
|
|
59
|
+
|
|
60
|
+
const handleSubmit = async (e: any) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
|
|
63
|
+
if (!stripe || !elements) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
setState({ confirming: true });
|
|
69
|
+
const method = intentType === 'payment_intent' ? 'confirmPayment' : 'confirmSetup';
|
|
70
|
+
const { error } = await stripe[method]({
|
|
71
|
+
elements,
|
|
72
|
+
redirect: 'if_required',
|
|
73
|
+
confirmParams: {
|
|
74
|
+
payment_method_data: {
|
|
75
|
+
billing_details: {
|
|
76
|
+
name: customer.name,
|
|
77
|
+
phone: customer.phone,
|
|
78
|
+
email: customer.email,
|
|
79
|
+
address: customer.address,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
setState({ confirming: false });
|
|
86
|
+
if (error) {
|
|
87
|
+
if (error.type === 'validation_error') {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setState({ message: error.message as string });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onConfirm();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(err);
|
|
98
|
+
setState({ confirming: false, message: err.message as string });
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Content onSubmit={handleSubmit}>
|
|
104
|
+
<PaymentElement
|
|
105
|
+
options={{ layout: 'auto', fields: { billingDetails: 'never' }, readOnly: state.confirming }}
|
|
106
|
+
onReady={() => setState({ loaded: true })}
|
|
107
|
+
/>
|
|
108
|
+
{(!stripe || !elements || !state.loaded) && (
|
|
109
|
+
<Center relative="parent">
|
|
110
|
+
<CircularProgress />
|
|
111
|
+
</Center>
|
|
112
|
+
)}
|
|
113
|
+
{stripe && elements && state.loaded && (
|
|
114
|
+
<LoadingButton
|
|
115
|
+
fullWidth
|
|
116
|
+
sx={{ mt: 2, mb: 1, borderRadius: 0, fontSize: '1.1rem' }}
|
|
117
|
+
type="submit"
|
|
118
|
+
disabled={state.confirming || !state.loaded}
|
|
119
|
+
loading={state.confirming}
|
|
120
|
+
loadingPosition="end"
|
|
121
|
+
variant="contained"
|
|
122
|
+
color="primary"
|
|
123
|
+
size="large">
|
|
124
|
+
{t('checkout.continue', { action: mode })}
|
|
125
|
+
</LoadingButton>
|
|
126
|
+
)}
|
|
127
|
+
{state.message && <Typography sx={{ mt: 1, color: 'error.main' }}>{state.message}</Typography>}
|
|
128
|
+
</Content>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const Content = styled('form')`
|
|
133
|
+
display: flex;
|
|
134
|
+
flex-direction: column;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
align-items: center;
|
|
137
|
+
width: 100%;
|
|
138
|
+
height: 100%;
|
|
139
|
+
min-height: 320px;
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
type StripeCheckoutProps = {
|
|
143
|
+
clientSecret: string;
|
|
144
|
+
intentType: string;
|
|
145
|
+
publicKey: string;
|
|
146
|
+
mode: string;
|
|
147
|
+
customer: TCustomer;
|
|
148
|
+
onConfirm: Function;
|
|
149
|
+
onCancel: Function;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default function StripeCheckout({
|
|
153
|
+
clientSecret,
|
|
154
|
+
intentType,
|
|
155
|
+
publicKey,
|
|
156
|
+
mode,
|
|
157
|
+
customer,
|
|
158
|
+
onConfirm,
|
|
159
|
+
onCancel,
|
|
160
|
+
}: StripeCheckoutProps) {
|
|
161
|
+
const stripePromise = loadStripe(publicKey);
|
|
162
|
+
const { t } = useLocaleContext();
|
|
163
|
+
const [state, setState] = useSetState({
|
|
164
|
+
open: true,
|
|
165
|
+
closable: true,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const handleClose = (_: any, reason: string) => {
|
|
169
|
+
if (reason === 'backdropClick') {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setState({ open: false });
|
|
174
|
+
onCancel();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<Dialog
|
|
179
|
+
title={t('checkout.cardPay', { action: t(`checkout.${mode}`) })}
|
|
180
|
+
showCloseButton={state.closable}
|
|
181
|
+
open={state.open}
|
|
182
|
+
onClose={handleClose}
|
|
183
|
+
disableEscapeKeyDown>
|
|
184
|
+
<Elements options={{ clientSecret }} stripe={stripePromise}>
|
|
185
|
+
<StripeCheckoutForm
|
|
186
|
+
clientSecret={clientSecret}
|
|
187
|
+
intentType={intentType}
|
|
188
|
+
mode={mode}
|
|
189
|
+
customer={customer}
|
|
190
|
+
onConfirm={onConfirm}
|
|
191
|
+
/>
|
|
192
|
+
</Elements>
|
|
193
|
+
</Dialog>
|
|
194
|
+
);
|
|
195
|
+
}
|