Commit 819302b5 authored by Jack Short's avatar Jack Short Committed by GitHub

feat: adding collection stats to collection page (#4391)

* feat: adding collection stats

* removing debounced callback

* addressing comments

* updating marquee and updating isMobile hook

* adding bool to useIsMobile
parent c53d7fcc
import { style } from '@vanilla-extract/css'
import { body, bodySmall } from 'nft/css/common.css'
import { breakpoints, sprinkles } from '../../css/sprinkles.css'
export const statsText = style([
sprinkles({
marginTop: { mobile: '8', tabletSm: '40' },
marginBottom: { mobile: '0', tabletSm: '28' },
}),
{
'@media': {
[`(max-width: ${breakpoints.tabletSm - 1}px)`]: {
marginLeft: '68px',
},
},
},
])
export const smallStatsText = style({
marginLeft: '84px',
})
export const statsRowItem = sprinkles({ paddingRight: '12' })
export const baseCollectionImage = sprinkles({
left: '0',
borderStyle: 'solid',
borderWidth: '4px',
borderColor: 'white',
})
export const collectionImage = style([
baseCollectionImage,
{
width: '143px',
height: '143px',
verticalAlign: 'top',
top: '-118px',
'@media': {
[`(max-width: ${breakpoints.tabletSm - 1}px)`]: {
width: '60px',
height: '60px',
borderWidth: '2px',
top: '-20px',
},
},
},
])
export const nameText = sprinkles({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})
export const description = style([
sprinkles({
fontSize: '14',
display: 'inline-block',
}),
{
maxWidth: 'min(calc(100% - 112px), 600px)',
verticalAlign: 'top',
lineHeight: '20px',
},
])
export const descriptionOpen = style([
{
whiteSpace: 'normal',
verticalAlign: 'top',
lineHeight: '20px',
},
sprinkles({
overflow: 'visible',
display: 'inline',
maxWidth: 'full',
}),
])
export const readMore = style([
{
verticalAlign: 'top',
lineHeight: '20px',
},
sprinkles({
color: 'blue400',
cursor: 'pointer',
marginLeft: '4',
fontSize: '14',
}),
])
export const statsLabel = style([
bodySmall,
sprinkles({
fontWeight: 'normal',
color: 'darkGray',
whiteSpace: 'nowrap',
}),
{
lineHeight: '20px',
},
])
export const statsValue = style([
body,
sprinkles({
fontWeight: 'medium',
}),
{
lineHeight: '24px',
},
])
import clsx from 'clsx'
import { Box, BoxProps } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { Marquee } from 'nft/components/layout/Marquee'
import { header2 } from 'nft/css/common.css'
import { themeVars } from 'nft/css/sprinkles.css'
import { GenieCollection } from 'nft/types'
import { ethNumberStandardFormatter } from 'nft/utils/currency'
import { putCommas } from 'nft/utils/putCommas'
import { useEffect, useReducer, useRef, useState } from 'react'
import { ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import { DiscordIcon, EllipsisIcon, ExternalIcon, InstagramIcon, TwitterIcon, VerifiedIcon, XMarkIcon } from '../icons'
import * as styles from './CollectionStats.css'
const MobileSocialsIcon = ({ children, href }: { children: ReactNode; href: string }) => {
return (
<Box
display="flex"
as="a"
target="_blank"
rel="noreferrer"
href={href}
height="40"
width="40"
borderRadius="round"
backgroundColor="white"
>
{children}
</Box>
)
}
const MobileSocialsPopover = ({
collectionStats,
collectionSocialsIsOpen,
toggleCollectionSocials,
}: {
collectionStats: GenieCollection
collectionSocialsIsOpen: boolean
toggleCollectionSocials: () => void
}) => {
return (
<>
<Row marginLeft="4" onClick={() => toggleCollectionSocials()}>
{collectionSocialsIsOpen ? (
<XMarkIcon width="28" height="28" fill={themeVars.colors.darkGray} />
) : (
<EllipsisIcon width="28" height="28" fill={themeVars.colors.darkGray} />
)}
</Row>
{collectionSocialsIsOpen && (
<Row
position="absolute"
gap="4"
alignItems="center"
justifyContent="center"
style={{
top: '-48px',
right: '-6px',
}}
>
{collectionStats.discordUrl ? (
<MobileSocialsIcon href={collectionStats.discordUrl}>
<Box margin="auto" paddingTop="4">
<DiscordIcon width={28} height={28} color={themeVars.colors.darkGray} />
</Box>
</MobileSocialsIcon>
) : null}
{collectionStats.twitter ? (
<MobileSocialsIcon href={'https://twitter.com/' + collectionStats.twitter}>
<Box margin="auto" paddingTop="6">
<TwitterIcon
fill={themeVars.colors.darkGray}
color={themeVars.colors.darkGray}
width="28px"
height="28px"
/>
</Box>
</MobileSocialsIcon>
) : null}
{collectionStats.instagram ? (
<MobileSocialsIcon href={'https://instagram.com/' + collectionStats.instagram}>
<Box margin="auto" paddingLeft="2" paddingTop="4">
<InstagramIcon fill={themeVars.colors.darkGray} width="28px" height="28px" />
</Box>
</MobileSocialsIcon>
) : null}
{collectionStats.externalUrl ? (
<MobileSocialsIcon href={collectionStats.externalUrl}>
<Box margin="auto" paddingTop="4">
<ExternalIcon fill={themeVars.colors.darkGray} width="28px" height="28px" />
</Box>
</MobileSocialsIcon>
) : null}
</Row>
)}
</>
)
}
const SocialsIcon = ({ children, href }: { children: ReactNode; href: string }) => {
return (
<Column as="a" target="_blank" rel="noreferrer" href={href} height="full" justifyContent="center">
{children}
</Column>
)
}
const CollectionName = ({
collectionStats,
name,
isVerified,
isMobile,
collectionSocialsIsOpen,
toggleCollectionSocials,
}: {
collectionStats: GenieCollection
name: string
isVerified: boolean
isMobile: boolean
collectionSocialsIsOpen: boolean
toggleCollectionSocials: () => void
}) => {
return (
<Row justifyContent="space-between">
<Row minWidth="0">
<Box
marginRight={!isVerified ? '12' : '0'}
className={clsx(isMobile ? header2 : header2, styles.nameText)}
style={{ lineHeight: '32px' }}
>
{name}
</Box>
{isVerified && <VerifiedIcon style={{ width: '32px', height: '32px' }} />}
<Row
display={{ mobile: 'none', tabletSm: 'flex' }}
alignItems="center"
justifyContent="center"
marginLeft="32"
gap="8"
height="32"
>
{collectionStats.discordUrl ? (
<SocialsIcon href={collectionStats.discordUrl}>
<DiscordIcon
fill={themeVars.colors.darkGray}
color={themeVars.colors.darkGray}
width="26px"
height="26px"
/>
</SocialsIcon>
) : null}
{collectionStats.twitter ? (
<SocialsIcon href={'https://twitter.com/' + collectionStats.twitter}>
<TwitterIcon
fill={themeVars.colors.darkGray}
color={themeVars.colors.darkGray}
width="26px"
height="26px"
/>
</SocialsIcon>
) : null}
{collectionStats.instagram ? (
<SocialsIcon href={'https://instagram.com/' + collectionStats.instagram}>
<InstagramIcon fill={themeVars.colors.darkGray} width="26px" height="26px" />
</SocialsIcon>
) : null}
{collectionStats.externalUrl ? (
<SocialsIcon href={collectionStats.externalUrl}>
<ExternalIcon fill={themeVars.colors.darkGray} width="26px" height="26px" />
</SocialsIcon>
) : null}
</Row>
</Row>
{isMobile &&
(collectionStats.discordUrl ||
collectionStats.twitter ||
collectionStats.instagram ||
collectionStats.externalUrl) && (
<MobileSocialsPopover
collectionStats={collectionStats}
collectionSocialsIsOpen={collectionSocialsIsOpen}
toggleCollectionSocials={toggleCollectionSocials}
/>
)}
</Row>
)
}
const CollectionDescription = ({ description }: { description: string }) => {
const [showReadMore, setShowReadMore] = useState(false)
const [readMore, toggleReadMore] = useReducer((state) => !state, false)
const baseRef = useRef<HTMLDivElement>(null)
const descriptionRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (
baseRef &&
descriptionRef &&
baseRef.current &&
descriptionRef.current &&
(descriptionRef.current.getBoundingClientRect().width >= baseRef.current?.getBoundingClientRect().width - 112 ||
descriptionRef.current.getBoundingClientRect().width >= 590)
)
setShowReadMore(true)
}, [descriptionRef, baseRef])
return (
<Box ref={baseRef} marginTop={{ mobile: '12', tabletSm: '16' }} style={{ maxWidth: '680px' }}>
<Box
ref={descriptionRef}
className={clsx(styles.description, styles.nameText, readMore && styles.descriptionOpen)}
>
<ReactMarkdown
source={description}
allowedTypes={['link', 'paragraph', 'strong', 'code', 'emphasis', 'text']}
renderers={{ paragraph: 'span' }}
/>
</Box>
<Box as="span" display={showReadMore ? 'inline' : 'none'} className={styles.readMore} onClick={toggleReadMore}>
Show {readMore ? 'less' : 'more'}
</Box>
</Box>
)
}
const StatsItem = ({ children, label, isMobile }: { children: ReactNode; label: string; isMobile: boolean }) => (
<Box display="flex" flexDirection={isMobile ? 'row' : 'column'} alignItems="baseline" gap="2" height="min">
<Box as="span" className={styles.statsLabel}>
{`${label}${isMobile ? ': ' : ''}`}
</Box>
<span className={styles.statsValue}>{children}</span>
</Box>
)
const StatsRow = ({ stats, isMobile, ...props }: { stats: GenieCollection; isMobile?: boolean } & BoxProps) => {
const numOwnersStr = stats.stats ? putCommas(stats.stats.num_owners) : 0
const totalSupplyStr = stats.stats ? putCommas(stats.stats.total_supply) : 0
const totalListingsStr = stats.stats ? putCommas(stats.stats.total_listings) : 0
// round daily volume & floorPrice to 3 decimals or less
const totalVolumeStr = ethNumberStandardFormatter(stats.stats?.total_volume)
const floorPriceStr = ethNumberStandardFormatter(stats.floorPrice)
return (
<Row gap={{ mobile: '20', tabletSm: '60' }} {...props}>
<StatsItem label="Items" isMobile={isMobile ?? false}>
{totalSupplyStr}
</StatsItem>
{numOwnersStr ? (
<StatsItem label="Owners" isMobile={isMobile ?? false}>
{numOwnersStr}
</StatsItem>
) : null}
{stats.floorPrice ? (
<StatsItem label="Floor Price" isMobile={isMobile ?? false}>
{floorPriceStr} ETH
</StatsItem>
) : null}
{stats.stats?.total_volume ? (
<StatsItem label="Total Volume" isMobile={isMobile ?? false}>
{totalVolumeStr} ETH
</StatsItem>
) : null}
{stats.stats?.total_listings ? (
<StatsItem label="Listings" isMobile={isMobile ?? false}>
{totalListingsStr}
</StatsItem>
) : null}
</Row>
)
}
export const CollectionStats = ({ stats, isMobile }: { stats: GenieCollection; isMobile: boolean }) => {
const [collectionSocialsIsOpen, toggleCollectionSocials] = useReducer((state) => !state, false)
if (!stats) {
return <div>Loading CollectionStats...</div>
}
return (
<Box
display="flex"
marginTop={isMobile && !stats.bannerImageUrl ? (collectionSocialsIsOpen ? '52' : '20') : '0'}
justifyContent="center"
position="relative"
flexDirection="column"
width="full"
>
<Box
as="img"
borderRadius="round"
position="absolute"
className={styles.collectionImage}
src={stats.isFoundation && !stats.imageUrl ? '/nft/svgs/marketplaces/foundation.svg' : stats.imageUrl}
/>
<Box className={styles.statsText}>
<CollectionName
collectionStats={stats}
name={stats.name}
isVerified={stats.isVerified}
isMobile={isMobile}
collectionSocialsIsOpen={collectionSocialsIsOpen}
toggleCollectionSocials={toggleCollectionSocials}
/>
{!isMobile && (
<>
{stats.description && <CollectionDescription description={stats.description} />}
<StatsRow stats={stats} marginTop="20" />
</>
)}
</Box>
{isMobile && (
<>
<Box marginBottom="12">{stats.description && <CollectionDescription description={stats.description} />}</Box>
<Marquee>
<StatsRow stats={stats} marginLeft="6" marginRight="6" marginBottom="28" isMobile />
</Marquee>
</>
)}
</Box>
)
}
import { globalKeyframes, style } from '@vanilla-extract/css'
import { sprinkles } from '../../css/sprinkles.css'
globalKeyframes('scroll', {
'0%': {
transform: 'translateX(0%)',
},
'100%': {
transform: 'translateX(-100%)',
},
})
export const marquee = style([
sprinkles({
minWidth: 'full',
zIndex: '1',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}),
{
flex: '0 0 auto',
animation: 'scroll var(--duration) linear infinite',
},
])
import { Box } from 'nft/components/Box'
import * as styles from 'nft/components/layout/Marquee.css'
import { ReactNode, useEffect, useRef, useState } from 'react'
export const Marquee = ({ children, speed = 20 }: { children: ReactNode; speed?: number }) => {
const [duration, setDuration] = useState(0)
const containerRef = useRef<HTMLDivElement | null>(null)
const marqueeRef = useRef<HTMLDivElement | null>(null)
const updateScrollDuration = () => {
let containerWidth = 0
let marqueeWidth = 0
try {
if (marqueeRef.current && containerRef.current) {
containerWidth = containerRef.current.getBoundingClientRect().width
marqueeWidth = marqueeRef.current.getBoundingClientRect().width
}
} catch (e) {}
if (marqueeWidth < containerWidth) {
setDuration(containerWidth / speed)
} else {
setDuration(marqueeWidth / speed)
}
}
useEffect(() => {
updateScrollDuration()
// Rerender on window resize
window.addEventListener('resize', updateScrollDuration)
return () => {
window.removeEventListener('resize', updateScrollDuration)
}
})
return (
<Box ref={containerRef} overflowX="hidden" display="flex" flexDirection="row" position="relative" width="full">
<div ref={marqueeRef} style={{ ['--duration' as string]: `${duration}s` }} className={styles.marquee}>
{children}
</div>
<div style={{ ['--duration' as string]: `${duration}s` }} className={styles.marquee}>
{children}
</div>
</Box>
)
}
import create from 'zustand' import { breakpoints } from 'nft/css/sprinkles.css'
import { devtools } from 'zustand/middleware' import { useEffect, useState } from 'react'
import { breakpoints } from '../css/sprinkles.css' const isClient = typeof window === 'object'
interface IsMobileState { function getIsMobile() {
isMobile: boolean return isClient ? window.innerWidth < breakpoints.tabletSm : false
width: number
setMobileWidth: (width: number) => void
} }
export const useIsMobile = create<IsMobileState>()( export function useIsMobile(): boolean {
devtools( const [isMobile, setIsMobile] = useState(getIsMobile)
(set) => ({
isMobile: true, useEffect(() => {
width: 800, function handleResize() {
setMobileWidth: (width: number) => setIsMobile(getIsMobile())
set(() => ({ }
width,
isMobile: width < breakpoints.tabletSm, if (isClient) {
})), window.addEventListener('resize', handleResize)
}), return () => {
{ name: 'isMobile' } window.removeEventListener('resize', handleResize)
) }
) }
return undefined
}, [])
return isMobile
}
import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionStats } from 'nft/components/collection/CollectionStats'
import { Column, Row } from 'nft/components/Flex'
import { CollectionProps } from 'nft/pages/collection/common'
import * as styles from 'nft/pages/collection/common.css'
export const CollectionDesktop = ({ collectionStats }: CollectionProps) => {
return (
<Column width="full">
<Box width="full" height="160">
<Box
as="img"
maxHeight="full"
width="full"
src={collectionStats?.bannerImageUrl}
className={`${styles.bannerImage}`}
/>
</Box>
{collectionStats && (
<Row paddingLeft="32" paddingRight="32">
<CollectionStats stats={collectionStats} isMobile={false} />
</Row>
)}
<Row alignItems="flex-start" position="relative" paddingLeft="32" paddingRight="32">
<AnimatedBox width="full">CollectionNfts</AnimatedBox>
</Row>
</Column>
)
}
import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionStats } from 'nft/components/collection/CollectionStats'
import { Column, Row } from 'nft/components/Flex'
import { CollectionProps } from 'nft/pages/collection/common'
import * as styles from 'nft/pages/collection/common.css'
export const CollectionMobile = ({ collectionStats }: CollectionProps) => {
return (
<Column width="full">
<Box width="full" height="160">
<Box
as="img"
maxHeight="full"
width="full"
src={collectionStats?.bannerImageUrl}
className={`${styles.bannerImage}`}
/>
</Box>
{collectionStats && (
<Row paddingLeft="32" paddingRight="32">
<CollectionStats stats={collectionStats} isMobile={true} />
</Row>
)}
<Row alignItems="flex-start" position="relative" paddingLeft="32" paddingRight="32">
<AnimatedBox width="full">CollectionNfts</AnimatedBox>
</Row>
</Column>
)
}
import { style } from '@vanilla-extract/css'
import { buttonTextMedium } from 'nft/css/common.css'
import { sprinkles, vars } from '../../css/sprinkles.css'
export const bannerContainerNoBanner = style({ height: '0', marginTop: '0px' })
export const bannerImage = style({ objectFit: 'cover' })
export const baseActivitySwitcherToggle = style([
buttonTextMedium,
sprinkles({
position: 'relative',
background: 'none',
border: 'none',
cursor: 'pointer',
}),
{
lineHeight: '24px',
},
])
export const activitySwitcherToggle = style([
baseActivitySwitcherToggle,
sprinkles({
color: 'darkGray',
}),
])
export const selectedActivitySwitcherToggle = style([
baseActivitySwitcherToggle,
sprinkles({
color: 'blackBlue',
}),
{
':after': {
content: '',
position: 'absolute',
background: vars.color.genieBlue,
width: '100%',
height: '2px',
left: '0px',
right: '0px',
bottom: '-8px',
},
},
])
import { GenieCollection } from 'nft/types'
export interface CollectionProps {
collectionStats: GenieCollection | undefined
}
export const Collection = () => { import { useQuery } from 'react-query'
return <div>Collection Page</div> import { useParams } from 'react-router-dom'
import { useIsMobile } from '../../hooks/useIsMobile'
import { CollectionStatsFetcher } from '../../queries'
import { CollectionDesktop } from './CollectionDesktop'
import { CollectionMobile } from './CollectionMobile'
const Collection = () => {
const { contractAddress } = useParams()
const isMobile = useIsMobile()
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
CollectionStatsFetcher(contractAddress as string)
)
return isMobile ? (
<CollectionMobile collectionStats={collectionStats} />
) : (
<CollectionDesktop collectionStats={collectionStats} />
)
} }
export default Collection export default Collection
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