From a1aa0afed98f164488a3caffaaff2fd060ab8b3d Mon Sep 17 00:00:00 2001 From: mikasackermn Date: Sat, 17 Aug 2024 06:34:57 +0000 Subject: [PATCH] feat: add functionality to support custom tokens --- widget/embedded/src/components/AppRoutes.tsx | 30 ++ .../BlockchainList/BlockchainList.styles.ts | 4 +- .../BlockchainList/BlockchainList.tsx | 43 +- .../BlockchainList/BlockchainList.types.ts | 1 + .../BlockchainSelectorButton.styles.ts | 49 +++ .../BlockchainSelectorButton.tsx | 47 ++ .../BlockchainSelectorButton.types.ts | 11 + .../BlockchainSelectorButton/index.ts | 1 + .../ConfirmWallets.helpers.ts | 10 - .../CustomDestination/CustomDestination.tsx | 8 +- .../CustomTokenModal.helpers.ts | 19 + .../CustomTokenModal.styles.ts | 57 +++ .../CustomTokenModal/CustomTokenModal.tsx | 92 ++++ .../CustomTokenModal.types.ts | 9 + .../src/components/CustomTokenModal/index.ts | 1 + .../components/TokenList/TokenList.styles.ts | 1 + .../src/components/TokenList/TokenList.tsx | 127 +++--- .../components/TokenList/TokenList.types.ts | 7 +- widget/embedded/src/constants/customTokens.ts | 6 + .../src/constants/navigationRoutes.ts | 2 + widget/embedded/src/constants/searchParams.ts | 3 + .../src/containers/Settings/Lists.tsx | 26 +- .../embedded/src/hooks/useFetchCustomToken.ts | 114 +++++ widget/embedded/src/hooks/useSwapInput.ts | 11 +- .../useSyncUrlAndStore/useSyncUrlAndStore.ts | 7 +- widget/embedded/src/hooks/useTheme.ts | 6 +- .../embedded/src/pages/AddCustomTokenPage.tsx | 210 +++++++++ .../embedded/src/pages/CustomTokensPage.tsx | 184 ++++++++ widget/embedded/src/pages/HistoryPage.tsx | 4 +- .../src/pages/LiquiditySourcePage.tsx | 4 +- .../src/pages/SelectBlockchainPage.tsx | 23 +- .../src/pages/SelectSwapItemsPage.tsx | 2 +- widget/embedded/src/store/app.ts | 1 + widget/embedded/src/store/quote.ts | 14 +- widget/embedded/src/store/slices/data.ts | 69 ++- widget/embedded/src/store/slices/settings.ts | 37 +- widget/embedded/src/store/utils/data.ts | 27 +- widget/embedded/src/types/config.ts | 5 +- widget/embedded/src/utils/meta.ts | 13 +- widget/embedded/src/utils/quote.ts | 19 +- widget/embedded/src/utils/settings.ts | 14 +- widget/embedded/src/utils/swap.ts | 14 +- .../src/components/Button/Button.styles.tsx | 6 +- .../src/components/Divider/Divider.styles.ts | 15 + .../components/NotFound/NotFound.styles.ts | 8 +- .../ui/src/components/NotFound/NotFound.tsx | 9 +- .../src/components/NotFound/NotFound.types.ts | 5 +- .../components/TextField/TextField.styles.ts | 3 + .../ui/src/containers/SwapInput/SwapInput.tsx | 38 +- widget/ui/src/icons/CustomTokensZeroState.tsx | 262 +++++++++++ .../src/icons/CustomTokensZeroStateDark.tsx | 415 ++++++++++++++++++ widget/ui/src/icons/Target.tsx | 24 + widget/ui/src/icons/index.ts | 5 +- widget/ui/src/theme.ts | 1 + .../resources/fill/CustomTokensZeroState.svg | 97 ++++ .../fill/CustomTokensZeroStateDark.svg | 112 +++++ widget/ui/svgs/resources/fill/Target.svg | 5 + 57 files changed, 2181 insertions(+), 156 deletions(-) create mode 100644 widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.styles.ts create mode 100644 widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.tsx create mode 100644 widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.types.ts create mode 100644 widget/embedded/src/components/BlockchainSelectorButton/index.ts delete mode 100644 widget/embedded/src/components/ConfirmWalletsModal/ConfirmWallets.helpers.ts create mode 100644 widget/embedded/src/components/CustomTokenModal/CustomTokenModal.helpers.ts create mode 100644 widget/embedded/src/components/CustomTokenModal/CustomTokenModal.styles.ts create mode 100644 widget/embedded/src/components/CustomTokenModal/CustomTokenModal.tsx create mode 100644 widget/embedded/src/components/CustomTokenModal/CustomTokenModal.types.ts create mode 100644 widget/embedded/src/components/CustomTokenModal/index.ts create mode 100644 widget/embedded/src/constants/customTokens.ts create mode 100644 widget/embedded/src/hooks/useFetchCustomToken.ts create mode 100644 widget/embedded/src/pages/AddCustomTokenPage.tsx create mode 100644 widget/embedded/src/pages/CustomTokensPage.tsx create mode 100644 widget/ui/src/icons/CustomTokensZeroState.tsx create mode 100644 widget/ui/src/icons/CustomTokensZeroStateDark.tsx create mode 100644 widget/ui/src/icons/Target.tsx create mode 100644 widget/ui/svgs/resources/fill/CustomTokensZeroState.svg create mode 100644 widget/ui/svgs/resources/fill/CustomTokensZeroStateDark.svg create mode 100644 widget/ui/svgs/resources/fill/Target.svg diff --git a/widget/embedded/src/components/AppRoutes.tsx b/widget/embedded/src/components/AppRoutes.tsx index d3b6e98b5f..9d9c92356b 100644 --- a/widget/embedded/src/components/AppRoutes.tsx +++ b/widget/embedded/src/components/AppRoutes.tsx @@ -4,7 +4,9 @@ import { useRoutes } from 'react-router-dom'; import { navigationRoutes } from '../constants/navigationRoutes'; import { useSyncStoresWithConfig } from '../hooks/useSyncStoresWithConfig'; import { useSyncUrlAndStore } from '../hooks/useSyncUrlAndStore'; +import { AddCustomTokenPage } from '../pages/AddCustomTokenPage'; import { ConfirmSwapPage } from '../pages/ConfirmSwapPage'; +import { CustomTokensPage } from '../pages/CustomTokensPage'; import { HistoryPage } from '../pages/HistoryPage'; import { Home } from '../pages/Home'; import { LanguagePage } from '../pages/LanguagePage'; @@ -80,8 +82,36 @@ export function AppRoutes() { path: navigationRoutes.bridges, element: , }, + { + path: navigationRoutes.customTokens, + children: [ + { + index: true, + element: , + }, + { + path: navigationRoutes.addCustomTokens, + children: [ + { + index: true, + element: , + }, + { + path: navigationRoutes.blockchains, + element: ( + + ), + }, + ], + }, + ], + }, ], }, + { path: navigationRoutes.swaps, children: [ diff --git a/widget/embedded/src/components/BlockchainList/BlockchainList.styles.ts b/widget/embedded/src/components/BlockchainList/BlockchainList.styles.ts index be3cbfeb05..de825faa8e 100644 --- a/widget/embedded/src/components/BlockchainList/BlockchainList.styles.ts +++ b/widget/embedded/src/components/BlockchainList/BlockchainList.styles.ts @@ -2,10 +2,12 @@ import { ImageContainer, styled } from '@rango-dev/ui'; import { ScrollableArea } from '../Layout'; -export const Container = styled('div', { +export const BlockchainListContainer = styled('div', { display: 'flex', flexDirection: 'column', overflow: 'hidden', + height: '100%', + justifyContent: 'center', }); export const List = styled(ScrollableArea, { diff --git a/widget/embedded/src/components/BlockchainList/BlockchainList.tsx b/widget/embedded/src/components/BlockchainList/BlockchainList.tsx index cc8b17b1fe..a8453d3a4a 100644 --- a/widget/embedded/src/components/BlockchainList/BlockchainList.tsx +++ b/widget/embedded/src/components/BlockchainList/BlockchainList.tsx @@ -14,11 +14,17 @@ import React, { useEffect, useState } from 'react'; import { useAppStore } from '../../store/AppStore'; import { filterBlockchains } from './BlockchainList.helpers'; -import { Container, List } from './BlockchainList.styles'; +import { BlockchainListContainer, List } from './BlockchainList.styles'; import { LoadingBlockchainList } from './LoadingBlockchainList'; export function BlockchainList(props: PropTypes) { - const { list, searchedFor, onChange, blockchainCategory } = props; + const { + list, + searchedFor, + onChange, + blockchainCategory, + showTitle = true, + } = props; const [blockchains, setBlockchains] = useState(list); const { fetchStatus } = useAppStore(); @@ -31,13 +37,10 @@ export function BlockchainList(props: PropTypes) { const renderList = () => { if (!blockchains.length && !!searchedFor) { return ( - <> - - - + ); } return ( @@ -61,13 +64,19 @@ export function BlockchainList(props: PropTypes) { }; return ( - - - {i18n.t('Select Blockchain')} - - - {fetchStatus === 'loading' && } - {fetchStatus === 'success' && renderList()} - + <> + {showTitle && ( + <> + + {i18n.t('Select Blockchain')} + + + + )} + + {fetchStatus === 'loading' && } + {fetchStatus === 'success' && renderList()} + + ); } diff --git a/widget/embedded/src/components/BlockchainList/BlockchainList.types.ts b/widget/embedded/src/components/BlockchainList/BlockchainList.types.ts index f06942805c..e5cb8562e8 100644 --- a/widget/embedded/src/components/BlockchainList/BlockchainList.types.ts +++ b/widget/embedded/src/components/BlockchainList/BlockchainList.types.ts @@ -5,4 +5,5 @@ export interface PropTypes { searchedFor: string; blockchainCategory: string; onChange: (blockchain: BlockchainMeta) => void; + showTitle?: boolean; } diff --git a/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.styles.ts b/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.styles.ts new file mode 100644 index 0000000000..132b871aec --- /dev/null +++ b/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.styles.ts @@ -0,0 +1,49 @@ +import { darkTheme, styled } from '@rango-dev/ui'; + +export const InputContainer = styled('div', { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + height: '$40', + padding: '$4 $10', + borderRadius: '$sm', + cursor: 'pointer', + alignItems: 'center', + backgroundColor: '$neutral300', + [`.${darkTheme} &`]: { + backgroundColor: '$neutral400', + }, + + '&:hover': { + backgroundColor: '$secondary100', + [`.${darkTheme} &`]: { + backgroundColor: '$neutral500', + }, + }, + variants: { + disabled: { + true: { + cursor: 'default', + '&:hover': { + borderColor: '$neutral300', + '& svg': { + color: '$neutral700', + }, + }, + }, + }, + }, +}); + +export const Container = styled('div', { + display: 'flex', + flexDirection: 'column', + width: '100%', + '.title_typography': { + textTransform: 'capitalize', + }, +}); + +export const FlexContainer = styled('div', { + display: 'flex', +}); diff --git a/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.tsx b/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.tsx new file mode 100644 index 0000000000..ddd68e056b --- /dev/null +++ b/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.tsx @@ -0,0 +1,47 @@ +import type { PropTypes } from './BlockchainSelectorButton.types'; + +import { ChevronRightIcon, Divider, Image, Typography } from '@rango-dev/ui'; +import React from 'react'; + +import { + Container, + FlexContainer, + InputContainer, +} from './BlockchainSelectorButton.styles'; + +export function BlockchainSelectorButton(props: PropTypes) { + const { onClick, value, title, hasLogo, placeholder, disabled } = props; + + return ( + + + {title} + + + + + {hasLogo && ( + <> + + + + )} + + {value?.name || placeholder} + + + + + + ); +} diff --git a/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.types.ts b/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.types.ts new file mode 100644 index 0000000000..380802a4dc --- /dev/null +++ b/widget/embedded/src/components/BlockchainSelectorButton/BlockchainSelectorButton.types.ts @@ -0,0 +1,11 @@ +export interface PropTypes { + onClick: () => void; + value?: { + name: string; + logo: string; + }; + title: string; + placeholder: string; + hasLogo?: boolean; + disabled?: boolean; +} diff --git a/widget/embedded/src/components/BlockchainSelectorButton/index.ts b/widget/embedded/src/components/BlockchainSelectorButton/index.ts new file mode 100644 index 0000000000..4e3c4b7013 --- /dev/null +++ b/widget/embedded/src/components/BlockchainSelectorButton/index.ts @@ -0,0 +1 @@ +export { BlockchainSelectorButton } from './BlockchainSelectorButton'; diff --git a/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWallets.helpers.ts b/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWallets.helpers.ts deleted file mode 100644 index d2dd141edd..0000000000 --- a/widget/embedded/src/components/ConfirmWalletsModal/ConfirmWallets.helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { BlockchainMeta } from 'rango-sdk'; - -export function isValidAddress( - chain: BlockchainMeta, - address: string -): boolean { - const regex = chain.addressPatterns; - const valid = regex.filter((r) => new RegExp(r).test(address)).length > 0; - return valid; -} diff --git a/widget/embedded/src/components/CustomDestination/CustomDestination.tsx b/widget/embedded/src/components/CustomDestination/CustomDestination.tsx index d88835b31b..7b2f1df3a5 100644 --- a/widget/embedded/src/components/CustomDestination/CustomDestination.tsx +++ b/widget/embedded/src/components/CustomDestination/CustomDestination.tsx @@ -16,8 +16,10 @@ import React, { useEffect, useRef } from 'react'; import { useAppStore } from '../../store/AppStore'; import { useQuoteStore } from '../../store/quote'; -import { getBlockchainDisplayNameFor } from '../../utils/meta'; -import { isValidAddress } from '../ConfirmWalletsModal/ConfirmWallets.helpers'; +import { + getBlockchainDisplayNameFor, + isValidTokenAddress, +} from '../../utils/meta'; import { CustomCollapsible } from '../CustomCollapsible/CustomCollapsible'; import { ExpandedIcon } from '../CustomCollapsible/CustomCollapsible.styles'; @@ -44,7 +46,7 @@ export function CustomDestination(props: PropTypes) { const isFirefox = navigator?.userAgent.includes('Firefox'); const isAddressChecked = open && !!customDestination && blockchain; const isAddressInvalid = - isAddressChecked && !isValidAddress(blockchain, customDestination); + isAddressChecked && !isValidTokenAddress(blockchain, customDestination); const handleClear = () => { setCustomDestination(''); }; diff --git a/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.helpers.ts b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.helpers.ts new file mode 100644 index 0000000000..1d7a4cba3e --- /dev/null +++ b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.helpers.ts @@ -0,0 +1,19 @@ +import type { BlockchainMeta } from 'rango-types'; + +export function generateExplorerLink( + address: string | null, + blockchain: BlockchainMeta +): string { + // We don't have Cosmos explorer url in API. + if (blockchain.type === 'COSMOS') { + return ''; + } + + const explorerLinkPattern = blockchain.info?.addressUrl; + + if (!explorerLinkPattern || !address) { + return ''; + } + + return explorerLinkPattern.replace('{wallet}', address); +} diff --git a/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.styles.ts b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.styles.ts new file mode 100644 index 0000000000..d8bc040646 --- /dev/null +++ b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.styles.ts @@ -0,0 +1,57 @@ +import { darkTheme, styled } from '@rango-dev/ui'; + +export const StyledLink = styled('a', { + textDecoration: 'none', + color: '$colors$neutral700', + [`.${darkTheme} &`]: { + color: '$colors$neutral900', + }, + + '& svg': { + marginLeft: '$4', + color: '$colors$neutral700', + [`.${darkTheme} &`]: { + color: '$colors$neutral900', + }, + }, + + variants: { + hasHover: { + true: { + '&:hover': { + color: '$colors$secondary550', + [`.${darkTheme} &`]: { + color: '$colors$secondary500', + }, + '& svg': { + color: '$colors$secondary550', + [`.${darkTheme} &`]: { + color: '$colors$secondary500', + }, + }, + }, + }, + false: {}, + }, + }, +}); + +export const Container = styled('div', { + display: 'flex', + justifyContent: 'center', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + '& ._blockchain-name, & ._coin-source': { + color: '$colors$neutral600', + [`.${darkTheme} &`]: { + color: '$colors$neutral800', + }, + }, + '& ._coin-source-name, & ._custom-token-description': { + color: '$colors$neutral700', + [`.${darkTheme} &`]: { + color: '$colors$neutral900', + }, + }, +}); diff --git a/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.tsx b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.tsx new file mode 100644 index 0000000000..6419b662fc --- /dev/null +++ b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.tsx @@ -0,0 +1,92 @@ +import type { PropTypes } from './CustomTokenModal.types'; + +import { i18n } from '@lingui/core'; +import { + Button, + Divider, + ExternalLinkIcon, + Image, + Typography, +} from '@rango-dev/ui'; +import React from 'react'; + +import { getContainer } from '../../utils/common'; +import { WatermarkedModal } from '../common/WatermarkedModal'; + +import { generateExplorerLink } from './CustomTokenModal.helpers'; +import { Container, StyledLink } from './CustomTokenModal.styles'; + +export function CustomTokenModal(props: PropTypes) { + const { open, onClose, token, onSubmitClick, blockchain } = props; + + const explorerLink = generateExplorerLink(token.address, blockchain); + + return ( + + + + + {token.name} + + + {blockchain.displayName} + + + + + {!!explorerLink ? ( + + {token.address} + + + ) : ( + {token.address} + )} + + + + + {token.coinSource && ( + + {i18n.t('via')}{' '} + + {token.coinSource} + + + )} + + + + {i18n.t( + `This token doesn't appear on the active token list(s). Make sure this is the token that you want to trade.` + )} + + + + + + + + ); +} diff --git a/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.types.ts b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.types.ts new file mode 100644 index 0000000000..a7f58d9666 --- /dev/null +++ b/widget/embedded/src/components/CustomTokenModal/CustomTokenModal.types.ts @@ -0,0 +1,9 @@ +import type { BlockchainMeta, Token } from 'rango-sdk'; + +export type PropTypes = { + open: boolean; + onClose: () => void; + onSubmitClick: () => void; + token: Token; + blockchain: BlockchainMeta; +}; diff --git a/widget/embedded/src/components/CustomTokenModal/index.ts b/widget/embedded/src/components/CustomTokenModal/index.ts new file mode 100644 index 0000000000..742d179c15 --- /dev/null +++ b/widget/embedded/src/components/CustomTokenModal/index.ts @@ -0,0 +1 @@ +export { CustomTokenModal } from './CustomTokenModal'; diff --git a/widget/embedded/src/components/TokenList/TokenList.styles.ts b/widget/embedded/src/components/TokenList/TokenList.styles.ts index 330a40446f..05fb1576ae 100644 --- a/widget/embedded/src/components/TokenList/TokenList.styles.ts +++ b/widget/embedded/src/components/TokenList/TokenList.styles.ts @@ -53,6 +53,7 @@ export const Container = styled('div', { flexDirection: 'column', flexGrow: 1, overflow: 'hidden', + justifyContent: 'center', }); export const Title = styled('div', { diff --git a/widget/embedded/src/components/TokenList/TokenList.tsx b/widget/embedded/src/components/TokenList/TokenList.tsx index 1c55b9643d..dae2e4ce77 100644 --- a/widget/embedded/src/components/TokenList/TokenList.tsx +++ b/widget/embedded/src/components/TokenList/TokenList.tsx @@ -92,7 +92,14 @@ const renderDesc = (props: RenderDescProps) => { }; export function TokenList(props: PropTypes) { - const { list, searchedFor = '', onChange, selectedBlockchain } = props; + const { + list, + searchedFor = '', + onChange, + selectedBlockchain, + showTitle = true, + action, + } = props; const [tokens, setTokens] = useState(list); const fetchStatus = useAppStore().fetchStatus; @@ -118,24 +125,48 @@ export function TokenList(props: PropTypes) { setTokens(list.slice(0, PAGE_SIZE)); }, [list.length, selectedBlockchain, balanceKey]); - const renderList = () => { - if (!tokens.length && !!searchedFor) { + const endRenderer = (token: Token) => { + const tokenBalance = formatBalance(getBalanceFor(token)); + + if (action) { + return action(token); + } + if (loadingWallet) { return ( - <> - - - + + + + + ); } + + return ( + tokenBalance && ( + + + {tokenBalance.amount} + +
+ {tokenBalance.usdValue && ( + + {`$${tokenBalance.usdValue}`} + + )} + + ) + ); + }; + + const renderList = () => { return ( { const token = tokens[index]; - const tokenBalance = formatBalance(getBalanceFor(token)); const address = token.address || ''; const blockchain = blockchains.find( (blockchain) => blockchain.name === token.blockchain @@ -172,15 +203,16 @@ export function TokenList(props: PropTypes) { key={`${token.symbol}${token.address}`} id={`${token.symbol}${token.address}`} hasDivider - onClick={() => onChange(tokens[index])} + onClick={() => onChange && onChange(tokens[index])} start={ - {isTokenPinned(token, props.type) && ( - - - - )} + {props.type !== 'custom-token' && + isTokenPinned(token, props.type) && ( + + + + )} } title={ @@ -219,32 +251,7 @@ export function TokenList(props: PropTypes) { }) : token.name || undefined } - end={ - loadingWallet ? ( - - - - - - ) : ( - tokenBalance && ( - - - {tokenBalance.amount} - -
- {tokenBalance.usdValue && ( - - {`$${tokenBalance.usdValue}`} - - )} - - ) - ) - } + end={endRenderer(token)} />
); @@ -256,13 +263,31 @@ export function TokenList(props: PropTypes) { }; return ( - - - {i18n.t('Select Token')} - - - {fetchStatus === 'loading' && } - {fetchStatus === 'success' && {renderList()}} - + <> + {showTitle && ( + <> + + {i18n.t('Select Token')} + + + + )} + + + + {fetchStatus === 'loading' && } + {fetchStatus === 'success' && + (tokens.length ? ( + {renderList()} + ) : ( + !!searchedFor && ( + + ) + ))} + + ); } diff --git a/widget/embedded/src/components/TokenList/TokenList.types.ts b/widget/embedded/src/components/TokenList/TokenList.types.ts index 8694f34887..6c623c4569 100644 --- a/widget/embedded/src/components/TokenList/TokenList.types.ts +++ b/widget/embedded/src/components/TokenList/TokenList.types.ts @@ -1,11 +1,14 @@ import type { Token } from 'rango-sdk'; +import type { ReactElement } from 'react'; export interface PropTypes { list: Token[]; searchedFor?: string; - onChange: (token: Token) => void; + onChange?: (token: Token) => void; selectedBlockchain?: string; - type: 'source' | 'destination'; + type: 'source' | 'destination' | 'custom-token'; + action?: (token: Token) => ReactElement; + showTitle?: boolean; } export interface LoadingTokenListProps { diff --git a/widget/embedded/src/constants/customTokens.ts b/widget/embedded/src/constants/customTokens.ts new file mode 100644 index 0000000000..d560a8af38 --- /dev/null +++ b/widget/embedded/src/constants/customTokens.ts @@ -0,0 +1,6 @@ +import { TransactionType } from 'rango-types'; + +export const ACTIVE_BLOCKCHAINS_FOR_CUSTOM_TOKENS: string[] = [ + TransactionType.EVM, + TransactionType.SOLANA, +]; diff --git a/widget/embedded/src/constants/navigationRoutes.ts b/widget/embedded/src/constants/navigationRoutes.ts index 0647c0fe9f..8ae33b60d9 100644 --- a/widget/embedded/src/constants/navigationRoutes.ts +++ b/widget/embedded/src/constants/navigationRoutes.ts @@ -4,6 +4,8 @@ export const navigationRoutes = { toSwap: 'to-swap', blockchains: 'blockchains', settings: 'settings', + customTokens: 'custom-tokens', + addCustomTokens: 'add-custom-tokens', liquiditySources: 'liquidity-sources', bridges: 'bridges', exchanges: 'exchanges', diff --git a/widget/embedded/src/constants/searchParams.ts b/widget/embedded/src/constants/searchParams.ts index 44aca06d71..ae8328fe7c 100644 --- a/widget/embedded/src/constants/searchParams.ts +++ b/widget/embedded/src/constants/searchParams.ts @@ -5,6 +5,9 @@ export enum SearchParams { TO_TOKEN = 'toToken', FROM_AMOUNT = 'fromAmount', AUTO_CONNECT = 'autoConnect', + + // This is for custom tokens + BLOCKCHAIN = 'blockchain', /* * dApps can transmit liquidity sources as a search parameter, * and these take precedence over widget configurations diff --git a/widget/embedded/src/containers/Settings/Lists.tsx b/widget/embedded/src/containers/Settings/Lists.tsx index 7a67dccc46..29a33ea46d 100644 --- a/widget/embedded/src/containers/Settings/Lists.tsx +++ b/widget/embedded/src/containers/Settings/Lists.tsx @@ -20,6 +20,7 @@ import { styled, Switch, Tabs, + TargetIcon, Tooltip, Typography, } from '@rango-dev/ui'; @@ -97,9 +98,11 @@ export function SettingsLists() { const { config: { features }, } = useAppStore(); + const customTokens = useAppStore().customTokens(); const isThemeHidden = isFeatureHidden('theme', features); const isLiquidityHidden = isFeatureHidden('liquiditySource', features); const isLanguageHidden = isFeatureHidden('language', features); + const isCustomTokensHidden = isFeatureHidden('customTokens', features); const infiniteApprove = useAppStore().infiniteApprove; const toggleInfiniteApprove = useAppStore().toggleInfiniteApprove; @@ -185,6 +188,25 @@ export function SettingsLists() { onClick: () => navigate(navigationRoutes.exchanges), }; + const customTokensItem = { + id: 'custom-tokens-item', + title: ( + + {i18n.t('Custom Tokens')} + + ), + end: ( + <> + + {`${customTokens.length}`} + + + + + ), + start: , + onClick: () => navigate(navigationRoutes.customTokens), + }; const languageItem = { id: 'language-item', title: ( @@ -261,7 +283,9 @@ export function SettingsLists() { const settingItems: ListPropTypes['items'] = isLiquidityHidden ? [] : [bridgeItem, exchangeItem]; - + if (!isCustomTokensHidden) { + settingItems.push(customTokensItem); + } if (!isLanguageHidden) { settingItems.push(languageItem); } diff --git a/widget/embedded/src/hooks/useFetchCustomToken.ts b/widget/embedded/src/hooks/useFetchCustomToken.ts new file mode 100644 index 0000000000..70858ed0e2 --- /dev/null +++ b/widget/embedded/src/hooks/useFetchCustomToken.ts @@ -0,0 +1,114 @@ +import type { Token } from 'rango-sdk'; + +import { i18n } from '@lingui/core'; +import { useState } from 'react'; + +import { useAppStore } from '../store/AppStore'; +import { getConfig } from '../utils/configs'; + +type ErrorType = { + title: string; + message: string; +}; + +interface UseFetchCustomToken { + fetchCustomToken: ({ + blockchain, + tokenAddress, + }: { + blockchain: string; + tokenAddress: string; + }) => Promise; + loading: boolean; + error?: ErrorType; +} + +export function useFetchCustomToken(): UseFetchCustomToken { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const { findToken } = useAppStore(); + const customTokens = useAppStore().customTokens(); + + function produceErrorMessage( + type: 'duplicated' | 'not-found' | 'token-exist', + blockchain?: string + ) { + switch (type) { + case 'duplicated': + return { + title: i18n.t('Duplicate Token'), + message: i18n.t( + 'The address you entered is duplicate, please enter a new address.' + ), + }; + case 'token-exist': + return { + title: i18n.t('Token Already Exists'), + message: i18n.t( + `There's no need to add this token again because it already exists and is supported by us.` + ), + }; + case 'not-found': + return { + title: i18n.t('Token Not Found'), + message: i18n.t( + `Sorry, no token was found on ${blockchain} blockchain with the provided address. please make sure you have entered the right token address.` + ), + }; + } + } + + const fetchCustomToken: UseFetchCustomToken['fetchCustomToken'] = async ({ + blockchain, + tokenAddress, + }) => { + setLoading(true); + try { + // Check for duplicate token in customTokens + const isDuplicate = customTokens.some( + (token) => token.address?.toLowerCase() === tokenAddress.toLowerCase() + ); + if (isDuplicate) { + const errorMessage = produceErrorMessage('duplicated'); + setError(errorMessage); + return undefined; + } + + const res = await fetch( + `${getConfig('BASE_URL')}/meta/token?apiKey=${getConfig( + 'API_KEY' + )}&blockchain=${blockchain}&address=${tokenAddress}` + ); + const contentType = res.headers.get('content-type'); + const response = + contentType && + contentType.includes('application/json') && + (await res.json()); + if (!response || response.error) { + const errorMessage = produceErrorMessage('not-found', blockchain); + setError(errorMessage); + return undefined; + } + + // Check if token is already in the system + const isTokenFound = findToken({ + blockchain: response.blockchain, + address: response.address, + symbol: response.symbol, + }); + if (isTokenFound) { + const errorMessage = produceErrorMessage('token-exist'); + setError(errorMessage); + return undefined; + } + + return response; + } catch (error: any) { + setError(undefined); + throw new Error(error.message); + } finally { + setLoading(false); + } + }; + return { fetchCustomToken, loading, error }; +} diff --git a/widget/embedded/src/hooks/useSwapInput.ts b/widget/embedded/src/hooks/useSwapInput.ts index 52bab2276d..5bca482537 100644 --- a/widget/embedded/src/hooks/useSwapInput.ts +++ b/widget/embedded/src/hooks/useSwapInput.ts @@ -11,6 +11,8 @@ import { isPositiveNumber } from '../utils/numbers'; import { generateQuoteWarnings, getDefaultQuote, + getQuoteFromTokenUsdPrice, + getQuoteToTokenUsdPrice, sortQuotesBy, } from '../utils/quote'; import { isFeatureEnabled } from '../utils/settings'; @@ -146,12 +148,15 @@ export function useSwapInput({ requestId: quote?.requestId || '', swaps: quote?.swaps, }); - + const outputUsdValue = + getQuoteToTokenUsdPrice(quote) || toToken?.usdPrice; + const inputUsdValue = + getQuoteFromTokenUsdPrice(quote) || fromToken?.usdPrice; const quoteWarning = quote && generateQuoteWarnings(quote, { - fromToken, - toToken, + fromToken: { ...fromToken, usdPrice: inputUsdValue }, + toToken: { ...toToken, usdPrice: outputUsdValue }, userSlippage, findToken, }); diff --git a/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts b/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts index de14e153dc..a41193dd7c 100644 --- a/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts +++ b/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts @@ -54,6 +54,8 @@ export function useSyncUrlAndStore() { const clientUrl = searchParams.get(SearchParams.CLIENT_URL); const liquiditySources = searchParams.get(SearchParams.LIQUIDITY_SOURCES); + const blockchain = searchParams.get(SearchParams.BLOCKCHAIN); + return { fromAmount, fromBlockchain, @@ -64,6 +66,7 @@ export function useSyncUrlAndStore() { clientUrl, liquiditySources, utmQueryParams, + blockchain, }; }; @@ -79,7 +82,8 @@ export function useSyncUrlAndStore() { }; useEffect(() => { - const { autoConnect, clientUrl, utmQueryParams } = getUrlSearchParams(); + const { autoConnect, clientUrl, utmQueryParams, blockchain } = + getUrlSearchParams(); if (isInRouterContext && fetchMetaStatus === 'success') { updateUrlSearchParams({ [SearchParams.FROM_BLOCKCHAIN]: fromBlockchain?.name, @@ -89,6 +93,7 @@ export function useSyncUrlAndStore() { [SearchParams.FROM_AMOUNT]: inputAmount, [SearchParams.AUTO_CONNECT]: autoConnect ?? undefined, [SearchParams.CLIENT_URL]: clientUrl ?? undefined, + [SearchParams.BLOCKCHAIN]: blockchain ?? undefined, [SearchParams.LIQUIDITY_SOURCES]: campaignMode ? liquiditySourcesParamsRef.current : undefined, diff --git a/widget/embedded/src/hooks/useTheme.ts b/widget/embedded/src/hooks/useTheme.ts index bb26afbbb1..cbe5a73e4d 100644 --- a/widget/embedded/src/hooks/useTheme.ts +++ b/widget/embedded/src/hooks/useTheme.ts @@ -93,8 +93,12 @@ export function useTheme(props: WidgetTheme) { if (theme === 'auto') { return OSTheme === 'dark' ? darkClassNames : lightClassNames; } + return theme === 'dark' ? darkClassNames : lightClassNames; }; - return { activeTheme: getActiveTheme }; + return { + activeTheme: getActiveTheme, + mode: theme === 'auto' ? OSTheme : theme, + }; } diff --git a/widget/embedded/src/pages/AddCustomTokenPage.tsx b/widget/embedded/src/pages/AddCustomTokenPage.tsx new file mode 100644 index 0000000000..516608b95d --- /dev/null +++ b/widget/embedded/src/pages/AddCustomTokenPage.tsx @@ -0,0 +1,210 @@ +import { i18n } from '@lingui/core'; +import { + Alert, + Button, + darkTheme, + Divider, + DoneIcon, + MessageBox, + styled, + TextField, + Typography, +} from '@rango-dev/ui'; +import { type Token } from 'rango-sdk'; +import React, { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { BlockchainSelectorButton } from '../components/BlockchainSelectorButton'; +import { WatermarkedModal } from '../components/common/WatermarkedModal'; +import { CustomTokenModal } from '../components/CustomTokenModal'; +import { Layout, PageContainer } from '../components/Layout'; +import { navigationRoutes } from '../constants/navigationRoutes'; +import { SearchParams } from '../constants/searchParams'; +import { useFetchCustomToken } from '../hooks/useFetchCustomToken'; +import { useNavigateBack } from '../hooks/useNavigateBack'; +import { useAppStore } from '../store/AppStore'; +import { getContainer } from '../utils/common'; +import { findBlockchain, isValidTokenAddress } from '../utils/meta'; + +const Content = styled('div', { + display: 'flex', + justifyContent: 'space-between', + flexDirection: 'column', + flex: 1, + '& ._text-field': { + padding: '$4 $10', + backgroundColor: '$neutral300', + [`.${darkTheme} &`]: { + backgroundColor: '$neutral400', + }, + borderRadius: '$sm', + height: '$40', + }, +}); +const CUSTOM_TOKEN_REFRESH_DELAY = 1000; + +export function AddCustomTokenPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const navigateBack = useNavigateBack(); + const { setCustomToken } = useAppStore(); + const blockchains = useAppStore().blockchains(); + + const blockchainName = searchParams.get(SearchParams.BLOCKCHAIN) || ''; + const blockchain = findBlockchain(blockchainName, blockchains); + const [address, setAddress] = useState(''); + const [isOpenErrorModal, setIsOpenErrorModal] = useState(false); + const [token, setToken] = useState(); + const { fetchCustomToken, loading, error } = useFetchCustomToken(); + const [networkError, setNetworkError] = useState(''); + const isValidAddress = + !!blockchain && isValidTokenAddress(blockchain, address); + const isImportDisabled = !blockchain || !address || !isValidAddress; + + const getCustomToken = async () => { + if (blockchain) { + try { + const res = await fetchCustomToken({ + blockchain: blockchainName, + tokenAddress: address, + }); + setNetworkError(''); + if (!!res) { + setToken(res); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.message === 'Failed to fetch') { + setNetworkError(i18n.t('Network Error')); + setIsOpenErrorModal(true); + } + } + } + }; + + const closeErrorModal = () => { + if (!!error) { + setAddress(''); + } + setIsOpenErrorModal(false); + }; + + const handleErrorModalButtonClick = async () => { + if (!!networkError) { + setTimeout(async () => { + await getCustomToken(); + }, CUSTOM_TOKEN_REFRESH_DELAY); + } + closeErrorModal(); + }; + + useEffect(() => { + if (!!error) { + setIsOpenErrorModal(true); + } + }, [error]); + return ( + + + +
+ + navigate(navigationRoutes.blockchains, { replace: true }) + } + hasLogo={!!blockchain?.logo} + value={ + !!blockchain + ? { + name: blockchain.displayName, + logo: blockchain.logo, + } + : undefined + } + title={i18n.t('Select chain')} + placeholder={i18n.t('Select chain')} + /> + + + {i18n.t('Enter Address')} + + + + } + onChange={(e) => setAddress(e.target.value)} + /> + {!isValidAddress && !!address && ( + <> + + + + )} +
+ + +
+ + + + + + + + + {blockchain && token && ( + { + if (token) { + setCustomToken(token); + setToken(undefined); + navigateBack(); + } + }} + onClose={() => setToken(undefined)} + open={!!blockchain && !!token} + /> + )} +
+
+ ); +} diff --git a/widget/embedded/src/pages/CustomTokensPage.tsx b/widget/embedded/src/pages/CustomTokensPage.tsx new file mode 100644 index 0000000000..7e166116fc --- /dev/null +++ b/widget/embedded/src/pages/CustomTokensPage.tsx @@ -0,0 +1,184 @@ +import type { Token } from 'rango-sdk'; + +import { i18n } from '@lingui/core'; +import { + Button, + CustomTokensZeroStateDarkIcon, + CustomTokensZeroStateIcon, + DeleteIcon, + Divider, + IconButton, + MessageBox, + NotFound, + styled, +} from '@rango-dev/ui'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { WatermarkedModal } from '../components/common/WatermarkedModal'; +import { Layout, PageContainer } from '../components/Layout'; +import { SearchInput } from '../components/SearchInput'; +import { TokenList } from '../components/TokenList'; +import { navigationRoutes } from '../constants/navigationRoutes'; +import { useTheme } from '../hooks/useTheme'; +import { useAppStore } from '../store/AppStore'; +import { useQuoteStore } from '../store/quote'; +import { containsText, getContainer } from '../utils/common'; +import { createTokenHash } from '../utils/meta'; + +const Content = styled('div', { + display: 'flex', + justifyContent: 'space-between', + flexDirection: 'column', + flex: 1, +}); +const NotFoundContent = styled('div', { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: '0.75', +}); +const DeleteIconButton = styled(IconButton, { + '&:hover': { + '& svg': { + color: '$secondary550', + }, + }, +}); +export function CustomTokensPage() { + const [searchedFor, setSearchedFor] = useState(''); + const { deleteCustomToken } = useAppStore(); + const customTokens = useAppStore().customTokens(); + const { fromToken, toToken, setFromToken, setToToken } = useQuoteStore(); + const { mode } = useTheme({}); + const navigate = useNavigate(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(); + const handleSearch = (event: React.ChangeEvent) => { + const value = event.target.value; + setSearchedFor(value); + }; + const isDarkTheme = mode === 'dark'; + + const customTokensResults = customTokens.filter( + (token) => + containsText(token.symbol, searchedFor) || + containsText(token.address || '', searchedFor) || + containsText(token.name || '', searchedFor) + ); + + const handleDeleteCustomToken = () => { + if (selectedToken) { + const toTokenHash = toToken ? createTokenHash(toToken) : null; + const fromTokenHash = fromToken ? createTokenHash(fromToken) : null; + const selectedTokenHash = createTokenHash(selectedToken); + + if (toTokenHash === selectedTokenHash) { + setToToken({ token: null }); + } else if (fromTokenHash === selectedTokenHash) { + setFromToken({ token: null }); + } + deleteCustomToken(selectedToken); + } + setIsDeleteModalOpen(false); + }; + + return ( + + + + {!!customTokens.length ? ( + <> + + + ( + { + setIsDeleteModalOpen(true); + setSelectedToken(token); + }}> + + + )} + /> + + ) : ( + + + ) : ( + + ) + } + title={i18n.t('No custom tokens')} + description={i18n.t( + 'press the button to add your custom token' + )} + /> + + )} + + + + + setIsDeleteModalOpen(false)} + container={getContainer()}> + + + + + + + + + + + + ); +} diff --git a/widget/embedded/src/pages/HistoryPage.tsx b/widget/embedded/src/pages/HistoryPage.tsx index 4b1e610093..60a262be68 100644 --- a/widget/embedded/src/pages/HistoryPage.tsx +++ b/widget/embedded/src/pages/HistoryPage.tsx @@ -87,7 +87,7 @@ export function HistoryPage() { const loading = !state.loadedFromPersistor; const [filterBy, setFilterBy] = useState(''); const [openClearModal, setOpenClearModal] = useState(false); - const searchHandler = (event: React.ChangeEvent) => { + const handleSearch = (event: React.ChangeEvent) => { const value = event.target.value; setSearchedFor(value); }; @@ -159,7 +159,7 @@ export function HistoryPage() { variant="contained" placeholder={i18n.t('Search Transaction')} autoFocus - onChange={searchHandler} + onChange={handleSearch} style={{ height: 36 }} value={searchedFor} /> diff --git a/widget/embedded/src/pages/LiquiditySourcePage.tsx b/widget/embedded/src/pages/LiquiditySourcePage.tsx index 4e4753b896..4002cad684 100644 --- a/widget/embedded/src/pages/LiquiditySourcePage.tsx +++ b/widget/embedded/src/pages/LiquiditySourcePage.tsx @@ -86,7 +86,7 @@ export function LiquiditySourcePage({ sourceType }: PropTypes) { }; }); - const searchHandler = (event: React.ChangeEvent) => { + const handleSearch = (event: React.ChangeEvent) => { const value = event.target.value; setSearchedFor(value); }; @@ -120,7 +120,7 @@ export function LiquiditySourcePage({ sourceType }: PropTypes) { placeholder={i18n.t('Search {sourceType}', { sourceType: types[sourceType], })} - onChange={searchHandler} + onChange={handleSearch} /> {fetchStatus === 'loading' && } diff --git a/widget/embedded/src/pages/SelectBlockchainPage.tsx b/widget/embedded/src/pages/SelectBlockchainPage.tsx index 7ed2a77ed5..35c2751b6c 100644 --- a/widget/embedded/src/pages/SelectBlockchainPage.tsx +++ b/widget/embedded/src/pages/SelectBlockchainPage.tsx @@ -5,6 +5,7 @@ import { SelectableCategoryList, } from '@rango-dev/ui'; import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { BlockchainList } from '../components/BlockchainList'; import { Layout, PageContainer } from '../components/Layout'; @@ -14,7 +15,8 @@ import { useAppStore } from '../store/AppStore'; import { useQuoteStore } from '../store/quote'; interface PropTypes { - type: 'source' | 'destination'; + type: 'source' | 'destination' | 'custom-token'; + hideCategory?: boolean; } export function SelectBlockchainPage(props: PropTypes) { @@ -24,15 +26,15 @@ export function SelectBlockchainPage(props: PropTypes) { const [blockchainCategory, setBlockchainCategory] = useState('ALL'); const setToBlockchain = useQuoteStore.use.setToBlockchain(); const setFromBlockchain = useQuoteStore.use.setFromBlockchain(); - const fetchStatus = useAppStore().fetchStatus; + const { fetchStatus } = useAppStore(); + const navigate = useNavigate(); const blockchains = useAppStore().blockchains({ type, }); - const activeCategoriesCount = getCategoriesCount(blockchains); - const showCategory = activeCategoriesCount !== 1; + const showCategory = !props.hideCategory && activeCategoriesCount !== 1; return ( { - if (type === 'source') { - setFromBlockchain(blockchain); + if (type === 'custom-token') { + navigate(`..?blockchain=${blockchain.name}`, { replace: true }); } else { - setToBlockchain(blockchain); + if (type === 'source') { + setFromBlockchain(blockchain); + } else { + setToBlockchain(blockchain); + } + navigateBack(); } - navigateBack(); }} /> diff --git a/widget/embedded/src/pages/SelectSwapItemsPage.tsx b/widget/embedded/src/pages/SelectSwapItemsPage.tsx index 75362f833a..2355662fa1 100644 --- a/widget/embedded/src/pages/SelectSwapItemsPage.tsx +++ b/widget/embedded/src/pages/SelectSwapItemsPage.tsx @@ -8,7 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { BlockchainsSection } from '../components/BlockchainsSection'; import { Layout, PageContainer } from '../components/Layout'; import { SearchInput } from '../components/SearchInput'; -import { TokenList } from '../components/TokenList/TokenList'; +import { TokenList } from '../components/TokenList'; import { navigationRoutes } from '../constants/navigationRoutes'; import { useNavigateBack } from '../hooks/useNavigateBack'; import { useAppStore } from '../store/AppStore'; diff --git a/widget/embedded/src/store/app.ts b/widget/embedded/src/store/app.ts index cfadfb096c..e2a94f4804 100644 --- a/widget/embedded/src/store/app.ts +++ b/widget/embedded/src/store/app.ts @@ -35,6 +35,7 @@ export function createAppStore(initialData?: WidgetConfig) { skipHydration: true, partialize: (state) => { return { + _customTokens: state._customTokens, theme: state.theme, language: state.language, affiliatePercent: state.affiliatePercent, diff --git a/widget/embedded/src/store/quote.ts b/widget/embedded/src/store/quote.ts index 4f94552f9f..e97ac1a6e9 100644 --- a/widget/embedded/src/store/quote.ts +++ b/widget/embedded/src/store/quote.ts @@ -21,7 +21,10 @@ import { WidgetEvents, } from '../types'; import { isPositiveNumber } from '../utils/numbers'; -import { getQuoteToTokenUsdPrice } from '../utils/quote'; +import { + getQuoteFromTokenUsdPrice, + getQuoteToTokenUsdPrice, +} from '../utils/quote'; import { calcOutputUsdValue } from '../utils/swap'; import createSelectors from './selectors'; @@ -125,6 +128,8 @@ export const useQuoteStore = createSelectors( set((state) => { let outputAmount: BigNumber | null = null; let outputUsdValue: BigNumber = ZERO; + + let inputUsdValue = state.inputUsdValue; if (!isPositiveNumber(state.inputAmount)) { return {}; } @@ -132,8 +137,12 @@ export const useQuoteStore = createSelectors( outputAmount = !!quote?.outputAmount ? new BigNumber(quote?.outputAmount) : null; + inputUsdValue = calcOutputUsdValue( + state.inputAmount, + getQuoteFromTokenUsdPrice(quote) || state.fromToken?.usdPrice + ); outputUsdValue = calcOutputUsdValue( - quote?.outputAmount, + quote.outputAmount, getQuoteToTokenUsdPrice(quote) || state.toToken?.usdPrice ); } @@ -142,6 +151,7 @@ export const useQuoteStore = createSelectors( ...(!!quote && { outputAmount, outputUsdValue, + inputUsdValue, }), }; }), diff --git a/widget/embedded/src/store/slices/data.ts b/widget/embedded/src/store/slices/data.ts index 7c6a3cb3ce..f8de9fd821 100644 --- a/widget/embedded/src/store/slices/data.ts +++ b/widget/embedded/src/store/slices/data.ts @@ -1,21 +1,35 @@ // We keep all the received data from server in this slice import type { ConfigSlice } from './config'; +import type { SettingsSlice } from './settings'; import type { CachedEntries } from '../../services/cacheService'; import type { Balance, Blockchain, TokenHash } from '../../types'; -import type { Asset, BlockchainMeta, SwapperMeta, Token } from 'rango-sdk'; import type { StateCreator } from 'zustand'; +import { + type Asset, + type BlockchainMeta, + type SwapperMeta, + type Token, +} from 'rango-sdk'; + +import { ACTIVE_BLOCKCHAINS_FOR_CUSTOM_TOKENS } from '../../constants/customTokens'; import { cacheService } from '../../services/cacheService'; import { httpService as sdk } from '../../services/httpService'; import { compareWithSearchFor, containsText } from '../../utils/common'; import { createTokenHash, isTokenNative } from '../../utils/meta'; -import { sortLiquiditySourcesByGroupTitle } from '../../utils/settings'; +import { + addCustomTokensToSupportedTokens, + sortLiquiditySourcesByGroupTitle, +} from '../../utils/settings'; import { areTokensEqual, compareTokenBalance } from '../../utils/wallets'; -import { matchTokensFromConfigWithMeta } from '../utils'; +import { + getSupportedBlockchainsFromConfig, + matchTokensFromConfigWithMeta, +} from '../utils'; type BlockchainOptions = { - type?: 'source' | 'destination'; + type?: 'source' | 'destination' | 'custom-token'; }; type TokenOptions = { @@ -45,7 +59,7 @@ export interface DataSlice { } export const createDataSlice: StateCreator< - DataSlice & ConfigSlice, + DataSlice & ConfigSlice & SettingsSlice, [], [], DataSlice @@ -67,8 +81,30 @@ export const createDataSlice: StateCreator< if (!options || !options?.type) { return blockchainsFromState; } - const config = get().config; + + if (options.type === 'custom-token') { + const supportedBlockchainsFromConfig = getSupportedBlockchainsFromConfig({ + config, + }); + + let supportedBlockchains: BlockchainMeta[] = blockchainsFromState; + + /* + * Supported blockchains can be configured and be limited. + * In this case, we only keep those active blockchains which exist in config. + */ + if (supportedBlockchainsFromConfig.length > 0) { + supportedBlockchains = supportedBlockchains.filter((blockchain) => + supportedBlockchainsFromConfig.includes(blockchain.name) + ); + } + + return supportedBlockchains.filter((blockchain) => + ACTIVE_BLOCKCHAINS_FOR_CUSTOM_TOKENS.includes(blockchain.type) + ); + } + const supportedBlockchainsFromConfig = (options.type === 'source' ? config.from?.blockchains @@ -88,7 +124,12 @@ export const createDataSlice: StateCreator< return list; }, tokens: (options) => { - const { _tokensMapByTokenHash, _tokensMapByBlockchainName, config } = get(); + const { + _tokensMapByTokenHash, + _tokensMapByBlockchainName, + config, + _customTokens, + } = get(); const tokensFromState = Array.from(_tokensMapByTokenHash.values()); const blockchainsMapByName = get()._blockchainsMapByName; if (!options || !options.type) { @@ -117,6 +158,12 @@ export const createDataSlice: StateCreator< cacheService.set(cacheKey, supportedTokens); } + supportedTokens = addCustomTokensToSupportedTokens( + supportedTokens, + _customTokens, + config.features + ); + const blockchains = get().blockchains({ type: options.type, }); @@ -217,8 +264,14 @@ export const createDataSlice: StateCreator< }, findToken: (asset) => { const tokensMapByHashToken = get()._tokensMapByTokenHash; + const customTokens = get().customTokens(); const tokenHash = createTokenHash(asset); - const token = tokensMapByHashToken.get(tokenHash); + let token = tokensMapByHashToken.get(tokenHash); + if (!token) { + token = customTokens.find( + (customToken) => createTokenHash(customToken) === tokenHash + ); + } return token; }, isTokenPinned: (token, type) => { diff --git a/widget/embedded/src/store/slices/settings.ts b/widget/embedded/src/store/slices/settings.ts index aa32e59824..1ca54b3754 100644 --- a/widget/embedded/src/store/slices/settings.ts +++ b/widget/embedded/src/store/slices/settings.ts @@ -1,6 +1,7 @@ import type { ConfigSlice } from './config'; +import type { DataSlice } from './data'; import type { WidgetConfig } from '../../types'; -import type { SwapperMeta } from 'rango-sdk'; +import type { SwapperMeta, Token } from 'rango-sdk'; import type { StateCreator } from 'zustand'; import { type Language } from '@rango-dev/ui'; @@ -10,6 +11,7 @@ import { DEFAULT_LANGUAGE } from '../../constants/languages'; import { DEFAULT_SLIPPAGE } from '../../constants/swapSettings'; import { removeDuplicateFrom } from '../../utils/common'; import { isFeatureHidden } from '../../utils/settings'; +import { getSupportedBlockchainsFromConfig } from '../utils'; export type ThemeMode = 'auto' | 'dark' | 'light'; @@ -25,6 +27,7 @@ export interface SettingsSlice { affiliateRef: string | null; affiliatePercent: number | null; affiliateWallets: { [key: string]: string } | null; + _customTokens: Token[]; setSlippage: (slippage: number) => void; setCustomSlippage: (customSlippage: number | null) => void; @@ -43,10 +46,13 @@ export interface SettingsSlice { ) => void; addPreferredBlockchain: (blockchain: string) => void; updateSettings: (config: WidgetConfig) => void; + setCustomToken: (token: Token) => void; + deleteCustomToken: (token: Token) => void; + customTokens: () => Token[]; } export const createSettingsSlice: StateCreator< - SettingsSlice & ConfigSlice, + SettingsSlice & DataSlice & ConfigSlice, [], [], SettingsSlice @@ -61,6 +67,7 @@ export const createSettingsSlice: StateCreator< affiliateRef: null, affiliatePercent: null, affiliateWallets: null, + _customTokens: [], addPreferredBlockchain: (blockchain) => { const currentPreferredBlockchains = get().preferredBlockchains; @@ -93,6 +100,7 @@ export const createSettingsSlice: StateCreator< set(() => ({ slippage: slippage, })), + setCustomSlippage: (customSlippage) => set(() => ({ customSlippage: customSlippage, @@ -131,6 +139,7 @@ export const createSettingsSlice: StateCreator< set((state) => ({ infiniteApprove: !state.infiniteApprove, })), + toggleLiquiditySource: (name) => set((state) => { if (state.disabledLiquiditySources.includes(name)) { @@ -177,4 +186,28 @@ export const createSettingsSlice: StateCreator< }), }); }, + setCustomToken: (token) => + set((state) => ({ + _customTokens: [token, ...state._customTokens], + })), + deleteCustomToken: (token) => + set((state) => ({ + _customTokens: state._customTokens.filter( + (customToken) => customToken.address !== token.address + ), + })), + customTokens: () => { + const config = get().config; + + const customTokens = get()._customTokens; + const supportedBlockchainsFromConfig = getSupportedBlockchainsFromConfig({ + config, + }); + + return !supportedBlockchainsFromConfig.length + ? customTokens + : customTokens.filter((token) => + supportedBlockchainsFromConfig.includes(token.blockchain) + ); + }, }); diff --git a/widget/embedded/src/store/utils/data.ts b/widget/embedded/src/store/utils/data.ts index 85a71ad264..943f20776f 100644 --- a/widget/embedded/src/store/utils/data.ts +++ b/widget/embedded/src/store/utils/data.ts @@ -1,4 +1,8 @@ -import type { BlockchainAndTokenConfig, TokenHash } from '../../types'; +import type { + BlockchainAndTokenConfig, + TokenHash, + WidgetConfig, +} from '../../types'; import type { DataSlice } from '../slices/data'; import type { Asset, Token } from 'rango-sdk'; @@ -115,3 +119,24 @@ export function matchTokensFromConfigWithMeta(params: { return Object.values(result); } + +export function getSupportedBlockchainsFromConfig(params: { + config: WidgetConfig; +}): string[] { + const { config } = params; + const configFromBlockchains = config.from?.blockchains || []; + const configToBlockchains = config.to?.blockchains || []; + + /* + * Empty array means all blockchains. So if any of to or from has an empty array, + * it means all blockchains is available to use and there is no limitation from config. + */ + if (!configFromBlockchains.length || !configToBlockchains.length) { + return []; + } + const blockchains = [...configFromBlockchains, ...configToBlockchains]; + + const supportedBlockchains = new Set(blockchains); + + return Array.from(supportedBlockchains); +} diff --git a/widget/embedded/src/types/config.ts b/widget/embedded/src/types/config.ts index 202e9b83f3..e6cfe9ed04 100644 --- a/widget/embedded/src/types/config.ts +++ b/widget/embedded/src/types/config.ts @@ -139,7 +139,8 @@ export type Features = Partial< | 'language' | 'connectWalletButton' | 'notification' - | 'liquiditySource', + | 'liquiditySource' + | 'customTokens', 'visible' | 'hidden' > > & @@ -198,7 +199,9 @@ export type TrezorManifest = { * - 'liquiditySource': Visibility state for liquidity source. * - 'connectWalletButton': Visibility state for the wallet connect icon. * - 'language': Visibility state for the language. + * - 'customTokens': Visibility state for the custom tokens. * - 'experimentalRoute': Enablement state for the experimental route. + * * @property {WidgetVariant} variant * If it is expanded, multiple routes will show up on the home page; * If it is full-expanded, multiple routes will show up on the home page with full routes; diff --git a/widget/embedded/src/utils/meta.ts b/widget/embedded/src/utils/meta.ts index e66396743b..170b06114d 100644 --- a/widget/embedded/src/utils/meta.ts +++ b/widget/embedded/src/utils/meta.ts @@ -57,5 +57,16 @@ export function isTokenNative( } export function createTokenHash(asset: Asset): TokenHash { - return `${asset.blockchain}-${asset.symbol}-${asset.address ?? ''}`; + return `${asset.blockchain.toLowerCase()}-${asset.symbol.toLowerCase()}-${( + asset.address ?? '' + ).toLowerCase()}`; +} + +export function isValidTokenAddress( + chain: BlockchainMeta, + address: string +): boolean { + const regex = chain.addressPatterns; + const valid = regex.filter((r) => new RegExp(r).test(address)).length > 0; + return valid; } diff --git a/widget/embedded/src/utils/quote.ts b/widget/embedded/src/utils/quote.ts index 0a581fa745..0555ad0107 100644 --- a/widget/embedded/src/utils/quote.ts +++ b/widget/embedded/src/utils/quote.ts @@ -25,12 +25,12 @@ import BigNumber from 'bignumber.js'; import { HIGH_PRIORITY_TAGS } from '../constants/quote'; import { HIGHT_PRICE_IMPACT, LOW_PRICE_IMPACT } from '../constants/routing'; import { HIGH_SLIPPAGE } from '../constants/swapSettings'; -import { getUsdValue } from '../store/quote'; import { QuoteWarningType } from '../types'; import { areEqual } from './common'; import { createTokenHash, findBlockchain } from './meta'; import { + calcOutputUsdValue, checkSlippageWarnings, getMinRequiredSlippage, getPercentageChange, @@ -46,6 +46,13 @@ export function getQuoteToTokenUsdPrice( return swaps[swaps.length - 1].to.usdPrice; } +export function getQuoteFromTokenUsdPrice( + quote: SelectedQuote | null +): number | null | undefined { + const swaps = quote?.swaps || []; + return swaps[0].from.usdPrice; +} + export function isNumberOfSwapsChanged( quoteA: SelectedQuote, quoteB: SelectedQuote @@ -198,8 +205,14 @@ export function generateQuoteWarnings( } ): QuoteWarning | null { const { fromToken, toToken, findToken, userSlippage } = params; - const inputUsdValue = getUsdValue(fromToken, quote.requestAmount); - const outputUsdValue = getUsdValue(toToken, quote?.outputAmount ?? ''); + const inputUsdValue = calcOutputUsdValue( + quote.requestAmount, + getQuoteFromTokenUsdPrice(quote) || fromToken?.usdPrice + ); + const outputUsdValue = calcOutputUsdValue( + quote.outputAmount, + getQuoteToTokenUsdPrice(quote) || toToken?.usdPrice + ); if (!!quote && inputUsdValue && outputUsdValue) { const priceImpact = getPriceImpact( diff --git a/widget/embedded/src/utils/settings.ts b/widget/embedded/src/utils/settings.ts index 8dd6f5cd00..8cc8ab3ca8 100644 --- a/widget/embedded/src/utils/settings.ts +++ b/widget/embedded/src/utils/settings.ts @@ -1,5 +1,5 @@ import type { Features } from '../types'; -import type { SwapperMeta, SwapperType } from 'rango-sdk'; +import type { SwapperMeta, SwapperType, Token } from 'rango-sdk'; import { removeDuplicateFrom } from './common'; @@ -75,3 +75,15 @@ export function isFeatureHidden(feature: keyof Features, features?: Features) { export function isFeatureEnabled(feature: keyof Features, features?: Features) { return features?.[feature] === 'enabled'; } + +export const addCustomTokensToSupportedTokens = ( + supportedTokens: Token[], + customTokens: Token[], + features?: Features +) => { + const isCustomTokensHidden = isFeatureHidden('customTokens', features); + + return isCustomTokensHidden + ? supportedTokens + : supportedTokens.concat(customTokens); +}; diff --git a/widget/embedded/src/utils/swap.ts b/widget/embedded/src/utils/swap.ts index 8709e9f50b..94f1577ad7 100644 --- a/widget/embedded/src/utils/swap.ts +++ b/widget/embedded/src/utils/swap.ts @@ -31,7 +31,6 @@ import { import BigNumber from 'bignumber.js'; import { PendingSwapNetworkStatus } from 'rango-types'; -import { isValidAddress } from '../components/ConfirmWalletsModal/ConfirmWallets.helpers'; import { errorMessages } from '../constants/errors'; import { swapButtonTitles } from '../constants/messages'; import { ZERO } from '../constants/numbers'; @@ -42,7 +41,7 @@ import { TOKEN_AMOUNT_MIN_DECIMALS, } from '../constants/routing'; -import { getBlockchainShortNameFor } from './meta'; +import { getBlockchainShortNameFor, isValidTokenAddress } from './meta'; import { numberToString } from './numbers'; import { getPriceImpact, getRequiredBalanceOfWallet } from './quote'; import { getQuoteWallets } from './wallets'; @@ -250,13 +249,14 @@ export function canComputePriceImpact( inputAmount: string, usdValue: BigNumber | null ) { + const inputAmountNumber = parseFloat(inputAmount || '0'); + return !( + quote && (!usdValue || usdValue.lte(ZERO)) && - !!quote && - !!inputAmount && + inputAmount && inputAmount !== '0' && - parseFloat(inputAmount || '0') !== 0 && - !!quote + inputAmountNumber !== 0 ); } @@ -748,7 +748,7 @@ export function isConfirmSwapDisabled( const customDestinationIsValid = customDestination && lastStepToBlockchain - ? isValidAddress(lastStepToBlockchain, customDestination) + ? isValidTokenAddress(lastStepToBlockchain, customDestination) : false; return ( diff --git a/widget/ui/src/components/Button/Button.styles.tsx b/widget/ui/src/components/Button/Button.styles.tsx index 58211f5ab1..c0db515aad 100644 --- a/widget/ui/src/components/Button/Button.styles.tsx +++ b/widget/ui/src/components/Button/Button.styles.tsx @@ -57,11 +57,12 @@ export const ButtonBase = styled('button', { outline: 0, }, '&:disabled': { + backgroundColor: '$neutral600', $$color: '$colors$background', [`.${darkTheme} &`]: { $$color: '$colors$foreground', + backgroundColor: '$neutral700', }, - backgroundColor: '$neutral600', color: '$$color', pointerEvents: 'none', }, @@ -137,10 +138,11 @@ export const ButtonBase = styled('button', { }, '&:disabled': { $$color: '$colors$background', + background: '$neutral600', [`.${darkTheme} &`]: { $$color: '$colors$foreground', + backgroundColor: '$neutral700', }, - background: '$neutral600', color: '$$color', pointerEvents: 'none', }, diff --git a/widget/ui/src/components/Divider/Divider.styles.ts b/widget/ui/src/components/Divider/Divider.styles.ts index 2cd3d1c1de..e9d3f02e2a 100644 --- a/widget/ui/src/components/Divider/Divider.styles.ts +++ b/widget/ui/src/components/Divider/Divider.styles.ts @@ -15,6 +15,7 @@ export const DividerContainer = styled('div', { 24: {}, 30: {}, 32: {}, + 36: {}, 40: {}, }, direction: { @@ -170,6 +171,20 @@ export const DividerContainer = styled('div', { width: '$32', }, }, + { + size: 36, + direction: 'vertical', + css: { + height: '$36', + }, + }, + { + size: 36, + direction: 'horizontal', + css: { + width: '$36', + }, + }, { size: 32, direction: 'vertical', diff --git a/widget/ui/src/components/NotFound/NotFound.styles.ts b/widget/ui/src/components/NotFound/NotFound.styles.ts index fe88fd4a09..8a5a7c651d 100644 --- a/widget/ui/src/components/NotFound/NotFound.styles.ts +++ b/widget/ui/src/components/NotFound/NotFound.styles.ts @@ -1,4 +1,4 @@ -import { styled } from '../../theme'; +import { darkTheme, styled } from '../../theme'; export const Container = styled('div', { display: 'flex', @@ -6,4 +6,10 @@ export const Container = styled('div', { alignItems: 'center', flexDirection: 'column', textAlign: 'center', + '& .not-found-description': { + color: '$neutral700', + [`.${darkTheme} &`]: { + color: '$neutral900', + }, + }, }); diff --git a/widget/ui/src/components/NotFound/NotFound.tsx b/widget/ui/src/components/NotFound/NotFound.tsx index 6886158303..9c18b81892 100644 --- a/widget/ui/src/components/NotFound/NotFound.tsx +++ b/widget/ui/src/components/NotFound/NotFound.tsx @@ -9,16 +9,19 @@ import { Typography } from '../Typography'; import { Container } from './NotFound.styles'; export function NotFound(props: NotFoundPropTypes) { - const { hasIcon = true, titleColor } = props; + const { icon, titleColor, hasIcon = true } = props; return ( - {hasIcon && } + {hasIcon && (icon || )} {props.title} - + {props.description} diff --git a/widget/ui/src/components/NotFound/NotFound.types.ts b/widget/ui/src/components/NotFound/NotFound.types.ts index d5900f1008..602ce6b100 100644 --- a/widget/ui/src/components/NotFound/NotFound.types.ts +++ b/widget/ui/src/components/NotFound/NotFound.types.ts @@ -1,6 +1,9 @@ +import type { ReactNode } from 'react'; + export type NotFoundPropTypes = { title: string; description?: string; - hasIcon?: boolean; titleColor?: string; + icon?: ReactNode; + hasIcon?: boolean; }; diff --git a/widget/ui/src/components/TextField/TextField.styles.ts b/widget/ui/src/components/TextField/TextField.styles.ts index eb5f0a85df..1c92f7e490 100644 --- a/widget/ui/src/components/TextField/TextField.styles.ts +++ b/widget/ui/src/components/TextField/TextField.styles.ts @@ -113,6 +113,9 @@ export const Input = styled('input', { '&::placeholder, &::-webkit-input-placeholder': { color: '$neutral700', + [`.${darkTheme} &`]: { + color: '$neutral900', + }, }, '&:focus-visible': { outline: 'none', diff --git a/widget/ui/src/containers/SwapInput/SwapInput.tsx b/widget/ui/src/containers/SwapInput/SwapInput.tsx index e956f8b617..e1c138222c 100644 --- a/widget/ui/src/containers/SwapInput/SwapInput.tsx +++ b/widget/ui/src/containers/SwapInput/SwapInput.tsx @@ -37,6 +37,15 @@ export function SwapInput(props: SwapInputPropTypes) { const showBalanceSkeleton = 'balance' in props && (props.loading || props.loadingBalance); + const price = props.price; + + const isUsdValueZeroOrFalsy = + !props.price.usdValue || props.price.usdValue === '0'; + + const displayUsdValue = + props.price.error || + (isUsdValueZeroOrFalsy ? '0.00' : `~$${props.price.usdValue}`); + return ( ) : ( - + - {props.price.usdValue - ? props.price.usdValue === '0' - ? '0.00' - : `~$${props.price.usdValue}` - : props.price.error} + {displayUsdValue} diff --git a/widget/ui/src/icons/CustomTokensZeroState.tsx b/widget/ui/src/icons/CustomTokensZeroState.tsx new file mode 100644 index 0000000000..4142423176 --- /dev/null +++ b/widget/ui/src/icons/CustomTokensZeroState.tsx @@ -0,0 +1,262 @@ +import type { SvgIconPropsWithChildren } from '../components/SvgIcon'; + +import React, { createElement } from 'react'; + +import { SvgIcon } from '../components/SvgIcon'; + +function SvgCustomTokensZeroState(props: SvgIconPropsWithChildren) { + return createElement( + SvgIcon, + props, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} +export default SvgCustomTokensZeroState; diff --git a/widget/ui/src/icons/CustomTokensZeroStateDark.tsx b/widget/ui/src/icons/CustomTokensZeroStateDark.tsx new file mode 100644 index 0000000000..8f04625a84 --- /dev/null +++ b/widget/ui/src/icons/CustomTokensZeroStateDark.tsx @@ -0,0 +1,415 @@ +import type { SvgIconPropsWithChildren } from '../components/SvgIcon'; + +import React, { createElement } from 'react'; + +import { SvgIcon } from '../components/SvgIcon'; + +function SvgCustomTokensZeroStateDark(props: SvgIconPropsWithChildren) { + return createElement( + SvgIcon, + props, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} +export default SvgCustomTokensZeroStateDark; diff --git a/widget/ui/src/icons/Target.tsx b/widget/ui/src/icons/Target.tsx new file mode 100644 index 0000000000..d46d851da9 --- /dev/null +++ b/widget/ui/src/icons/Target.tsx @@ -0,0 +1,24 @@ +import type { SvgIconPropsWithChildren } from '../components/SvgIcon'; + +import React, { createElement } from 'react'; + +import { SvgIcon } from '../components/SvgIcon'; + +function SvgTarget(props: SvgIconPropsWithChildren) { + return createElement( + SvgIcon, + props, + + + + + + ); +} +export default SvgTarget; diff --git a/widget/ui/src/icons/index.ts b/widget/ui/src/icons/index.ts index ed227ddcfd..8254a0f474 100644 --- a/widget/ui/src/icons/index.ts +++ b/widget/ui/src/icons/index.ts @@ -14,6 +14,8 @@ export { default as CopyIcon } from './Copy'; export { default as CosmosCategoryIcon } from './CosmosCategory'; export { default as CreditCardIcon } from './CreditCard'; export { default as CustomColorsIcon } from './CustomColors'; +export { default as CustomTokensZeroStateIcon } from './CustomTokensZeroState'; +export { default as CustomTokensZeroStateDarkIcon } from './CustomTokensZeroStateDark'; export { default as DarkModeIcon } from './DarkMode'; export { default as DeleteIcon } from './Delete'; export { default as DesktopIcon } from './Desktop'; @@ -27,6 +29,7 @@ export { default as ExitIcon } from './Exit'; export { default as ExplorerIcon } from './Explorer'; export { default as ExternalLinkIcon } from './ExternalLink'; export { default as FirstIcon } from './First'; +export { default as FilterIcon } from './Filter'; export { default as FontIcon } from './Font'; export { default as GasIcon } from './Gas'; export { default as HeightIcon } from './Height'; @@ -64,6 +67,7 @@ export { default as ShareIcon } from './Share'; export { default as StatsIcon } from './Stats'; export { default as SupportIcon } from './Support'; export { default as SwapIcon } from './Swap'; +export { default as TargetIcon } from './Target'; export { default as TimeIcon } from './Time'; export { default as TransactionIcon } from './Transaction'; export { default as TuneIcon } from './Tune'; @@ -76,7 +80,6 @@ export { default as BullhornIcon } from './Bullhorn'; export { default as ChainsIcon } from './Chains'; export { default as ColorsIcon } from './Colors'; export { default as DiscordIcon } from './Discord'; -export { default as FilterIcon } from './Filter'; export { default as InfinityIcon } from './Infinity'; export { default as KeyIcon } from './Key'; export { default as MediumIcon } from './Medium'; diff --git a/widget/ui/src/theme.ts b/widget/ui/src/theme.ts index 890ef10d02..9c631ef079 100644 --- a/widget/ui/src/theme.ts +++ b/widget/ui/src/theme.ts @@ -87,6 +87,7 @@ export const theme = { 24: '24px', 28: '28px', 32: '32px', + 36: '36px', }, radii: { diff --git a/widget/ui/svgs/resources/fill/CustomTokensZeroState.svg b/widget/ui/svgs/resources/fill/CustomTokensZeroState.svg new file mode 100644 index 0000000000..c7c737d6f4 --- /dev/null +++ b/widget/ui/svgs/resources/fill/CustomTokensZeroState.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/widget/ui/svgs/resources/fill/CustomTokensZeroStateDark.svg b/widget/ui/svgs/resources/fill/CustomTokensZeroStateDark.svg new file mode 100644 index 0000000000..2afd5190b4 --- /dev/null +++ b/widget/ui/svgs/resources/fill/CustomTokensZeroStateDark.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/widget/ui/svgs/resources/fill/Target.svg b/widget/ui/svgs/resources/fill/Target.svg new file mode 100644 index 0000000000..96a6cc2686 --- /dev/null +++ b/widget/ui/svgs/resources/fill/Target.svg @@ -0,0 +1,5 @@ + + + + +