diff --git a/src/assets/images/bg-tile.svg b/src/assets/images/bg-tile.svg new file mode 100644 index 00000000..7d74fc0c --- /dev/null +++ b/src/assets/images/bg-tile.svg @@ -0,0 +1 @@ + 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/Icons.tsx b/src/popup/components/Icons.tsx index 36a951a5..6c327633 100644 --- a/src/popup/components/Icons.tsx +++ b/src/popup/components/Icons.tsx @@ -72,7 +72,7 @@ export const Settings = (props: React.SVGProps) => { diff --git a/src/popup/components/InputAmount.tsx b/src/popup/components/InputAmount.tsx new file mode 100644 index 00000000..1ddf4494 --- /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 | React.ReactNode; + 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/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 312f9330..d887d3ca 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -1,21 +1,15 @@ -import { Button } from '@/popup/components/ui/Button'; -import { Input } from '@/popup/components/ui/Input'; -import { useMessage, usePopupState } from '@/popup/lib/context'; -import { - getCurrencySymbol, - charIsNumber, - formatNumber, -} from '@/popup/lib/utils'; -import React, { useMemo } from 'react'; -import { useForm } from 'react-hook-form'; +import React from 'react'; import { AnimatePresence, m } from 'framer-motion'; -import { Spinner } from './Icons'; -import { cn } from '@/shared/helpers'; -import { ErrorMessage } from './ErrorMessage'; +import { Button } from '@/popup/components/ui/Button'; +import { Spinner } from '@/popup/components/Icons'; +import { ErrorMessage } from '@/popup/components/ErrorMessage'; +import { InputAmount } from '@/popup/components/InputAmount'; +import { cn, ErrorWithKeyLike } from '@/shared/helpers'; +import { useMessage, usePopupState, useTranslation } from '@/popup/lib/context'; -interface PayWebsiteFormProps { - amount: string; -} +type ErrorInfo = { message: string; info?: ErrorWithKeyLike }; +type ErrorsParams = 'amount' | 'pay'; +type Errors = Record; const BUTTON_STATE = { idle: 'Send now', @@ -24,45 +18,65 @@ const BUTTON_STATE = { }; export const PayWebsiteForm = () => { + const t = useTranslation(); const message = useMessage(); const { state: { walletAddress, tab }, } = usePopupState(); + + const toErrorInfo = React.useCallback( + (err?: string | ErrorWithKeyLike | null): ErrorInfo | null => { + if (!err) return null; + if (typeof err === 'string') return { message: err }; + return { message: t(err), info: err }; + }, + [t], + ); + + const [amount, setAmount] = React.useState(''); + const [errors, setErrors] = React.useState({ + amount: null, + pay: null, + }); + + const form = React.useRef(null); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [buttonState, setButtonState] = React.useState('idle'); - const isIdle = useMemo(() => buttonState === 'idle', [buttonState]); + const isIdle = React.useMemo(() => buttonState === 'idle', [buttonState]); - const { - register, - formState: { errors, isSubmitting }, - setValue, - handleSubmit, - ...form - } = useForm(); - - const onSubmit = handleSubmit(async (data) => { + const onSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); if (buttonState !== 'idle') return; + setErrors({ amount: null, pay: null }); setButtonState('loading'); + setIsSubmitting(true); - const response = await message.send('PAY_WEBSITE', { amount: data.amount }); + const response = await message.send('PAY_WEBSITE', { amount }); if (!response.success) { setButtonState('idle'); - form.setError('root', { message: response.message }); + setErrors((prev) => ({ ...prev, pay: toErrorInfo(response.message) })); } else { setButtonState('success'); - form.reset(); + setAmount(''); + form.current?.reset(); setTimeout(() => { setButtonState('idle'); - }, 2000); + }, 3000); } - }); + setIsSubmitting(false); + }; return ( -
+ - {errors.root ? ( + {errors.pay ? ( { className="overflow-hidden" key="form-error" > - + ) : null} - - Pay {tab.url} + Support{' '} + {tab.url}

} + walletAddress={walletAddress} + amount={amount} placeholder="0.00" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - onSubmit(); - } else if ( - !charIsNumber(e.key) && - e.key !== 'Backspace' && - e.key !== 'Delete' && - e.key !== 'Tab' - ) { - e.preventDefault(); - } - }} errorMessage={errors.amount?.message} - {...register('amount', { - required: { value: true, message: 'Amount is required.' }, - valueAsNumber: true, - onBlur: (e: React.FocusEvent) => { - setValue( - 'amount', - formatNumber(+e.currentTarget.value, walletAddress.assetScale), - ); - }, - })} + onChange={(amountValue) => { + setErrors({ pay: null, amount: null }); + setAmount(amountValue); + }} + onError={(error) => + setErrors((prev) => ({ ...prev, amount: toErrorInfo(error) })) + } /> +