From 1578268f75a99207b0b1f5366d71bfc2720a1238 Mon Sep 17 00:00:00 2001 From: Ji Young Lee <641712+jiyounglee@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:42:54 +1100 Subject: [PATCH] [NO CHANGELOG] [Add Tokens Widget] feat: Search on tokens menu drawer (#2378) --- .../add-tokens/components/TokenDrawerMenu.tsx | 273 ++++++++++++++++++ .../src/widgets/add-tokens/utils/animation.ts | 13 + .../src/widgets/add-tokens/utils/config.ts | 2 + .../widgets/add-tokens/views/AddTokens.tsx | 198 +------------ 4 files changed, 302 insertions(+), 184 deletions(-) create mode 100644 packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx create mode 100644 packages/checkout/widgets-lib/src/widgets/add-tokens/utils/animation.ts diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx new file mode 100644 index 0000000000..3f0ba1f8d5 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/components/TokenDrawerMenu.tsx @@ -0,0 +1,273 @@ +import { Checkout, TokenFilterTypes, TokenInfo } from '@imtbl/checkout-sdk'; +import { + Box, + ButtCon, + Drawer, + FramedImage, + MenuItem, + SmartClone, + TextInput, + VerticalMenu, +} from '@biom3/react'; +import { + type MouseEventHandler, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { Environment } from '@imtbl/config'; +import type { StrongCheckoutWidgetsConfig } from '../../../lib/withDefaultWidgetConfig'; +import { + AddTokensActions, + AddTokensContext, +} from '../context/AddTokensContext'; +import { useError } from '../hooks/useError'; +import { + getDefaultTokenImage, + getTokenImageByAddress, + isNativeToken, +} from '../../../lib/utils'; +import { + useAnalytics, + UserJourney, +} from '../../../context/analytics-provider/SegmentAnalyticsProvider'; +import { getL2ChainId } from '../../../lib'; +import { AddTokensErrorTypes } from '../types'; +import { TokenImage } from '../../../components/TokenImage/TokenImage'; +import { TOKEN_PRIORITY_ORDER } from '../utils/config'; +import { PULSE_SHADOW } from '../utils/animation'; + +export interface TokenDrawerMenuProps { + checkout: Checkout; + config: StrongCheckoutWidgetsConfig; + toTokenAddress?: string; +} + +export function TokenDrawerMenu({ + checkout, + config, + toTokenAddress, +}: TokenDrawerMenuProps) { + const { + addTokensState: { tokens, selectedToken }, + addTokensDispatch, + } = useContext(AddTokensContext); + const { showErrorHandover } = useError(config.environment); + const [visible, setVisible] = useState(false); + const [allowedTokens, setAllowedTokens] = useState([]); + const [searchValue, setSearchValue] = useState(''); + const defaultTokenImage = getDefaultTokenImage( + checkout?.config.environment, + config.theme, + ); + const { track } = useAnalytics(); + + const setSelectedToken = (token: TokenInfo | undefined) => { + track({ + userJourney: UserJourney.ADD_TOKENS, + screen: 'InputScreen', + control: 'TokensMenu', + controlType: 'MenuItem', + extras: { + tokenAddress: token?.address, + }, + }); + + addTokensDispatch({ + payload: { + type: AddTokensActions.SET_SELECTED_TOKEN, + selectedToken: token, + }, + }); + }; + + const handleTokenChange = useCallback((token: TokenInfo) => { + setSelectedToken(token); + setVisible(false); + setSearchValue(''); + }, []); + + const isSelected = useCallback( + (token: TokenInfo) => token.address === selectedToken?.address, + [selectedToken], + ); + + const tokenChoiceOptions = useMemo( + () => allowedTokens.filter((token) => { + if (!searchValue) return true; + return token.symbol.toLowerCase().startsWith(searchValue.toLowerCase()); + }), + [tokens, handleTokenChange, isSelected, defaultTokenImage, searchValue], + ); + + const handleTokenIconClick = useCallback< + MouseEventHandler + >(() => { + setVisible(!visible); + }, [visible]); + + const handleDrawerClose = useCallback(() => { + setVisible(false); + setSearchValue(''); + }, [setVisible, setSearchValue]); + + useEffect(() => { + if (!checkout) return; + + (async () => { + try { + const tokenResponse = await checkout.getTokenAllowList({ + type: TokenFilterTypes.SWAP, + chainId: getL2ChainId(checkout.config), + }); + + if (tokenResponse?.tokens.length > 0) { + const updatedTokens = tokenResponse.tokens.map((token) => { + if (isNativeToken(token.address)) { + return { + ...token, + icon: getTokenImageByAddress( + checkout.config.environment as Environment, + token.symbol, + ), + }; + } + return token; + }); + updatedTokens.sort((a, b) => { + const aIndex = TOKEN_PRIORITY_ORDER.findIndex((token) => token === a.symbol); + const bIndex = TOKEN_PRIORITY_ORDER.findIndex((token) => token === b.symbol); + // If both tokens are not in the priority list, sort by symbol + if (aIndex === -1 && bIndex === -1) { + return a.symbol.localeCompare(b.symbol); + } + // If only one token is in the priority list, sort it first + if (aIndex === -1) { + return 1; + } + if (bIndex === -1) { + return -1; + } + // If both tokens are in the priority list, sort by index + return aIndex < bIndex ? -1 : 1; + }); + + setAllowedTokens(updatedTokens); + + if (toTokenAddress) { + const preselectedToken = updatedTokens.find( + (token) => token.address?.toLowerCase() === toTokenAddress.toLowerCase(), + ); + + if (preselectedToken) { + setSelectedToken(preselectedToken); + } + } + + addTokensDispatch({ + payload: { + type: AddTokensActions.SET_ALLOWED_TOKENS, + allowedTokens: tokenResponse.tokens, + }, + }); + } + } catch (error) { + showErrorHandover(AddTokensErrorTypes.SERVICE_BREAKDOWN, { error }); + } + })(); + }, [checkout, toTokenAddress]); + + return ( + + + {selectedToken ? ( + } + > + + )} + padded + emphasized + circularFrame + sx={{ + cursor: 'pointer', + mb: 'base.spacing.x1', + // eslint-disable-next-line @typescript-eslint/naming-convention + '&:hover': { + boxShadow: ({ base }) => `0 0 0 ${base.border.size[200]} ${base.color.text.body.primary}`, + }, + }} + /> + + ) : ( + + + + )} + + + { + setSearchValue(event.target.value); + }} + > + + + + {tokenChoiceOptions.length > 0 + && tokenChoiceOptions.map((token) => ( + handleTokenChange(token)} + selected={isSelected(token)} + emphasized + > + + )} + emphasized={false} + /> + {token.symbol} + {token.symbol !== token.name && ( + {token.name} + )} + + ))} + + + + ); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/animation.ts b/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/animation.ts new file mode 100644 index 0000000000..161fc4f066 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/animation.ts @@ -0,0 +1,13 @@ +import { keyframes } from '@emotion/react'; + +export const PULSE_SHADOW = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 1); + } + 50% { + box-shadow: 0 0 10px 3px rgba(54, 210, 227, 0.1); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } +`; diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts b/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts index e94778c8bd..8bc0f58d9e 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/utils/config.ts @@ -9,3 +9,5 @@ export const FIXED_HANDOVER_DURATION = 2000; export const APPROVE_TXN_ANIMATION = '/access_coins.riv'; export const EXECUTE_TXN_ANIMATION = '/swapping_coins.riv'; + +export const TOKEN_PRIORITY_ORDER = ['IMX', 'USDC', 'ETH']; diff --git a/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx b/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx index b222a1d325..edae5b7176 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-tokens/views/AddTokens.tsx @@ -1,14 +1,11 @@ import { Body, - Box, ButtCon, Button, FramedIcon, - FramedImage, HeroFormControl, HeroTextInput, MenuItem, - OverflowDrawerMenu, Stack, } from '@biom3/react'; import debounce from 'lodash.debounce'; @@ -23,7 +20,6 @@ import { } from '@imtbl/checkout-sdk'; import { type ChangeEvent, - useCallback, useContext, useEffect, useMemo, @@ -31,9 +27,7 @@ import { useState, } from 'react'; import { Web3Provider } from '@ethersproject/providers'; -import { Environment } from '@imtbl/config'; import { useTranslation } from 'react-i18next'; -import { keyframes } from '@emotion/react'; import { SimpleLayout } from '../../../components/SimpleLayout/SimpleLayout'; import { EventTargetContext } from '../../../context/event-target-context/EventTargetContext'; import { @@ -43,9 +37,10 @@ import { import { getL2ChainId } from '../../../lib'; import { orchestrationEvents } from '../../../lib/orchestrationEvents'; import { OptionsDrawer } from '../components/OptionsDrawer'; -import { AddTokensActions, AddTokensContext } from '../context/AddTokensContext'; -import { TokenImage } from '../../../components/TokenImage/TokenImage'; -import { getDefaultTokenImage, getTokenImageByAddress, isNativeToken } from '../../../lib/utils'; +import { + AddTokensActions, + AddTokensContext, +} from '../context/AddTokensContext'; import type { StrongCheckoutWidgetsConfig } from '../../../lib/withDefaultWidgetConfig'; import { useRoutes } from '../hooks/useRoutes'; import { SQUID_NATIVE_TOKEN } from '../utils/config'; @@ -68,9 +63,9 @@ import { validateToAmount } from '../functions/amountValidation'; import { OnboardingDrawer } from '../components/OnboardingDrawer'; import { useError } from '../hooks/useError'; import { SquidFooter } from '../components/SquidFooter'; -import { - getFormattedNumberWithDecimalPlaces, -} from '../functions/getFormattedNumber'; +import { getFormattedNumberWithDecimalPlaces } from '../functions/getFormattedNumber'; +import { TokenDrawerMenu } from '../components/TokenDrawerMenu'; +import { PULSE_SHADOW } from '../utils/animation'; interface AddTokensProps { checkout: Checkout; @@ -132,7 +127,6 @@ export function AddTokens({ const [onRampAllowedTokens, setOnRampAllowedTokens] = useState( [], ); - const [allowedTokens, setAllowedTokens] = useState([]); const [inputValue, setInputValue] = useState( selectedAmount || toAmount || '', ); @@ -166,25 +160,6 @@ export function AddTokens({ [], ); - const setSelectedToken = (token: TokenInfo | undefined) => { - track({ - userJourney: UserJourney.ADD_TOKENS, - screen: 'InputScreen', - control: 'TokensMenu', - controlType: 'MenuItem', - extras: { - tokenAddress: token?.address, - }, - }); - - addTokensDispatch({ - payload: { - type: AddTokensActions.SET_SELECTED_TOKEN, - selectedToken: token, - }, - }); - }; - const setSelectedRouteData = (route: RouteData | undefined) => { if (route) { track({ @@ -320,57 +295,6 @@ export function AddTokens({ } }, [routes]); - useEffect(() => { - if (!checkout) return; - - const fetchTokens = async () => { - try { - const tokenResponse = await checkout.getTokenAllowList({ - type: TokenFilterTypes.SWAP, - chainId: getL2ChainId(checkout.config), - }); - - if (tokenResponse?.tokens.length > 0) { - const updatedTokens = tokenResponse.tokens.map((token) => { - if (isNativeToken(token.address)) { - return { - ...token, - icon: getTokenImageByAddress( - checkout.config.environment as Environment, - token.symbol, - ), - }; - } - return token; - }); - - setAllowedTokens(updatedTokens); - - if (toTokenAddress) { - const preselectedToken = updatedTokens.find( - (token) => token.address?.toLowerCase() === toTokenAddress.toLowerCase(), - ); - - if (preselectedToken) { - setSelectedToken(preselectedToken); - } - } - - addTokensDispatch({ - payload: { - type: AddTokensActions.SET_ALLOWED_TOKENS, - allowedTokens: tokenResponse.tokens, - }, - }); - } - } catch (error) { - showErrorHandover(AddTokensErrorTypes.SERVICE_BREAKDOWN, { error }); - } - }; - - fetchTokens(); - }, [checkout, toTokenAddress]); - useEffect(() => { if (!checkout) return; @@ -391,15 +315,6 @@ export function AddTokens({ fetchOnRampTokens(); }, [checkout]); - const isSelected = useCallback( - (token: TokenInfo) => token.address === selectedToken, - [selectedToken], - ); - - const handleTokenChange = useCallback((token: TokenInfo) => { - setSelectedToken(token); - }, []); - const sendRequestOnRampEvent = () => { track({ userJourney: UserJourney.ADD_TOKENS, @@ -499,45 +414,6 @@ export function AddTokens({ }, [selectedToken, onRampAllowedTokens, showOnrampOption]); const showInitialEmptyState = !selectedToken; - const defaultTokenImage = getDefaultTokenImage( - checkout?.config.environment, - config.theme, - ); - - useEffect(() => { - if (inputRef.current && !showInitialEmptyState) { - inputRef.current.focus(); - } - }, [showInitialEmptyState]); - - const tokenChoiceOptions = useMemo( - () => allowedTokens.map((token) => ( - handleTokenChange(token)} - selected={isSelected(token)} - emphasized - > - - )} - emphasized={false} - /> - {token.symbol} - {token.symbol !== token.name && ( - {token.name} - )} - - )), - [allowedTokens, handleTokenChange, isSelected, defaultTokenImage], - ); const shouldShowBackButton = showBackButton && onBackButtonClick; const routeInputsReady = !!selectedToken @@ -575,18 +451,6 @@ export function AddTokens({ ); }; - const pulseShadow = keyframes` - 0% { - box-shadow: 0 0 0 0 rgba(255, 255, 255, 1); - } - 50% { - box-shadow: 0 0 10px 3px rgba(54, 210, 227, 0.1); - } - 100% { - box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); - } - `; - return ( - - - - ) : ( - - )} - padded - emphasized - circularFrame - sx={{ - cursor: 'pointer', - mb: 'base.spacing.x1', - // eslint-disable-next-line @typescript-eslint/naming-convention - '&:hover': { - boxShadow: ({ base }) => `0 0 0 ${base.border.size[200]} ${base.color.text.body.primary}`, - }, - }} - /> - )} - drawerSize="full" - headerBarTitle="Add Token" - drawerCloseIcon="ChevronExpand" - > - {tokenChoiceOptions} - - + {showInitialEmptyState ? ( Add Token ) : ( @@ -717,7 +547,7 @@ export function AddTokens({ sx={selectedToken && !fromAddress && inputValue - ? { animation: `${pulseShadow} 2s infinite ease-in-out` } + ? { animation: `${PULSE_SHADOW} 2s infinite ease-in-out` } : {}} label="Send from" providerInfo={{ @@ -767,7 +597,7 @@ export function AddTokens({ && fromAddress && !toAddress && inputValue - ? { animation: `${pulseShadow} 2s infinite ease-in-out` } + ? { animation: `${PULSE_SHADOW} 2s infinite ease-in-out` } : {}} label="Deliver to" providerInfo={{