diff --git a/package.json b/package.json index 33f8f1345a..93708838bc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx b/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx index 3f09e73329..8423b07a5b 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx @@ -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) diff --git a/src/components/NavBar/SuggestionRow.tsx b/src/components/NavBar/SuggestionRow.tsx index c8fdc95aa1..61c15f2941 100644 --- a/src/components/NavBar/SuggestionRow.tsx +++ b/src/components/NavBar/SuggestionRow.tsx @@ -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) => { diff --git a/src/components/NavBar/index.tsx b/src/components/NavBar/index.tsx index 9e1cacc572..aaeed3908b 100644 --- a/src/components/NavBar/index.tsx +++ b/src/components/NavBar/index.tsx @@ -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 ( <> Swap - - Tokens - + {infoExplorePageEnabled ? ( + + Explore + + ) : ( + + Tokens + + )} {!shouldDisableNFTRoutes && ( NFTs diff --git a/src/components/Tokens/TokenDetails/Skeleton.tsx b/src/components/Tokens/TokenDetails/Skeleton.tsx index e347f38287..2d1b636480 100644 --- a/src/components/Tokens/TokenDetails/Skeleton.tsx +++ b/src/components/Tokens/TokenDetails/Skeleton.tsx @@ -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 ( - + Tokens diff --git a/src/components/Tokens/TokenDetails/index.tsx b/src/components/Tokens/TokenDetails/index.tsx index 11c8dd983c..21b189f78e 100644 --- a/src/components/Tokens/TokenDetails/index.tsx +++ b/src/components/Tokens/TokenDetails/index.tsx @@ -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) => void }>() @@ -207,7 +219,7 @@ export default function TokenDetails({ {detailedToken && !isPending ? ( - + Tokens diff --git a/src/components/Tokens/TokenTable/NetworkFilter.tsx b/src/components/Tokens/TokenTable/NetworkFilter.tsx index 3a9b39adf5..6927b58907 100644 --- a/src/components/Tokens/TokenTable/NetworkFilter.tsx +++ b/src/components/Tokens/TokenTable/NetworkFilter.tsx @@ -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; - left: 0px; + + ${({ 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 ( - {chainInfo.label} + {!isInfoExplorePageEnabled && chainInfo.label} {open ? ( @@ -142,7 +159,7 @@ export default function NetworkFilter() { {open && ( - + {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() }} > diff --git a/src/components/Tokens/TokenTable/SearchBar.tsx b/src/components/Tokens/TokenTable/SearchBar.tsx index 62087ec991..fb9e535872 100644 --- a/src/components/Tokens/TokenTable/SearchBar.tsx +++ b/src/components/Tokens/TokenTable/SearchBar.tsx @@ -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 ( - + ( setLocalFilterString(value)} + isOpen={isOpen} + onFocus={isInfoExplorePageEnabled ? handleFocus : undefined} + onBlur={isInfoExplorePageEnabled ? handleBlur : undefined} /> )} > - Filter tokens + {isInfoExplorePageEnabled ? (tab === 'tokens' ? 'Search tokens' : 'Search pools') : 'Filter tokens'} ) diff --git a/src/components/Tokens/TokenTable/TimeSelector.tsx b/src/components/Tokens/TokenTable/TimeSelector.tsx index 33334411a6..a9bdc6e661 100644 --- a/src/components/Tokens/TokenTable/TimeSelector.tsx +++ b/src/components/Tokens/TokenTable/TimeSelector.tsx @@ -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; - @media only screen and (max-width: ${SMALL_MEDIA_BREAKPOINT}) { - right: 0px; - left: unset; - } + ${({ isInfoExplorePageEnabled }) => + !isInfoExplorePageEnabled && + css` + @media only screen and (max-width: ${SMALL_MEDIA_BREAKPOINT}) { + 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; - @media only screen and (max-width: ${MOBILE_MEDIA_BREAKPOINT}) { - width: 72px; - } + ${({ 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 ( - + - - {DISPLAYS[activeTime]} + + {isInfoExplorePageEnabled ? ( + <> + {DISPLAYS[activeTime]} volume + + ) : ( + DISPLAYS[activeTime] + )} {open ? ( @@ -124,7 +143,7 @@ export default function TimeSelector() { {open && ( - + {ORDERED_TIMES.map((time) => ( -
{DISPLAYS[time]}
+ {isInfoExplorePageEnabled ? ( +
+ {DISPLAYS[time]} volume +
+ ) : ( +
{DISPLAYS[time]}
+ )} {time === activeTime && }
))} diff --git a/src/components/Tokens/TokenTable/TokenRow.tsx b/src/components/Tokens/TokenTable/TokenRow.tsx index 0f5c9a1839..ee33e7b7b8 100644 --- a/src/components/Tokens/TokenTable/TokenRow.tsx +++ b/src/components/Tokens/TokenTable/TokenRow.tsx @@ -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 sendAnalyticsEvent(InterfaceEventName.EXPLORE_TOKEN_ROW_CLICKED, exploreTokenSelectedEventProperties) } diff --git a/src/components/Tokens/constants.ts b/src/components/Tokens/constants.ts index e549d5097b..6a9bc2b262 100644 --- a/src/components/Tokens/constants.ts +++ b/src/components/Tokens/constants.ts @@ -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' diff --git a/src/featureFlags/flags/infoExplore.ts b/src/featureFlags/flags/infoExplore.ts index c5b74aaf64..e20be24a15 100644 --- a/src/featureFlags/flags/infoExplore.ts +++ b/src/featureFlags/flags/infoExplore.ts @@ -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 +} diff --git a/src/graphql/data/util.tsx b/src/graphql/data/util.tsx index d5088efbec..fd99f83061 100644 --- a/src/graphql/data/util.tsx +++ b/src/graphql/data/util.tsx @@ -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< diff --git a/src/pages/Explore/index.tsx b/src/pages/Explore/index.tsx new file mode 100644 index 0000000000..5f4c6cc8ed --- /dev/null +++ b/src/pages/Explore/index.tsx @@ -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 = [ + { + title: Tokens, + key: ExploreTab.Tokens, + component: TokenTable, + loggingElementName: InterfaceElementName.EXPLORE_TOKENS_TAB, + }, + { + title: Pools, + key: ExploreTab.Pools, + component: TokenTable, + loggingElementName: InterfaceElementName.EXPLORE_POOLS_TAB, + }, + { + title: Transactions, + 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 ( + + + {/* TODO(WEB-2749 & WEB-2750): add graphs to explore page */} + {!isInfoExplorePageEnabled && ( + + This table contains the top tokens by Uniswap volume, sorted based on your input.} + placement="bottom" + > + + Top tokens on Uniswap + + + + )} + + {isInfoExplorePageEnabled && ( + + {Pages.map(({ title, loggingElementName, key }, index) => { + const handleNavItemClick = () => { + setCurrentTab(index) + navigate(`/explore/${key}`) + } + return ( + + + {title} + + + ) + })} + + )} + {isInfoExplorePageEnabled ? ( + + + + {currentKey === ExploreTab.Tokens && } + + + {currentKey !== ExploreTab.Transactions && } + + + ) : ( + <> + + + + + + + + + )} + + {isInfoExplorePageEnabled ? : } + + + ) +} + +export default Explore diff --git a/src/pages/Explore/redirects.tsx b/src/pages/Explore/redirects.tsx new file mode 100644 index 0000000000..d5f81919e7 --- /dev/null +++ b/src/pages/Explore/redirects.tsx @@ -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 + } else if (chainName && tokenAddress) { + return + } else if (tab && chainName) { + return + } else if (chainName) { + return + } + return +} diff --git a/src/pages/RouteDefinitions.tsx b/src/pages/RouteDefinitions.tsx index b87d31a53f..fa713a3b35 100644 --- a/src/pages/RouteDefinitions.tsx +++ b/src/pages/RouteDefinitions.tsx @@ -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: () => , + path: '/explore', + nestedPaths: [':tab', ':chainName'], + getElement: () => , + enabled: (args) => Boolean(args.infoExplorePageEnabled), }), - createRouteDefinition({ path: '/tokens/:chainName/:tokenAddress', getElement: () => }), createRouteDefinition({ - path: '/pools/:chainName/:poolAddress', + path: '/explore', + nestedPaths: [':tab/:chainName'], + getElement: () => , + enabled: (args) => Boolean(args.infoExplorePageEnabled), + }), + createRouteDefinition({ + path: '/explore/tokens/:chainName/:tokenAddress', + getElement: () => , + enabled: (args) => Boolean(args.infoExplorePageEnabled), + }), + createRouteDefinition({ + path: '/tokens', + getElement: (args) => { + return args.infoExplorePageEnabled ? : + }, + }), + createRouteDefinition({ + path: '/tokens/:chainName', + getElement: (args) => { + return args.infoExplorePageEnabled ? : + }, + }), + createRouteDefinition({ + path: '/tokens/:chainName/:tokenAddress', + getElement: (args) => { + return args.infoExplorePageEnabled ? : + }, + }), + createRouteDefinition({ + path: 'explore/pools/:chainName/:poolAddress', getElement: () => , - enabled: (args) => Boolean(args.infoPoolPageEnabled), + enabled: (args) => Boolean(args.infoExplorePageEnabled && args.infoPoolPageEnabled), }), createRouteDefinition({ path: '/vote/*', diff --git a/src/pages/Tokens/index.tsx b/src/pages/Tokens/index.tsx deleted file mode 100644 index 91496edbf5..0000000000 --- a/src/pages/Tokens/index.tsx +++ /dev/null @@ -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 ( - - - - This table contains the top tokens by Uniswap volume, sorted based on your input.} - placement="bottom" - > - - Top tokens on Uniswap - - - - - - - - - - - - - - - - ) -} - -export default Tokens diff --git a/src/pages/__snapshots__/routes.test.ts.snap b/src/pages/__snapshots__/routes.test.ts.snap index 1e87906873..5bb8372beb 100644 --- a/src/pages/__snapshots__/routes.test.ts.snap +++ b/src/pages/__snapshots__/routes.test.ts.snap @@ -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], diff --git a/yarn.lock b/yarn.lock index ab48e6af92..36e480eca7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"