@blocklet/payment-react 1.13.113

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 (173) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +29 -0
  3. package/babel.config.es.js +8 -0
  4. package/build.config.ts +29 -0
  5. package/es/api.d.ts +2 -0
  6. package/es/api.js +18 -0
  7. package/es/checkout/index.d.ts +15 -0
  8. package/es/checkout/index.js +61 -0
  9. package/es/components/input.d.ts +23 -0
  10. package/es/components/input.js +44 -0
  11. package/es/components/livemode.d.ts +2 -0
  12. package/es/components/livemode.js +24 -0
  13. package/es/components/pricing-table.d.ts +18 -0
  14. package/es/components/pricing-table.js +175 -0
  15. package/es/components/status.d.ts +3 -0
  16. package/es/components/status.js +20 -0
  17. package/es/components/switch.d.ts +6 -0
  18. package/es/components/switch.js +42 -0
  19. package/es/contexts/payment.d.ts +29 -0
  20. package/es/contexts/payment.js +45 -0
  21. package/es/dayjs.d.ts +2 -0
  22. package/es/dayjs.js +14 -0
  23. package/es/index.d.ts +16 -0
  24. package/es/index.js +29 -0
  25. package/es/locales/en.d.ts +2 -0
  26. package/es/locales/en.js +213 -0
  27. package/es/locales/index.d.ts +10 -0
  28. package/es/locales/index.js +20 -0
  29. package/es/locales/zh.d.ts +2 -0
  30. package/es/locales/zh.js +213 -0
  31. package/es/payment/amount.d.ts +12 -0
  32. package/es/payment/amount.js +22 -0
  33. package/es/payment/error.d.ts +13 -0
  34. package/es/payment/error.js +12 -0
  35. package/es/payment/footer.d.ts +4 -0
  36. package/es/payment/footer.js +9 -0
  37. package/es/payment/form/addon.d.ts +2 -0
  38. package/es/payment/form/addon.js +14 -0
  39. package/es/payment/form/address.d.ts +7 -0
  40. package/es/payment/form/address.js +119 -0
  41. package/es/payment/form/index.d.ts +9 -0
  42. package/es/payment/form/index.js +337 -0
  43. package/es/payment/form/phone.d.ts +4 -0
  44. package/es/payment/form/phone.js +97 -0
  45. package/es/payment/form/stripe.d.ts +13 -0
  46. package/es/payment/form/stripe.js +158 -0
  47. package/es/payment/header.d.ts +7 -0
  48. package/es/payment/header.js +29 -0
  49. package/es/payment/index.d.ts +28 -0
  50. package/es/payment/index.js +327 -0
  51. package/es/payment/product-card.d.ts +21 -0
  52. package/es/payment/product-card.js +34 -0
  53. package/es/payment/product-item.d.ts +19 -0
  54. package/es/payment/product-item.js +107 -0
  55. package/es/payment/product-skeleton.d.ts +4 -0
  56. package/es/payment/product-skeleton.js +34 -0
  57. package/es/payment/skeleton/overview.d.ts +2 -0
  58. package/es/payment/skeleton/overview.js +13 -0
  59. package/es/payment/skeleton/payment.d.ts +2 -0
  60. package/es/payment/skeleton/payment.js +19 -0
  61. package/es/payment/success.d.ts +8 -0
  62. package/es/payment/success.js +164 -0
  63. package/es/payment/summary.d.ts +12 -0
  64. package/es/payment/summary.js +178 -0
  65. package/es/theme.d.ts +1 -0
  66. package/es/theme.js +17 -0
  67. package/es/types/index.d.ts +19 -0
  68. package/es/types/index.js +0 -0
  69. package/es/types/shims.d.ts +18 -0
  70. package/es/util.d.ts +52 -0
  71. package/es/util.js +390 -0
  72. package/lib/api.d.ts +2 -0
  73. package/lib/api.js +26 -0
  74. package/lib/checkout/index.d.ts +15 -0
  75. package/lib/checkout/index.js +83 -0
  76. package/lib/components/input.d.ts +23 -0
  77. package/lib/components/input.js +72 -0
  78. package/lib/components/livemode.d.ts +2 -0
  79. package/lib/components/livemode.js +29 -0
  80. package/lib/components/pricing-table.d.ts +18 -0
  81. package/lib/components/pricing-table.js +232 -0
  82. package/lib/components/status.d.ts +3 -0
  83. package/lib/components/status.js +23 -0
  84. package/lib/components/switch.d.ts +6 -0
  85. package/lib/components/switch.js +51 -0
  86. package/lib/contexts/payment.d.ts +29 -0
  87. package/lib/contexts/payment.js +73 -0
  88. package/lib/dayjs.d.ts +2 -0
  89. package/lib/dayjs.js +21 -0
  90. package/lib/index.d.ts +16 -0
  91. package/lib/index.js +143 -0
  92. package/lib/locales/en.d.ts +2 -0
  93. package/lib/locales/en.js +220 -0
  94. package/lib/locales/index.d.ts +10 -0
  95. package/lib/locales/index.js +33 -0
  96. package/lib/locales/zh.d.ts +2 -0
  97. package/lib/locales/zh.js +220 -0
  98. package/lib/payment/amount.d.ts +12 -0
  99. package/lib/payment/amount.js +28 -0
  100. package/lib/payment/error.d.ts +13 -0
  101. package/lib/payment/error.js +52 -0
  102. package/lib/payment/footer.d.ts +4 -0
  103. package/lib/payment/footer.js +25 -0
  104. package/lib/payment/form/addon.d.ts +2 -0
  105. package/lib/payment/form/addon.js +37 -0
  106. package/lib/payment/form/address.d.ts +7 -0
  107. package/lib/payment/form/address.js +152 -0
  108. package/lib/payment/form/index.d.ts +9 -0
  109. package/lib/payment/form/index.js +464 -0
  110. package/lib/payment/form/phone.d.ts +4 -0
  111. package/lib/payment/form/phone.js +133 -0
  112. package/lib/payment/form/stripe.d.ts +13 -0
  113. package/lib/payment/form/stripe.js +213 -0
  114. package/lib/payment/header.d.ts +7 -0
  115. package/lib/payment/header.js +58 -0
  116. package/lib/payment/index.d.ts +28 -0
  117. package/lib/payment/index.js +382 -0
  118. package/lib/payment/product-card.d.ts +21 -0
  119. package/lib/payment/product-card.js +81 -0
  120. package/lib/payment/product-item.d.ts +19 -0
  121. package/lib/payment/product-item.js +160 -0
  122. package/lib/payment/product-skeleton.d.ts +4 -0
  123. package/lib/payment/product-skeleton.js +71 -0
  124. package/lib/payment/skeleton/overview.d.ts +2 -0
  125. package/lib/payment/skeleton/overview.js +48 -0
  126. package/lib/payment/skeleton/payment.d.ts +2 -0
  127. package/lib/payment/skeleton/payment.js +54 -0
  128. package/lib/payment/success.d.ts +8 -0
  129. package/lib/payment/success.js +215 -0
  130. package/lib/payment/summary.d.ts +12 -0
  131. package/lib/payment/summary.js +225 -0
  132. package/lib/theme.d.ts +1 -0
  133. package/lib/theme.js +19 -0
  134. package/lib/types/index.d.ts +19 -0
  135. package/lib/types/index.js +1 -0
  136. package/lib/types/shims.d.ts +18 -0
  137. package/lib/util.d.ts +52 -0
  138. package/lib/util.js +487 -0
  139. package/package.json +104 -0
  140. package/src/api.ts +24 -0
  141. package/src/checkout/index.tsx +74 -0
  142. package/src/components/input.tsx +58 -0
  143. package/src/components/livemode.tsx +23 -0
  144. package/src/components/pricing-table.tsx +207 -0
  145. package/src/components/status.tsx +19 -0
  146. package/src/components/switch.tsx +48 -0
  147. package/src/contexts/payment.tsx +74 -0
  148. package/src/dayjs.ts +17 -0
  149. package/src/index.ts +32 -0
  150. package/src/locales/en.tsx +218 -0
  151. package/src/locales/index.tsx +30 -0
  152. package/src/locales/zh.tsx +214 -0
  153. package/src/payment/amount.tsx +24 -0
  154. package/src/payment/error.tsx +29 -0
  155. package/src/payment/footer.tsx +12 -0
  156. package/src/payment/form/addon.tsx +24 -0
  157. package/src/payment/form/address.tsx +119 -0
  158. package/src/payment/form/index.tsx +401 -0
  159. package/src/payment/form/phone.tsx +103 -0
  160. package/src/payment/form/stripe.tsx +195 -0
  161. package/src/payment/header.tsx +40 -0
  162. package/src/payment/index.tsx +367 -0
  163. package/src/payment/product-card.tsx +55 -0
  164. package/src/payment/product-item.tsx +121 -0
  165. package/src/payment/product-skeleton.tsx +39 -0
  166. package/src/payment/skeleton/overview.tsx +21 -0
  167. package/src/payment/skeleton/payment.tsx +35 -0
  168. package/src/payment/success.tsx +186 -0
  169. package/src/payment/summary.tsx +198 -0
  170. package/src/theme.ts +18 -0
  171. package/src/types/index.ts +29 -0
  172. package/src/types/shims.d.ts +18 -0
  173. package/src/util.ts +543 -0
@@ -0,0 +1,401 @@
1
+ import 'react-international-phone/style.css';
2
+
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+ import { useTheme } from '@arcblock/ux/lib/Theme';
5
+ import Toast from '@arcblock/ux/lib/Toast';
6
+ import type { TCustomer, TPaymentIntent, TPaymentMethodExpanded } from '@blocklet/payment-types';
7
+ import { LoadingButton } from '@mui/lab';
8
+ import { Avatar, Fade, InputAdornment, MenuItem, Select, Stack, Typography } from '@mui/material';
9
+ import { useCreation, useSetState, useSize } from 'ahooks';
10
+ import { PhoneNumberUtil } from 'google-libphonenumber';
11
+ import pWaitFor from 'p-wait-for';
12
+ import { useEffect } from 'react';
13
+ import { Controller, useFormContext, useWatch } from 'react-hook-form';
14
+ import { dispatch } from 'use-bus';
15
+ import isEmail from 'validator/es/lib/isEmail';
16
+
17
+ import api from '../../api';
18
+ import FormInput from '../../components/input';
19
+ import { usePaymentContext } from '../../contexts/payment';
20
+ import { CheckoutCallbacks, CheckoutContext } from '../../types';
21
+ import { formatError, getStatementDescriptor } from '../../util';
22
+ import UserButtons from './addon';
23
+ import AddressForm from './address';
24
+ import PhoneInput from './phone';
25
+ import StripeCheckout from './stripe';
26
+
27
+ const phoneUtil = PhoneNumberUtil.getInstance();
28
+
29
+ const waitForCheckoutComplete = async (sessionId: string) => {
30
+ let result: CheckoutContext;
31
+
32
+ await pWaitFor(
33
+ async () => {
34
+ const { data } = await api.get(`/api/checkout-sessions/retrieve/${sessionId}`);
35
+ if (
36
+ data.paymentIntent &&
37
+ data.paymentIntent.status === 'requires_action' &&
38
+ data.paymentIntent.last_payment_error
39
+ ) {
40
+ throw new Error(data.paymentIntent.last_payment_error.message);
41
+ }
42
+
43
+ result = data;
44
+
45
+ return (
46
+ data.checkoutSession?.status === 'complete' &&
47
+ ['paid', 'no_payment_required'].includes(data.checkoutSession?.payment_status)
48
+ );
49
+ },
50
+ { interval: 2000, timeout: 3 * 60 * 1000 }
51
+ );
52
+
53
+ // @ts-ignore
54
+ return result;
55
+ };
56
+
57
+ type PageData = CheckoutContext & CheckoutCallbacks;
58
+
59
+ PaymentForm.defaultProps = {};
60
+
61
+ // FIXME: https://stripe.com/docs/elements/address-element
62
+ // TODO: https://country-regions.github.io/react-country-region-selector/
63
+ // https://www.npmjs.com/package/postal-codes-js
64
+ // https://www.npmjs.com/package/val-zip
65
+ // https://npm.runkit.com/zips
66
+ export default function PaymentForm({
67
+ checkoutSession,
68
+ paymentMethods,
69
+ paymentIntent,
70
+ customer,
71
+ onPaid,
72
+ onError,
73
+ mode,
74
+ }: PageData) {
75
+ const theme = useTheme();
76
+ const { t } = useLocaleContext();
77
+ const { session, connect } = usePaymentContext();
78
+ const { control, getValues, setValue, handleSubmit } = useFormContext();
79
+ const [state, setState] = useSetState<{
80
+ submitting: boolean;
81
+ paying: boolean;
82
+ paid: boolean;
83
+ paymentIntent?: TPaymentIntent;
84
+ stripeContext?: {
85
+ client_secret: string;
86
+ intent_type: string;
87
+ status: string;
88
+ };
89
+ customer?: TCustomer;
90
+ stripePaying: boolean;
91
+ }>({
92
+ submitting: false,
93
+ paying: false,
94
+ paid: false,
95
+ paymentIntent,
96
+ stripeContext: undefined,
97
+ customer,
98
+ stripePaying: false,
99
+ });
100
+
101
+ useEffect(() => {
102
+ if (session.user) {
103
+ const values = getValues();
104
+ if (!values.customer_name) {
105
+ setValue('customer_name', session.user.fullName);
106
+ }
107
+ if (!values.customer_email) {
108
+ setValue('customer_email', session.user.email);
109
+ }
110
+ if (!values.customer_phone) {
111
+ setValue('customer_phone', session.user.phone);
112
+ }
113
+ }
114
+ }, [session.user, getValues, setValue]);
115
+
116
+ const paymentMethod = useWatch({ control, name: 'payment_method' });
117
+ const paymentCurrency = useWatch({ control, name: 'payment_currency' });
118
+ const paymentCurrencies = paymentMethods.find((x) => x.id === paymentMethod)?.payment_currencies || [];
119
+
120
+ const domSize = useSize(document.body);
121
+
122
+ const isColumnLayout = useCreation(() => {
123
+ if (domSize) {
124
+ if (domSize?.width <= theme.breakpoints.values.md) {
125
+ return true;
126
+ }
127
+ }
128
+ return false;
129
+ }, [domSize, theme]);
130
+
131
+ const payee = getStatementDescriptor(checkoutSession.line_items);
132
+ const buttonText = session.user
133
+ ? t(`payment.checkout.${checkoutSession.mode}`)
134
+ : t('payment.checkout.connect', { action: t(`payment.checkout.${checkoutSession.mode}`) });
135
+
136
+ const method = paymentMethods.find((x) => x.id === paymentMethod) as TPaymentMethodExpanded;
137
+
138
+ const handleMethodChange = (e: any) => {
139
+ setValue('payment_method', e.target.value);
140
+ const currencies = paymentMethods.find((x) => x.id === e.target.value)?.payment_currencies || [];
141
+ if (currencies.some((x) => x.id === paymentCurrency) === false) {
142
+ setValue('payment_currency', currencies[0]?.id);
143
+ }
144
+ };
145
+
146
+ const handleConnected = async () => {
147
+ try {
148
+ const result = await waitForCheckoutComplete(checkoutSession.id);
149
+ setState({ paid: true, paying: false });
150
+ onPaid(result);
151
+ } catch (err) {
152
+ Toast.error(formatError(err));
153
+ } finally {
154
+ setState({ paying: false });
155
+ }
156
+ };
157
+
158
+ const onUserLoggedIn = async () => {
159
+ const { data: profile } = await api.get('/api/customers/me');
160
+ if (profile) {
161
+ const values = getValues();
162
+ if (!values.customer_name) {
163
+ setValue('customer_name', profile.name);
164
+ }
165
+ if (!values.customer_email) {
166
+ setValue('customer_email', profile.email);
167
+ }
168
+ if (!values.customer_phone) {
169
+ setValue('customer_phone', profile.phone);
170
+ }
171
+ if (profile.address?.country) {
172
+ setValue('billing_address.country', profile.address.country);
173
+ }
174
+ if (profile.address?.state) {
175
+ setValue('billing_address.state', profile.address.state);
176
+ }
177
+ if (profile.address?.line1) {
178
+ setValue('billing_address.line1', profile.address.line1);
179
+ }
180
+ if (profile.address?.line2) {
181
+ setValue('billing_address.line2', profile.address.line2);
182
+ }
183
+ if (profile.address?.city) {
184
+ setValue('billing_address.city', profile.address.city);
185
+ }
186
+ if (profile.address?.postal_code) {
187
+ setValue('billing_address.postal_code', profile.address.postal_code);
188
+ }
189
+ }
190
+ };
191
+
192
+ const onSubmit = async (data: any) => {
193
+ setState({ submitting: true });
194
+ try {
195
+ const result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, data);
196
+
197
+ setState({
198
+ paymentIntent: result.data.paymentIntent,
199
+ stripeContext: result.data.stripeContext,
200
+ customer: result.data.customer,
201
+ submitting: false,
202
+ });
203
+
204
+ if (['arcblock', 'ethereum'].includes(method.type)) {
205
+ setState({ paying: true });
206
+ if (result.data.balance?.sufficient || result.data.delegation?.sufficient) {
207
+ await handleConnected();
208
+ } else {
209
+ connect.open({
210
+ action: checkoutSession.mode,
211
+ timeout: 5 * 60 * 1000,
212
+ extraParams: { checkoutSessionId: checkoutSession.id },
213
+ onSuccess: async () => {
214
+ connect.close();
215
+ await handleConnected();
216
+ },
217
+ onClose: () => {
218
+ connect.close();
219
+ setState({ submitting: false, paying: false });
220
+ },
221
+ onError: (err: any) => {
222
+ setState({ submitting: false, paying: false });
223
+ onError(err);
224
+ },
225
+ });
226
+ }
227
+ }
228
+ if (['stripe'].includes(method.type)) {
229
+ if (result.data.stripeContext?.status === 'succeeded') {
230
+ setState({ paying: true });
231
+ } else {
232
+ setState({ stripePaying: true });
233
+ }
234
+ }
235
+ } catch (err: any) {
236
+ if (err.response?.data?.code) {
237
+ dispatch(`error.${err.response?.data?.code}`);
238
+ }
239
+ Toast.error(formatError(err));
240
+ } finally {
241
+ setState({ submitting: false });
242
+ }
243
+ };
244
+
245
+ const onAction = () => {
246
+ if (session.user) {
247
+ handleSubmit(onSubmit)();
248
+ } else {
249
+ session.login({
250
+ onSuccess: onUserLoggedIn,
251
+ extraParams: {},
252
+ });
253
+ }
254
+ };
255
+
256
+ const onStripeConfirm = async () => {
257
+ setState({ stripePaying: false, paying: true });
258
+ await handleConnected();
259
+ };
260
+
261
+ const onStripeCancel = () => {
262
+ setState({ stripePaying: false });
263
+ };
264
+
265
+ return (
266
+ <>
267
+ <Fade in>
268
+ <Stack className="cko-payment-contact">
269
+ <Stack direction="row" sx={{ mb: 1 }} alignItems="center" justifyContent="space-between">
270
+ <Typography sx={{ color: 'text.primary', fontWeight: 600 }}>{t('payment.checkout.contact')}</Typography>
271
+ {isColumnLayout || mode !== 'standalone' ? null : <UserButtons />}
272
+ </Stack>
273
+ <Stack direction="column" className="cko-payment-form" spacing={0}>
274
+ <FormInput
275
+ name="customer_name"
276
+ variant="outlined"
277
+ errorPosition="right"
278
+ rules={{
279
+ required: t('payment.checkout.required'),
280
+ }}
281
+ InputProps={{
282
+ startAdornment: <InputAdornment position="start">{t('payment.checkout.customer.name')}</InputAdornment>,
283
+ }}
284
+ />
285
+ <FormInput
286
+ name="customer_email"
287
+ variant="outlined"
288
+ errorPosition="right"
289
+ rules={{
290
+ required: t('payment.checkout.required'),
291
+ validate: (x) => (isEmail(x) ? true : t('payment.checkout.invalid')),
292
+ }}
293
+ InputProps={{
294
+ startAdornment: (
295
+ <InputAdornment position="start">{t('payment.checkout.customer.email')}</InputAdornment>
296
+ ),
297
+ }}
298
+ />
299
+ {checkoutSession.phone_number_collection?.enabled && (
300
+ <PhoneInput
301
+ name="customer_phone"
302
+ variant="outlined"
303
+ errorPosition="right"
304
+ placeholder="Phone number"
305
+ rules={{
306
+ required: t('payment.checkout.required'),
307
+ validate: (x: string) => {
308
+ try {
309
+ const parsed = phoneUtil.parseAndKeepRawInput(x);
310
+ return phoneUtil.isValidNumber(parsed) ? true : t('payment.checkout.invalid');
311
+ } catch {
312
+ return t('payment.checkout.invalid');
313
+ }
314
+ },
315
+ }}
316
+ />
317
+ )}
318
+ </Stack>
319
+ </Stack>
320
+ </Fade>
321
+ <AddressForm mode={checkoutSession.billing_address_collection as string} stripe={method?.type === 'stripe'} />
322
+ <Fade in>
323
+ <Stack direction="column" alignItems="flex-start" className="cko-payment-methods">
324
+ <Typography sx={{ mb: 2, color: 'text.primary', fontWeight: 600 }}>{t('payment.checkout.method')}</Typography>
325
+ <Stack direction="row" sx={{ width: 1 }} spacing={1}>
326
+ <Controller
327
+ name="payment_method"
328
+ control={control}
329
+ render={({ field }) => (
330
+ <Select {...field} onChange={handleMethodChange} sx={{ flex: 1 }} size="small">
331
+ {paymentMethods.map((x) => {
332
+ const selected = x.id === paymentMethod;
333
+ return (
334
+ <MenuItem key={x.id} value={x.id}>
335
+ <Stack direction="row" spacing={1}>
336
+ <Avatar src={x.logo} alt={x.name} sx={{ width: 20, height: 20 }} />
337
+ <Typography color={selected ? 'text.primary' : 'text.secondary'}>{x.name}</Typography>
338
+ </Stack>
339
+ </MenuItem>
340
+ );
341
+ })}
342
+ </Select>
343
+ )}
344
+ />
345
+ <Controller
346
+ name="payment_currency"
347
+ control={control}
348
+ render={({ field }) => (
349
+ <Select {...field} sx={{ flex: 1 }} size="small">
350
+ {paymentCurrencies.map((x) => {
351
+ const selected = x.id === paymentCurrency;
352
+ return (
353
+ <MenuItem key={x.id} value={x.id}>
354
+ <Stack direction="row" spacing={1}>
355
+ <Avatar src={x.logo} alt={x.name} sx={{ width: 20, height: 20 }} />
356
+ <Typography color={selected ? 'text.primary' : 'text.secondary'}>{x.symbol}</Typography>
357
+ </Stack>
358
+ </MenuItem>
359
+ );
360
+ })}
361
+ </Select>
362
+ )}
363
+ />
364
+ </Stack>
365
+ {state.stripePaying && state.stripeContext && (
366
+ <StripeCheckout
367
+ clientSecret={state.stripeContext.client_secret}
368
+ intentType={state.stripeContext.intent_type}
369
+ publicKey={method.settings.stripe?.publishable_key as string}
370
+ customer={state.customer as TCustomer}
371
+ mode={checkoutSession.mode}
372
+ onConfirm={onStripeConfirm}
373
+ onCancel={onStripeCancel}
374
+ />
375
+ )}
376
+ </Stack>
377
+ </Fade>
378
+ <Fade in>
379
+ <Stack className="cko-payment-submit">
380
+ <LoadingButton
381
+ variant="contained"
382
+ color="primary"
383
+ size="large"
384
+ onClick={onAction}
385
+ fullWidth
386
+ loadingPosition="end"
387
+ disabled={state.submitting || state.paying || state.stripePaying}
388
+ loading={state.submitting || state.paying}>
389
+ {state.submitting || state.paying ? t('payment.checkout.processing') : buttonText}
390
+ </LoadingButton>
391
+ {['subscription', 'setup'].includes(checkoutSession.mode) && (
392
+ <Typography
393
+ sx={{ mt: 1, color: 'text.secondary', fontSize: '0.9rem', lineHeight: '1.1rem', textAlign: 'center' }}>
394
+ {t('payment.checkout.confirm', { payee })}
395
+ </Typography>
396
+ )}
397
+ </Stack>
398
+ </Fade>
399
+ </>
400
+ );
401
+ }
@@ -0,0 +1,103 @@
1
+ /* eslint-disable react/prop-types */
2
+ import { InputAdornment, MenuItem, Select, Typography } from '@mui/material';
3
+ import omit from 'lodash/omit';
4
+ import { useEffect } from 'react';
5
+ import { useFormContext, useWatch } from 'react-hook-form';
6
+ import { CountryIso2, FlagEmoji, defaultCountries, parseCountry, usePhoneInput } from 'react-international-phone';
7
+
8
+ import FormInput from '../../components/input';
9
+ import { isValidCountry } from '../../util';
10
+
11
+ export default function PhoneInput({ ...props }) {
12
+ const countryFieldName = props.countryFieldName || 'billing_address.country';
13
+
14
+ const { control, getValues, setValue } = useFormContext();
15
+ const values = getValues();
16
+
17
+ const { phone, handlePhoneValueChange, inputRef, country, setCountry } = usePhoneInput({
18
+ defaultCountry: isValidCountry(values[countryFieldName]) ? values[countryFieldName] : 'us',
19
+ value: values[props.name] || '',
20
+ countries: defaultCountries,
21
+ onChange: (data) => {
22
+ setValue(props.name, data.phone);
23
+ },
24
+ });
25
+
26
+ const userCountry = useWatch({ control, name: countryFieldName });
27
+
28
+ useEffect(() => {
29
+ if (userCountry !== country) {
30
+ setCountry(userCountry);
31
+ }
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [userCountry]);
34
+
35
+ const onCountryChange = (e: any) => {
36
+ setCountry(e.target.value as CountryIso2);
37
+ setValue(countryFieldName, e.target.value);
38
+ };
39
+
40
+ return (
41
+ // @ts-ignore
42
+ <FormInput
43
+ value={phone}
44
+ onChange={handlePhoneValueChange}
45
+ type="tel"
46
+ inputRef={inputRef}
47
+ InputProps={{
48
+ startAdornment: (
49
+ <InputAdornment position="start" style={{ marginRight: '2px', marginLeft: '-8px' }}>
50
+ <Select
51
+ MenuProps={{
52
+ style: {
53
+ height: '300px',
54
+ width: '360px',
55
+ top: '10px',
56
+ left: '-34px',
57
+ },
58
+ transformOrigin: {
59
+ vertical: 'top',
60
+ horizontal: 'left',
61
+ },
62
+ }}
63
+ sx={{
64
+ width: 'max-content',
65
+ // Remove default outline (display only on focus)
66
+ fieldset: {
67
+ display: 'none',
68
+ },
69
+ '&.Mui-focused:has(div[aria-expanded="false"])': {
70
+ fieldset: {
71
+ display: 'block',
72
+ },
73
+ },
74
+ // Update default spacing
75
+ '.MuiSelect-select': {
76
+ padding: '8px',
77
+ paddingRight: '24px !important',
78
+ },
79
+ svg: {
80
+ right: 0,
81
+ },
82
+ }}
83
+ value={country}
84
+ onChange={onCountryChange}
85
+ renderValue={(code) => <FlagEmoji iso2={code} style={{ display: 'flex' }} />}>
86
+ {defaultCountries.map((c) => {
87
+ const parsed = parseCountry(c);
88
+ return (
89
+ <MenuItem key={parsed.iso2} value={parsed.iso2}>
90
+ <FlagEmoji iso2={parsed.iso2} style={{ marginRight: '8px' }} />
91
+ <Typography marginRight="8px">{parsed.name}</Typography>
92
+ <Typography color="gray">+{parsed.dialCode}</Typography>
93
+ </MenuItem>
94
+ );
95
+ })}
96
+ </Select>
97
+ </InputAdornment>
98
+ ),
99
+ }}
100
+ {...omit(props, ['countryFieldName'])}
101
+ />
102
+ );
103
+ }
@@ -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 '@blocklet/payment-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: any) {
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('payment.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('payment.checkout.cardPay', { action: t(`payment.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
+ }