From ed450a0faed7951f03f64c3ef342b2d0c0eb9af2 Mon Sep 17 00:00:00 2001 From: Andrew718PLTS Date: Mon, 13 Jan 2025 16:20:28 +0100 Subject: [PATCH 1/4] Feat: Added specified error toasts, and improved button states --- src/components/buttons/3DButton.tsx | 77 +++++++++++++----- src/components/tooltip/Tooltip.tsx | 10 +++ src/config/tokens.ts | 4 +- src/features/swap/SwapConfirm.tsx | 34 ++++++-- src/features/swap/SwapForm.tsx | 112 ++++++++++++++++++-------- src/features/swap/useFormValidator.ts | 1 + src/features/swap/useSwapQuote.ts | 67 +++++++++++---- src/utils/debounce.ts | 16 ++++ src/utils/string.ts | 5 ++ 9 files changed, 251 insertions(+), 75 deletions(-) create mode 100644 src/components/tooltip/Tooltip.tsx diff --git a/src/components/buttons/3DButton.tsx b/src/components/buttons/3DButton.tsx index 4681d45..789f425 100644 --- a/src/components/buttons/3DButton.tsx +++ b/src/components/buttons/3DButton.tsx @@ -2,35 +2,48 @@ import { PropsWithChildren } from 'react' type BaseButtonProps = { onClick?: () => void - error?: boolean - fullWidth?: boolean type?: 'button' | 'submit' | 'reset' + isError?: boolean + isFullWidth?: boolean + isDisabled?: boolean + isAccountReady?: boolean } -export const Button3D = ({ children, ...restProps }: PropsWithChildren) => { - return <_3DButtonLink {...restProps}>{children} -} - -const _3DButtonLink = ({ +export const Button3D = ({ children, - error, - fullWidth, onClick, - type, + type = 'button', + isError, + isFullWidth, + isDisabled, + isAccountReady, }: PropsWithChildren) => { return ( - ) } + +function getSubstrateButtonColor({ isDisabled, isAccountReady, isError }: IGetButtonColorArgs) { + switch (true) { + case isDisabled: + return 'bg-[#666666]' + case isError && isAccountReady: + return 'bg-[#863636]' + default: + return 'bg-[#2A326A]' + } +} + +function getButtonColor({ isDisabled, isAccountReady, isError }: IGetButtonColorArgs) { + switch (true) { + case isDisabled: + return 'bg-[#888888] text-white cursor-not-allowed' + case isError && isAccountReady: + return 'bg-[#E14F4F] text-white' + default: + return 'bg-[#4D62F0] text-white ' + } +} + +interface IGetButtonColorArgs { + isAccountReady: boolean | undefined + isDisabled: boolean | undefined + isError: boolean | undefined +} diff --git a/src/components/tooltip/Tooltip.tsx b/src/components/tooltip/Tooltip.tsx new file mode 100644 index 0000000..3d21f5a --- /dev/null +++ b/src/components/tooltip/Tooltip.tsx @@ -0,0 +1,10 @@ +export const Tooltip = ({ text, dataTestId = 'tooltipText' }: TooltipProps) => ( +
+ {text} +
+) + +interface TooltipProps { + text: string + dataTestId?: string +} diff --git a/src/config/tokens.ts b/src/config/tokens.ts index 44fbe77..f131109 100644 --- a/src/config/tokens.ts +++ b/src/config/tokens.ts @@ -220,8 +220,8 @@ export function getTokenOptionsByChainId(chainId: ChainId): TokenId[] { : [] } -export function getTokenById(id: string): Token | null { - return Tokens[id as TokenId] || null +export function getTokenById(id: TokenId | string): Token { + return Tokens[id as TokenId] } export function getTokenAddress(id: TokenId, chainId: ChainId): Address { diff --git a/src/features/swap/SwapConfirm.tsx b/src/features/swap/SwapConfirm.tsx index e2aa73f..bdc6411 100644 --- a/src/features/swap/SwapConfirm.tsx +++ b/src/features/swap/SwapConfirm.tsx @@ -5,6 +5,7 @@ import mentoLoaderBlue from 'src/animations/Mentoloader_blue.json' import mentoLoaderGreen from 'src/animations/Mentoloader_green.json' import { toastToYourSuccess } from 'src/components/TxSuccessToast' import { Button3D } from 'src/components/buttons/3DButton' +import { Tooltip } from 'src/components/tooltip/Tooltip' import { TokenId, Tokens } from 'src/config/tokens' import { useAppDispatch, useAppSelector } from 'src/features/store/hooks' import { setConfirmView, setFormValues } from 'src/features/swap/swapSlice' @@ -19,6 +20,7 @@ import { FloatingBox } from 'src/layout/FloatingBox' import { Modal } from 'src/layout/Modal' import { fromWeiRounded, getAdjustedAmount, toSignificant } from 'src/utils/amount' import { logger } from 'src/utils/logger' +import { truncateTextByLength } from 'src/utils/string' import { useAccount, useChainId } from 'wagmi' interface Props { @@ -230,7 +232,7 @@ export function SwapConfirmCard({ formValues }: Props) {
- + Swap
@@ -253,9 +255,29 @@ interface SwapConfirmSummaryProps { } export function SwapConfirmSummary({ from, to, rate }: SwapConfirmSummaryProps) { + const optimalAmountLength = 8 const fromToken = Tokens[from.token] const toToken = Tokens[to.token] + const handleAmount = (amount: string) => { + const shouldTruncate = amount.length > optimalAmountLength + const displayedAmount = shouldTruncate + ? truncateTextByLength(optimalAmountLength, amount) + : amount + + return shouldTruncate ? ( + // todo: Replace to module.css. Couldn't been replaced because of incorrect styles while replacing +
+ {displayedAmount} + +
+ ) : ( +
+ {displayedAmount} +
+ ) + } + return (
@@ -265,20 +287,16 @@ export function SwapConfirmSummary({ from, to, rate }: SwapConfirmSummaryProps)
{fromToken.symbol}
-
- {toSignificant(from.amount)} -
+ {handleAmount(toSignificant(from.amount))}
-
+
{toToken.symbol}
-
- {toSignificant(to.amount) || '0'} -
+ {handleAmount(toSignificant(to.amount) || '0')}
diff --git a/src/features/swap/SwapForm.tsx b/src/features/swap/SwapForm.tsx index a706018..e436245 100644 --- a/src/features/swap/SwapForm.tsx +++ b/src/features/swap/SwapForm.tsx @@ -1,6 +1,6 @@ import { useConnectModal } from '@rainbow-me/rainbowkit' import { Form, Formik, useFormikContext } from 'formik' -import { ReactNode, SVGProps, useEffect, useMemo } from 'react' +import { ReactNode, SVGProps, useCallback, useEffect, useMemo } from 'react' import { toast } from 'react-toastify' import { Spinner } from 'src/components/animation/Spinner' import { Button3D } from 'src/components/buttons/3DButton' @@ -26,6 +26,7 @@ import { useFormValidator } from 'src/features/swap/useFormValidator' import { useSwapQuote } from 'src/features/swap/useSwapQuote' import { FloatingBox } from 'src/layout/FloatingBox' import { fromWei, fromWeiRounded, toSignificant } from 'src/utils/amount' +import { debounce } from 'src/utils/debounce' import { logger } from 'src/utils/logger' import { escapeRegExp, inputRegex } from 'src/utils/string' import { useAccount, useNetwork, useSwitchNetwork } from 'wagmi' @@ -72,11 +73,15 @@ function SwapForm() { const storedFormValues = useAppSelector((s) => s.swap.formValues) // Get stored form values const initialFormValues = storedFormValues || initialValues // Use stored values if they exist + const debouncedValidate = debounce(async (values: SwapFormValues) => { + return await validateForm(values) + }, 750) + return ( initialValues={initialFormValues} onSubmit={onSubmit} - validate={validateForm} + validate={debouncedValidate} validateOnChange={true} validateOnBlur={false} > @@ -99,7 +104,7 @@ function SwapFormInputs({ balances }: { balances: AccountBalances }) { return chain ? getTokenOptionsByChainId(chain?.id) : getTokenOptionsByChainId(Celo.chainId) }, [chain]) - const { values, setFieldValue } = useFormikContext() + const { values, setFieldValue, setFieldError } = useFormikContext() const swappableTokenOptions = useMemo(() => { return getSwappableTokenOptions(values.fromTokenId, chain ? chain?.id : Celo.chainId) @@ -110,10 +115,10 @@ function SwapFormInputs({ balances }: { balances: AccountBalances }) { const { isLoading, quote, rate } = useSwapQuote(amount, direction, fromTokenId, toTokenId) useEffect(() => { - if (values.direction === 'in' && quote) { + if (values.direction === 'in' && quote && values.quote !== quote) { setFieldValue('quote', quote) } - }, [quote, setFieldValue, values.direction]) + }, [quote, setFieldError, setFieldValue, values.direction, values.quote]) useEffect(() => { if (chain && isConnected && !isSwappable(values.fromTokenId, values.toTokenId, chain?.id)) { @@ -286,12 +291,12 @@ function SubmitButton() { const { switchNetworkAsync } = useSwitchNetwork() const { openConnectModal } = useConnectModal() const dispatch = useAppDispatch() - const { errors, touched } = useFormikContext() + const { errors, touched, values, isValidating } = useFormikContext() const isAccountReady = address && isConnected const isOnCelo = chains.some((chn) => chn.id === chain?.id) - const switchToNetwork = async () => { + const switchToNetwork = useCallback(async () => { try { if (!switchNetworkAsync) throw new Error('switchNetworkAsync undefined') logger.debug('Resetting and switching to Celo') @@ -304,40 +309,83 @@ function SubmitButton() { logger.error('Error updating network', error) toast.error('Could not switch network, does wallet support switching?') } - } + }, [switchNetworkAsync, dispatch]) - const error = - touched.amount && (errors.amount || errors.fromTokenId || errors.toTokenId || errors.slippage) - let text - - if (error) { - text = error - } else if (!isAccountReady) { - text = 'Connect Wallet' - } else if (!isOnCelo) { - text = 'Switch to Celo Network' - } else { - text = 'Continue' - } + const amountWasModified = touched.amount || values.amount + const quoteLikelyStillLoading = useMemo( + () => + values.amount && values.quote && values.quote === '0' && errors.amount === 'Amount Required', + [values.amount, values.quote, errors.amount] + ) - const type = isAccountReady ? 'submit' : 'button' - let onClick + const hasError = useMemo(() => { + if (!amountWasModified) return false + if (quoteLikelyStillLoading) return false - if (!isAccountReady) { - onClick = openConnectModal - } else if (!isOnCelo) { - onClick = switchToNetwork - } + return !!( + errors.amount || + errors.quote || + errors.fromTokenId || + errors.toTokenId || + errors.slippage + ) + }, [amountWasModified, quoteLikelyStillLoading, errors]) + + const errorText = useMemo( + () => + errors.amount || + errors.quote || + errors.fromTokenId || + errors.toTokenId || + errors.slippage || + '', + [errors] + ) - const showLongError = typeof error === 'string' && error?.length > 50 + const showLongError = useMemo(() => errorText.length > 50, [errorText]) + + const buttonText = useMemo(() => { + if (!isAccountReady) return 'Connect Wallet' + if (isValidating && (!errors.amount || !quoteLikelyStillLoading)) return 'Continue' + if (!isOnCelo) return 'Switch to Celo Network' + if (hasError) return showLongError ? 'Error' : errorText + return 'Continue' + }, [ + errors.amount, + errorText, + hasError, + isAccountReady, + isOnCelo, + isValidating, + quoteLikelyStillLoading, + showLongError, + ]) + + const onClick = useMemo(() => { + if (!isAccountReady) return openConnectModal + if (!isOnCelo) return switchToNetwork + return undefined + }, [isAccountReady, isOnCelo, openConnectModal, switchToNetwork]) + + const buttonType = useMemo( + () => (isAccountReady && !hasError ? 'submit' : 'button'), + [isAccountReady, hasError] + ) return (
{showLongError ? ( -
{error}
+
{errorText}
) : null} - - {showLongError ? 'Error' : text} + + {buttonText}
) diff --git a/src/features/swap/useFormValidator.ts b/src/features/swap/useFormValidator.ts index 6aac3d8..f006d35 100644 --- a/src/features/swap/useFormValidator.ts +++ b/src/features/swap/useFormValidator.ts @@ -28,6 +28,7 @@ export function useFormValidator(balances: AccountBalances, lastUpdated: number } const { exceeds, errorMsg } = await checkTradingLimits(values, chainId) if (exceeds) return { amount: errorMsg } + if (values.amount && values.quote === '0') return { quote: 'Error' } return {} })().catch((error) => { logger.error(error) diff --git a/src/features/swap/useSwapQuote.ts b/src/features/swap/useSwapQuote.ts index f6842ba..e0d4d4e 100644 --- a/src/features/swap/useSwapQuote.ts +++ b/src/features/swap/useSwapQuote.ts @@ -3,7 +3,7 @@ import { ethers } from 'ethers' import { useEffect } from 'react' import { toast } from 'react-toastify' import { SWAP_QUOTE_REFETCH_INTERVAL } from 'src/config/consts' -import { TokenId, Tokens, getTokenAddress } from 'src/config/tokens' +import { TokenId, getTokenAddress, getTokenById } from 'src/config/tokens' import { getMentoSdk } from 'src/features/sdk' import { SwapDirection } from 'src/features/swap/types' import { @@ -23,14 +23,13 @@ export function useSwapQuote( toTokenId: TokenId ) { const chainId = useChainId() + const fromToken = getTokenById(fromTokenId) + const toToken = getTokenById(toTokenId) + const debouncedAmount = useDebounce(amount, 0) - const debouncedAmount = useDebounce(amount, 350) - - const { isLoading, isError, error, data, refetch } = useQuery( + const { isLoading, isError, error, data, refetch } = useQuery( ['useSwapQuote', debouncedAmount, fromTokenId, toTokenId, direction], - async () => { - const fromToken = Tokens[fromTokenId] - const toToken = Tokens[toTokenId] + async (): Promise => { const isSwapIn = direction === 'in' const amountWei = parseInputExchangeAmount(amount, isSwapIn ? fromTokenId : toTokenId) const amountWeiBN = ethers.BigNumber.from(amountWei) @@ -41,12 +40,9 @@ export function useSwapQuote( const toTokenAddr = getTokenAddress(toTokenId, chainId) const mento = await getMentoSdk(chainId) - let quoteWei: string - if (isSwapIn) { - quoteWei = (await mento.getAmountOut(fromTokenAddr, toTokenAddr, amountWeiBN)).toString() - } else { - quoteWei = (await mento.getAmountIn(fromTokenAddr, toTokenAddr, amountWeiBN)).toString() - } + const quoteWei = isSwapIn + ? (await mento.getAmountOut(fromTokenAddr, toTokenAddr, amountWeiBN)).toString() + : (await mento.getAmountIn(fromTokenAddr, toTokenAddr, amountWeiBN)).toString() const quote = fromWei(quoteWei, quoteDecimals) const rateIn = calcExchangeRate(amountWei, amountDecimals, quoteWei, quoteDecimals) @@ -67,10 +63,14 @@ export function useSwapQuote( useEffect(() => { if (error) { - toast.error('Unable to fetch swap out amount') + const toastError = getToastErrorMessage(error.message, { + fromTokenSymbol: fromToken.symbol, + toTokenSymbol: toToken.symbol, + }) + toast.error(toastError) logger.error(error) } - }, [error]) + }, [error, fromToken.symbol, toToken.symbol]) return { isLoading, @@ -82,3 +82,40 @@ export function useSwapQuote( refetch, } } + +function getToastErrorMessage( + swapErrorMessage: string, + { fromTokenSymbol, toTokenSymbol }: IGetToastErrorOptions = {} +): string { + switch (true) { + case swapErrorMessage.includes(`overflow x1y1`): + return 'Swap out amount is too large' + case swapErrorMessage.includes(`can't create fixidity number larger than`): + return 'Swap in amount is too large' + case swapErrorMessage.includes(`no valid median`): + case swapErrorMessage.includes(`Trading is suspended for this reference rate`): + return ( + 'Trading temporarily paused. ' + + `Unable to determine accurate ${fromTokenSymbol} to ${toTokenSymbol} exchange rate now. ` + + 'Please try again later.' + ) + default: + return 'Unable to fetch swap amount' + } +} + +interface IGetToastErrorOptions { + fromTokenSymbol?: string + toTokenSymbol?: string +} + +interface ISwapError { + message: string +} + +interface ISwapData { + amountWei: string + quoteWei: string + quote: string + rate: string +} diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts index 80a4b9a..4972adb 100644 --- a/src/utils/debounce.ts +++ b/src/utils/debounce.ts @@ -16,3 +16,19 @@ export function useDebounce(value: T, delayMs = 500): T { return debouncedValue } + +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => Promise> { + let timeout: NodeJS.Timeout | null = null + + return (...args: Parameters) => { + return new Promise((resolve) => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + resolve(func(...args)) + }, wait) + }) + } +} diff --git a/src/utils/string.ts b/src/utils/string.ts index 8e31149..ad9bb7f 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -5,4 +5,9 @@ export function toTitleCase(str: string) { } export const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group + export const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string + +export const truncateTextByLength = (length: number, text: string): string => { + return text.length > length ? text.slice(0, length) + '...' : text +} From deb242532b54c95951253b9263ef4bedb9654c9c Mon Sep 17 00:00:00 2001 From: Andrew718PLTS Date: Mon, 3 Feb 2025 13:41:40 +0100 Subject: [PATCH 2/4] Fix: Button state's flashing, button disabling when there's quote, and error state --- src/components/buttons/3DButton.tsx | 18 +++--- src/features/swap/SwapForm.tsx | 92 ++++++++++++--------------- src/features/swap/types.ts | 12 ++++ src/features/swap/useFormValidator.ts | 35 +++++----- 4 files changed, 78 insertions(+), 79 deletions(-) diff --git a/src/components/buttons/3DButton.tsx b/src/components/buttons/3DButton.tsx index 789f425..4750e4d 100644 --- a/src/components/buttons/3DButton.tsx +++ b/src/components/buttons/3DButton.tsx @@ -6,7 +6,7 @@ type BaseButtonProps = { isError?: boolean isFullWidth?: boolean isDisabled?: boolean - isAccountReady?: boolean + isWalletConnected?: boolean } export const Button3D = ({ @@ -16,7 +16,7 @@ export const Button3D = ({ isError, isFullWidth, isDisabled, - isAccountReady, + isWalletConnected, }: PropsWithChildren) => { return (