diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index 0c9f0c00..469b7ee9 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -5,12 +5,8 @@ import { Switch } from '@/popup/components/ui/Switch'; import { Code } from '@/popup/components/ui/Code'; import { ErrorMessage } from '@/popup/components/ErrorMessage'; import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; -import { - charIsNumber, - formatNumber, - getCurrencySymbol, - toWalletAddressUrl, -} from '@/popup/lib/utils'; +import { InputAmount, validateAmount } from '@/popup/components/InputAmount'; +import { toWalletAddressUrl } from '@/popup/lib/utils'; import { useTranslation } from '@/popup/lib/context'; import { cn, @@ -107,11 +103,6 @@ export const ConnectWalletForm = ({ state?.status?.startsWith('connecting') || false, ); - const [currencySymbol, setCurrencySymbol] = React.useState<{ - symbol: string; - scale: number; - }>({ symbol: '$', scale: 2 }); - const getWalletInformation = React.useCallback( async (walletAddressUrl: string): Promise => { setErrors((prev) => ({ ...prev, walletAddressUrl: null })); @@ -153,25 +144,19 @@ export const ConnectWalletForm = ({ ); const handleAmountChange = React.useCallback( - (value: string, input: HTMLInputElement) => { - const error = validateAmount(value, currencySymbol.symbol); - setErrors((prev) => ({ ...prev, amount: toErrorInfo(error) })); - - const amountValue = formatNumber(+value, currencySymbol.scale); - if (!error) { - setAmount(amountValue); - input.value = amountValue; - } - saveValue('amount', error ? value : amountValue); + (amountValue: string) => { + setErrors((prev) => ({ ...prev, amount: null })); + setAmount(amountValue); + saveValue('amount', amountValue); }, - [saveValue, currencySymbol, toErrorInfo], + [saveValue], ); const handleSubmit = async (ev?: React.FormEvent) => { ev?.preventDefault(); const errWalletAddressUrl = validateWalletAddressUrl(walletAddressUrl); - const errAmount = validateAmount(amount, currencySymbol.symbol); + const errAmount = validateAmount(amount, walletAddressInfo!); if (errAmount || errWalletAddressUrl) { setErrors((prev) => ({ ...prev, @@ -221,14 +206,6 @@ export const ConnectWalletForm = ({ } }; - React.useEffect(() => { - if (!walletAddressInfo) return; - setCurrencySymbol({ - symbol: getCurrencySymbol(walletAddressInfo.assetCode), - scale: walletAddressInfo.assetScale, - }); - }, [walletAddressInfo]); - React.useEffect(() => { if (defaultValues.walletAddressUrl) { handleWalletAddressUrlChange(defaultValues.walletAddressUrl); @@ -332,27 +309,23 @@ export const ConnectWalletForm = ({ {t('connectWallet_labelGroup_amount')}
- {currencySymbol.symbol}} - aria-invalid={!!errors.amount} - aria-describedby={errors.amount?.message} - required={true} - onKeyDown={allowOnlyNumericInput} - onBlur={(ev) => { - const value = ev.currentTarget.value; - if (value === amount && !ev.currentTarget.required) { - return; - } - handleAmountChange(value, ev.currentTarget); + onError={(err) => { + setErrors((prev) => ({ ...prev, amount: toErrorInfo(err) })); }} + onChange={handleAmountChange} + className="max-w-32" + placeholder="5.00" /> ) { - if ( - (!charIsNumber(ev.key) && - ev.key !== 'Backspace' && - ev.key !== 'Delete' && - ev.key !== 'Enter' && - ev.key !== 'Tab') || - (ev.key === '.' && ev.currentTarget.value.includes('.')) - ) { - ev.preventDefault(); - } -} diff --git a/src/popup/components/InputAmount.tsx b/src/popup/components/InputAmount.tsx new file mode 100644 index 00000000..6d403713 --- /dev/null +++ b/src/popup/components/InputAmount.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Input } from './ui/Input'; +import type { WalletAddress } from '@interledger/open-payments'; +import { charIsNumber, formatNumber, getCurrencySymbol } from '../lib/utils'; +import { + errorWithKey, + ErrorWithKeyLike, + formatCurrency, +} from '@/shared/helpers'; + +interface Props { + id: string; + label: string; + walletAddress: Pick; + amount: string; + onChange: (amount: string, inputEl: HTMLInputElement) => void; + onError: (error: ErrorWithKeyLike) => void; + className?: string; + placeholder?: string; + errorMessage?: string; + readOnly?: boolean; + labelHidden?: boolean; + errorHidden?: boolean; + min?: number; + max?: number; + controls?: boolean; +} + +export const InputAmount = ({ + label, + id, + walletAddress, + amount, + className, + placeholder, + errorMessage, + onChange, + onError, + labelHidden, + errorHidden, + min = 0, + max, + readOnly, +}: Props) => { + const currencySymbol = getCurrencySymbol(walletAddress.assetCode); + return ( + {currencySymbol}} + errorMessage={errorHidden ? '' : errorMessage} + aria-invalid={errorHidden ? !!errorMessage : false} + required={true} + onKeyDown={allowOnlyNumericInput} + onBlur={(ev) => { + const input = ev.currentTarget; + const value = input.value; + if (value === amount && !input.required) { + return; + } + const error = validateAmount(value, walletAddress, min, max); + if (error) { + onError(error); + } else { + const amountValue = formatNumber(+value, walletAddress.assetScale); + input.value = amountValue; + onChange(amountValue, input); + } + }} + /> + ); +}; + +export function validateAmount( + value: string, + walletAddress: Pick, + min: number = 0, + _max?: number, +): null | ErrorWithKeyLike { + if (!value) { + return errorWithKey('connectWallet_error_amountRequired'); + } + const val = Number(value); + if (Number.isNaN(val)) { + return errorWithKey('connectWallet_error_amountInvalidNumber'); + } + if (val <= min) { + return errorWithKey('connectWallet_error_amountMinimum', [ + formatCurrency(min, walletAddress.assetCode, walletAddress.assetScale), + ]); + } + return null; +} + +function allowOnlyNumericInput(ev: React.KeyboardEvent) { + if ( + (!charIsNumber(ev.key) && + ev.key !== 'Backspace' && + ev.key !== 'Delete' && + ev.key !== 'Enter' && + ev.key !== 'Tab') || + (ev.key === '.' && ev.currentTarget.value.includes('.')) + ) { + ev.preventDefault(); + } +} diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 2a4ea470..7a5005be 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -18,12 +18,17 @@ export const cn = (...inputs: CxOptions) => { return twMerge(cx(inputs)); }; -export const formatCurrency = (value: any): string => { - if (value < 1) { - return `${Math.round(value * 100)}c`; - } else { - return `$${parseFloat(value).toFixed(2)}`; - } +export const formatCurrency = ( + value: string | number, + currency: string, + maximumFractionDigits = 2, + locale?: string, +): string => { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + maximumFractionDigits, + }).format(Number(value)); }; const isWalletAddress = (o: any): o is WalletAddress => {