feat: [info] add explore page (#7381)

* feat: add explore page

* add explore filter options

* add search bar defocus

* use Tokens Tab state for filters

* flag-gate /tokens

* filter bar css

* remove duplicate Explore page, use flag instead

* fixes

* create tabbednav interface

* rename Tokens to Explore

* simplify routing

* nit

* padding nit

* pr review

* fix menu flyouts

* fix TDP nav

* add analytics events + ui updates + pr review

* nit

* nit

* add small comment

* menu flyout nit

* fix merge conflict

* min width expand menu flyouts

* fix redirects

* update routing snapshot

* nit css

* oops

* breakpoints

* fix tab routing

* pr review

* add better tab dynamic routing

* fix redirects

* error handle edge urls

* further fix routing

* define return val for useExploreParams

* Update snapshot
This commit is contained in:
Kristie Huang 2023-10-17 15:34:34 -04:00 committed by GitHub
parent 9d439e7f62
commit ed6afb50de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 507 additions and 165 deletions

@ -194,7 +194,7 @@
"@types/react-helmet": "^6.1.7",
"@types/react-window-infinite-loader": "^1.0.6",
"@uniswap/analytics": "1.5.0",
"@uniswap/analytics-events": "^2.24.0",
"@uniswap/analytics-events": "^2.25.0",
"@uniswap/governance": "^1.0.2",
"@uniswap/liquidity-staker": "^1.0.2",
"@uniswap/merkle-distributor": "^1.0.1",

@ -3,6 +3,7 @@ import { TraceEvent } from 'analytics'
import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
import Row from 'components/Row'
import { DeltaArrow } from 'components/Tokens/TokenDetails/Delta'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { TokenBalance } from 'graphql/data/__generated__/types-and-hooks'
import { getTokenDetailsURL, gqlToCurrency, logSentryErrorForUnsupportedChain } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
@ -76,10 +77,12 @@ function TokenRow({ token, quantity, denominatedValue, tokenProjectMarket }: Tok
const navigate = useNavigate()
const toggleWalletDrawer = useToggleAccountDrawer()
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
const navigateToTokenDetails = useCallback(async () => {
navigate(getTokenDetailsURL(token))
navigate(getTokenDetailsURL({ ...token, isInfoExplorePageEnabled }))
toggleWalletDrawer()
}, [navigate, token, toggleWalletDrawer])
}, [navigate, token, isInfoExplorePageEnabled, toggleWalletDrawer])
const { formatNumber } = useFormatter()
const currency = gqlToCurrency(token)

@ -4,6 +4,7 @@ import clsx from 'clsx'
import QueryTokenLogo from 'components/Logo/QueryTokenLogo'
import TokenSafetyIcon from 'components/TokenSafety/TokenSafetyIcon'
import { checkSearchTokenWarning } from 'constants/tokenSafety'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { Chain, TokenStandard } from 'graphql/data/__generated__/types-and-hooks'
import { SearchToken } from 'graphql/data/SearchTokens'
import { getTokenDetailsURL } from 'graphql/data/util'
@ -138,7 +139,9 @@ export const TokenRow = ({ token, isHovered, setHoveredIndex, toggleOpen, index,
sendAnalyticsEvent(InterfaceEventName.NAVBAR_RESULT_SELECTED, { ...eventProperties })
}, [addRecentlySearchedAsset, token, toggleOpen, eventProperties])
const tokenDetailsPath = getTokenDetailsURL(token)
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
const tokenDetailsPath = getTokenDetailsURL({ ...token, isInfoExplorePageEnabled })
// Close the modal on escape
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {

@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core'
import { useAccountDrawer } from 'components/AccountDrawer'
import Web3Status from 'components/Web3Status'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { chainIdToBackendName } from 'graphql/data/util'
import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes'
import { useIsNftPage } from 'hooks/useIsNftPage'
@ -61,15 +62,22 @@ export const PageTabs = () => {
const isNftPage = useIsNftPage()
const shouldDisableNFTRoutes = useDisableNFTRoutes()
const infoExplorePageEnabled = useInfoExplorePageEnabled()
return (
<>
<MenuItem href="/swap" isActive={pathname.startsWith('/swap')}>
<Trans>Swap</Trans>
</MenuItem>
{infoExplorePageEnabled ? (
<MenuItem href={`/explore/tokens/${chainName.toLowerCase()}`} isActive={pathname.startsWith('/explore')}>
<Trans>Explore</Trans>
</MenuItem>
) : (
<MenuItem href={`/tokens/${chainName.toLowerCase()}`} isActive={pathname.startsWith('/tokens')}>
<Trans>Tokens</Trans>
</MenuItem>
)}
{!shouldDisableNFTRoutes && (
<MenuItem dataTestId="nft-nav" href="/nfts" isActive={isNftPage}>
<Trans>NFTs</Trans>

@ -1,4 +1,5 @@
import { SwapSkeleton } from 'components/swap/SwapSkeleton'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { ArrowLeft } from 'react-feather'
import { useParams } from 'react-router-dom'
import styled, { useTheme } from 'styled-components'
@ -220,9 +221,12 @@ function LoadingStats() {
/* Loading State: row component with loading bubbles */
export default function TokenDetailsSkeleton() {
const { chainName } = useParams<{ chainName?: string }>()
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
return (
<LeftPanel>
<BreadcrumbNavLink to={chainName ? `/tokens/${chainName}` : `/explore`}>
<BreadcrumbNavLink
to={(isInfoExplorePageEnabled ? '/explore' : '') + (chainName ? `/tokens/${chainName}` : `/tokens`)}
>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<TokenInfoContainer>

@ -23,6 +23,7 @@ import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP'
import { TokenPriceQuery } from 'graphql/data/__generated__/types-and-hooks'
import { Chain, TokenQuery, TokenQueryData } from 'graphql/data/Token'
@ -137,6 +138,8 @@ export default function TokenDetails({
const isBlockedToken = tokenWarning?.canProceed === false
const navigate = useNavigate()
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
// Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again.
const [isPending, startTokenTransition] = useTransition()
const navigateToTokenForChain = useCallback(
@ -144,12 +147,20 @@ export default function TokenDetails({
if (!address) return
const bridgedAddress = crossChainMap[update]
if (bridgedAddress) {
startTokenTransition(() => navigate(getTokenDetailsURL({ address: bridgedAddress, chain: update })))
startTokenTransition(() =>
navigate(
getTokenDetailsURL({
address: bridgedAddress,
chain: update,
isInfoExplorePageEnabled,
})
)
)
} else if (didFetchFromChain || detailedToken?.isNative) {
startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain: update })))
startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain: update, isInfoExplorePageEnabled })))
}
},
[address, crossChainMap, didFetchFromChain, navigate, detailedToken?.isNative]
[address, crossChainMap, didFetchFromChain, detailedToken?.isNative, navigate, isInfoExplorePageEnabled]
)
useOnGlobalChainSwitch(navigateToTokenForChain)
@ -175,11 +186,12 @@ export default function TokenDetails({
tokens[Field.INPUT] && tokens[Field.INPUT]?.currencyId !== newDefaultTokenID
? tokens[Field.INPUT]?.currencyId
: null,
isInfoExplorePageEnabled,
})
)
)
},
[address, chain, navigate]
[address, chain, isInfoExplorePageEnabled, navigate]
)
const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>()
@ -207,7 +219,7 @@ export default function TokenDetails({
<TokenDetailsLayout>
{detailedToken && !isPending ? (
<LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chain.toLowerCase()}`}>
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chain.toLowerCase()}`}>
<ArrowLeft data-testid="token-details-return-button" size={14} /> Tokens
</BreadcrumbNavLink>
<TokenInfoContainer data-testid="token-info-container">

@ -1,6 +1,7 @@
import Badge from 'components/Badge'
import { ChainLogo } from 'components/Logo/ChainLogo'
import { getChainInfo } from 'constants/chainInfo'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import {
BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS,
BACKEND_SUPPORTED_CHAINS,
@ -8,6 +9,7 @@ import {
validateUrlChainParam,
} from 'graphql/data/util'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useExploreParams } from 'pages/Explore/redirects'
import { useRef } from 'react'
import { Check, ChevronDown, ChevronUp } from 'react-feather'
import { useNavigate, useParams } from 'react-router-dom'
@ -48,8 +50,8 @@ const InternalLinkMenuItem = styled(InternalMenuItem)<{ disabled?: boolean }>`
pointer-events: none;
`}
`
const MenuTimeFlyout = styled.span`
min-width: 240px;
const MenuTimeFlyout = styled.span<{ isInfoExplorePageEnabled: boolean }>`
min-width: ${({ isInfoExplorePageEnabled }) => (isInfoExplorePageEnabled ? '150px' : '240px')};
max-height: 350px;
overflow: auto;
background-color: ${({ theme }) => theme.surface1};
@ -63,7 +65,18 @@ const MenuTimeFlyout = styled.span`
position: absolute;
top: 48px;
z-index: 100;
${({ isInfoExplorePageEnabled }) =>
isInfoExplorePageEnabled
? css`
right: 0px;
@media screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) {
left: 0px;
}
`
: css`
left: 0px;
`}
`
const StyledMenu = styled.div`
display: flex;
@ -96,8 +109,8 @@ const CheckContainer = styled.div`
display: flex;
flex-direction: flex-end;
`
const NetworkFilterOption = styled(FilterOption)`
min-width: 156px;
const NetworkFilterOption = styled(FilterOption)<{ isInfoExplorePageEnabled: boolean }>`
${({ isInfoExplorePageEnabled }) => !isInfoExplorePageEnabled && 'min-width: 156px;'}
`
const Tag = styled(Badge)`
background-color: ${({ theme }) => theme.surface2};
@ -114,6 +127,9 @@ export default function NetworkFilter() {
const toggleMenu = useToggleModal(ApplicationModal.NETWORK_FILTER)
useOnClickOutside(node, open ? toggleMenu : undefined)
const navigate = useNavigate()
const { tab } = useExploreParams()
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
const currentChainName = validateUrlChainParam(useParams().chainName)
const chainId = supportedChainIdFromGQLChain(currentChainName)
@ -123,6 +139,7 @@ export default function NetworkFilter() {
return (
<StyledMenu ref={node}>
<NetworkFilterOption
isInfoExplorePageEnabled={isInfoExplorePageEnabled}
onClick={toggleMenu}
aria-label="networkFilter"
active={open}
@ -130,7 +147,7 @@ export default function NetworkFilter() {
>
<StyledMenuContent>
<NetworkLabel>
<ChainLogo chainId={chainId} size={20} /> {chainInfo.label}
<ChainLogo chainId={chainId} size={20} /> {!isInfoExplorePageEnabled && chainInfo.label}
</NetworkLabel>
<Chevron open={open}>
{open ? (
@ -142,7 +159,7 @@ export default function NetworkFilter() {
</StyledMenuContent>
</NetworkFilterOption>
{open && (
<MenuTimeFlyout>
<MenuTimeFlyout isInfoExplorePageEnabled={isInfoExplorePageEnabled}>
{BACKEND_SUPPORTED_CHAINS.map((network) => {
const chainId = supportedChainIdFromGQLChain(network)
const chainInfo = getChainInfo(chainId)
@ -151,7 +168,9 @@ export default function NetworkFilter() {
key={network}
data-testid={`tokens-network-filter-option-${network.toLowerCase()}`}
onClick={() => {
navigate(`/tokens/${network.toLowerCase()}`)
isInfoExplorePageEnabled
? navigate(`/explore/${tab}/${network.toLowerCase()}`)
: navigate(`/tokens/${network.toLowerCase()}`)
toggleMenu()
}}
>

@ -3,6 +3,7 @@ import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap
import { TraceEvent } from 'analytics'
import searchIcon from 'assets/svg/search.svg'
import xIcon from 'assets/svg/x.svg'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import useDebounce from 'hooks/useDebounce'
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import { useEffect, useState } from 'react'
@ -12,11 +13,12 @@ import { MEDIUM_MEDIA_BREAKPOINT } from '../constants'
import { filterStringAtom } from '../state'
const ICON_SIZE = '20px'
const SearchBarContainer = styled.div`
const SearchBarContainer = styled.div<{ isInfoExplorePageEnabled: boolean }>`
display: flex;
flex: 1;
${({ isInfoExplorePageEnabled }) => isInfoExplorePageEnabled && 'justify-content: flex-end;'}
`
const SearchInput = styled.input`
const SearchInput = styled.input<{ isInfoExplorePageEnabled: boolean; isOpen?: boolean }>`
background: no-repeat scroll 7px 7px;
background-image: url(${searchIcon});
background-size: 20px 20px;
@ -25,12 +27,14 @@ const SearchInput = styled.input`
border-radius: 12px;
border: 1px solid ${({ theme }) => theme.surface3};
height: 100%;
width: min(200px, 100%);
width: ${({ isInfoExplorePageEnabled, isOpen }) =>
isInfoExplorePageEnabled ? (isOpen ? '200px' : '0') : 'min(200px, 100%)'};
font-size: 16px;
font-weight: 485;
padding-left: 40px;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
${(isInfoExplorePageEnabled) => isInfoExplorePageEnabled && 'text-overflow: ellipsis;'}
:hover {
background-color: ${({ theme }) => theme.surface1};
@ -58,15 +62,18 @@ const SearchInput = styled.input`
}
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
width: 100%;
width: ${({ isInfoExplorePageEnabled, isOpen }) =>
isInfoExplorePageEnabled ? (isOpen ? 'min(100%, 200px)' : '0') : '100%'};
}
`
export default function SearchBar() {
export default function SearchBar({ tab }: { tab?: string }) {
const currentString = useAtomValue(filterStringAtom)
const [localFilterString, setLocalFilterString] = useState(currentString)
const setFilterString = useUpdateAtom(filterStringAtom)
const debouncedLocalFilterString = useDebounce(localFilterString, 300)
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
setLocalFilterString(currentString)
@ -76,8 +83,14 @@ export default function SearchBar() {
setFilterString(debouncedLocalFilterString)
}, [debouncedLocalFilterString, setFilterString])
const handleFocus = () => setIsOpen(true)
const handleBlur = () => {
if (localFilterString === '') setIsOpen(false)
}
return (
<SearchBarContainer>
<SearchBarContainer isInfoExplorePageEnabled={isInfoExplorePageEnabled}>
<Trans
render={({ translation }) => (
<TraceEvent
@ -86,6 +99,7 @@ export default function SearchBar() {
element={InterfaceElementName.EXPLORE_SEARCH_INPUT}
>
<SearchInput
isInfoExplorePageEnabled={isInfoExplorePageEnabled}
data-cy="explore-tokens-search-input"
type="search"
placeholder={`${translation}`}
@ -93,11 +107,14 @@ export default function SearchBar() {
autoComplete="off"
value={localFilterString}
onChange={({ target: { value } }) => setLocalFilterString(value)}
isOpen={isOpen}
onFocus={isInfoExplorePageEnabled ? handleFocus : undefined}
onBlur={isInfoExplorePageEnabled ? handleBlur : undefined}
/>
</TraceEvent>
)}
>
Filter tokens
{isInfoExplorePageEnabled ? (tab === 'tokens' ? 'Search tokens' : 'Search pools') : 'Filter tokens'}
</Trans>
</SearchBarContainer>
)

@ -1,3 +1,5 @@
import { Trans } from '@lingui/macro'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { TimePeriod } from 'graphql/data/util'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useAtom } from 'jotai'
@ -5,7 +7,7 @@ import { useRef } from 'react'
import { Check, ChevronDown, ChevronUp } from 'react-feather'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { useTheme } from 'styled-components'
import styled, { css, useTheme } from 'styled-components'
import { MOBILE_MEDIA_BREAKPOINT, SMALL_MEDIA_BREAKPOINT } from '../constants'
import { filterTimeAtom } from '../state'
@ -52,8 +54,8 @@ const InternalLinkMenuItem = styled(InternalMenuItem)`
text-decoration: none;
}
`
const MenuTimeFlyout = styled.span`
min-width: 240px;
const MenuTimeFlyout = styled.span<{ isInfoExplorePageEnabled: boolean }>`
min-width: ${({ isInfoExplorePageEnabled }) => (isInfoExplorePageEnabled ? '150px' : '240px')};
max-height: 300px;
overflow: auto;
background-color: ${({ theme }) => theme.surface1};
@ -69,12 +71,16 @@ const MenuTimeFlyout = styled.span`
z-index: 100;
left: 0px;
${({ isInfoExplorePageEnabled }) =>
!isInfoExplorePageEnabled &&
css`
@media only screen and (max-width: ${SMALL_MEDIA_BREAKPOINT}) {
right: 0px;
left: unset;
right: 0px;
}
`}
`
const StyledMenu = styled.div`
const StyledMenu = styled.div<{ isInfoExplorePageEnabled: boolean }>`
display: flex;
justify-content: center;
align-items: center;
@ -82,11 +88,15 @@ const StyledMenu = styled.div`
border: none;
text-align: left;
${({ isInfoExplorePageEnabled }) =>
!isInfoExplorePageEnabled &&
css`
@media only screen and (max-width: ${MOBILE_MEDIA_BREAKPOINT}) {
width: 72px;
}
`}
`
const StyledMenuContent = styled.div`
const StyledMenuContent = styled.div<{ isInfoExplorePageEnabled: boolean }>`
display: flex;
justify-content: space-between;
gap: 8px;
@ -94,6 +104,7 @@ const StyledMenuContent = styled.div`
border: none;
width: 100%;
vertical-align: middle;
${({ isInfoExplorePageEnabled }) => isInfoExplorePageEnabled && 'white-space: nowrap;'}
`
const Chevron = styled.span<{ open: boolean }>`
padding-top: 1px;
@ -109,11 +120,19 @@ export default function TimeSelector() {
useOnClickOutside(node, open ? toggleMenu : undefined)
const [activeTime, setTime] = useAtom(filterTimeAtom)
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
return (
<StyledMenu ref={node}>
<StyledMenu ref={node} isInfoExplorePageEnabled={isInfoExplorePageEnabled}>
<FilterOption onClick={toggleMenu} aria-label="timeSelector" active={open} data-testid="time-selector">
<StyledMenuContent>
{DISPLAYS[activeTime]}
<StyledMenuContent isInfoExplorePageEnabled={isInfoExplorePageEnabled}>
{isInfoExplorePageEnabled ? (
<>
{DISPLAYS[activeTime]} <Trans>volume</Trans>
</>
) : (
DISPLAYS[activeTime]
)}
<Chevron open={open}>
{open ? (
<ChevronUp width={20} height={15} viewBox="0 0 24 20" />
@ -124,7 +143,7 @@ export default function TimeSelector() {
</StyledMenuContent>
</FilterOption>
{open && (
<MenuTimeFlyout>
<MenuTimeFlyout isInfoExplorePageEnabled={isInfoExplorePageEnabled}>
{ORDERED_TIMES.map((time) => (
<InternalLinkMenuItem
key={DISPLAYS[time]}
@ -134,7 +153,13 @@ export default function TimeSelector() {
toggleMenu()
}}
>
{isInfoExplorePageEnabled ? (
<div>
{DISPLAYS[time]} <Trans>volume</Trans>
</div>
) : (
<div>{DISPLAYS[time]}</div>
)}
{time === activeTime && <Check color={theme.accent1} size={16} />}
</InternalLinkMenuItem>
))}

@ -8,6 +8,7 @@ import { ArrowChangeUp } from 'components/Icons/ArrowChangeUp'
import { Info } from 'components/Icons/Info'
import QueryTokenLogo from 'components/Logo/QueryTokenLogo'
import { MouseoverTooltip } from 'components/Tooltip'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
import { getTokenDetailsURL, supportedChainIdFromGQLChain, validateUrlChainParam } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
@ -466,11 +467,13 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
// A simple 0 price indicates the price is not currently available from the api
const price = token.market?.price?.value === 0 ? '-' : formatFiatPrice({ price: token.market?.price?.value })
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
// TODO: currency logo sizing mobile (32px) vs. desktop (24px)
return (
<div ref={ref} data-testid={`token-table-row-${token.address}`}>
<StyledLink
to={getTokenDetailsURL(token)}
to={getTokenDetailsURL({ ...token, isInfoExplorePageEnabled })}
onClick={() =>
sendAnalyticsEvent(InterfaceEventName.EXPLORE_TOKEN_ROW_CLICKED, exploreTokenSelectedEventProperties)
}

@ -1,5 +1,6 @@
import { ChainId } from '@uniswap/sdk-core'
// Breakpoints specifically for the token pages
export const MAX_WIDTH_MEDIA_BREAKPOINT = '1200px'
export const XLARGE_MEDIA_BREAKPOINT = '960px'
export const LARGE_MEDIA_BREAKPOINT = '840px'

@ -3,3 +3,7 @@ import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
export function useInfoExploreFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.infoExplore)
}
export function useInfoExplorePageEnabled(): boolean {
return useInfoExploreFlag() === BaseVariant.Enabled
}

@ -207,15 +207,17 @@ export function getTokenDetailsURL({
address,
chain,
inputAddress,
isInfoExplorePageEnabled,
}: {
address?: string | null
chain: Chain
inputAddress?: string | null
isInfoExplorePageEnabled: boolean
}) {
const chainName = chain.toLowerCase()
const tokenAddress = address ?? NATIVE_CHAIN_ID
const inputAddressSuffix = inputAddress ? `?inputCurrency=${inputAddress}` : ''
return `/tokens/${chainName}/${tokenAddress}${inputAddressSuffix}`
return (isInfoExplorePageEnabled ? '/explore' : '') + `/tokens/${chainName}/${tokenAddress}${inputAddressSuffix}`
}
export function unwrapToken<

246
src/pages/Explore/index.tsx Normal file

@ -0,0 +1,246 @@
import { Trans } from '@lingui/macro'
import { BrowserEvent, InterfaceElementName, InterfacePageName, SharedEventName } from '@uniswap/analytics-events'
import { TraceEvent } from 'analytics'
import { Trace } from 'analytics'
import { AutoRow } from 'components/Row'
import { MAX_WIDTH_MEDIA_BREAKPOINT, MEDIUM_MEDIA_BREAKPOINT } from 'components/Tokens/constants'
import { filterStringAtom } from 'components/Tokens/state'
import NetworkFilter from 'components/Tokens/TokenTable/NetworkFilter'
import SearchBar from 'components/Tokens/TokenTable/SearchBar'
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
import TokenTable from 'components/Tokens/TokenTable/TokenTable'
import { MouseoverTooltip } from 'components/Tooltip'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { useResetAtom } from 'jotai/utils'
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import styled, { css } from 'styled-components'
import { ThemedText } from 'theme/components'
import { useExploreParams } from './redirects'
const ExploreContainer = styled.div`
width: 100%;
min-width: 320px;
padding: 68px 12px 0px;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
padding-top: 48px;
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
padding-top: 20px;
}
`
const TitleContainer = styled.div`
margin-bottom: 32px;
max-width: ${MAX_WIDTH_MEDIA_BREAKPOINT};
margin-left: auto;
margin-right: auto;
display: flex;
`
const NavWrapper = styled.div<{ isInfoExplorePageEnabled: boolean }>`
display: flex;
max-width: ${MAX_WIDTH_MEDIA_BREAKPOINT};
margin: 0 auto;
margin-bottom: ${({ isInfoExplorePageEnabled }) => (isInfoExplorePageEnabled ? '16px' : '20px')};
color: ${({ theme }) => theme.neutral3};
flex-direction: row;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
flex-direction: column;
gap: 8px;
}
${({ isInfoExplorePageEnabled }) =>
isInfoExplorePageEnabled &&
css`
@media screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) {
flex-direction: column;
gap: 16px;
}
`};
`
const TabBar = styled(AutoRow)`
gap: 24px;
@media screen and (max-width: ${({ theme }) => theme.breakpoint.md}px) {
gap: 16px;
}
`
const TabItem = styled(ThemedText.HeadlineMedium)<{ active?: boolean }>`
align-items: center;
color: ${({ theme, active }) => (active ? theme.neutral1 : theme.neutral2)};
cursor: pointer;
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} color`};
`
const FiltersContainer = styled.div<{ isInfoExplorePageEnabled: boolean }>`
display: flex;
gap: 8px;
height: 40px;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
${({ isInfoExplorePageEnabled }) => !isInfoExplorePageEnabled && 'order: 2;'}
}
@media screen and (max-width: ${({ theme }) => theme.breakpoint.md}px) {
${({ isInfoExplorePageEnabled }) => isInfoExplorePageEnabled && 'justify-content: space-between;'}
}
`
const DropdownFilterContainer = styled(FiltersContainer)<{ isInfoExplorePageEnabled: boolean }>`
${({ isInfoExplorePageEnabled }) =>
isInfoExplorePageEnabled
? css`
@media screen and (max-width: ${({ theme }) => theme.breakpoint.md}px) {
justify-content: flex-start;
}
`
: css`
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
justify-content: flex-start;
}
`};
`
const SearchContainer = styled(FiltersContainer)<{ isInfoExplorePageEnabled: boolean }>`
${({ isInfoExplorePageEnabled }) => !isInfoExplorePageEnabled && 'margin-left: 8px;'}
width: 100%;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
${({ isInfoExplorePageEnabled }) => !isInfoExplorePageEnabled && 'order: 1; margin: 0px;'}
}
@media screen and (max-width: ${({ theme }) => theme.breakpoint.md}px) {
${({ isInfoExplorePageEnabled }) => isInfoExplorePageEnabled && 'justify-content: flex-end;'}
}
`
export enum ExploreTab {
Tokens = 'tokens',
Pools = 'pools',
Transactions = 'transactions',
}
interface Page {
title: React.ReactNode
key: ExploreTab
component: () => JSX.Element
loggingElementName: string
}
const Pages: Array<Page> = [
{
title: <Trans>Tokens</Trans>,
key: ExploreTab.Tokens,
component: TokenTable,
loggingElementName: InterfaceElementName.EXPLORE_TOKENS_TAB,
},
{
title: <Trans>Pools</Trans>,
key: ExploreTab.Pools,
component: TokenTable,
loggingElementName: InterfaceElementName.EXPLORE_POOLS_TAB,
},
{
title: <Trans>Transactions</Trans>,
key: ExploreTab.Transactions,
component: TokenTable,
loggingElementName: InterfaceElementName.EXPLORE_TRANSACTIONS_TAB,
},
]
const Explore = ({ initialTab }: { initialTab?: ExploreTab }) => {
const resetFilterString = useResetAtom(filterStringAtom)
const location = useLocation()
const navigate = useNavigate()
const initialKey: number = useMemo(() => {
const key = initialTab && Pages.findIndex((page) => page.key === initialTab)
if (!key || key === -1) return 0
return key
}, [initialTab])
const [currentTab, setCurrentTab] = useState(initialKey)
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
// to allow backward navigation between tabs
const { tab } = useExploreParams()
useEffect(() => {
const tabIndex = Pages.findIndex((page) => page.key === tab)
if (tabIndex !== -1) {
setCurrentTab(tabIndex)
}
}, [tab])
useEffect(() => {
resetFilterString()
}, [location, resetFilterString])
const { component: Page, key: currentKey } = Pages[currentTab]
return (
<Trace
page={isInfoExplorePageEnabled ? InterfacePageName.EXPLORE_PAGE : InterfacePageName.TOKENS_PAGE}
shouldLogImpression
>
<ExploreContainer>
{/* TODO(WEB-2749 & WEB-2750): add graphs to explore page */}
{!isInfoExplorePageEnabled && (
<TitleContainer>
<MouseoverTooltip
text={<Trans>This table contains the top tokens by Uniswap volume, sorted based on your input.</Trans>}
placement="bottom"
>
<ThemedText.LargeHeader>
<Trans>Top tokens on Uniswap</Trans>
</ThemedText.LargeHeader>
</MouseoverTooltip>
</TitleContainer>
)}
<NavWrapper isInfoExplorePageEnabled={isInfoExplorePageEnabled}>
{isInfoExplorePageEnabled && (
<TabBar data-testid="explore-navbar">
{Pages.map(({ title, loggingElementName, key }, index) => {
const handleNavItemClick = () => {
setCurrentTab(index)
navigate(`/explore/${key}`)
}
return (
<TraceEvent
events={[BrowserEvent.onClick]}
name={SharedEventName.NAVBAR_CLICKED}
element={loggingElementName}
key={index}
>
<TabItem onClick={handleNavItemClick} active={currentTab === index} key={key}>
{title}
</TabItem>
</TraceEvent>
)
})}
</TabBar>
)}
{isInfoExplorePageEnabled ? (
<FiltersContainer isInfoExplorePageEnabled>
<DropdownFilterContainer isInfoExplorePageEnabled>
<NetworkFilter />
{currentKey === ExploreTab.Tokens && <TimeSelector />}
</DropdownFilterContainer>
<SearchContainer isInfoExplorePageEnabled>
{currentKey !== ExploreTab.Transactions && <SearchBar tab={currentKey} />}
</SearchContainer>
</FiltersContainer>
) : (
<>
<FiltersContainer isInfoExplorePageEnabled={false}>
<NetworkFilter />
<TimeSelector />
</FiltersContainer>
<SearchContainer isInfoExplorePageEnabled={false}>
<SearchBar />
</SearchContainer>
</>
)}
</NavWrapper>
{isInfoExplorePageEnabled ? <Page /> : <TokenTable />}
</ExploreContainer>
</Trace>
)
}
export default Explore

@ -0,0 +1,39 @@
import { Navigate, useParams } from 'react-router-dom'
import Explore, { ExploreTab } from '.'
// useParams struggles to distinguish between /explore/:chainId and /explore/:tab
export function useExploreParams(): {
tab?: ExploreTab
chainName?: string
tokenAddress?: string
} {
const { tab, chainName, tokenAddress } = useParams<{ tab: string; chainName: string; tokenAddress: string }>()
const exploreTabs = Object.values(ExploreTab)
if (tab && !chainName && exploreTabs.includes(tab as ExploreTab)) {
// /explore/:tab
return { tab: tab as ExploreTab, chainName: undefined, tokenAddress }
} else if (tab && !chainName) {
// /explore/:chainName
return { tab: ExploreTab.Tokens, chainName: tab, tokenAddress }
} else if (!tab && !chainName) {
// legacy /tokens
return { tab: ExploreTab.Tokens, chainName: undefined, tokenAddress: undefined }
} else {
// /explore/:tab/:chainName
return { tab: tab as ExploreTab, chainName, tokenAddress }
}
}
export default function RedirectExplore() {
const { tab, chainName, tokenAddress } = useExploreParams()
if (tab && chainName && tokenAddress) {
return <Navigate to={`/explore/${tab}/${chainName}/${tokenAddress}`} replace />
} else if (chainName && tokenAddress) {
return <Navigate to={`/explore/tokens/${chainName}/${tokenAddress}`} replace />
} else if (tab && chainName) {
return <Navigate to={`/explore/${tab}/${chainName}`} replace />
} else if (chainName) {
return <Navigate to={`/explore/tokens/${chainName}`} replace />
}
return <Explore initialTab={tab as ExploreTab} />
}

@ -1,3 +1,4 @@
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { useInfoPoolPageEnabled } from 'featureFlags/flags/infoPoolPage'
import { useAtom } from 'jotai'
import { lazy, ReactNode, Suspense, useMemo } from 'react'
@ -15,8 +16,10 @@ const Collection = lazy(() => import('nft/pages/collection'))
const Profile = lazy(() => import('nft/pages/profile'))
const Asset = lazy(() => import('nft/pages/asset/Asset'))
const AddLiquidity = lazy(() => import('pages/AddLiquidity'))
const Explore = lazy(() => import('pages/Explore'))
const RedirectDuplicateTokenIds = lazy(() => import('pages/AddLiquidity/redirects'))
const RedirectDuplicateTokenIdsV2 = lazy(() => import('pages/AddLiquidityV2/redirects'))
const RedirectExplore = lazy(() => import('pages/Explore/redirects'))
const MigrateV2 = lazy(() => import('pages/MigrateV2'))
const MigrateV2Pair = lazy(() => import('pages/MigrateV2/MigrateV2Pair'))
const NotFound = lazy(() => import('pages/NotFound'))
@ -28,7 +31,6 @@ const PoolFinder = lazy(() => import('pages/PoolFinder'))
const RemoveLiquidity = lazy(() => import('pages/RemoveLiquidity'))
const RemoveLiquidityV3 = lazy(() => import('pages/RemoveLiquidity/V3'))
const TokenDetails = lazy(() => import('pages/TokenDetails'))
const Tokens = lazy(() => import('pages/Tokens'))
const Vote = lazy(() => import('pages/Vote'))
// this is the same svg defined in assets/images/blue-loader.svg
@ -48,6 +50,7 @@ const LazyLoadSpinner = () => (
interface RouterConfig {
browserRouterEnabled?: boolean
hash?: string
infoExplorePageEnabled?: boolean
infoPoolPageEnabled?: boolean
shouldDisableNFTRoutes?: boolean
}
@ -59,15 +62,17 @@ export function useRouterConfig(): RouterConfig {
const browserRouterEnabled = isBrowserRouterEnabled()
const { hash } = useLocation()
const infoPoolPageEnabled = useInfoPoolPageEnabled()
const infoExplorePageEnabled = useInfoExplorePageEnabled()
const [shouldDisableNFTRoutes] = useAtom(shouldDisableNFTRoutesAtom)
return useMemo(
() => ({
browserRouterEnabled,
hash,
infoExplorePageEnabled,
infoPoolPageEnabled,
shouldDisableNFTRoutes: Boolean(shouldDisableNFTRoutes),
}),
[browserRouterEnabled, hash, infoPoolPageEnabled, shouldDisableNFTRoutes]
[browserRouterEnabled, hash, infoExplorePageEnabled, infoPoolPageEnabled, shouldDisableNFTRoutes]
)
}
@ -98,15 +103,44 @@ export const routes: RouteDefinition[] = [
},
}),
createRouteDefinition({
path: '/tokens',
nestedPaths: [':chainName'],
getElement: () => <Tokens />,
path: '/explore',
nestedPaths: [':tab', ':chainName'],
getElement: () => <RedirectExplore />,
enabled: (args) => Boolean(args.infoExplorePageEnabled),
}),
createRouteDefinition({ path: '/tokens/:chainName/:tokenAddress', getElement: () => <TokenDetails /> }),
createRouteDefinition({
path: '/pools/:chainName/:poolAddress',
path: '/explore',
nestedPaths: [':tab/:chainName'],
getElement: () => <Explore />,
enabled: (args) => Boolean(args.infoExplorePageEnabled),
}),
createRouteDefinition({
path: '/explore/tokens/:chainName/:tokenAddress',
getElement: () => <TokenDetails />,
enabled: (args) => Boolean(args.infoExplorePageEnabled),
}),
createRouteDefinition({
path: '/tokens',
getElement: (args) => {
return args.infoExplorePageEnabled ? <Navigate to="/explore/tokens" replace /> : <Explore />
},
}),
createRouteDefinition({
path: '/tokens/:chainName',
getElement: (args) => {
return args.infoExplorePageEnabled ? <RedirectExplore /> : <Explore />
},
}),
createRouteDefinition({
path: '/tokens/:chainName/:tokenAddress',
getElement: (args) => {
return args.infoExplorePageEnabled ? <RedirectExplore /> : <TokenDetails />
},
}),
createRouteDefinition({
path: 'explore/pools/:chainName/:poolAddress',
getElement: () => <PoolDetails />,
enabled: (args) => Boolean(args.infoPoolPageEnabled),
enabled: (args) => Boolean(args.infoExplorePageEnabled && args.infoPoolPageEnabled),
}),
createRouteDefinition({
path: '/vote/*',

@ -1,105 +0,0 @@
import { Trans } from '@lingui/macro'
import { InterfacePageName } from '@uniswap/analytics-events'
import { Trace } from 'analytics'
import { MAX_WIDTH_MEDIA_BREAKPOINT, MEDIUM_MEDIA_BREAKPOINT } from 'components/Tokens/constants'
import { filterStringAtom } from 'components/Tokens/state'
import NetworkFilter from 'components/Tokens/TokenTable/NetworkFilter'
import SearchBar from 'components/Tokens/TokenTable/SearchBar'
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
import TokenTable from 'components/Tokens/TokenTable/TokenTable'
import { MouseoverTooltip } from 'components/Tooltip'
import { useResetAtom } from 'jotai/utils'
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
import { ThemedText } from 'theme/components'
const ExploreContainer = styled.div`
width: 100%;
min-width: 320px;
padding: 68px 12px 0px;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
padding-top: 48px;
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
padding-top: 20px;
}
`
const TitleContainer = styled.div`
margin-bottom: 32px;
max-width: ${MAX_WIDTH_MEDIA_BREAKPOINT};
margin-left: auto;
margin-right: auto;
display: flex;
`
const FiltersContainer = styled.div`
display: flex;
gap: 8px;
height: 40px;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
order: 2;
}
`
const SearchContainer = styled(FiltersContainer)`
margin-left: 8px;
width: 100%;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
margin: 0px;
order: 1;
}
`
const FiltersWrapper = styled.div`
display: flex;
max-width: ${MAX_WIDTH_MEDIA_BREAKPOINT};
margin: 0 auto;
margin-bottom: 20px;
color: ${({ theme }) => theme.neutral3};
flex-direction: row;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
flex-direction: column;
gap: 8px;
}
`
const Tokens = () => {
const resetFilterString = useResetAtom(filterStringAtom)
const location = useLocation()
useEffect(() => {
resetFilterString()
}, [location, resetFilterString])
return (
<Trace page={InterfacePageName.TOKENS_PAGE} shouldLogImpression>
<ExploreContainer>
<TitleContainer>
<MouseoverTooltip
text={<Trans>This table contains the top tokens by Uniswap volume, sorted based on your input.</Trans>}
placement="bottom"
>
<ThemedText.LargeHeader>
<Trans>Top tokens on Uniswap</Trans>
</ThemedText.LargeHeader>
</MouseoverTooltip>
</TitleContainer>
<FiltersWrapper>
<FiltersContainer>
<NetworkFilter />
<TimeSelector />
</FiltersContainer>
<SearchContainer>
<SearchBar />
</SearchContainer>
</FiltersWrapper>
<TokenTable />
</ExploreContainer>
</Trace>
)
}
export default Tokens

@ -12,10 +12,37 @@ Array [
"enabled": [Function],
"getElement": [Function],
"nestedPaths": Array [
":tab",
":chainName",
],
"path": "/explore",
},
Object {
"enabled": [Function],
"getElement": [Function],
"nestedPaths": Array [
":tab/:chainName",
],
"path": "/explore",
},
Object {
"enabled": [Function],
"getElement": [Function],
"nestedPaths": Array [],
"path": "/explore/tokens/:chainName/:tokenAddress",
},
Object {
"enabled": [Function],
"getElement": [Function],
"nestedPaths": Array [],
"path": "/tokens",
},
Object {
"enabled": [Function],
"getElement": [Function],
"nestedPaths": Array [],
"path": "/tokens/:chainName",
},
Object {
"enabled": [Function],
"getElement": [Function],
@ -26,7 +53,7 @@ Array [
"enabled": [Function],
"getElement": [Function],
"nestedPaths": Array [],
"path": "/pools/:chainName/:poolAddress",
"path": "explore/pools/:chainName/:poolAddress",
},
Object {
"enabled": [Function],

@ -6059,10 +6059,10 @@
"@typescript-eslint/types" "5.59.1"
eslint-visitor-keys "^3.3.0"
"@uniswap/analytics-events@^2.24.0":
version "2.24.0"
resolved "https://registry.yarnpkg.com/@uniswap/analytics-events/-/analytics-events-2.24.0.tgz#c81d0c24da4f052b7f6b2663ff42bfa787be91b5"
integrity sha512-MhX9L95Y7i28a3KxRFJnpmNxNHAgownBVPyhT+mu4PnCXiPEuSovml+uJr277tysKSqxRYqLnCeAw4LocTBIfg==
"@uniswap/analytics-events@^2.25.0":
version "2.25.0"
resolved "https://registry.yarnpkg.com/@uniswap/analytics-events/-/analytics-events-2.25.0.tgz#06f2d81342b2e4dc516bdfa1222ddaa7c274ac04"
integrity sha512-0syw7gZtoHXSCVb+zV464L+Zgy1ICnGDOrbK2xoVtpiQ8rBjUXPWvcKuaiNPfTsS9tIZNtqOmEyZEjWwvFSLUw==
"@uniswap/analytics@1.5.0":
version "1.5.0"