Commit d0e4659d authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

feat: transition between tokens details (#4981)

* fix: navigate to widget-selected token

* fix: leave tokens if default is already set

* refactor: clean up widget skeleton

* fix: clean widget skeleton

* feat: transition between tokens

* fix: flicker on chart draw

* fix: nits

* fix: pixel-match loader

* fix: rm debug clause

* fix: hr color
parent 2d87e692
......@@ -124,7 +124,7 @@ export default function ChartSection({
</TokenInfoContainer>
<ChartContainer>
<ParentSize>
{({ width, height }) => prices && <PriceChart prices={prices[timePeriod]} width={width} height={height} />}
{({ width }) => prices && <PriceChart prices={prices[timePeriod]} width={width} height={436} />}
</ParentSize>
</ChartContainer>
</ChartHeader>
......
......@@ -284,7 +284,7 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
message={prices && prices.length === 0 ? <NoV3DataMessage /> : <MissingDataMessage />}
/>
) : (
<svg width={width} height={graphHeight}>
<svg width={width} height={graphHeight} style={{ minWidth: '100%' }}>
<AnimatedInLineChart
data={prices}
getX={getX}
......@@ -341,6 +341,19 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
) : (
<AxisBottom scale={timeScale} stroke={theme.backgroundOutline} top={graphHeight - 1} hideTicks />
)}
{!width && (
// Ensures an axis is drawn even if the width is not yet initialized.
<line
x1={0}
y1={graphHeight - 1}
x2="100%"
y2={graphHeight - 1}
fill="transparent"
shapeRendering="crispEdges"
stroke={theme.backgroundOutline}
strokeWidth={1}
/>
)}
<rect
x={0}
y={0}
......@@ -391,7 +404,7 @@ function MissingPriceChart({ width, height, message }: { width: number; height:
const theme = useTheme()
const midPoint = height / 2 + 45
return (
<StyledMissingChart width={width} height={height}>
<StyledMissingChart width={width} height={height} style={{ minWidth: '100%' }}>
<path
d={`M 0 ${midPoint} Q 104 ${midPoint - 70}, 208 ${midPoint} T 416 ${midPoint}
M 416 ${midPoint} Q 520 ${midPoint - 70}, 624 ${midPoint} T 832 ${midPoint}`}
......
import { WidgetSkeleton } from 'components/Widget'
import { LeftPanel, RightPanel, TokenDetailsLayout } from 'pages/TokenDetails'
import { WIDGET_WIDTH } from 'components/Widget'
import { ArrowLeft } from 'react-feather'
import { useParams } from 'react-router-dom'
import styled, { useTheme } from 'styled-components/macro'
import { LoadingBubble } from '../loading'
import { AboutContainer, AboutHeader, ResourcesContainer } from './About'
import { ContractAddressSection } from './AddressSection'
import { AboutContainer, AboutHeader } from './About'
import { BreadcrumbNavLink } from './BreadcrumbNavLink'
import { ChartContainer, ChartHeader, TokenInfoContainer, TokenNameCell } from './ChartSection'
import { DeltaContainer, TokenPrice } from './PriceChart'
import { StatPair, StatWrapper, TokenStatsSection } from './StatsSection'
import { StatPair, StatsWrapper, StatWrapper } from './StatsSection'
export const Hr = styled.hr`
background-color: ${({ theme }) => theme.backgroundOutline};
border: none;
height: 0.5px;
`
export const TokenDetailsLayout = styled.div`
display: flex;
padding: 0 8px 52px;
justify-content: center;
width: 100%;
@media screen and (min-width: ${({ theme }) => theme.breakpoint.sm}px) {
gap: 16px;
padding: 0 16px;
}
@media screen and (min-width: ${({ theme }) => theme.breakpoint.md}px) {
gap: 20px;
padding: 48px 20px;
}
@media screen and (min-width: ${({ theme }) => theme.breakpoint.xl}px) {
gap: 40px;
}
`
export const LeftPanel = styled.div`
flex: 1;
max-width: 780px;
overflow: hidden;
`
export const RightPanel = styled.div`
display: none;
flex-direction: column;
gap: 20px;
width: ${WIDGET_WIDTH}px;
@media screen and (min-width: ${({ theme }) => theme.breakpoint.lg}px) {
display: flex;
}
`
const LoadingChartContainer = styled(ChartContainer)`
height: 336px;
border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline};
height: 313px; // save 1px for the border-bottom (ie y-axis)
overflow: hidden;
`
/* Loading state bubbles */
const LoadingDetailBubble = styled(LoadingBubble)`
const DetailBubble = styled(LoadingBubble)`
height: 16px;
width: 180px;
`
const TitleLoadingBubble = styled(LoadingDetailBubble)`
width: 140px;
`
const SquareLoadingBubble = styled(LoadingDetailBubble)`
const SquaredBubble = styled(DetailBubble)`
height: 32px;
border-radius: 8px;
margin-bottom: 10px;
`
const PriceLoadingBubble = styled(SquareLoadingBubble)`
const TokenLogoBubble = styled(DetailBubble)`
width: 32px;
height: 32px;
border-radius: 50%;
`
const TitleBubble = styled(DetailBubble)`
width: 140px;
`
const PriceBubble = styled(SquaredBubble)`
height: 40px;
`
const LongLoadingBubble = styled(LoadingDetailBubble)`
margin-top: 6px;
width: 100%;
const DeltaBubble = styled(DetailBubble)`
width: 96px;
`
const SectionBubble = styled(SquaredBubble)`
width: 96px;
`
const StatTitleBubble = styled(DetailBubble)`
width: 25%;
margin-bottom: 4px;
`
const HalfLoadingBubble = styled(LoadingDetailBubble)`
margin-top: 6px;
const StatBubble = styled(SquaredBubble)`
width: 50%;
`
const IconLoadingBubble = styled(LoadingDetailBubble)`
width: 32px;
height: 32px;
border-radius: 50%;
const WideBubble = styled(DetailBubble)`
margin-bottom: 6px;
width: 100%;
`
const StatLoadingBubble = styled(SquareLoadingBubble)`
width: 116px;
const HalfWideBubble = styled(WideBubble)`
width: 50%;
`
const StatsLoadingContainer = styled.div`
width: 100%;
display: flex;
gap: 24px;
flex-wrap: wrap;
`
const ChartAnimation = styled.div`
display: flex;
animation: wave 8s cubic-bezier(0.36, 0.45, 0.63, 0.53) infinite;
display: flex;
overflow: hidden;
@keyframes wave {
......@@ -70,7 +119,7 @@ const Space = styled.div<{ heightSize: number }>`
height: ${({ heightSize }) => `${heightSize}px`};
`
export function Wave() {
function Wave() {
const theme = useTheme()
return (
<svg width="416" height="160" xmlns="http://www.w3.org/2000/svg">
......@@ -79,82 +128,95 @@ export function Wave() {
)
}
function LoadingChart() {
return (
<ChartHeader>
<TokenInfoContainer>
<TokenNameCell>
<TokenLogoBubble />
<TitleBubble />
</TokenNameCell>
</TokenInfoContainer>
<TokenPrice>
<PriceBubble />
</TokenPrice>
<DeltaContainer>
<DeltaBubble />
</DeltaContainer>
<Space heightSize={6} />
<LoadingChartContainer>
<div>
<ChartAnimation>
<Wave />
<Wave />
<Wave />
<Wave />
<Wave />
</ChartAnimation>
</div>
</LoadingChartContainer>
</ChartHeader>
)
}
function LoadingStats() {
return (
<StatsWrapper>
<SectionBubble />
<StatsLoadingContainer>
<StatPair>
<StatWrapper>
<StatTitleBubble />
<StatBubble />
</StatWrapper>
<StatWrapper>
<StatTitleBubble />
<StatBubble />
</StatWrapper>
</StatPair>
<StatPair>
<StatWrapper>
<StatTitleBubble />
<StatBubble />
</StatWrapper>
<StatWrapper>
<StatTitleBubble />
<StatBubble />
</StatWrapper>
</StatPair>
</StatsLoadingContainer>
</StatsWrapper>
)
}
/* Loading State: row component with loading bubbles */
export default function LoadingTokenDetail() {
export default function TokenDetailsSkeleton() {
const { chainName } = useParams<{ chainName?: string }>()
return (
<LeftPanel>
<BreadcrumbNavLink to="/explore">
<Space heightSize={20} />
<BreadcrumbNavLink to={{ chainName } ? `/tokens/${chainName}` : `/explore`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<ChartHeader>
<TokenInfoContainer>
<TokenNameCell>
<IconLoadingBubble />
<TitleLoadingBubble />
</TokenNameCell>
</TokenInfoContainer>
<TokenPrice>
<PriceLoadingBubble />
</TokenPrice>
<DeltaContainer>
<Space heightSize={20} />
</DeltaContainer>
<LoadingChartContainer>
<div>
<ChartAnimation>
<Wave />
<Wave />
<Wave />
<Wave />
<Wave />
</ChartAnimation>
</div>
</LoadingChartContainer>
<Space heightSize={32} />
</ChartHeader>
<TokenStatsSection>
<StatsLoadingContainer>
<StatPair>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</StatWrapper>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</StatWrapper>
</StatPair>
<StatPair>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</StatWrapper>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</StatWrapper>
</StatPair>
</StatsLoadingContainer>
</TokenStatsSection>
<LoadingChart />
<Space heightSize={45} />
<LoadingStats />
<Hr />
<AboutContainer>
<AboutHeader>
<SquareLoadingBubble />
<SectionBubble />
</AboutHeader>
<LongLoadingBubble />
<LongLoadingBubble />
<HalfLoadingBubble />
<ResourcesContainer>{null}</ResourcesContainer>
</AboutContainer>
<ContractAddressSection>{null}</ContractAddressSection>
<WideBubble />
<WideBubble />
<HalfWideBubble />
</LeftPanel>
)
}
export function LoadingTokenDetails() {
export function TokenDetailsPageSkeleton() {
return (
<TokenDetailsLayout>
<LoadingTokenDetail />
<TokenDetailsSkeleton />
<RightPanel>
<WidgetSkeleton />
</RightPanel>
......
......@@ -44,7 +44,7 @@ const StatPrice = styled.span`
const NoData = styled.div`
color: ${({ theme }) => theme.textTertiary};
`
const Wrapper = styled.div`
export const StatsWrapper = styled.div`
gap: 16px;
${textFadeIn}
`
......@@ -84,7 +84,7 @@ export default function StatsSection(props: StatsSectionProps) {
const { priceLow52W, priceHigh52W, TVL, volume24H } = props
if (TVL || volume24H || priceLow52W || priceHigh52W) {
return (
<Wrapper>
<StatsWrapper>
<Header>
<Trans>Stats</Trans>
</Header>
......@@ -110,7 +110,7 @@ export default function StatsSection(props: StatsSectionProps) {
<Stat value={priceHigh52W} title={<Trans>52W high</Trans>} isPrice={true} />
</StatPair>
</TokenStatsSection>
</Wrapper>
</StatsWrapper>
)
} else {
return <NoData>No stats available</NoData>
......
......@@ -20,7 +20,7 @@ import ErrorBoundary from '../components/ErrorBoundary'
import NavBar from '../components/NavBar'
import Polling from '../components/Polling'
import Popups from '../components/Popups'
import { LoadingTokenDetails } from '../components/Tokens/TokenDetails/LoadingTokenDetails'
import { TokenDetailsPageSkeleton } from '../components/Tokens/TokenDetails/Skeleton'
import { useIsExpertMode } from '../state/user/hooks'
import DarkModeQueryParamReader from '../theme/DarkModeQueryParamReader'
import AddLiquidity from './AddLiquidity'
......@@ -165,7 +165,7 @@ export default function App() {
<Route
path="tokens/:chainName/:tokenAddress"
element={
<Suspense fallback={<LoadingTokenDetails />}>
<Suspense fallback={<TokenDetailsPageSkeleton />}>
<TokenDetails />
</Suspense>
}
......
......@@ -9,10 +9,16 @@ import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary'
import { BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink'
import ChartSection from 'components/Tokens/TokenDetails/ChartSection'
import MobileBalanceSummaryFooter from 'components/Tokens/TokenDetails/MobileBalanceSummaryFooter'
import TokenDetailsSkeleton, {
Hr,
LeftPanel,
RightPanel,
TokenDetailsLayout,
} from 'components/Tokens/TokenDetails/Skeleton'
import StatsSection from 'components/Tokens/TokenDetails/StatsSection'
import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import Widget, { WIDGET_WIDTH } from 'components/Widget'
import Widget from 'components/Widget'
import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety'
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql'
......@@ -23,50 +29,9 @@ import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useAtomValue } from 'jotai/utils'
import { useTokenFromQuery } from 'lib/hooks/useCurrency'
import useCurrencyBalance, { useTokenBalance } from 'lib/hooks/useCurrencyBalance'
import { useCallback, useState } from 'react'
import { useCallback, useState, useTransition } from 'react'
import { ArrowLeft } from 'react-feather'
import { useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components/macro'
const Hr = styled.hr`
background-color: ${({ theme }) => theme.textSecondary};
opacity: 24%;
border: none;
height: 0.5px;
`
export const TokenDetailsLayout = styled.div`
display: flex;
padding: 0 8px 52px;
justify-content: center;
width: 100%;
@media screen and (min-width: ${({ theme }) => theme.breakpoint.sm}px) {
gap: 16px;
padding: 0 16px;
}
@media screen and (min-width: ${({ theme }) => theme.breakpoint.md}px) {
gap: 20px;
padding: 48px 20px;
}
@media screen and (min-width: ${({ theme }) => theme.breakpoint.xl}px) {
gap: 40px;
}
`
export const LeftPanel = styled.div`
flex: 1;
max-width: 780px;
overflow: hidden;
`
export const RightPanel = styled.div`
display: none;
flex-direction: column;
gap: 20px;
width: ${WIDGET_WIDTH}px;
@media screen and (min-width: ${({ theme }) => theme.breakpoint.lg}px) {
display: flex;
}
`
export default function TokenDetails() {
const { tokenAddress, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>()
......@@ -92,15 +57,15 @@ export default function TokenDetails() {
const isBlockedToken = tokenWarning?.canProceed === false
const navigate = useNavigate()
// Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again.
const [isPending, startTransition] = useTransition()
const navigateToTokenForChain = useCallback(
(chain: Chain) => {
const chainName = chain.toLowerCase()
const token = tokenQueryData?.project?.tokens.find((token) => token.chain === chain && token.address)
if (isNative) {
navigate(`/tokens/${chainName}/${NATIVE_CHAIN_ID}`)
} else if (token) {
navigate(`/tokens/${chainName}/${token.address}`)
}
const address = isNative ? NATIVE_CHAIN_ID : token?.address
if (!address) return
startTransition(() => navigate(`/tokens/${chainName}/${address}`))
},
[isNative, navigate, tokenQueryData?.project?.tokens]
)
......@@ -110,7 +75,7 @@ export default function TokenDetails() {
const update = output || input
if (!token || !update || input?.equals(token) || output?.equals(token)) return
const address = update.isNative ? NATIVE_CHAIN_ID : update.address
navigate(`/tokens/${chainName}/${address}`)
startTransition(() => navigate(`/tokens/${chainName}/${address}`))
},
[chainName, navigate, token]
)
......@@ -135,63 +100,62 @@ export default function TokenDetails() {
return (
<Trace page={PageName.TOKEN_DETAILS_PAGE} properties={{ tokenAddress, tokenName: chainName }} shouldLogImpression>
<TokenDetailsLayout>
{tokenQueryData && (
<>
<LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chainName}`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<ChartSection
token={tokenQueryData}
currency={token}
nativeCurrency={isNative ? nativeCurrency : undefined}
prices={prices}
/>
<StatsSection
TVL={tokenQueryData.market?.totalValueLocked?.value}
volume24H={tokenQueryData.market?.volume24H?.value}
priceHigh52W={tokenQueryData.market?.priceHigh52W?.value}
priceLow52W={tokenQueryData.market?.priceLow52W?.value}
/>
<Hr />
<AboutSection
address={tokenQueryData.address ?? ''}
description={tokenQueryData.project?.description}
homepageUrl={tokenQueryData.project?.homepageUrl}
twitterName={tokenQueryData.project?.twitterName}
/>
<AddressSection address={tokenQueryData.address ?? ''} />
</LeftPanel>
<RightPanel>
<Widget
defaultToken={token === null ? undefined : token ?? nativeCurrency} // a null token is still loading, and should not be overridden.
onTokensChange={navigateToWidgetSelectedToken}
onReviewSwapClick={onReviewSwapClick}
/>
{tokenWarning && (
<TokenSafetyMessage tokenAddress={tokenQueryData.address ?? ''} warning={tokenWarning} />
)}
<BalanceSummary
tokenAmount={tokenBalance}
nativeCurrencyAmount={nativeCurrencyBalance}
isNative={isNative}
/>
</RightPanel>
{tokenQueryAddress && (
<MobileBalanceSummaryFooter
tokenAmount={tokenBalance}
tokenAddress={tokenQueryAddress}
nativeCurrencyAmount={nativeCurrencyBalance}
isNative={isNative}
/>
{tokenQueryData && !isPending ? (
<LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chainName}`}>
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<ChartSection
token={tokenQueryData}
currency={token}
nativeCurrency={isNative ? nativeCurrency : undefined}
prices={prices}
/>
<StatsSection
TVL={tokenQueryData.market?.totalValueLocked?.value}
volume24H={tokenQueryData.market?.volume24H?.value}
priceHigh52W={tokenQueryData.market?.priceHigh52W?.value}
priceLow52W={tokenQueryData.market?.priceLow52W?.value}
/>
{!isNative && (
<>
<Hr />
<AboutSection
address={tokenQueryData.address ?? ''}
description={tokenQueryData.project?.description}
homepageUrl={tokenQueryData.project?.homepageUrl}
twitterName={tokenQueryData.project?.twitterName}
/>
<AddressSection address={tokenQueryData.address ?? ''} />
</>
)}
</>
</LeftPanel>
) : (
<TokenDetailsSkeleton />
)}
<RightPanel>
<Widget
defaultToken={token === null ? undefined : token ?? nativeCurrency} // a null token is still loading, and should not be overridden.
onTokensChange={navigateToWidgetSelectedToken}
onReviewSwapClick={onReviewSwapClick}
/>
{tokenWarning && <TokenSafetyMessage tokenAddress={tokenAddress ?? ''} warning={tokenWarning} />}
<BalanceSummary tokenAmount={tokenBalance} nativeCurrencyAmount={nativeCurrencyBalance} isNative={isNative} />
</RightPanel>
{tokenQueryAddress && (
<MobileBalanceSummaryFooter
tokenAmount={tokenBalance}
tokenAddress={tokenQueryAddress}
nativeCurrencyAmount={nativeCurrencyBalance}
isNative={isNative}
/>
)}
{tokenAddress && (
{tokenQueryAddress && (
<TokenSafetyModal
isOpen={isBlockedToken || !!continueSwap}
tokenAddress={tokenAddress}
tokenAddress={tokenQueryAddress}
onContinue={() => onResolveSwap(true)}
onBlocked={() => navigate(-1)}
onCancel={() => onResolveSwap(false)}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment