@blocklet/payment-react 1.18.24 → 1.18.26

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 (67) hide show
  1. package/es/checkout/donate.js +11 -1
  2. package/es/components/country-select.js +243 -21
  3. package/es/components/over-due-invoice-payment.d.ts +3 -1
  4. package/es/components/over-due-invoice-payment.js +6 -4
  5. package/es/contexts/payment.d.ts +2 -1
  6. package/es/contexts/payment.js +8 -1
  7. package/es/hooks/keyboard.js +3 -0
  8. package/es/index.d.ts +1 -0
  9. package/es/index.js +1 -0
  10. package/es/libs/api.js +4 -0
  11. package/es/libs/currency.d.ts +3 -0
  12. package/es/libs/currency.js +22 -0
  13. package/es/libs/phone-validator.js +2 -0
  14. package/es/libs/util.d.ts +2 -2
  15. package/es/libs/util.js +7 -4
  16. package/es/libs/validator.d.ts +1 -0
  17. package/es/libs/validator.js +70 -0
  18. package/es/payment/form/address.js +17 -3
  19. package/es/payment/form/index.js +10 -1
  20. package/es/payment/form/phone.js +12 -1
  21. package/es/payment/form/stripe/form.js +72 -15
  22. package/es/payment/index.js +33 -11
  23. package/es/payment/product-donation.js +110 -12
  24. package/es/types/shims.d.ts +2 -0
  25. package/lib/checkout/donate.js +11 -1
  26. package/lib/components/country-select.js +243 -39
  27. package/lib/components/over-due-invoice-payment.d.ts +3 -1
  28. package/lib/components/over-due-invoice-payment.js +7 -4
  29. package/lib/contexts/payment.d.ts +2 -1
  30. package/lib/contexts/payment.js +9 -1
  31. package/lib/hooks/keyboard.js +3 -0
  32. package/lib/index.d.ts +1 -0
  33. package/lib/index.js +12 -0
  34. package/lib/libs/api.js +4 -0
  35. package/lib/libs/currency.d.ts +3 -0
  36. package/lib/libs/currency.js +31 -0
  37. package/lib/libs/phone-validator.js +1 -0
  38. package/lib/libs/util.d.ts +2 -2
  39. package/lib/libs/util.js +7 -4
  40. package/lib/libs/validator.d.ts +1 -0
  41. package/lib/libs/validator.js +20 -0
  42. package/lib/payment/form/address.js +15 -2
  43. package/lib/payment/form/index.js +12 -1
  44. package/lib/payment/form/phone.js +13 -1
  45. package/lib/payment/form/stripe/form.js +98 -29
  46. package/lib/payment/index.js +34 -10
  47. package/lib/payment/product-donation.js +106 -15
  48. package/lib/types/shims.d.ts +2 -0
  49. package/package.json +8 -8
  50. package/src/checkout/donate.tsx +11 -1
  51. package/src/components/country-select.tsx +265 -20
  52. package/src/components/over-due-invoice-payment.tsx +6 -2
  53. package/src/contexts/payment.tsx +11 -1
  54. package/src/hooks/keyboard.ts +5 -3
  55. package/src/index.ts +1 -0
  56. package/src/libs/api.ts +4 -1
  57. package/src/libs/currency.ts +25 -0
  58. package/src/libs/phone-validator.ts +1 -0
  59. package/src/libs/util.ts +18 -4
  60. package/src/libs/validator.ts +70 -0
  61. package/src/payment/form/address.tsx +17 -4
  62. package/src/payment/form/index.tsx +11 -1
  63. package/src/payment/form/phone.tsx +15 -1
  64. package/src/payment/form/stripe/form.tsx +104 -32
  65. package/src/payment/index.tsx +45 -14
  66. package/src/payment/product-donation.tsx +129 -10
  67. package/src/types/shims.d.ts +2 -0
@@ -1,9 +1,11 @@
1
- import { useMemo, forwardRef } from 'react';
2
- import { Box, MenuItem, Select, Typography } from '@mui/material';
1
+ /* eslint-disable @typescript-eslint/indent */
2
+ import { useMemo, forwardRef, useState, useEffect, ChangeEvent, useRef, KeyboardEvent } from 'react';
3
+ import { Box, MenuItem, Select, Typography, TextField } from '@mui/material';
3
4
  import { useFormContext } from 'react-hook-form';
4
5
  import { FlagEmoji, defaultCountries, parseCountry } from 'react-international-phone';
5
6
  import type { SxProps } from '@mui/material';
6
7
  import type { CountryIso2 } from 'react-international-phone';
8
+ import { useMobile } from '../hooks/mobile';
7
9
 
8
10
  export type CountrySelectProps = {
9
11
  value: CountryIso2;
@@ -14,6 +16,123 @@ export type CountrySelectProps = {
14
16
 
15
17
  const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(({ value, onChange, name, sx }, ref) => {
16
18
  const { setValue } = useFormContext();
19
+ const [open, setOpen] = useState(false);
20
+ const [searchText, setSearchText] = useState('');
21
+ const inputRef = useRef<HTMLInputElement>(null);
22
+ const menuRef = useRef<HTMLDivElement>(null);
23
+ const listRef = useRef<HTMLDivElement>(null);
24
+ const [focusedIndex, setFocusedIndex] = useState(-1);
25
+ const itemHeightRef = useRef<number>(40);
26
+ const { isMobile } = useMobile();
27
+ const measuredRef = useRef(false);
28
+
29
+ // Handle window resize
30
+ useEffect(() => {
31
+ if (!open) return () => {};
32
+ const handleResize = () => {
33
+ measuredRef.current = false;
34
+ };
35
+
36
+ window.addEventListener('resize', handleResize);
37
+ return () => {
38
+ window.removeEventListener('resize', handleResize);
39
+ };
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [open]);
42
+
43
+ const scrollToTop = () => {
44
+ if (listRef.current) {
45
+ listRef.current.scrollTop = 0;
46
+ }
47
+ };
48
+
49
+ const measureItemHeight = () => {
50
+ if (!listRef.current || !open) return;
51
+
52
+ const items = listRef.current.querySelectorAll('.MuiMenuItem-root');
53
+ if (items.length > 0) {
54
+ const firstItem = items[0] as HTMLElement;
55
+ if (firstItem.offsetHeight > 0) {
56
+ itemHeightRef.current = firstItem.offsetHeight;
57
+ }
58
+ }
59
+ };
60
+
61
+ const controlScrollPosition = (index: number) => {
62
+ if (!listRef.current) return;
63
+
64
+ // Always measure height when dropdown is open for accuracy
65
+ if (open && !measuredRef.current) {
66
+ measureItemHeight();
67
+ measuredRef.current = true;
68
+ }
69
+
70
+ const listHeight = listRef.current.clientHeight;
71
+ const targetPosition = index * itemHeightRef.current;
72
+
73
+ if (index < 2) {
74
+ listRef.current.scrollTop = 0;
75
+ } else if (index > filteredCountries.length - 3) {
76
+ listRef.current.scrollTop = listRef.current.scrollHeight - listHeight;
77
+ } else {
78
+ const scrollPosition = targetPosition - listHeight / 2 + itemHeightRef.current / 2;
79
+ listRef.current.scrollTop = Math.max(0, scrollPosition);
80
+ }
81
+ };
82
+
83
+ useEffect(() => {
84
+ let timeout: NodeJS.Timeout | null = null;
85
+ if (open) {
86
+ timeout = setTimeout(() => {
87
+ scrollToTop();
88
+ if (!isMobile && inputRef.current) {
89
+ inputRef.current.focus();
90
+ }
91
+ }, 100);
92
+ } else {
93
+ setSearchText('');
94
+ setFocusedIndex(-1);
95
+ }
96
+ return () => {
97
+ if (timeout) {
98
+ clearTimeout(timeout);
99
+ }
100
+ };
101
+ }, [open, isMobile]);
102
+
103
+ const filteredCountries = useMemo(() => {
104
+ if (!searchText) return defaultCountries;
105
+
106
+ return defaultCountries.filter((c) => {
107
+ const parsed = parseCountry(c);
108
+ return (
109
+ parsed.name.toLowerCase().includes(searchText.toLowerCase()) ||
110
+ parsed.iso2.toLowerCase().includes(searchText.toLowerCase()) ||
111
+ `+${parsed.dialCode}`.includes(searchText)
112
+ );
113
+ });
114
+ }, [searchText]);
115
+
116
+ useEffect(() => {
117
+ scrollToTop();
118
+ setFocusedIndex(-1);
119
+ }, [searchText]);
120
+
121
+ useEffect(() => {
122
+ let timeout: NodeJS.Timeout | null = null;
123
+ if (focusedIndex >= 0) {
124
+ timeout = setTimeout(() => {
125
+ controlScrollPosition(focusedIndex);
126
+ }, 10);
127
+ }
128
+ return () => {
129
+ if (timeout) {
130
+ clearTimeout(timeout);
131
+ }
132
+ };
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [focusedIndex, filteredCountries.length]);
135
+
17
136
  const countryDetail = useMemo(() => {
18
137
  const item = defaultCountries.find((v) => v[1] === value);
19
138
  return value && item ? parseCountry(item) : { name: '' };
@@ -24,12 +143,68 @@ const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(({ value, o
24
143
  setValue(name, e.target.value);
25
144
  };
26
145
 
146
+ const handleCountryClick = (code: CountryIso2) => {
147
+ onChange(code);
148
+ setValue(name, code);
149
+ setOpen(false);
150
+ };
151
+
152
+ const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
153
+ e.stopPropagation();
154
+ setSearchText(e.target.value);
155
+ };
156
+
157
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
158
+ e.stopPropagation();
159
+
160
+ if (e.key === 'Escape') {
161
+ setOpen(false);
162
+ return;
163
+ }
164
+
165
+ const handleNavigation = (direction: 'next' | 'prev') => {
166
+ e.preventDefault();
167
+ setFocusedIndex((prev) => {
168
+ if (direction === 'next') {
169
+ if (prev === -1) return 0;
170
+ return prev >= filteredCountries.length - 1 ? 0 : prev + 1;
171
+ }
172
+ if (prev === -1) return filteredCountries.length - 1;
173
+ return prev <= 0 ? filteredCountries.length - 1 : prev - 1;
174
+ });
175
+ };
176
+
177
+ if (e.key === 'Tab') {
178
+ handleNavigation(e.shiftKey ? 'prev' : 'next');
179
+ return;
180
+ }
181
+
182
+ if (e.key === 'ArrowDown') {
183
+ handleNavigation('next');
184
+ return;
185
+ }
186
+
187
+ if (e.key === 'ArrowUp') {
188
+ handleNavigation('prev');
189
+ return;
190
+ }
191
+
192
+ if (e.key === 'Enter' && focusedIndex >= 0 && focusedIndex < filteredCountries.length) {
193
+ e.preventDefault();
194
+ const country = parseCountry(filteredCountries[focusedIndex]);
195
+ handleCountryClick(country.iso2);
196
+ }
197
+ };
198
+
27
199
  return (
28
200
  <Select
29
201
  ref={ref}
202
+ open={open}
203
+ onOpen={() => setOpen(true)}
204
+ onClose={() => setOpen(false)}
30
205
  MenuProps={{
31
206
  style: {
32
- height: '300px',
207
+ maxHeight: '300px',
33
208
  top: '10px',
34
209
  },
35
210
  anchorOrigin: {
@@ -40,6 +215,18 @@ const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(({ value, o
40
215
  vertical: 'top',
41
216
  horizontal: 'left',
42
217
  },
218
+ PaperProps: {
219
+ ref: menuRef,
220
+ sx: {
221
+ display: 'flex',
222
+ '& .MuiList-root': {
223
+ pt: 0,
224
+ display: 'flex',
225
+ flexDirection: 'column',
226
+ overflowY: 'hidden',
227
+ },
228
+ },
229
+ },
43
230
  }}
44
231
  sx={{
45
232
  width: '100%',
@@ -65,24 +252,82 @@ const CountrySelect = forwardRef<HTMLDivElement, CountrySelectProps>(({ value, o
65
252
  }}
66
253
  value={value}
67
254
  onChange={onCountryChange}
68
- renderValue={(code) => {
69
- return (
70
- <Box display="flex" alignItems="center" flexWrap="nowrap" gap={0.5} sx={{ cursor: 'pointer' }}>
71
- <FlagEmoji iso2={code} style={{ display: 'flex' }} />
72
- <Typography>{countryDetail?.name}</Typography>
73
- </Box>
74
- );
75
- }}>
76
- {defaultCountries.map((c: any) => {
77
- const parsed = parseCountry(c);
78
- return (
79
- <MenuItem key={parsed.iso2} value={parsed.iso2}>
80
- <FlagEmoji iso2={parsed.iso2} style={{ marginRight: '8px' }} />
81
- <Typography marginRight="8px">{parsed.name}</Typography>
82
- <Typography color="gray">+{parsed.dialCode}</Typography>
255
+ renderValue={(code) => (
256
+ <Box display="flex" alignItems="center" flexWrap="nowrap" gap={0.5} sx={{ cursor: 'pointer' }}>
257
+ <FlagEmoji iso2={code} style={{ display: 'flex' }} />
258
+ <Typography>{countryDetail?.name}</Typography>
259
+ </Box>
260
+ )}>
261
+ <Box
262
+ sx={{
263
+ position: 'sticky',
264
+ top: 0,
265
+ zIndex: 1,
266
+ bgcolor: 'background.paper',
267
+ p: 1,
268
+ }}
269
+ onClick={(e) => {
270
+ e.stopPropagation();
271
+ }}>
272
+ <TextField
273
+ inputRef={inputRef}
274
+ autoFocus={!isMobile}
275
+ fullWidth
276
+ placeholder="Search country..."
277
+ value={searchText}
278
+ onChange={handleSearchChange}
279
+ onKeyDown={handleKeyDown}
280
+ onClick={(e) => e.stopPropagation()}
281
+ size="small"
282
+ variant="outlined"
283
+ />
284
+ </Box>
285
+
286
+ <Box
287
+ ref={listRef}
288
+ sx={{
289
+ flex: 1,
290
+ overflowY: 'auto',
291
+ overflowX: 'hidden',
292
+ maxHeight: 'calc(300px - 65px)',
293
+ scrollBehavior: 'smooth',
294
+ }}>
295
+ {filteredCountries.length > 0 ? (
296
+ filteredCountries.map((c: any, index) => {
297
+ const parsed = parseCountry(c);
298
+ const isFocused = index === focusedIndex;
299
+
300
+ return (
301
+ <MenuItem
302
+ key={parsed.iso2}
303
+ value={parsed.iso2}
304
+ selected={parsed.iso2 === value}
305
+ onClick={() => handleCountryClick(parsed.iso2)}
306
+ sx={{
307
+ '&.Mui-selected': {
308
+ backgroundColor: 'rgba(0, 0, 0, 0.04)',
309
+ },
310
+ '&:hover': {
311
+ backgroundColor: 'var(--backgrounds-bg-highlight, #eff6ff)',
312
+ },
313
+ ...(isFocused
314
+ ? {
315
+ backgroundColor: 'var(--backgrounds-bg-highlight, #eff6ff)',
316
+ outline: 'none',
317
+ }
318
+ : {}),
319
+ }}>
320
+ <FlagEmoji iso2={parsed.iso2} style={{ marginRight: '8px' }} />
321
+ <Typography>{parsed.name}</Typography>
322
+ </MenuItem>
323
+ );
324
+ })
325
+ ) : (
326
+ <MenuItem disabled>
327
+ <Typography color="text.secondary">No countries found</Typography>
83
328
  </MenuItem>
84
- );
85
- })}
329
+ )}
330
+ </Box>
86
331
  </Select>
87
332
  );
88
333
  });
@@ -46,6 +46,7 @@ type Props = {
46
46
  detailUrl: string;
47
47
  }
48
48
  ) => React.ReactNode;
49
+ authToken?: string;
49
50
  };
50
51
 
51
52
  type SummaryItem = {
@@ -64,6 +65,7 @@ type OverdueInvoicesResult = {
64
65
  const fetchOverdueInvoices = async (params: {
65
66
  subscriptionId?: string;
66
67
  customerId?: string;
68
+ authToken?: string;
67
69
  }): Promise<OverdueInvoicesResult> => {
68
70
  if (!params.subscriptionId && !params.customerId) {
69
71
  throw new Error('Either subscriptionId or customerId must be provided');
@@ -76,7 +78,7 @@ const fetchOverdueInvoices = async (params: {
76
78
  url = `/api/customers/${params.customerId}/overdue/invoices`;
77
79
  }
78
80
 
79
- const res = await api.get(url);
81
+ const res = await api.get(params.authToken ? joinURL(url, `?authToken=${params.authToken}`) : url);
80
82
  return res.data;
81
83
  };
82
84
 
@@ -90,6 +92,7 @@ function OverdueInvoicePayment({
90
92
  detailLinkOptions = { enabled: true },
91
93
  successToast = true,
92
94
  alertMessage = '',
95
+ authToken,
93
96
  }: Props) {
94
97
  const { t } = useLocaleContext();
95
98
  const { connect } = usePaymentContext();
@@ -109,7 +112,7 @@ function OverdueInvoicePayment({
109
112
  error,
110
113
  loading,
111
114
  runAsync: refresh,
112
- } = useRequest(() => fetchOverdueInvoices({ subscriptionId, customerId }), {
115
+ } = useRequest(() => fetchOverdueInvoices({ subscriptionId, customerId, authToken }), {
113
116
  ready: !!subscriptionId || !!customerId,
114
117
  });
115
118
 
@@ -520,6 +523,7 @@ OverdueInvoicePayment.defaultProps = {
520
523
  customerId: undefined,
521
524
  successToast: true,
522
525
  alertMessage: '',
526
+ authToken: undefined,
523
527
  };
524
528
 
525
529
  export default OverdueInvoicePayment;
@@ -34,6 +34,8 @@ export type PaymentContextProps = {
34
34
  children: any;
35
35
  // eslint-disable-next-line react/require-default-props
36
36
  baseUrl?: string;
37
+ // eslint-disable-next-line react/require-default-props
38
+ authToken?: string;
37
39
  };
38
40
 
39
41
  const formatData = (data: any) => {
@@ -72,10 +74,18 @@ const getMethod = (methodId: string, methods: TPaymentMethodExpanded[]) => {
72
74
  return methods.find((x) => x.id === methodId);
73
75
  };
74
76
 
75
- function PaymentProvider({ session, connect, children, baseUrl }: PaymentContextProps) {
77
+ function PaymentProvider({ session, connect, children, baseUrl, authToken }: PaymentContextProps) {
76
78
  if (baseUrl) {
77
79
  // This is hack but efficient to share
78
80
  window.__PAYMENT_KIT_BASE_URL = baseUrl;
81
+ } else {
82
+ window.__PAYMENT_KIT_BASE_URL = '';
83
+ }
84
+
85
+ if (authToken) {
86
+ window.__PAYMENT_KIT_AUTH_TOKEN = authToken;
87
+ } else {
88
+ window.__PAYMENT_KIT_AUTH_TOKEN = '';
79
89
  }
80
90
 
81
91
  const [livemode, setLivemode] = useLocalStorageState('livemode', { defaultValue: true });
@@ -86,12 +86,14 @@ export const useTabNavigation = <T>(
86
86
  // if tabbed, find current focused element
87
87
  const focusedElement = document.activeElement;
88
88
  const navigableElements = findNavigableElements();
89
-
90
89
  for (let i = 0; i < navigableElements.length; i++) {
91
90
  if (navigableElements[i] === focusedElement) {
92
91
  return i;
93
92
  }
94
93
  }
94
+ if (includeCustom && navigableElements.length > 0) {
95
+ return navigableElements.length - 1;
96
+ }
95
97
  }
96
98
 
97
99
  return -1;
@@ -107,10 +109,10 @@ export const useTabNavigation = <T>(
107
109
  }
108
110
 
109
111
  if (isShiftKey) {
110
- // Shift+Tab forward
112
+ // Shift+Tab backward
111
113
  return currentIndex === 0 ? totalOptions - 1 : currentIndex - 1;
112
114
  }
113
- // Tab backward
115
+ // Tab next
114
116
  return currentIndex === totalOptions - 1 ? 0 : currentIndex + 1;
115
117
  },
116
118
  [items, includeCustom]
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export * from './hooks/mobile';
45
45
  export * from './hooks/table';
46
46
  export * from './hooks/scroll';
47
47
  export * from './hooks/keyboard';
48
+ export * from './libs/validator';
48
49
 
49
50
  export { translations, createTranslator } from './locales';
50
51
 
package/src/libs/api.ts CHANGED
@@ -11,10 +11,13 @@ api.interceptors.request.use(
11
11
  (config: any) => {
12
12
  const prefix = getPrefix();
13
13
  config.baseURL = prefix || '';
14
-
15
14
  const locale = getLocale(window.blocklet?.languages);
16
15
  const query = new URLSearchParams(config.url?.split('?').pop());
17
16
  config.params = { ...(config.params || {}), locale };
17
+ const authToken = window.__PAYMENT_KIT_AUTH_TOKEN;
18
+ if (authToken && typeof config.params.authToken === 'undefined' && !query.has('authToken')) {
19
+ config.params.authToken = authToken;
20
+ }
18
21
  // If we do not have livemode neither in config nor in query params, we will use the value from localStorage
19
22
  if (typeof config.params.livemode === 'undefined' && query.has('livemode') === false) {
20
23
  const livemode = localStorage.getItem('livemode');
@@ -0,0 +1,25 @@
1
+ const CURRENCY_PREFERENCE_KEY_BASE = 'payment-currency-preference';
2
+
3
+ export const getUserStorageKey = (base: string, did?: string) => {
4
+ return did ? `${base}:${did}` : base;
5
+ };
6
+
7
+ export const saveCurrencyPreference = (currencyId: string, did?: string) => {
8
+ try {
9
+ localStorage.setItem(getUserStorageKey(CURRENCY_PREFERENCE_KEY_BASE, did), currencyId);
10
+ } catch (e) {
11
+ console.warn('Failed to save currency preference', e);
12
+ }
13
+ };
14
+
15
+ export const getCurrencyPreference = (did?: string, availableCurrencyIds?: string[]) => {
16
+ try {
17
+ const saved = localStorage.getItem(getUserStorageKey(CURRENCY_PREFERENCE_KEY_BASE, did));
18
+ if (saved && (!availableCurrencyIds || availableCurrencyIds.includes(saved))) {
19
+ return saved;
20
+ }
21
+ } catch (e) {
22
+ console.warn('Failed to access localStorage', e);
23
+ }
24
+ return null;
25
+ };
@@ -13,6 +13,7 @@ export const getPhoneUtil = async () => {
13
13
  };
14
14
 
15
15
  export const validatePhoneNumber = async (phoneNumber: string) => {
16
+ if (!phoneNumber) return true;
16
17
  try {
17
18
  let util: any = null;
18
19
  try {
package/src/libs/util.ts CHANGED
@@ -130,17 +130,31 @@ export const formatError = (err: any) => {
130
130
  return err.message;
131
131
  };
132
132
 
133
- export function formatBNStr(str: string = '', decimals: number = 18, precision: number = 6, trim: boolean = true) {
134
- return formatNumber(fromUnitToToken(str, decimals), precision, trim);
133
+ export function formatBNStr(
134
+ str: string = '',
135
+ decimals: number = 18,
136
+ precision: number = 6,
137
+ trim: boolean = true,
138
+ thousandSeparated: boolean = true
139
+ ) {
140
+ if (!str) {
141
+ return '0';
142
+ }
143
+ return formatNumber(fromUnitToToken(str, decimals), precision, trim, thousandSeparated);
135
144
  }
136
145
 
137
- export function formatNumber(n: number | string, precision: number = 6, trim: boolean = true) {
146
+ export function formatNumber(
147
+ n: number | string,
148
+ precision: number = 6,
149
+ trim: boolean = true,
150
+ thousandSeparated: boolean = true
151
+ ) {
138
152
  if (!n || n === '0') {
139
153
  return '0';
140
154
  }
141
155
  const num = numbro(n);
142
156
  const options = {
143
- thousandSeparated: true,
157
+ thousandSeparated,
144
158
  ...((precision || precision === 0) && { mantissa: precision }),
145
159
  };
146
160
  const result = num.format(options);
@@ -0,0 +1,70 @@
1
+ import isPostalCode, { PostalCodeLocale } from 'validator/lib/isPostalCode';
2
+
3
+ const POSTAL_CODE_SUPPORTED_COUNTRIES: PostalCodeLocale[] = [
4
+ 'AD',
5
+ 'AT',
6
+ 'AU',
7
+ 'BE',
8
+ 'BG',
9
+ 'BR',
10
+ 'CA',
11
+ 'CH',
12
+ 'CN',
13
+ 'CZ',
14
+ 'DE',
15
+ 'DK',
16
+ 'DZ',
17
+ 'EE',
18
+ 'ES',
19
+ 'FI',
20
+ 'FR',
21
+ 'GB',
22
+ 'GR',
23
+ 'HR',
24
+ 'HU',
25
+ 'ID',
26
+ 'IE',
27
+ 'IL',
28
+ 'IN',
29
+ 'IR',
30
+ 'IS',
31
+ 'IT',
32
+ 'JP',
33
+ 'KE',
34
+ 'KR',
35
+ 'LI',
36
+ 'LT',
37
+ 'LU',
38
+ 'LV',
39
+ 'MX',
40
+ 'MT',
41
+ 'NL',
42
+ 'NO',
43
+ 'NZ',
44
+ 'PL',
45
+ 'PR',
46
+ 'PT',
47
+ 'RO',
48
+ 'RU',
49
+ 'SA',
50
+ 'SE',
51
+ 'SI',
52
+ 'SK',
53
+ 'TN',
54
+ 'TW',
55
+ 'UA',
56
+ 'US',
57
+ 'ZA',
58
+ 'ZM',
59
+ ];
60
+ export function validatePostalCode(postalCode: string, country?: string): boolean {
61
+ if (!postalCode) return true;
62
+ const countryUpper = country?.toUpperCase();
63
+ const isSupported = country && POSTAL_CODE_SUPPORTED_COUNTRIES.includes(countryUpper as PostalCodeLocale);
64
+ try {
65
+ return isPostalCode(postalCode, isSupported ? (countryUpper as PostalCodeLocale) : 'any');
66
+ } catch (error) {
67
+ console.error(error);
68
+ return false;
69
+ }
70
+ }
@@ -1,9 +1,10 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import { Fade, FormLabel, InputAdornment, Stack } from '@mui/material';
3
3
  import type { SxProps } from '@mui/material';
4
- import { Controller, useFormContext } from 'react-hook-form';
4
+ import { Controller, useFormContext, useWatch } from 'react-hook-form';
5
5
  import FormInput from '../../components/input';
6
6
  import CountrySelect from '../../components/country-select';
7
+ import { validatePostalCode } from '../../libs/validator';
7
8
 
8
9
  type Props = {
9
10
  mode: string;
@@ -18,7 +19,7 @@ AddressForm.defaultProps = {
18
19
  export default function AddressForm({ mode, stripe, sx = {} }: Props) {
19
20
  const { t } = useLocaleContext();
20
21
  const { control } = useFormContext();
21
-
22
+ const country = useWatch({ control, name: 'billing_address.country' });
22
23
  if (mode === 'required') {
23
24
  return (
24
25
  <Fade in>
@@ -51,7 +52,13 @@ export default function AddressForm({ mode, stripe, sx = {} }: Props) {
51
52
  <FormLabel className="base-label">{t('payment.checkout.billing.postal_code')}</FormLabel>
52
53
  <FormInput
53
54
  name="billing_address.postal_code"
54
- rules={{ required: t('payment.checkout.required') }}
55
+ rules={{
56
+ required: t('payment.checkout.required'),
57
+ validate: (x: string) => {
58
+ const isValid = validatePostalCode(x, country);
59
+ return isValid ? true : t('payment.checkout.invalid');
60
+ },
61
+ }}
55
62
  errorPosition="right"
56
63
  variant="outlined"
57
64
  placeholder={t('payment.checkout.billing.postal_code')}
@@ -91,7 +98,13 @@ export default function AddressForm({ mode, stripe, sx = {} }: Props) {
91
98
  <Stack direction="row" spacing={0}>
92
99
  <FormInput
93
100
  name="billing_address.postal_code"
94
- rules={{ required: t('payment.checkout.required') }}
101
+ rules={{
102
+ required: t('payment.checkout.required'),
103
+ validate: (x: string) => {
104
+ const isValid = validatePostalCode(x, country);
105
+ return isValid ? true : t('payment.checkout.invalid');
106
+ },
107
+ }}
95
108
  errorPosition="right"
96
109
  variant="outlined"
97
110
  placeholder={t('payment.checkout.billing.postal_code')}
@@ -42,6 +42,7 @@ import { useMobile } from '../../hooks/mobile';
42
42
  import { formatPhone, validatePhoneNumber } from '../../libs/phone-validator';
43
43
  import LoadingButton from '../../components/loading-button';
44
44
  import OverdueInvoicePayment from '../../components/over-due-invoice-payment';
45
+ import { saveCurrencyPreference } from '../../libs/currency';
45
46
 
46
47
  export const waitForCheckoutComplete = async (sessionId: string) => {
47
48
  let result: CheckoutContext;
@@ -220,6 +221,14 @@ export default function PaymentForm({
220
221
  return index >= 0 ? index : 0;
221
222
  });
222
223
 
224
+ const handleCurrencyChange = (index: number) => {
225
+ setPaymentCurrencyIndex(index);
226
+ const selectedCurrencyId = currencies[index]?.id;
227
+ if (selectedCurrencyId) {
228
+ saveCurrencyPreference(selectedCurrencyId, session?.user?.did);
229
+ }
230
+ };
231
+
223
232
  const onCheckoutComplete = useMemoizedFn(async ({ response }: { response: TCheckoutSession }) => {
224
233
  if (response.id === checkoutSession.id && state.paid === false) {
225
234
  await handleConnected();
@@ -357,6 +366,7 @@ export default function PaymentForm({
357
366
  const skipBindWallet = method.type === 'stripe';
358
367
 
359
368
  const handleConnected = async () => {
369
+ setState({ paying: true });
360
370
  try {
361
371
  const result = await waitForCheckoutComplete(checkoutSession.id);
362
372
  if (state.paid === false) {
@@ -614,7 +624,7 @@ export default function PaymentForm({
614
624
  <CurrencySelector
615
625
  value={paymentCurrencyIndex}
616
626
  currencies={currencies}
617
- onChange={setPaymentCurrencyIndex}
627
+ onChange={handleCurrencyChange}
618
628
  />
619
629
  )}
620
630
  />