feat: explore carousel (#5042)

* feat: explore carousel

* removing peeking cards

* mobile carousel and pr changes

* collectionStats fetching

* total listings calc
This commit is contained in:
Jack Short 2022-11-01 14:37:00 -04:00 committed by GitHub
parent 734a15e350
commit d4fb0913a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 557 additions and 166 deletions

@ -1,29 +1,72 @@
import clsx from 'clsx'
import { useWindowSize } from 'hooks/useWindowSize'
import { Box } from 'nft/components/Box'
import { Center, Column, Row } from 'nft/components/Flex'
import { VerifiedIcon } from 'nft/components/icons'
import { bodySmall, buttonMedium, headlineLarge } from 'nft/css/common.css'
import { breakpoints, vars } from 'nft/css/sprinkles.css'
import { ActivityFetcher, fetchTrendingCollections } from 'nft/queries'
import { TimePeriod, TrendingCollection } from 'nft/types'
import { formatEthPrice } from 'nft/utils/currency'
import { putCommas } from 'nft/utils/putCommas'
import { formatChange, toSignificant } from 'nft/utils/toSignificant'
import { useEffect, useState } from 'react'
import { QueryClient, useQuery } from 'react-query'
import { Link } from 'react-router-dom'
import { useIsMobile } from 'nft/hooks'
import { fetchTrendingCollections } from 'nft/queries'
import { TimePeriod } from 'nft/types'
import { useQuery } from 'react-query'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro'
import ActivityFeed from './ActivityFeed'
import * as styles from './Explore.css'
import { Carousel } from './Carousel'
import { CarouselCard, LoadingCarouselCard } from './CarouselCard'
const queryClient = new QueryClient()
const BannerContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
height: 320px;
margin-top: 24px;
gap: 36px;
max-width: 1200px;
justify-content: space-between;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
flex-direction: column;
height: 100%;
gap: 14px;
margin-top: 4px;
margin-bottom: 30px;
}
`
const HeaderContainer = styled.div`
display: flex;
max-width: 500px;
font-weight: 500;
font-size: 60px;
line-height: 73px;
justify-content: start;
align-items: start;
padding-top: 40px;
flex-shrink: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0) 100%), #fc72ff;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) {
font-size: 48px;
line-height: 67px;
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
font-size: 36px;
line-height: 50px;
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
font-size: 20px;
line-height: 28px;
justify-content: center;
align-items: center;
padding-top: 0px;
}
`
const DEFAULT_TRENDING_COLLECTION_QUERY_AMOUNT = 5
const Banner = () => {
/* Sets initially displayed collection to random number between 0 and 4 */
const [current, setCurrent] = useState(Math.floor(Math.random() * 5))
const [hovered, setHover] = useState(false)
const { width: windowWidth } = useWindowSize()
const navigate = useNavigate()
const isMobile = useIsMobile()
const { data: collections } = useQuery(
['trendingCollections'],
() => {
@ -36,152 +79,31 @@ const Banner = () => {
}
)
useEffect(() => {
/* Rotate through Top 5 Collections on 15 second interval */
let stale = false
if (hovered || stale) return
const interval = setInterval(async () => {
if (collections) {
const nextCollectionIndex = (current + 1) % collections.length
const nextCollectionAddress = collections[nextCollectionIndex].address
setCurrent(nextCollectionIndex)
await queryClient.prefetchQuery(['collectionActivity', nextCollectionAddress], () =>
ActivityFetcher(nextCollectionAddress as string)
)
}
}, 15_000)
return () => {
stale = true
clearInterval(interval)
}
}, [current, collections, hovered])
return (
<Box onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} cursor="pointer" width="full">
{collections && collections[current] ? (
<Link to={`/nfts/collection/${collections[current].address}`} style={{ textDecoration: 'none' }}>
<Box style={{ height: '386px' }}>
<div
className={styles.bannerWrap}
style={{ backgroundImage: `url(${collections[current].bannerImageUrl})` }}
>
<Box className={styles.bannerOverlay} width="full" />
<Box as="section" className={styles.section} display="flex" flexDirection="row" flexWrap="nowrap">
<CollectionDetails collection={collections[current]} hovered={hovered} rank={current + 1} />
{windowWidth && windowWidth > breakpoints.lg && <ActivityFeed address={collections[current].address} />}
</Box>
<CarouselProgress length={collections.length} currentIndex={current} setCurrent={setCurrent} />
</div>
</Box>
</Link>
<BannerContainer>
<HeaderContainer>
Best price. {!isMobile && <br />}
Every listing.
</HeaderContainer>
{collections ? (
<Carousel>
{collections.map((collection) => (
<CarouselCard
key={collection.address}
collection={collection}
onClick={() => navigate(`/nfts/collection/${collection.address}`)}
/>
))}
</Carousel>
) : (
<>
{/* TODO: Improve Loading State */}
<p>Loading</p>
</>
<Carousel>
{[...Array(DEFAULT_TRENDING_COLLECTION_QUERY_AMOUNT)].map((index) => (
<LoadingCarouselCard key={'carouselCard' + index} />
))}
</Carousel>
)}
</Box>
</BannerContainer>
)
}
export default Banner
/* Collection Details: displays collection stats within Banner */
const CollectionDetails = ({
collection,
rank,
hovered,
}: {
collection: TrendingCollection
rank: number
hovered: boolean
}) => (
<Column className={styles.collectionDetails} paddingTop="40">
<div className={styles.volumeRank}>#{rank} volume in 24hr</div>
<Row>
<Box as="span" marginTop="16" className={clsx(headlineLarge, styles.collectionName)}>
{collection.name}
</Box>
{collection.isVerified && (
<Box as="span" marginTop="24">
<VerifiedIcon height="32" width="32" />
</Box>
)}
</Row>
<Row className={bodySmall} marginTop="12" color="explicitWhite">
<Box>
<Box as="span" color="textSecondary" marginRight="4">
Floor:
</Box>
{collection.floor ? formatEthPrice(collection.floor.toString()) : '--'} ETH
</Box>
<Box>
{collection.floorChange ? (
<Box as="span" color={collection.floorChange > 0 ? 'green200' : 'accentFailure'} marginLeft="4">
{collection.floorChange > 0 && '+'}
{formatChange(collection.floorChange)}%
</Box>
) : null}
</Box>
<Box marginLeft="24" color="explicitWhite">
<Box as="span" color="textSecondary" marginRight="4">
Volume:
</Box>
{collection.volume ? putCommas(+toSignificant(collection.volume.toString())) : '--'} ETH
</Box>
<Box>
{collection.volumeChange ? (
<Box as="span" color={collection.volumeChange > 0 ? 'green200' : 'accentFailure'} marginLeft="4">
{collection.volumeChange > 0 && '+'}
{formatChange(collection.volumeChange)}%
</Box>
) : null}
</Box>
</Row>
<Link
className={clsx(buttonMedium, styles.exploreCollection)}
to={`/nfts/collection/${collection.address}`}
style={{ textDecoration: 'none', backgroundColor: `${hovered ? vars.color.blue400 : vars.color.grey700}` }}
>
Explore collection
</Link>
</Column>
)
/* Carousel Progress indicators */
const CarouselProgress = ({
length,
currentIndex,
setCurrent,
}: {
length: number
currentIndex: number
setCurrent: React.Dispatch<React.SetStateAction<number>>
}) => (
<Center marginTop="16">
{Array(length)
.fill(null)
.map((value, carouselIndex) => (
<Box
cursor="pointer"
paddingTop="16"
paddingBottom="16"
position="relative"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setCurrent(carouselIndex)
}}
key={carouselIndex}
>
<Box
as="span"
className={styles.carouselIndicator}
display="inline-block"
backgroundColor={currentIndex === carouselIndex ? 'explicitWhite' : 'accentTextLightTertiary'}
/>
</Box>
))}
</Center>
)

@ -0,0 +1,160 @@
import { useWindowSize } from 'hooks/useWindowSize'
import { ChevronLeftIcon } from 'nft/components/icons'
import { calculateCardIndex, calculateFirstCardIndex, calculateRank } from 'nft/utils'
import { ReactNode, useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { a, useSprings } from 'react-spring'
import styled from 'styled-components/macro'
const CarouselContainer = styled.div`
display: flex;
max-width: 592px;
width: 100%;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
height: 320px;
}
`
const CarouselCardContainer = styled.div`
max-width: 512px;
position: relative;
width: 100%;
overflow-x: hidden;
`
const CarouselCard = styled(a.div)`
position: absolute;
padding-left: 16px;
padding-right: 16px;
display: flex;
top: 3px;
height: 280px;
will-change: transform;
justify-content: center;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
height: 296px;
}
`
const IconContainer = styled.div<{ right?: boolean }>`
display: flex;
height: 280px;
justify-content: center;
align-items: center;
cursor: pointer;
padding: 8px;
${({ right }) => (right ? 'transform: rotate(180deg)' : undefined)};
color: ${({ theme }) => theme.textPrimary};
:hover {
opacity: ${({ theme }) => theme.opacity.hover};
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.md}px`}) {
height: 296px;
}
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
display: none;
}
`
interface CarouselProps {
children: ReactNode[]
}
const FIRST_CARD_OFFSET = 0
const MAX_CARD_WIDTH = 512
export const Carousel = ({ children }: CarouselProps) => {
const { width } = useWindowSize()
const carouselCardContainerRef = useRef<HTMLDivElement>(null)
const [cardWidth, setCardWidth] = useState(MAX_CARD_WIDTH)
const [resetTimer, toggleResetTimer] = useReducer((state) => !state, false)
useEffect(() => {
if (carouselCardContainerRef.current) {
setCardWidth(Math.min(carouselCardContainerRef.current.offsetWidth, MAX_CARD_WIDTH))
}
}, [width])
const idx = useCallback((x: number, l = children.length) => calculateCardIndex(x, l), [children])
const getPos = useCallback(
(i: number, firstVis: number, firstVisIdx: number) => calculateFirstCardIndex(i, firstVis, firstVisIdx, idx),
[idx]
)
const [springs, set] = useSprings(children.length, (i) => ({
x: (i < children.length - 1 ? i : -1) * cardWidth + FIRST_CARD_OFFSET,
}))
const prev = useRef([0, 1])
const runSprings = useCallback(
(y: number, vy: number) => {
const firstVis = idx(Math.floor(y / cardWidth) % children.length)
const firstVisIdx = vy < 0 ? children.length - 2 : 1
set((i) => {
const position = getPos(i, firstVis, firstVisIdx)
const prevPosition = getPos(i, prev.current[0], prev.current[1])
const rank = calculateRank(firstVis, firstVisIdx, position, children.length, y)
return {
x: (-y % (cardWidth * children.length)) + cardWidth * rank + FIRST_CARD_OFFSET,
immediate: vy < 0 ? prevPosition > position : prevPosition < position,
config: { tension: 250, friction: 30 },
}
})
prev.current = [firstVis, firstVisIdx]
},
[idx, getPos, set, cardWidth, children.length]
)
useEffect(() => {
runSprings(index.current, 0)
}, [runSprings])
const index = useRef(0)
const toggleSlide = useCallback(
(next: -1 | 1) => {
const offset = cardWidth * next
index.current += offset
runSprings(index.current, next)
toggleResetTimer()
},
[runSprings, cardWidth]
)
useEffect(() => {
const interval = setInterval(async () => {
toggleSlide(1)
}, 7_000)
return () => {
clearInterval(interval)
}
}, [toggleSlide, resetTimer])
return (
<CarouselContainer>
<IconContainer onClick={() => toggleSlide(-1)}>
<ChevronLeftIcon width="16px" height="16px" />
</IconContainer>
<CarouselCardContainer ref={carouselCardContainerRef}>
{springs.map(({ x }, i) => (
<CarouselCard
key={i}
style={{
width: cardWidth,
x,
}}
>
{children[i]}
</CarouselCard>
))}
</CarouselCardContainer>
<IconContainer right onClick={() => toggleSlide(1)}>
<ChevronLeftIcon width="16px" height="16px" />
</IconContainer>
</CarouselContainer>
)
}

@ -0,0 +1,281 @@
import { loadingAnimation } from 'components/Loader/styled'
import { LoadingBubble } from 'components/Tokens/loading'
import { VerifiedIcon } from 'nft/components/icons'
import { CollectionStatsFetcher } from 'nft/queries'
import { Markets, TrendingCollection } from 'nft/types'
import { formatWeiToDecimal } from 'nft/utils'
import { useQuery } from 'react-query'
import styled, { useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme'
const CarouselCardContainer = styled.div`
display: flex;
flex-direction: column;
gap: 20px;
background-color: ${({ theme }) => theme.backgroundSurface};
border-radius: 20px;
outline: 1px solid ${({ theme }) => theme.backgroundOutline};
width: 100%;
cursor: pointer;
overflow: hidden;
:hover {
outline: 3px solid ${({ theme }) => theme.backgroundOutline};
box-shadow: ${({ theme }) => theme.deepShadow};
}
`
const CardHeaderContainer = styled.div<{ src: string }>`
position: relative;
width: 100%;
height: 108px;
padding-top: 32px;
padding-bottom: 16px;
padding-left: 28px;
padding-right: 28px;
background-image: ${({ src }) => `url(${src})`};
background-size: cover;
background-position: center;
`
const LoadingCardHeaderContainer = styled.div`
position: relative;
width: 100%;
height: 108px;
padding-top: 32px;
padding-bottom: 16px;
padding-left: 28px;
padding-right: 28px;
animation: ${loadingAnimation} 1.5s infinite;
animation-fill-mode: both;
background: linear-gradient(
to left,
${({ theme }) => theme.backgroundInteractive} 25%,
${({ theme }) => theme.backgroundOutline} 50%,
${({ theme }) => theme.backgroundInteractive} 75%
);
will-change: background-position;
background-size: 400%;
`
const CardHeaderRow = styled.div`
position: relative;
z-index: 1;
display: flex;
gap: 12px;
align-items: center;
`
const CardNameRow = styled.div`
display: flex;
gap: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`
const IconContainer = styled.div`
display: flex;
flex-shrink: 0;
align-items: center;
`
const CollectionNameContainer = styled.div`
display: flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`
const LoadingCollectionNameContainer = styled(LoadingBubble)`
width: 50%;
`
const HeaderOverlay = styled.div`
position: absolute;
height: 108px;
top: 0px;
right: 0px;
left: 0px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.6) 100%, rgba(0, 0, 0, 0.08));
z-index: 0;
`
const CollectionImage = styled.img`
width: 60px;
height: 60px;
background: ${({ theme }) => theme.accentTextLightPrimary};
border: 2px solid ${({ theme }) => theme.accentTextLightPrimary};
border-radius: 100px;
`
const LoadingCollectionImage = styled.div`
width: 60px;
height: 60px;
border-radius: 100px;
animation: ${loadingAnimation} 1.5s infinite;
animation-fill-mode: both;
background: linear-gradient(
to left,
${({ theme }) => theme.backgroundInteractive} 25%,
${({ theme }) => theme.backgroundOutline} 50%,
${({ theme }) => theme.backgroundInteractive} 75%
);
will-change: background-position;
background-size: 400%;
`
const CardBottomContainer = styled.div`
display: grid;
grid-template-columns: auto auto auto;
row-gap: 16px;
column-gap: 20px;
padding-right: 28px;
padding-left: 28px;
padding-bottom: 20px;
justify-content: space-between;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) {
row-gap: 12px;
}
`
const HeaderRow = styled.div`
color: ${({ theme }) => theme.userThemeColor};
font-size: 16px;
line-height: 24px;
font-weight: 500;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.lg}px`}) {
row-gap: 12px;
font-size: 14px;
line-height: 20px;
}
`
const LoadingTableElement = styled(LoadingBubble)`
width: 50px;
`
const TableElement = styled.div`
color: ${({ theme }) => theme.textSecondary};
font-size: 14px;
font-weight: 400;
line-height: 20px;
`
interface MarketplaceRowProps {
marketplace: string
floor?: string
listings?: string
}
export const MarketplaceRow = ({ marketplace, floor, listings }: MarketplaceRowProps) => {
return (
<>
<TableElement>{marketplace}</TableElement>
<TableElement>{floor ?? '-'}</TableElement>
<TableElement>{listings ?? '-'}</TableElement>
</>
)
}
interface CarouselCardProps {
collection: TrendingCollection
onClick: () => void
}
const MARKETS_TO_CHECK = [Markets.Opensea, Markets.X2Y2, Markets.LooksRare] as const
const MARKETS_ENUM_TO_NAME = {
[Markets.Opensea]: 'OpenSea',
[Markets.X2Y2]: 'X2Y2',
[Markets.LooksRare]: 'LooksRare',
}
export const CarouselCard = ({ collection, onClick }: CarouselCardProps) => {
const { data: collectionStats, isLoading } = useQuery(
['trendingCollectionStats', collection.address],
() => CollectionStatsFetcher(collection.address),
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}
)
const theme = useTheme()
return (
<CarouselCardContainer onClick={onClick}>
<CardHeaderContainer src={collection.bannerImageUrl}>
<CardHeaderRow>
<CollectionImage src={collection.imageUrl} />
<CardNameRow>
<CollectionNameContainer>
<ThemedText.MediumHeader color={theme.accentTextLightPrimary} fontWeight="500" lineHeight="28px">
{collection.name}
</ThemedText.MediumHeader>
</CollectionNameContainer>
{collection.isVerified && (
<IconContainer>
<VerifiedIcon width="24px" height="24px" />
</IconContainer>
)}
</CardNameRow>
</CardHeaderRow>
<HeaderOverlay />
</CardHeaderContainer>
<CardBottomContainer>
{isLoading || !collectionStats ? (
<LoadingTable />
) : (
<>
<HeaderRow>Uniswap</HeaderRow>
<HeaderRow>{formatWeiToDecimal(collection.floor.toString())} ETH Floor</HeaderRow>
<HeaderRow>{collectionStats.marketplaceCount?.reduce((acc, cur) => acc + cur.count, 0)} Listings</HeaderRow>
{MARKETS_TO_CHECK.map((market) => {
const marketplace = collectionStats.marketplaceCount?.find(
(marketplace) => marketplace.marketplace === market
)
return (
<MarketplaceRow
key={'trendingCollection' + collection.address}
marketplace={MARKETS_ENUM_TO_NAME[market]}
listings={marketplace?.count.toString()}
/>
)
})}
</>
)}
</CardBottomContainer>
</CarouselCardContainer>
)
}
const DEFAULT_TABLE_ELEMENTS = 12
export const LoadingTable = () => {
return (
<>
{[...Array(DEFAULT_TABLE_ELEMENTS)].map((index) => (
<LoadingTableElement key={index} />
))}
</>
)
}
export const LoadingCarouselCard = () => {
return (
<CarouselCardContainer>
<LoadingCardHeaderContainer>
<CardHeaderRow>
<LoadingCollectionImage />
<LoadingCollectionNameContainer />
</CardHeaderRow>
<HeaderOverlay />
</LoadingCardHeaderContainer>
<CardBottomContainer>
<LoadingTable />
</CardBottomContainer>
</CarouselCardContainer>
)
}

@ -2,13 +2,24 @@ import { PageName } from 'analytics/constants'
import { Trace } from 'analytics/Trace'
import Banner from 'nft/components/explore/Banner'
import TrendingCollections from 'nft/components/explore/TrendingCollections'
import styled from 'styled-components/macro'
const ExploreContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 16px;
`
const NftExplore = () => {
return (
<>
<Trace page={PageName.NFT_EXPLORE_PAGE} shouldLogImpression>
<Banner />
<TrendingCollections />
<ExploreContainer>
<Banner />
<TrendingCollections />
</ExploreContainer>
</Trace>
</>
)

16
src/nft/utils/carousel.ts Normal file

@ -0,0 +1,16 @@
export const calculateCardIndex = (x: number, l: number) => {
return (x < 0 ? x + l : x) % l
}
export const calculateFirstCardIndex = (
i: number,
firstVis: number,
firstVisIdx: number,
idx: (x: number, l?: number) => number
) => {
return idx(i - firstVis + firstVisIdx)
}
export const calculateRank = (firstVis: number, firstVisIdx: number, position: number, l: number, y: number) => {
return firstVis - (y < 0 ? l : 0) + position - firstVisIdx + (y < 0 && firstVis === 0 ? l : 0)
}

@ -2,6 +2,7 @@ export * from './asset'
export * from './buildActivityAsset'
export * from './buildSellObject'
export * from './calcPoolPrice'
export * from './carousel'
export * from './currency'
export * from './fetchPrice'
export * from './isAudio'