@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.
- package/es/checkout/donate.js +11 -1
- package/es/components/country-select.js +243 -21
- package/es/components/over-due-invoice-payment.d.ts +3 -1
- package/es/components/over-due-invoice-payment.js +6 -4
- package/es/contexts/payment.d.ts +2 -1
- package/es/contexts/payment.js +8 -1
- package/es/hooks/keyboard.js +3 -0
- package/es/index.d.ts +1 -0
- package/es/index.js +1 -0
- package/es/libs/api.js +4 -0
- package/es/libs/currency.d.ts +3 -0
- package/es/libs/currency.js +22 -0
- package/es/libs/phone-validator.js +2 -0
- package/es/libs/util.d.ts +2 -2
- package/es/libs/util.js +7 -4
- package/es/libs/validator.d.ts +1 -0
- package/es/libs/validator.js +70 -0
- package/es/payment/form/address.js +17 -3
- package/es/payment/form/index.js +10 -1
- package/es/payment/form/phone.js +12 -1
- package/es/payment/form/stripe/form.js +72 -15
- package/es/payment/index.js +33 -11
- package/es/payment/product-donation.js +110 -12
- package/es/types/shims.d.ts +2 -0
- package/lib/checkout/donate.js +11 -1
- package/lib/components/country-select.js +243 -39
- package/lib/components/over-due-invoice-payment.d.ts +3 -1
- package/lib/components/over-due-invoice-payment.js +7 -4
- package/lib/contexts/payment.d.ts +2 -1
- package/lib/contexts/payment.js +9 -1
- package/lib/hooks/keyboard.js +3 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +12 -0
- package/lib/libs/api.js +4 -0
- package/lib/libs/currency.d.ts +3 -0
- package/lib/libs/currency.js +31 -0
- package/lib/libs/phone-validator.js +1 -0
- package/lib/libs/util.d.ts +2 -2
- package/lib/libs/util.js +7 -4
- package/lib/libs/validator.d.ts +1 -0
- package/lib/libs/validator.js +20 -0
- package/lib/payment/form/address.js +15 -2
- package/lib/payment/form/index.js +12 -1
- package/lib/payment/form/phone.js +13 -1
- package/lib/payment/form/stripe/form.js +98 -29
- package/lib/payment/index.js +34 -10
- package/lib/payment/product-donation.js +106 -15
- package/lib/types/shims.d.ts +2 -0
- package/package.json +8 -8
- package/src/checkout/donate.tsx +11 -1
- package/src/components/country-select.tsx +265 -20
- package/src/components/over-due-invoice-payment.tsx +6 -2
- package/src/contexts/payment.tsx +11 -1
- package/src/hooks/keyboard.ts +5 -3
- package/src/index.ts +1 -0
- package/src/libs/api.ts +4 -1
- package/src/libs/currency.ts +25 -0
- package/src/libs/phone-validator.ts +1 -0
- package/src/libs/util.ts +18 -4
- package/src/libs/validator.ts +70 -0
- package/src/payment/form/address.tsx +17 -4
- package/src/payment/form/index.tsx +11 -1
- package/src/payment/form/phone.tsx +15 -1
- package/src/payment/form/stripe/form.tsx +104 -32
- package/src/payment/index.tsx +45 -14
- package/src/payment/product-donation.tsx +129 -10
- package/src/types/shims.d.ts +2 -0
|
@@ -6,13 +6,15 @@ import { useFormContext, useWatch } from 'react-hook-form';
|
|
|
6
6
|
import { defaultCountries, usePhoneInput } from 'react-international-phone';
|
|
7
7
|
import type { CountryIso2 } from 'react-international-phone';
|
|
8
8
|
|
|
9
|
+
import { useMount } from 'ahooks';
|
|
9
10
|
import FormInput from '../../components/input';
|
|
10
11
|
import { isValidCountry } from '../../libs/util';
|
|
11
12
|
import CountrySelect from '../../components/country-select';
|
|
13
|
+
import { getPhoneUtil } from '../../libs/phone-validator';
|
|
12
14
|
|
|
13
15
|
export default function PhoneInput({ ...props }) {
|
|
14
16
|
const countryFieldName = props.countryFieldName || 'billing_address.country';
|
|
15
|
-
const { control, getValues, setValue } = useFormContext();
|
|
17
|
+
const { control, getValues, setValue, trigger } = useFormContext();
|
|
16
18
|
const values = getValues();
|
|
17
19
|
|
|
18
20
|
const isUpdatingRef = useRef(false);
|
|
@@ -51,6 +53,12 @@ export default function PhoneInput({ ...props }) {
|
|
|
51
53
|
});
|
|
52
54
|
}, [userCountry, country, setCountry, safeUpdate]);
|
|
53
55
|
|
|
56
|
+
useMount(() => {
|
|
57
|
+
getPhoneUtil().catch((err) => {
|
|
58
|
+
console.error('Failed to preload phone validator:', err);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
54
62
|
const onCountryChange = useCallback(
|
|
55
63
|
(v: CountryIso2) => {
|
|
56
64
|
safeUpdate(() => {
|
|
@@ -60,6 +68,11 @@ export default function PhoneInput({ ...props }) {
|
|
|
60
68
|
[setCountry, safeUpdate]
|
|
61
69
|
);
|
|
62
70
|
|
|
71
|
+
const handleBlur = useCallback(() => {
|
|
72
|
+
trigger(props.name);
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [props.name]);
|
|
75
|
+
|
|
63
76
|
return (
|
|
64
77
|
// @ts-ignore
|
|
65
78
|
<FormInput
|
|
@@ -67,6 +80,7 @@ export default function PhoneInput({ ...props }) {
|
|
|
67
80
|
onChange={handlePhoneValueChange}
|
|
68
81
|
type="tel"
|
|
69
82
|
inputRef={inputRef}
|
|
83
|
+
onBlur={handleBlur}
|
|
70
84
|
InputProps={{
|
|
71
85
|
startAdornment: (
|
|
72
86
|
<InputAdornment position="start" style={{ marginRight: '2px', marginLeft: '-8px' }}>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/indent */
|
|
1
2
|
import Center from '@arcblock/ux/lib/Center';
|
|
2
3
|
import Dialog from '@arcblock/ux/lib/Dialog';
|
|
3
4
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
@@ -21,6 +22,14 @@ export type StripeCheckoutFormProps = {
|
|
|
21
22
|
returnUrl: string;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
const PaymentElementContainer = styled('div')`
|
|
26
|
+
opacity: 0;
|
|
27
|
+
transition: opacity 300ms ease;
|
|
28
|
+
&.visible {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
|
|
24
33
|
// @doc https://stripe.com/docs/js/elements_object/create_payment_element
|
|
25
34
|
function StripeCheckoutForm({
|
|
26
35
|
clientSecret,
|
|
@@ -38,8 +47,39 @@ function StripeCheckoutForm({
|
|
|
38
47
|
message: '',
|
|
39
48
|
confirming: false,
|
|
40
49
|
loaded: false,
|
|
50
|
+
showBillingForm: false,
|
|
51
|
+
isTransitioning: false,
|
|
52
|
+
paymentMethod: 'card',
|
|
41
53
|
});
|
|
42
54
|
|
|
55
|
+
const handlePaymentMethodChange = (event: any) => {
|
|
56
|
+
const method = event.value?.type;
|
|
57
|
+
const needsBillingInfo = method === 'google_pay' || method === 'apple_pay';
|
|
58
|
+
const shouldShowForm = needsBillingInfo && !isCompleteBillingAddress(customer.address);
|
|
59
|
+
|
|
60
|
+
if (shouldShowForm && !state.showBillingForm) {
|
|
61
|
+
setState({ isTransitioning: true });
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
setState({
|
|
64
|
+
isTransitioning: false,
|
|
65
|
+
paymentMethod: method,
|
|
66
|
+
showBillingForm: true,
|
|
67
|
+
});
|
|
68
|
+
}, 300);
|
|
69
|
+
} else {
|
|
70
|
+
// if shouldShowForm is false, set showBillingForm to false immediately
|
|
71
|
+
setState({
|
|
72
|
+
showBillingForm: false,
|
|
73
|
+
paymentMethod: method,
|
|
74
|
+
isTransitioning: false,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const isCompleteBillingAddress = (address: any) => {
|
|
80
|
+
return address && address.line1 && address.city && address.state && address.postal_code && address.country;
|
|
81
|
+
};
|
|
82
|
+
|
|
43
83
|
useEffect(() => {
|
|
44
84
|
if (!stripe) {
|
|
45
85
|
return;
|
|
@@ -76,29 +116,53 @@ function StripeCheckoutForm({
|
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
try {
|
|
79
|
-
setState({ confirming: true });
|
|
119
|
+
setState({ confirming: true, message: '' });
|
|
80
120
|
const method = intentType === 'payment_intent' ? 'confirmPayment' : 'confirmSetup';
|
|
81
|
-
|
|
121
|
+
|
|
122
|
+
const { error: submitError } = await elements.submit();
|
|
123
|
+
if (submitError) {
|
|
124
|
+
setState({ confirming: false });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { error, paymentIntent, setupIntent } = await stripe[method]({
|
|
82
129
|
elements,
|
|
83
130
|
redirect: 'if_required',
|
|
84
131
|
confirmParams: {
|
|
85
132
|
return_url: returnUrl || window.location.href,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
133
|
+
...(!state.showBillingForm
|
|
134
|
+
? {
|
|
135
|
+
payment_method_data: {
|
|
136
|
+
billing_details: {
|
|
137
|
+
name: customer.name,
|
|
138
|
+
phone: customer.phone,
|
|
139
|
+
email: customer.email,
|
|
140
|
+
address: {
|
|
141
|
+
...(customer.address || {}),
|
|
142
|
+
country: customer.address?.country || 'us',
|
|
143
|
+
line1: customer.address?.line1 || '',
|
|
144
|
+
line2: customer.address?.line2 || '',
|
|
145
|
+
city: customer.address?.city || '',
|
|
146
|
+
state: customer.address?.state || '',
|
|
147
|
+
postal_code: customer.address?.postal_code || '00000',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
94
153
|
},
|
|
95
154
|
});
|
|
155
|
+
const intent = paymentIntent || setupIntent;
|
|
156
|
+
if (intent?.status === 'canceled' || intent?.status === 'requires_payment_method') {
|
|
157
|
+
setState({ confirming: false });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
96
161
|
setState({ confirming: false });
|
|
97
162
|
if (error) {
|
|
98
163
|
if (error.type === 'validation_error') {
|
|
99
164
|
return;
|
|
100
165
|
}
|
|
101
|
-
|
|
102
166
|
setState({ message: error.message as string });
|
|
103
167
|
return;
|
|
104
168
|
}
|
|
@@ -109,32 +173,40 @@ function StripeCheckoutForm({
|
|
|
109
173
|
setState({ confirming: false, message: err.message as string });
|
|
110
174
|
}
|
|
111
175
|
},
|
|
112
|
-
[customer, intentType, stripe] // eslint-disable-line
|
|
176
|
+
[customer, intentType, stripe, state.showBillingForm, returnUrl] // eslint-disable-line
|
|
113
177
|
);
|
|
114
178
|
|
|
115
179
|
return (
|
|
116
180
|
<Content onSubmit={handleSubmit}>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
phone: customer.phone,
|
|
131
|
-
email: customer.email,
|
|
132
|
-
address: customer.address,
|
|
181
|
+
{(!state.paymentMethod || state.paymentMethod === 'card') && (
|
|
182
|
+
<LinkAuthenticationElement
|
|
183
|
+
options={{
|
|
184
|
+
defaultEmail: customer.email,
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
<PaymentElementContainer className={!state.isTransitioning ? 'visible' : ''}>
|
|
189
|
+
<PaymentElement
|
|
190
|
+
options={{
|
|
191
|
+
layout: 'auto',
|
|
192
|
+
fields: {
|
|
193
|
+
billingDetails: state.showBillingForm ? 'auto' : 'never',
|
|
133
194
|
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
readOnly: state.confirming,
|
|
196
|
+
defaultValues: {
|
|
197
|
+
billingDetails: {
|
|
198
|
+
name: customer.name,
|
|
199
|
+
phone: customer.phone,
|
|
200
|
+
email: customer.email,
|
|
201
|
+
address: customer.address,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
}}
|
|
205
|
+
onChange={handlePaymentMethodChange}
|
|
206
|
+
onReady={() => setState({ loaded: true })}
|
|
207
|
+
/>
|
|
208
|
+
</PaymentElementContainer>
|
|
209
|
+
|
|
138
210
|
{(!stripe || !elements || !state.loaded) && (
|
|
139
211
|
<Center relative="parent">
|
|
140
212
|
<CircularProgress />
|
package/src/payment/index.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { Box, Fade, Stack, type BoxProps } from '@mui/material';
|
|
|
16
16
|
import { styled } from '@mui/system';
|
|
17
17
|
import { fromTokenToUnit } from '@ocap/util';
|
|
18
18
|
import { useSetState } from 'ahooks';
|
|
19
|
-
import { useEffect, useState } from 'react';
|
|
19
|
+
import { useEffect, useState, useMemo } from 'react';
|
|
20
20
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
21
21
|
import trim from 'lodash/trim';
|
|
22
22
|
import type { LiteralUnion } from 'type-fest';
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import type { CheckoutCallbacks, CheckoutContext, CheckoutFormData } from '../types';
|
|
35
35
|
import PaymentError from './error';
|
|
36
36
|
import CheckoutFooter from './footer';
|
|
37
|
-
import PaymentForm from './form';
|
|
37
|
+
import PaymentForm, { hasDidWallet } from './form';
|
|
38
38
|
// import PaymentHeader from './header';
|
|
39
39
|
import OverviewSkeleton from './skeleton/overview';
|
|
40
40
|
import PaymentSkeleton from './skeleton/payment';
|
|
@@ -42,6 +42,7 @@ import PaymentSuccess from './success';
|
|
|
42
42
|
import PaymentSummary from './summary';
|
|
43
43
|
import { useMobile } from '../hooks/mobile';
|
|
44
44
|
import { formatPhone } from '../libs/phone-validator';
|
|
45
|
+
import { getCurrencyPreference } from '../libs/currency';
|
|
45
46
|
|
|
46
47
|
// eslint-disable-next-line react/no-unused-prop-types
|
|
47
48
|
type Props = CheckoutContext & CheckoutCallbacks & { completed?: boolean; error?: any; showCheckoutSummary?: boolean };
|
|
@@ -70,8 +71,48 @@ function PaymentInner({
|
|
|
70
71
|
const { isMobile } = useMobile();
|
|
71
72
|
const [state, setState] = useSetState<{ checkoutSession: TCheckoutSessionExpanded }>({ checkoutSession });
|
|
72
73
|
const query = getQueryParams(window.location.href);
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
|
|
75
|
+
const availableCurrencyIds = useMemo(() => {
|
|
76
|
+
const currencyIds = new Set<string>();
|
|
77
|
+
paymentMethods.forEach((method) => {
|
|
78
|
+
method.payment_currencies.forEach((currency) => {
|
|
79
|
+
if (currency.active) {
|
|
80
|
+
currencyIds.add(currency.id);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return Array.from(currencyIds);
|
|
85
|
+
}, [paymentMethods]);
|
|
86
|
+
|
|
87
|
+
const defaultCurrencyId = useMemo(() => {
|
|
88
|
+
// 1. first check url currencyId
|
|
89
|
+
if (query.currencyId && availableCurrencyIds.includes(query.currencyId)) {
|
|
90
|
+
return query.currencyId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. if user has no wallet, use the first available currency of stripe payment method
|
|
94
|
+
if (session?.user && !hasDidWallet(session.user)) {
|
|
95
|
+
const stripeCurrencyId = paymentMethods
|
|
96
|
+
.find((m) => m.type === 'stripe')
|
|
97
|
+
?.payment_currencies.find((c) => c.active)?.id;
|
|
98
|
+
if (stripeCurrencyId) {
|
|
99
|
+
return stripeCurrencyId;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. then check user's saved currency preference
|
|
104
|
+
const savedPreference = getCurrencyPreference(session?.user?.did, availableCurrencyIds);
|
|
105
|
+
if (savedPreference) {
|
|
106
|
+
return savedPreference;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. finally use the currency in checkoutSession or the first available currency
|
|
110
|
+
if (state.checkoutSession.currency_id && availableCurrencyIds.includes(state.checkoutSession.currency_id)) {
|
|
111
|
+
return state.checkoutSession.currency_id;
|
|
112
|
+
}
|
|
113
|
+
return availableCurrencyIds?.[0];
|
|
114
|
+
}, [query.currencyId, availableCurrencyIds, session?.user, state.checkoutSession.currency_id, paymentMethods]);
|
|
115
|
+
|
|
75
116
|
const defaultMethodId = paymentMethods.find((m) => m.payment_currencies.some((c) => c.id === defaultCurrencyId))?.id;
|
|
76
117
|
const hideSummaryCard = mode.endsWith('-minimal') || !showCheckoutSummary;
|
|
77
118
|
|
|
@@ -120,16 +161,6 @@ function PaymentInner({
|
|
|
120
161
|
};
|
|
121
162
|
}, []);
|
|
122
163
|
|
|
123
|
-
useEffect(() => {
|
|
124
|
-
if (!methods || query.currencyId) {
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (state.checkoutSession.currency_id !== defaultCurrencyId) {
|
|
128
|
-
methods.setValue('payment_currency', state.checkoutSession.currency_id);
|
|
129
|
-
}
|
|
130
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
-
}, [state.checkoutSession, defaultCurrencyId, query.currencyId]);
|
|
132
|
-
|
|
133
164
|
const currencyId = useWatch({ control: methods.control, name: 'payment_currency', defaultValue: defaultCurrencyId });
|
|
134
165
|
const currency =
|
|
135
166
|
(findCurrency(paymentMethods as TPaymentMethodExpanded[], currencyId as string) as TPaymentCurrency) ||
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
2
|
import type { DonationSettings, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
|
|
3
|
-
import { Avatar, Box, Card, CardActionArea, Grid, Stack, TextField, Typography } from '@mui/material';
|
|
3
|
+
import { Avatar, Box, Card, CardActionArea, Grid, Stack, TextField, Typography, IconButton } from '@mui/material';
|
|
4
|
+
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
|
4
5
|
import { useSetState } from 'ahooks';
|
|
5
6
|
import { useEffect, useRef } from 'react';
|
|
6
7
|
|
|
@@ -78,6 +79,7 @@ export default function ProductDonation({
|
|
|
78
79
|
input: defaultCustomAmount,
|
|
79
80
|
custom: !supportPreset || defaultPreset === 'custom',
|
|
80
81
|
error: '',
|
|
82
|
+
animating: false,
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
const customInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -91,15 +93,98 @@ export default function ProductDonation({
|
|
|
91
93
|
};
|
|
92
94
|
|
|
93
95
|
const handleCustomSelect = () => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
// Set custom mode and prepare for random amount
|
|
97
|
+
setState({
|
|
98
|
+
custom: true,
|
|
99
|
+
selected: '',
|
|
100
|
+
animating: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Prepare random amount range with correctly sorted presets
|
|
104
|
+
const hasPresets = presets.length > 0;
|
|
105
|
+
let sortedPresets: number[] = [];
|
|
106
|
+
if (hasPresets) {
|
|
107
|
+
sortedPresets = [...presets].map((p) => parseFloat(p)).sort((a, b) => a - b);
|
|
102
108
|
}
|
|
109
|
+
|
|
110
|
+
// Get min and max values for random amount
|
|
111
|
+
const minPreset = hasPresets ? sortedPresets[0] : 1;
|
|
112
|
+
const middleIndex = Math.floor(sortedPresets.length / 2);
|
|
113
|
+
const maxPreset = hasPresets ? sortedPresets[middleIndex] : 10;
|
|
114
|
+
|
|
115
|
+
// Detect precision from existing presets
|
|
116
|
+
const detectPrecision = () => {
|
|
117
|
+
let maxPrecision = 2; // Default to 2 decimal places for currency
|
|
118
|
+
|
|
119
|
+
// If no presets, default to 0 precision (integers) for simplicity
|
|
120
|
+
if (!hasPresets) return 0;
|
|
121
|
+
|
|
122
|
+
const allIntegers = presets.every((preset) => {
|
|
123
|
+
const num = parseFloat(preset);
|
|
124
|
+
return num === Math.floor(num);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (allIntegers) return 0;
|
|
128
|
+
|
|
129
|
+
presets.forEach((preset) => {
|
|
130
|
+
const decimalPart = preset.toString().split('.')[1];
|
|
131
|
+
if (decimalPart) {
|
|
132
|
+
maxPrecision = Math.max(maxPrecision, decimalPart.length);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return maxPrecision;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const precision = detectPrecision();
|
|
140
|
+
|
|
141
|
+
// Generate random amount with matching precision
|
|
142
|
+
let randomAmount;
|
|
143
|
+
if (precision === 0) {
|
|
144
|
+
randomAmount = (Math.round(Math.random() * (maxPreset - minPreset) + minPreset) || 1).toString();
|
|
145
|
+
} else {
|
|
146
|
+
randomAmount = (Math.random() * (maxPreset - minPreset) + minPreset).toFixed(precision);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Get starting value for animation - use either current input, cached value, or 0
|
|
150
|
+
const startValue = state.input ? parseFloat(state.input) : 0;
|
|
151
|
+
const targetValue = parseFloat(randomAmount);
|
|
152
|
+
const difference = targetValue - startValue;
|
|
153
|
+
|
|
154
|
+
// Animate value change
|
|
155
|
+
const startTime = Date.now();
|
|
156
|
+
const duration = 800;
|
|
157
|
+
|
|
158
|
+
const updateCounter = () => {
|
|
159
|
+
const currentTime = Date.now();
|
|
160
|
+
const elapsed = currentTime - startTime;
|
|
161
|
+
|
|
162
|
+
if (elapsed < duration) {
|
|
163
|
+
const progress = elapsed / duration;
|
|
164
|
+
const intermediateValue = startValue + difference * progress;
|
|
165
|
+
const currentValue =
|
|
166
|
+
precision === 0 ? Math.floor(intermediateValue).toString() : intermediateValue.toFixed(precision);
|
|
167
|
+
|
|
168
|
+
setState({ input: currentValue });
|
|
169
|
+
requestAnimationFrame(updateCounter);
|
|
170
|
+
} else {
|
|
171
|
+
// Animation complete
|
|
172
|
+
setState({
|
|
173
|
+
input: randomAmount,
|
|
174
|
+
animating: false,
|
|
175
|
+
error: '',
|
|
176
|
+
});
|
|
177
|
+
onChange({ priceId: item.price_id, amount: formatAmount(randomAmount) });
|
|
178
|
+
setPayable(true);
|
|
179
|
+
localStorage.setItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE), formatAmount(randomAmount));
|
|
180
|
+
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
customInputRef.current?.focus();
|
|
183
|
+
}, 200);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
requestAnimationFrame(updateCounter);
|
|
103
188
|
localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), 'custom');
|
|
104
189
|
};
|
|
105
190
|
|
|
@@ -111,7 +196,6 @@ export default function ProductDonation({
|
|
|
111
196
|
}
|
|
112
197
|
};
|
|
113
198
|
|
|
114
|
-
// 使用useTabNavigation进行键盘导航
|
|
115
199
|
const { handleKeyDown } = useTabNavigation(presets, handleTabSelect, {
|
|
116
200
|
includeCustom: supportCustom,
|
|
117
201
|
currentValue: state.custom ? undefined : state.selected,
|
|
@@ -286,14 +370,49 @@ export default function ProductDonation({
|
|
|
286
370
|
InputProps={{
|
|
287
371
|
endAdornment: (
|
|
288
372
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ ml: 1 }}>
|
|
373
|
+
<IconButton
|
|
374
|
+
size="small"
|
|
375
|
+
onClick={handleCustomSelect}
|
|
376
|
+
disabled={state.animating}
|
|
377
|
+
sx={{
|
|
378
|
+
mr: 0.5,
|
|
379
|
+
opacity: state.animating ? 0.5 : 1,
|
|
380
|
+
transition: 'all 0.2s ease',
|
|
381
|
+
'&:hover': {
|
|
382
|
+
transform: 'scale(1.2)',
|
|
383
|
+
transition: 'transform 0.3s ease',
|
|
384
|
+
},
|
|
385
|
+
}}
|
|
386
|
+
aria-label={t('common.random')}>
|
|
387
|
+
<AutoAwesomeIcon fontSize="small" />
|
|
388
|
+
</IconButton>
|
|
289
389
|
<Avatar src={currency?.logo} sx={{ width: 16, height: 16 }} alt={currency?.symbol} />
|
|
290
390
|
<Typography>{currency?.symbol}</Typography>
|
|
291
391
|
</Stack>
|
|
292
392
|
),
|
|
293
393
|
autoComplete: 'off',
|
|
394
|
+
sx: {
|
|
395
|
+
'& input': {
|
|
396
|
+
transition: 'all 0.25s ease',
|
|
397
|
+
},
|
|
398
|
+
},
|
|
294
399
|
}}
|
|
295
400
|
sx={{
|
|
296
401
|
mt: defaultPreset !== '0' ? 0 : 1,
|
|
402
|
+
'& .MuiInputBase-root': {
|
|
403
|
+
transition: 'all 0.3s ease',
|
|
404
|
+
},
|
|
405
|
+
'& input[type=number]': {
|
|
406
|
+
MozAppearance: 'textfield',
|
|
407
|
+
},
|
|
408
|
+
'& input[type=number]::-webkit-outer-spin-button': {
|
|
409
|
+
WebkitAppearance: 'none',
|
|
410
|
+
margin: 0,
|
|
411
|
+
},
|
|
412
|
+
'& input[type=number]::-webkit-inner-spin-button': {
|
|
413
|
+
WebkitAppearance: 'none',
|
|
414
|
+
margin: 0,
|
|
415
|
+
},
|
|
297
416
|
}}
|
|
298
417
|
/>
|
|
299
418
|
)}
|