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.
Files changed (109) hide show
  1. package/README.md +14 -0
  2. package/api/src/index.ts +17 -6
  3. package/api/src/integrations/stripe/handlers/index.ts +53 -0
  4. package/api/src/integrations/stripe/handlers/invoice.ts +252 -0
  5. package/api/src/integrations/stripe/handlers/payment-intent.ts +172 -0
  6. package/api/src/integrations/stripe/handlers/setup-intent.ts +42 -0
  7. package/api/src/integrations/stripe/handlers/subscription.ts +61 -0
  8. package/api/src/integrations/stripe/resource.ts +317 -0
  9. package/api/src/integrations/stripe/setup.ts +50 -0
  10. package/api/src/jobs/invoice.ts +11 -0
  11. package/api/src/jobs/payment.ts +15 -7
  12. package/api/src/jobs/subscription.ts +18 -2
  13. package/api/src/libs/session.ts +104 -8
  14. package/api/src/libs/util.ts +47 -1
  15. package/api/src/routes/checkout-sessions.ts +134 -27
  16. package/api/src/routes/connect/collect.ts +12 -4
  17. package/api/src/routes/connect/pay.ts +30 -20
  18. package/api/src/routes/connect/setup.ts +12 -4
  19. package/api/src/routes/connect/shared.ts +28 -4
  20. package/api/src/routes/connect/subscribe.ts +12 -5
  21. package/api/src/routes/customers.ts +5 -5
  22. package/api/src/routes/events.ts +9 -6
  23. package/api/src/routes/index.ts +2 -0
  24. package/api/src/routes/integrations/stripe.ts +64 -0
  25. package/api/src/routes/invoices.ts +19 -9
  26. package/api/src/routes/payment-intents.ts +19 -9
  27. package/api/src/routes/payment-links.ts +57 -15
  28. package/api/src/routes/payment-methods.ts +98 -1
  29. package/api/src/routes/prices.ts +71 -14
  30. package/api/src/routes/products.ts +79 -22
  31. package/api/src/routes/settings.ts +10 -11
  32. package/api/src/routes/subscription-items.ts +5 -5
  33. package/api/src/routes/subscriptions.ts +61 -10
  34. package/api/src/routes/usage-records.ts +52 -18
  35. package/api/src/routes/webhook-attempts.ts +5 -5
  36. package/api/src/routes/webhook-endpoints.ts +5 -5
  37. package/api/src/store/migrations/20230905-genesis.ts +2 -2
  38. package/api/src/store/migrations/20230911-seeding.ts +4 -3
  39. package/api/src/store/models/checkout-session.ts +15 -7
  40. package/api/src/store/models/index.ts +31 -7
  41. package/api/src/store/models/invoice.ts +1 -1
  42. package/api/src/store/models/payment-intent.ts +2 -5
  43. package/api/src/store/models/payment-link.ts +1 -1
  44. package/api/src/store/models/payment-method.ts +54 -33
  45. package/api/src/store/models/price.ts +52 -17
  46. package/api/src/store/models/product.ts +0 -3
  47. package/api/src/store/models/subscription.ts +3 -5
  48. package/api/src/store/models/types.ts +56 -2
  49. package/api/third.d.ts +2 -0
  50. package/blocklet.yml +1 -1
  51. package/package.json +36 -29
  52. package/public/currencies/dai.png +0 -0
  53. package/public/currencies/dollar.png +0 -0
  54. package/public/currencies/usdc.png +0 -0
  55. package/public/currencies/usdt.png +0 -0
  56. package/public/methods/arcblock.png +0 -0
  57. package/public/methods/binance.png +0 -0
  58. package/public/methods/coinbase.png +0 -0
  59. package/public/methods/ethereum.jpg +0 -0
  60. package/public/methods/stripe.png +0 -0
  61. package/src/components/checkout/form/address.tsx +86 -10
  62. package/src/components/checkout/form/index.tsx +169 -83
  63. package/src/components/checkout/form/phone.tsx +96 -0
  64. package/src/components/checkout/form/stripe.tsx +195 -0
  65. package/src/components/checkout/pay.tsx +115 -34
  66. package/src/components/checkout/product-item.tsx +4 -3
  67. package/src/components/checkout/summary.tsx +5 -4
  68. package/src/components/drawer-form.tsx +4 -4
  69. package/src/components/input.tsx +22 -4
  70. package/src/components/invoice/table.tsx +8 -3
  71. package/src/components/payment-link/before-pay.tsx +11 -6
  72. package/src/components/payment-link/chrome.tsx +13 -0
  73. package/src/components/payment-link/preview.tsx +31 -0
  74. package/src/components/payment-link/product-select.tsx +8 -3
  75. package/src/components/payment-method/arcblock.tsx +53 -0
  76. package/src/components/payment-method/bitcoin.tsx +53 -0
  77. package/src/components/payment-method/ethereum.tsx +53 -0
  78. package/src/components/payment-method/form.tsx +54 -0
  79. package/src/components/payment-method/stripe.tsx +45 -0
  80. package/src/components/portal/invoice/list.tsx +1 -1
  81. package/src/components/portal/subscription/list.tsx +1 -1
  82. package/src/components/price/currency-select.tsx +53 -0
  83. package/src/components/price/form.tsx +118 -24
  84. package/src/components/product/add-price.tsx +1 -1
  85. package/src/components/product/edit-price.tsx +6 -2
  86. package/src/components/subscription/items/index.tsx +7 -6
  87. package/src/components/subscription/items/usage-records.tsx +98 -0
  88. package/src/components/subscription/list.tsx +3 -2
  89. package/src/components/subscription/status.tsx +68 -0
  90. package/src/contexts/settings.tsx +2 -2
  91. package/src/env.d.ts +2 -0
  92. package/src/libs/util.ts +116 -21
  93. package/src/locales/en.tsx +71 -3
  94. package/src/pages/admin/billing/invoices/detail.tsx +5 -2
  95. package/src/pages/admin/billing/subscriptions/detail.tsx +6 -6
  96. package/src/pages/admin/customers/customers/detail.tsx +13 -1
  97. package/src/pages/admin/payments/intents/detail.tsx +8 -3
  98. package/src/pages/admin/payments/links/create.tsx +23 -3
  99. package/src/pages/admin/payments/links/detail.tsx +13 -26
  100. package/src/pages/admin/products/prices/detail.tsx +55 -11
  101. package/src/pages/admin/products/prices/list.tsx +7 -1
  102. package/src/pages/admin/products/products/create.tsx +1 -1
  103. package/src/pages/admin/products/products/detail.tsx +14 -7
  104. package/src/pages/admin/settings/index.tsx +16 -6
  105. package/src/pages/admin/settings/payment-methods/create.tsx +81 -0
  106. package/src/pages/admin/settings/{payment-methods.tsx → payment-methods/index.tsx} +9 -6
  107. package/src/pages/checkout/pay.tsx +3 -1
  108. package/src/pages/customer/index.tsx +12 -1
  109. 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, FormProvider, useForm, useFormContext } from 'react-hook-form';
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
- PaymentFormInner.defaultProps = {
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
- // FIXME: add form validation
52
- export function PaymentFormInner({ checkoutSession, paymentMethods, paymentIntent, onPaid, onError }: PageData) {
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 currencies = paymentMethods.find((x) => x.id === getValues().payment_method)?.payment_currencies || [];
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
- setState({ paymentIntent: result.data.paymentIntent, submitting: false, paying: true });
106
- if (result.data.delegation.sufficient) {
107
- await handleConnected();
108
- } else {
109
- connectApi.open({
110
- action: checkoutSession.mode,
111
- timeout: 5 * 60 * 1000,
112
- extraParams: { checkoutSessionId: checkoutSession.id },
113
- onSuccess: async () => {
114
- connectApi.close();
115
- await handleConnected();
116
- },
117
- onClose: () => {
118
- connectApi.close();
119
- setState({ submitting: false, paying: false });
120
- },
121
- onError: (err: any) => {
122
- setState({ submitting: false, paying: false });
123
- onError(err);
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
- <SessionManager session={session} />
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
- <FormInput
280
+ <PhoneInput
173
281
  name="customer_phone"
174
282
  variant="outlined"
175
- InputProps={{
176
- startAdornment: <InputAdornment position="start">{t('checkout.customer.phone')}</InputAdornment>,
177
- endAdornment: (
178
- <InputAdornment position="end">
179
- <Tooltip title={t('checkout.customer.phoneTip')} arrow>
180
- <InfoOutlined fontSize="small" sx={{ cursor: 'pointer' }} />
181
- </Tooltip>
182
- </InputAdornment>
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 === getValues().payment_method;
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
- {currencies.map((x) => {
220
- const selected = x.id === getValues().payment_currency;
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
+ }