import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import type { DonationSettings, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types'; import { Avatar, Box, Card, CardActionArea, Grid, Stack, TextField, Typography, IconButton } from '@mui/material'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import { useSetState } from 'ahooks'; import { useEffect, useRef } from 'react'; import { formatAmountPrecisionLimit } from '../libs/util'; import { usePaymentContext } from '../contexts/payment'; import { usePreventWheel } from '../hooks/scroll'; import { useTabNavigation } from '../hooks/keyboard'; // LocalStorage key base for preset selection const DONATION_PRESET_KEY_BASE = 'payment-donation-preset'; const DONATION_CUSTOM_AMOUNT_KEY_BASE = 'payment-donation-custom-amount'; const formatAmount = (amount: string | number): string => { const str = String(amount); if (!str || str === '0') return str; const num = parseFloat(str); if (Number.isNaN(num)) return str; return num.toString(); }; export default function ProductDonation({ item, settings, onChange, currency, }: { item: TLineItemExpanded; settings: DonationSettings; onChange: Function; currency: TPaymentCurrency; }) { const { t, locale } = useLocaleContext(); const { setPayable, session } = usePaymentContext(); usePreventWheel(); const presets = (settings?.amount?.presets || []).map(formatAmount); const getUserStorageKey = (base: string) => { const userDid = session?.user?.did; return userDid ? `${base}:${userDid}` : base; }; const getSavedCustomAmount = () => { try { const saved = localStorage.getItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE)) || ''; return saved ? formatAmount(saved) : ''; } catch (e) { console.warn('Failed to access localStorage', e); return ''; } }; const getDefaultPreset = () => { try { const savedPreset = localStorage.getItem(getUserStorageKey(DONATION_PRESET_KEY_BASE)); if (savedPreset) { if (presets.includes(formatAmount(savedPreset))) { return formatAmount(savedPreset); } if (savedPreset === 'custom' && supportCustom) { return 'custom'; } } } catch (e) { console.warn('Failed to access localStorage', e); } if (presets.length > 0) { const middleIndex = Math.floor(presets.length / 2); return presets[middleIndex]; } return '0'; }; const supportPreset = presets.length > 0; const supportCustom = !!settings?.amount?.custom; const defaultPreset = getDefaultPreset(); const defaultCustomAmount = defaultPreset === 'custom' ? getSavedCustomAmount() : ''; const [state, setState] = useSetState({ selected: defaultPreset === 'custom' ? '' : defaultPreset, input: defaultCustomAmount, custom: !supportPreset || defaultPreset === 'custom', error: '', animating: false, }); const customInputRef = useRef(null); const containerRef = useRef(null); const handleSelect = (amount: string) => { setPayable(true); setState({ selected: formatAmount(amount), custom: false, error: '' }); onChange({ priceId: item.price_id, amount: formatAmount(amount) }); localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), formatAmount(amount)); }; const handleCustomSelect = () => { // Set custom mode and prepare for random amount setState({ custom: true, selected: '', animating: true, }); // Prepare random amount range with correctly sorted presets const hasPresets = presets.length > 0; let sortedPresets: number[] = []; if (hasPresets) { sortedPresets = [...presets].map((p) => parseFloat(p)).sort((a, b) => a - b); } // Get min and max values for random amount const minPreset = hasPresets ? sortedPresets[sortedPresets.length - 1] : 1; let maxPreset = hasPresets ? sortedPresets[sortedPresets.length - 1] * 5 : 100; const systemMax = settings.amount.maximum ? parseFloat(settings.amount.maximum) : Infinity; maxPreset = Math.min(maxPreset, systemMax); // Detect precision from existing presets const detectPrecision = () => { let maxPrecision = 2; // Default to 2 decimal places for currency // If no presets, default to 0 precision (integers) for simplicity if (!hasPresets) return 0; const allIntegers = presets.every((preset) => { const num = parseFloat(preset); return num === Math.floor(num); }); if (allIntegers) return 0; presets.forEach((preset) => { const decimalPart = preset.toString().split('.')[1]; if (decimalPart) { maxPrecision = Math.max(maxPrecision, decimalPart.length); } }); return maxPrecision; }; const precision = detectPrecision(); // Generate random amount with matching precision let randomAmount; if (precision === 0) { randomAmount = (Math.round(Math.random() * (maxPreset - minPreset) + minPreset) || 1).toString(); } else { randomAmount = (Math.random() * (maxPreset - minPreset) + minPreset).toFixed(precision); } // Get starting value for animation - use either current input, cached value, or 0 const startValue = state.input ? parseFloat(state.input) : 0; const targetValue = parseFloat(randomAmount); const difference = targetValue - startValue; // Animate value change const startTime = Date.now(); const duration = 800; const updateCounter = () => { const currentTime = Date.now(); const elapsed = currentTime - startTime; if (elapsed < duration) { const progress = elapsed / duration; const intermediateValue = startValue + difference * progress; const currentValue = precision === 0 ? Math.floor(intermediateValue).toString() : intermediateValue.toFixed(precision); setState({ input: currentValue }); requestAnimationFrame(updateCounter); } else { // Animation complete setState({ input: randomAmount, animating: false, error: '', }); onChange({ priceId: item.price_id, amount: formatAmount(randomAmount) }); setPayable(true); localStorage.setItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE), formatAmount(randomAmount)); setTimeout(() => { customInputRef.current?.focus(); }, 200); } }; requestAnimationFrame(updateCounter); localStorage.setItem(getUserStorageKey(DONATION_PRESET_KEY_BASE), 'custom'); }; const handleTabSelect = (selectedItem: string | 'custom') => { if (selectedItem === 'custom') { handleCustomSelect(); } else { handleSelect(selectedItem as string); } }; const { handleKeyDown } = useTabNavigation(presets, handleTabSelect, { includeCustom: supportCustom, currentValue: state.custom ? undefined : state.selected, isCustomSelected: state.custom, enabled: true, selector: '.tab-navigable-card button', containerRef, }); useEffect(() => { const currentPreset = getDefaultPreset(); const isCustom = currentPreset === 'custom'; setState({ selected: isCustom ? '' : currentPreset, custom: !supportPreset || currentPreset === 'custom', input: defaultCustomAmount, error: '', }); if (!isCustom) { onChange({ priceId: item.price_id, amount: currentPreset }); setPayable(true); } else if (defaultCustomAmount) { onChange({ priceId: item.price_id, amount: defaultCustomAmount }); setPayable(true); } else { setPayable(false); } }, [settings.amount.preset, settings.amount.presets, supportPreset]); // eslint-disable-line useEffect(() => { if (containerRef.current) { containerRef.current.focus(); } if (state.custom) { setTimeout(() => { customInputRef.current?.focus(); }, 0); } }, [state.custom]); const handleInput = (event: any) => { const { value } = event.target; const min = parseFloat(settings.amount.minimum || '0'); const max = settings.amount.maximum ? parseFloat(settings.amount.maximum) : Infinity; const precision = currency?.maximum_precision || 6; if (formatAmountPrecisionLimit(value, locale)) { setState({ input: value, error: formatAmountPrecisionLimit(value, locale, precision) }); setPayable(false); return; } if (value < min || value > max) { setState({ input: value, error: t('payment.checkout.donation.between', { min, max }) }); setPayable(false); return; } setPayable(true); setState({ error: '', input: value }); onChange({ priceId: item.price_id, amount: formatAmount(value) }); localStorage.setItem(getUserStorageKey(DONATION_CUSTOM_AMOUNT_KEY_BASE), formatAmount(value)); }; return ( {supportPreset && ( {presets.map((amount) => ( handleSelect(amount)} tabIndex={0} aria-selected={formatAmount(state.selected) === formatAmount(amount) && !state.custom}> {amount} {currency?.symbol} ))} {supportCustom && ( {t('common.custom')} )} )} {state.custom && ( {currency?.symbol} ), autoComplete: 'off', }, }} autoComplete="off" /> )} ); }