@blocklet/payment-react 1.18.17 → 1.18.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.
@@ -0,0 +1,159 @@
1
+ import { useCallback, KeyboardEvent, useRef, RefObject } from 'react';
2
+
3
+ type TabNavigationOptions<T> = {
4
+ /** whether to include custom option as the last item */
5
+ includeCustom?: boolean;
6
+ /** the value or index of the current selected item */
7
+ currentValue?: T | number;
8
+ /** whether the current selected item is custom */
9
+ isCustomSelected?: boolean;
10
+ /** a function to compare values, used to determine the current selected item */
11
+ compareValue?: (item: T, value: any) => boolean;
12
+ /** whether to allow Tab key navigation */
13
+ enabled?: boolean;
14
+ /** a selector to find navigable elements */
15
+ selector?: string;
16
+ /** an element container reference, limiting the query DOM range to improve performance */
17
+ containerRef?: RefObject<HTMLElement>;
18
+ /** the type of the current value, can be 'index' or 'value' */
19
+ valueType?: 'index' | 'value';
20
+ };
21
+
22
+ /**
23
+ * Tab key navigation hook - implement Tab key circular navigation between a set of options
24
+ *
25
+ * @param items an array of options, can be a simple type (string, number) array or an object array
26
+ * @param onSelect callback when an item is selected
27
+ * @param options configuration options
28
+ * @returns an object containing the event handler and control functions
29
+ *
30
+ * @example
31
+ * // simple string array
32
+ * const { handleKeyDown } = useTabNavigation(['10', '20', '50'], handleSelect);
33
+ *
34
+ * // object array
35
+ * const { handleKeyDown } = useTabNavigation(
36
+ * [{id: 1, name: 'A'}, {id: 2, name: 'B'}],
37
+ * handleSelect,
38
+ * { compareValue: (item, value) => item.id === value.id }
39
+ * );
40
+ */
41
+ export const useTabNavigation = <T>(
42
+ items: T[],
43
+ onSelect: (item: T | 'custom', index: number) => void,
44
+ options?: TabNavigationOptions<T>
45
+ ) => {
46
+ const {
47
+ valueType = 'value',
48
+ includeCustom = false,
49
+ currentValue,
50
+ isCustomSelected = false,
51
+ compareValue = (item: T, value: any) => item === value,
52
+ enabled = true,
53
+ selector = '.tab-navigable-card button',
54
+ containerRef,
55
+ } = options || {};
56
+
57
+ const hasTabbed = useRef(false);
58
+
59
+ const findNavigableElements = useCallback(() => {
60
+ if (containerRef?.current) {
61
+ return containerRef.current.querySelectorAll(selector);
62
+ }
63
+ return document.querySelectorAll(selector);
64
+ }, [containerRef, selector]);
65
+
66
+ // get current index
67
+ const determineCurrentIndex = useCallback(() => {
68
+ const allOptions = includeCustom ? [...items, 'custom' as any] : items;
69
+ if (allOptions.length === 0) return -1;
70
+
71
+ // if not tabbed, determine start point by currentValue
72
+ if (!hasTabbed.current) {
73
+ if (isCustomSelected && includeCustom) {
74
+ return items.length; // current selected custom option
75
+ }
76
+
77
+ if (currentValue !== undefined) {
78
+ if (valueType === 'index' && typeof currentValue === 'number') {
79
+ // if currentValue is index
80
+ return currentValue >= 0 && currentValue < items.length ? currentValue : -1;
81
+ }
82
+ // if currentValue is value, find matched item
83
+ return items.findIndex((item) => compareValue(item, currentValue));
84
+ }
85
+ } else {
86
+ // if tabbed, find current focused element
87
+ const focusedElement = document.activeElement;
88
+ const navigableElements = findNavigableElements();
89
+
90
+ for (let i = 0; i < navigableElements.length; i++) {
91
+ if (navigableElements[i] === focusedElement) {
92
+ return i;
93
+ }
94
+ }
95
+ }
96
+
97
+ return -1;
98
+ }, [items, includeCustom, isCustomSelected, currentValue, valueType, compareValue, findNavigableElements]);
99
+
100
+ // get next index
101
+ const getNextIndex = useCallback(
102
+ (currentIndex: number, isShiftKey: boolean) => {
103
+ const totalOptions = includeCustom ? items.length + 1 : items.length;
104
+
105
+ if (currentIndex === -1) {
106
+ return 0; // no current selected item, start from first item
107
+ }
108
+
109
+ if (isShiftKey) {
110
+ // Shift+Tab forward
111
+ return currentIndex === 0 ? totalOptions - 1 : currentIndex - 1;
112
+ }
113
+ // Tab backward
114
+ return currentIndex === totalOptions - 1 ? 0 : currentIndex + 1;
115
+ },
116
+ [items, includeCustom]
117
+ );
118
+
119
+ const handleKeyDown = useCallback(
120
+ (e: KeyboardEvent) => {
121
+ // if navigation is disabled or not Tab key, do not handle event
122
+ if (!enabled || e.key !== 'Tab') return;
123
+
124
+ e.preventDefault();
125
+ e.stopPropagation();
126
+
127
+ // determine current index and next index
128
+ const currentIndex = determineCurrentIndex();
129
+ const nextIndex = getNextIndex(currentIndex, e.shiftKey);
130
+
131
+ // mark as handled Tab event
132
+ hasTabbed.current = true;
133
+
134
+ // execute select callback
135
+ const selectedItem = nextIndex === items.length ? 'custom' : items[nextIndex];
136
+ onSelect(selectedItem, nextIndex);
137
+
138
+ // focus to next element
139
+ setTimeout(() => {
140
+ const elements = findNavigableElements();
141
+ if (elements[nextIndex]) {
142
+ (elements[nextIndex] as HTMLElement).focus();
143
+ }
144
+ }, 0);
145
+ },
146
+ [items, onSelect, enabled, determineCurrentIndex, getNextIndex, findNavigableElements]
147
+ );
148
+
149
+ // reset Tab state method
150
+ const resetTabNavigation = useCallback(() => {
151
+ hasTabbed.current = false;
152
+ }, []);
153
+
154
+ return {
155
+ handleKeyDown,
156
+ resetTabNavigation,
157
+ isTabNavigationActive: hasTabbed.current,
158
+ };
159
+ };
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export * from './hooks/subscription';
44
44
  export * from './hooks/mobile';
45
45
  export * from './hooks/table';
46
46
  export * from './hooks/scroll';
47
+ export * from './hooks/keyboard';
47
48
 
48
49
  export { translations, createTranslator } from './locales';
49
50
 
@@ -164,7 +164,7 @@ export default flat({
164
164
  payment: 'Thanks for your purchase',
165
165
  subscription: 'Thanks for your subscribing',
166
166
  setup: 'Thanks for your subscribing',
167
- donate: 'Thanks for your support',
167
+ donate: 'Thanks for your tip',
168
168
  tip: 'A payment to {payee} has been completed. You can view the details of this payment in your account.',
169
169
  },
170
170
  confirm: 'Confirming allows {payee} to charge or reduce your staking. You can cancel or revoke staking anytime.',
@@ -429,6 +429,26 @@ export default function PaymentForm({
429
429
  setState({ stripePaying: false });
430
430
  };
431
431
 
432
+ useEffect(() => {
433
+ const handleKeyDown = (e: KeyboardEvent) => {
434
+ if (
435
+ e.key === 'Enter' &&
436
+ !state.submitting &&
437
+ !state.paying &&
438
+ !state.stripePaying &&
439
+ quantityInventoryStatus &&
440
+ payable
441
+ ) {
442
+ onAction();
443
+ }
444
+ };
445
+
446
+ window.addEventListener('keydown', handleKeyDown);
447
+ return () => {
448
+ window.removeEventListener('keydown', handleKeyDown);
449
+ };
450
+ }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, payable]); // eslint-disable-line react-hooks/exhaustive-deps
451
+
432
452
  if (onlyShowBtn) {
433
453
  return (
434
454
  <>
@@ -7,6 +7,15 @@ import { useEffect, useRef } from 'react';
7
7
  import { formatAmountPrecisionLimit } from '../libs/util';
8
8
  import { usePaymentContext } from '../contexts/payment';
9
9
  import { usePreventWheel } from '../hooks/scroll';
10
+ import { useTabNavigation } from '../hooks/keyboard';
11
+
12
+ // LocalStorage key base for preset selection
13
+ const DONATION_PRESET_KEY_BASE = 'payment-donation-preset';
14
+ const DONATION_CUSTOM_AMOUNT_KEY_BASE = 'payment-donation-custom-amount';
15
+
16
+ const formatAmount = (amount: string | number): string => {
17
+ return String(amount);
18
+ };
10
19
 
11
20
  export default function ProductDonation({
12
21
  item,
@@ -20,32 +29,129 @@ export default function ProductDonation({
20
29
  currency: TPaymentCurrency;
21
30
  }) {
22
31
  const { t, locale } = useLocaleContext();
23
- const { setPayable } = usePaymentContext();
32
+ const { setPayable, session } = usePaymentContext();
24
33
  usePreventWheel();
25
- const presets = settings?.amount?.presets || [];
26
- const preset = settings?.amount?.preset || presets?.[0] || '0';
34
+ const presets = (settings?.amount?.presets || []).map(formatAmount);
35
+
36
+ const getUserStorageKey = (base: string) => {
37
+ const userDid = session?.user?.did;
38
+ return userDid ? `${base}:${userDid}` : base;
39
+ };
40
+
41
+ const getSavedCustomAmount = () => {
42
+ try {
43
+ return localStorage.getItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE)) || '';
44
+ } catch (e) {
45
+ console.warn('Failed to access localStorage', e);
46
+ return '';
47
+ }
48
+ };
49
+
50
+ const getDefaultPreset = () => {
51
+ if (settings?.amount?.preset) {
52
+ return formatAmount(settings.amount.preset);
53
+ }
54
+
55
+ try {
56
+ const savedPreset = localStorage.getItem(getUserStorageKey(DONATION_PRESET_KEY_BASE));
57
+ if (savedPreset) {
58
+ if (presets.includes(formatAmount(savedPreset))) {
59
+ return formatAmount(savedPreset);
60
+ }
61
+ if (savedPreset === 'custom' && supportCustom) {
62
+ return 'custom';
63
+ }
64
+ }
65
+ } catch (e) {
66
+ console.warn('Failed to access localStorage', e);
67
+ }
68
+ if (presets.length > 0) {
69
+ const middleIndex = Math.floor(presets.length / 2);
70
+ return presets[middleIndex] || presets[0];
71
+ }
72
+ return '0';
73
+ };
74
+
27
75
  const supportPreset = presets.length > 0;
28
76
  const supportCustom = !!settings?.amount?.custom;
77
+ const defaultPreset = getDefaultPreset();
78
+ const defaultCustomAmount = defaultPreset === 'custom' ? getSavedCustomAmount() : '';
79
+
29
80
  const [state, setState] = useSetState({
30
- selected: preset,
31
- input: '',
32
- custom: !supportPreset,
81
+ selected: defaultPreset === 'custom' ? '' : defaultPreset,
82
+ input: defaultCustomAmount,
83
+ custom: !supportPreset || defaultPreset === 'custom',
33
84
  error: '',
34
85
  });
86
+
35
87
  const customInputRef = useRef<HTMLInputElement>(null);
88
+ const containerRef = useRef<HTMLDivElement>(null);
89
+
90
+ const handleSelect = (amount: string) => {
91
+ setPayable(true);
92
+ setState({ selected: formatAmount(amount), custom: false, error: '' });
93
+ onChange({ priceId: item.price_id, amount: formatAmount(amount) });
94
+ localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), formatAmount(amount));
95
+ };
96
+
97
+ const handleCustomSelect = () => {
98
+ setState({ custom: true, selected: '', error: '' });
99
+ const savedCustomAmount = getSavedCustomAmount();
100
+ if (savedCustomAmount) {
101
+ setState({ input: savedCustomAmount });
102
+ onChange({ priceId: item.price_id, amount: savedCustomAmount });
103
+ setPayable(true);
104
+ } else if (!state.input) {
105
+ setPayable(false);
106
+ }
107
+ localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), 'custom');
108
+ };
109
+
110
+ const handleTabSelect = (selectedItem: string | 'custom') => {
111
+ if (selectedItem === 'custom') {
112
+ handleCustomSelect();
113
+ } else {
114
+ handleSelect(selectedItem as string);
115
+ }
116
+ };
117
+
118
+ // 使用useTabNavigation进行键盘导航
119
+ const { handleKeyDown } = useTabNavigation(presets, handleTabSelect, {
120
+ includeCustom: supportCustom,
121
+ currentValue: state.custom ? undefined : state.selected,
122
+ isCustomSelected: state.custom,
123
+ enabled: true,
124
+ selector: '.tab-navigable-card button',
125
+ containerRef,
126
+ });
36
127
 
37
- // Set default amount
38
128
  useEffect(() => {
39
129
  if (settings.amount.preset) {
40
130
  setState({ selected: settings.amount.preset, custom: false });
41
131
  onChange({ priceId: item.price_id, amount: settings.amount.preset });
42
132
  } else if (settings.amount.presets && settings.amount.presets.length > 0) {
43
- setState({ selected: settings.amount.presets[0], custom: false });
44
- onChange({ priceId: item.price_id, amount: settings.amount.presets[0] });
133
+ const isCustom = defaultPreset === 'custom';
134
+ setState({
135
+ selected: isCustom ? '' : defaultPreset,
136
+ custom: isCustom,
137
+ input: isCustom ? getSavedCustomAmount() : '',
138
+ });
139
+
140
+ if (!isCustom) {
141
+ onChange({ priceId: item.price_id, amount: defaultPreset });
142
+ } else if (defaultCustomAmount) {
143
+ onChange({ priceId: item.price_id, amount: defaultCustomAmount });
144
+ setPayable(true);
145
+ } else {
146
+ setPayable(false);
147
+ }
45
148
  }
46
149
  }, [settings.amount.preset, settings.amount.presets]); // eslint-disable-line
47
150
 
48
151
  useEffect(() => {
152
+ if (containerRef.current) {
153
+ containerRef.current.focus();
154
+ }
49
155
  if (state.custom) {
50
156
  setTimeout(() => {
51
157
  customInputRef.current?.focus();
@@ -53,12 +159,6 @@ export default function ProductDonation({
53
159
  }
54
160
  }, [state.custom]);
55
161
 
56
- const handleSelect = (amount: string) => {
57
- setPayable(true);
58
- setState({ selected: amount, custom: false, error: '' });
59
- onChange({ priceId: item.price_id, amount });
60
- };
61
-
62
162
  const handleInput = (event: any) => {
63
163
  const { value } = event.target;
64
164
  const min = parseFloat(settings.amount.minimum || '0');
@@ -78,69 +178,75 @@ export default function ProductDonation({
78
178
  }
79
179
  setPayable(true);
80
180
  setState({ error: '', input: value });
81
- onChange({ priceId: item.price_id, amount: value });
82
- };
83
-
84
- const handleCustomSelect = () => {
85
- setState({ custom: true, error: '' });
86
- if (!state.input) {
87
- setPayable(false);
88
- }
181
+ onChange({ priceId: item.price_id, amount: formatAmount(value) });
182
+ localStorage.setItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE), formatAmount(value));
89
183
  };
90
184
 
91
185
  return (
92
- <Box display="flex" flexDirection="column" alignItems="flex-start" gap={1.5}>
186
+ <Box
187
+ ref={containerRef}
188
+ display="flex"
189
+ flexDirection="column"
190
+ alignItems="flex-start"
191
+ gap={1.5}
192
+ onKeyDown={handleKeyDown}
193
+ tabIndex={0}
194
+ sx={{ outline: 'none' }}>
93
195
  {supportPreset && (
94
196
  <Grid container spacing={2}>
95
- {settings.amount.presets &&
96
- settings.amount.presets.length > 0 &&
97
- settings.amount.presets.map((amount) => (
98
- <Grid item xs={6} sm={3} key={amount}>
99
- <Card
100
- key={amount}
101
- variant="outlined"
102
- sx={{
103
- minWidth: 115,
104
- textAlign: 'center',
105
- transition: 'all 0.3s',
106
- cursor: 'pointer',
107
- '&:hover': {
108
- transform: 'translateY(-4px)',
109
- boxShadow: 3,
110
- },
111
- height: '42px',
112
- ...(state.selected === amount && !state.custom
113
- ? { borderColor: 'primary.main', borderWidth: 1 }
114
- : {}),
115
- }}>
116
- <CardActionArea onClick={() => handleSelect(amount)}>
117
- <Stack
118
- direction="row"
119
- sx={{ py: 1.5, px: 1.5 }}
120
- spacing={0.5}
121
- alignItems="center"
122
- justifyContent="center">
123
- <Avatar src={currency?.logo} sx={{ width: 16, height: 16, mr: 0.5 }} alt={currency?.symbol} />
124
- <Typography
125
- component="strong"
126
- lineHeight={1}
127
- variant="h3"
128
- sx={{ fontVariantNumeric: 'tabular-nums', fontWeight: 400 }}>
129
- {amount}
130
- </Typography>
131
- <Typography lineHeight={1} fontSize={14} color="text.secondary">
132
- {currency?.symbol}
133
- </Typography>
134
- </Stack>
135
- </CardActionArea>
136
- </Card>
137
- </Grid>
138
- ))}
197
+ {presets.map((amount) => (
198
+ <Grid item xs={6} sm={3} key={amount}>
199
+ <Card
200
+ variant="outlined"
201
+ className="tab-navigable-card"
202
+ sx={{
203
+ minWidth: 115,
204
+ textAlign: 'center',
205
+ transition: 'all 0.3s',
206
+ cursor: 'pointer',
207
+ '&:hover': {
208
+ transform: 'translateY(-4px)',
209
+ boxShadow: 3,
210
+ },
211
+ '.MuiCardActionArea-focusHighlight': {
212
+ backgroundColor: 'transparent',
213
+ },
214
+ height: '42px',
215
+ ...(formatAmount(state.selected) === formatAmount(amount) && !state.custom
216
+ ? { borderColor: 'primary.main', borderWidth: 1 }
217
+ : {}),
218
+ }}>
219
+ <CardActionArea
220
+ onClick={() => handleSelect(amount)}
221
+ tabIndex={0}
222
+ aria-selected={formatAmount(state.selected) === formatAmount(amount) && !state.custom}>
223
+ <Stack
224
+ direction="row"
225
+ sx={{ py: 1.5, px: 1.5 }}
226
+ spacing={0.5}
227
+ alignItems="center"
228
+ justifyContent="center">
229
+ <Avatar src={currency?.logo} sx={{ width: 16, height: 16, mr: 0.5 }} alt={currency?.symbol} />
230
+ <Typography
231
+ component="strong"
232
+ lineHeight={1}
233
+ variant="h3"
234
+ sx={{ fontVariantNumeric: 'tabular-nums', fontWeight: 400 }}>
235
+ {amount}
236
+ </Typography>
237
+ <Typography lineHeight={1} fontSize={14} color="text.secondary">
238
+ {currency?.symbol}
239
+ </Typography>
240
+ </Stack>
241
+ </CardActionArea>
242
+ </Card>
243
+ </Grid>
244
+ ))}
139
245
  {supportCustom && (
140
246
  <Grid item xs={6} sm={3} key="custom">
141
247
  <Card
142
- key="custom"
143
248
  variant="outlined"
249
+ className="tab-navigable-card"
144
250
  sx={{
145
251
  textAlign: 'center',
146
252
  transition: 'all 0.3s',
@@ -149,10 +255,13 @@ export default function ProductDonation({
149
255
  transform: 'translateY(-4px)',
150
256
  boxShadow: 3,
151
257
  },
258
+ '.MuiCardActionArea-focusHighlight': {
259
+ backgroundColor: 'transparent',
260
+ },
152
261
  height: '42px',
153
262
  ...(state.custom ? { borderColor: 'primary.main', borderWidth: 1 } : {}),
154
263
  }}>
155
- <CardActionArea onClick={() => handleCustomSelect()}>
264
+ <CardActionArea onClick={handleCustomSelect} tabIndex={0} aria-selected={state.custom}>
156
265
  <Stack
157
266
  direction="row"
158
267
  sx={{ py: 1.5, px: 1.5 }}
@@ -179,7 +288,6 @@ export default function ProductDonation({
179
288
  error={!!state.error}
180
289
  helperText={state.error}
181
290
  inputRef={customInputRef}
182
- // eslint-disable-next-line react/jsx-no-duplicate-props
183
291
  InputProps={{
184
292
  endAdornment: (
185
293
  <Stack direction="row" spacing={0.5} alignItems="center" sx={{ ml: 1 }}>
@@ -190,7 +298,7 @@ export default function ProductDonation({
190
298
  autoComplete: 'off',
191
299
  }}
192
300
  sx={{
193
- mt: preset !== '0' ? 0 : 1,
301
+ mt: defaultPreset !== '0' ? 0 : 1,
194
302
  }}
195
303
  />
196
304
  )}