import { GetServerSideProps, InferGetServerSidePropsType, NextPage } from 'next' import { Text, Flex, Box, Input, FormatCrypto, FormatCryptoCurrency, } from '../../../components/primitives' import { useCollections, useCollectionActivity, useDynamicTokens, useAttributes, } from '@reservoir0x/reservoir-kit-ui' import { paths } from '@reservoir0x/reservoir-sdk' import Layout from 'components/Layout' import { useEffect, useMemo, useRef, useState } from 'react' import { truncateAddress } from 'utils/truncate' import TokenCard from 'components/collections/TokenCard' import { AttributeFilters } from 'components/collections/filters/AttributeFilters' import { FilterButton } from 'components/common/FilterButton' import SelectedAttributes from 'components/collections/filters/SelectedAttributes' import { CollectionOffer } from 'components/buttons' import { Grid } from 'components/primitives/Grid' import { useIntersectionObserver } from 'usehooks-ts' import fetcher from 'utils/fetcher' import { useRouter } from 'next/router' import { SortTokens } from 'components/collections/SortTokens' import { useMediaQuery } from 'react-responsive' import { TabsList, TabsTrigger, TabsContent } from 'components/primitives/Tab' import * as Tabs from '@radix-ui/react-tabs' import { useDebounce } from 'usehooks-ts' import { NAVBAR_HEIGHT } from 'components/navbar' import { CollectionActivityTable } from 'components/collections/CollectionActivityTable' import { ActivityFilters } from 'components/common/ActivityFilters' import { MobileAttributeFilters } from 'components/collections/filters/MobileAttributeFilters' import { MobileActivityFilters } from 'components/common/MobileActivityFilters' import titleCase from 'utils/titleCase' import LoadingCard from 'components/common/LoadingCard' import { useChainCurrency, useMounted } from 'hooks' import { NORMALIZE_ROYALTIES } from 'pages/_app' import { faCog, faCube, faGlobe, faHand, faMagnifyingGlass, faSeedling, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import supportedChains, { DefaultChain } from 'utils/chains' import { Head } from 'components/Head' import { OpenSeaVerified } from 'components/common/OpenSeaVerified' import { Address, useAccount } from 'wagmi' import Img from 'components/primitives/Img' import Sweep from 'components/buttons/Sweep' import Mint from 'components/buttons/Mint' import optimizeImage from 'utils/optimizeImage' import CopyText from 'components/common/CopyText' import { CollectionDetails } from 'components/collections/CollectionDetails' import useTokenUpdateStream from 'hooks/useTokenUpdateStream' import LiveState from 'components/common/LiveState' type ActivityTypes = Exclude< NonNullable< NonNullable< Exclude['0'], boolean> >['types'] >, string > type Props = InferGetServerSidePropsType const CollectionPage: NextPage = ({ id, ssr }) => { const router = useRouter() const { address } = useAccount() const [attributeFiltersOpen, setAttributeFiltersOpen] = useState(true) const [activityFiltersOpen, setActivityFiltersOpen] = useState(true) const [tokenSearchQuery, setTokenSearchQuery] = useState('') const chainCurrency = useChainCurrency() const debouncedSearch = useDebounce(tokenSearchQuery, 500) const [socketState, setSocketState] = useState(null) const [activityTypes, setActivityTypes] = useState([ 'sale', 'mint', ]) const [initialTokenFallbackData, setInitialTokenFallbackData] = useState(true) const isMounted = useMounted() const isSmallDevice = useMediaQuery({ maxWidth: 905 }) && isMounted const [playingElement, setPlayingElement] = useState< HTMLAudioElement | HTMLVideoElement | null >() const loadMoreRef = useRef(null) const loadMoreObserver = useIntersectionObserver(loadMoreRef, {}) const [path, _] = router.asPath.split('?') const routerPath = path.split('/') const isSweepRoute = routerPath[routerPath.length - 1] === 'sweep' const isMintRoute = routerPath[routerPath.length - 1] === 'mint' const sweepOpenState = useState(true) const mintOpenState = useState(true) const scrollRef = useRef(null) const collectionChain = supportedChains.find( (chain) => router.query?.chain === chain.routePrefix ) || DefaultChain const scrollToTop = () => { let top = (scrollRef.current?.offsetTop || 0) - (NAVBAR_HEIGHT + 16) window.scrollTo({ top: top }) } let chainName = collectionChain?.name let collectionQuery: Parameters['0'] = { id, includeSalesCount: true, includeMintStages: true, includeSecurityConfigs: true, } const { data: collections } = useCollections(collectionQuery, { fallbackData: [ssr.collection], }) let collection = collections && collections[0] const mintData = collection?.mintStages?.find( (stage) => stage.kind === 'public' ) const mintPriceDecimal = mintData?.price?.amount?.decimal const mintCurrency = mintData?.price?.currency?.symbol?.toUpperCase() const mintPrice = typeof mintPriceDecimal === 'number' && mintPriceDecimal !== null && mintPriceDecimal !== undefined ? mintPriceDecimal === 0 ? 'Free' : `${mintPriceDecimal} ${mintCurrency}` : undefined let tokenQuery: Parameters['0'] = { limit: 20, collection: id, sortBy: 'floorAskPrice', sortDirection: 'asc', includeQuantity: true, includeLastSale: true, ...(debouncedSearch.length > 0 && { tokenName: debouncedSearch, }), } const sortDirection = router.query['sortDirection']?.toString() const sortBy = router.query['sortBy']?.toString() if (sortBy === 'tokenId' || sortBy === 'rarity') tokenQuery.sortBy = sortBy if (sortDirection === 'desc') tokenQuery.sortDirection = 'desc' // Extract all queries of attribute type Object.keys({ ...router.query }).map((key) => { if ( key.startsWith('attributes[') && key.endsWith(']') && router.query[key] !== '' ) { //@ts-ignore tokenQuery[key] = router.query[key] } }) const { data: tokens, mutate, fetchNextPage, setSize, resetCache, isFetchingInitialData, isFetchingPage, hasNextPage, } = useDynamicTokens(tokenQuery, { fallbackData: initialTokenFallbackData ? [ssr.tokens] : undefined, }) useTokenUpdateStream(id as string, collectionChain.id, { onClose: () => setSocketState(0), onOpen: () => setSocketState(1), onMessage: ({ data: reservoirEvent, }: MessageEvent) => { if (Object.keys(router.query).some((key) => key.includes('attribute'))) return const tokenName = reservoirEvent.data.token.name || '' if ( tokenSearchQuery && tokenSearchQuery.length > 0 && !tokenName.includes(tokenSearchQuery) ) { return } let hasChange = false const newTokens = [...tokens] const price = NORMALIZE_ROYALTIES ? reservoirEvent.data?.market?.floorAskNormalized?.price?.amount?.native : reservoirEvent.data?.market?.floorAsk?.price?.amount?.native const tokenIndex = tokens.findIndex( (token) => token.token?.tokenId === reservoirEvent?.data.token.tokenId ) const token = tokenIndex > -1 ? tokens[tokenIndex] : null if (token) { if (token?.market?.floorAsk?.dynamicPricing) { return } newTokens.splice(tokenIndex, 1) } if (!price) { if (token) { const endOfListingsIndex = tokens.findIndex( (token) => !token.market?.floorAsk?.price?.amount?.native ) if (endOfListingsIndex === -1) { hasChange = true } else { const newTokenIndex = sortBy === 'rarity' ? tokenIndex : endOfListingsIndex > -1 ? endOfListingsIndex : 0 newTokens.splice(newTokenIndex, 0, { ...token, market: { floorAsk: { id: undefined, price: undefined, maker: undefined, validFrom: undefined, validUntil: undefined, source: {}, }, }, }) hasChange = true } } } else { let updatedToken = token ? token : reservoirEvent.data updatedToken = { ...updatedToken, market: { floorAsk: NORMALIZE_ROYALTIES ? reservoirEvent.data.market.floorAskNormalized : reservoirEvent.data.market.floorAsk, }, } if (tokens) { let updatedTokenPosition = sortBy === 'rarity' ? tokenIndex : tokens.findIndex((token) => { let currentTokenPrice = token.market?.floorAsk?.price?.amount?.native if (currentTokenPrice !== undefined) { return sortDirection === 'desc' ? updatedToken.market.floorAsk.price.amount.native >= currentTokenPrice : updatedToken.market.floorAsk.price.amount.native <= currentTokenPrice } return true }) if (updatedTokenPosition <= -1) { return } newTokens.splice(updatedTokenPosition, 0, updatedToken) hasChange = true } } if (hasChange) { mutate( [ { tokens: newTokens, }, ], { revalidate: false, optimisticData: [ { tokens: newTokens, }, ], } ) } }, }) const attributesData = useAttributes(id) const attributes = useMemo(() => { if (!attributesData.data) { return [] } return attributesData.data ?.filter( (attribute) => attribute.kind != 'number' && attribute.kind != 'range' ) .sort((a, b) => a.key.localeCompare(b.key)) }, [attributesData.data]) if (attributeFiltersOpen && attributesData.response && !attributes.length) { setAttributeFiltersOpen(false) } const rarityEnabledCollection = Boolean( collection?.tokenCount && +collection.tokenCount >= 2 && attributes && attributes?.length >= 2 ) const hasSecurityConfig = collection?.securityConfig && Object.values(collection.securityConfig).some(Boolean) const contractKind = `${collection?.contractKind?.toUpperCase()}${ hasSecurityConfig ? 'C' : '' }` useEffect(() => { const isVisible = !!loadMoreObserver?.isIntersecting if (isVisible) { fetchNextPage() } }, [loadMoreObserver?.isIntersecting]) useEffect(() => { if (isMounted && initialTokenFallbackData) { setInitialTokenFallbackData(false) } }, [router.query]) let nativePrice = collection?.floorAsk?.price?.amount?.native let topBidPrice = collection?.topBid?.price?.amount?.native //get owners for collection: // const [owners, setOwners] = useState([]) // const options = { // method: 'GET', // headers: { accept: '*/*', 'x-api-key': 'demo-api-key' }, // } // useEffect(() => { // let collection // console.log(id) // fetch( // `https://api.reservoir.tools/owners/v2?collection=${id}&limit=500`, // options // ) // .then((response) => response.json()) // .then((response) => setOwners(response?.owners)) // .catch((err) => console.error(err)) // }, [id]) // console.log(owners) return ( { if (value === 'items') { resetCache() setSize(1) mutate() } }} > {collection ? ( {/* Commenting out the collection logo */} {/* Collection Page Image */} {/* Commenting out the collection name */} {/* {collection.name} */} {/* Commenting out the contract address */} {/* {truncateAddress(collection?.primaryContract || '')} */} {/* Commenting out the token standard */} {/* {contractKind} {chainName} */} {/* Commenting out Minting Now */} {/* {mintData && ( Minting Now )} */} {/* Commenting out Collect button */} {/* {nativePrice ? ( Collect {chainCurrency.symbol} } buttonCss={{ '@lg': { order: 2 } }} mutate={mutate} /> ) : null} */} {/* Commenting out Mint buttons */} {/* {mintData && mintPrice ? ( {isSmallDevice && ( )} {!isSmallDevice && ( Mint )} {!isSmallDevice && ( {`${mintPrice}`} )} } buttonCss={{ minWidth: 'max-content', whiteSpace: 'nowrap', flexShrink: 0, flexGrow: 1, justifyContent: 'center', px: '$2', maxWidth: '220px', '@md': { order: 1, }, }} mutate={mutate} /> ) : null} */} } buttonProps={{ color: mintData ? 'gray3' : 'primary' }} buttonCss={{ px: '$4' }} mutate={mutate} /> Items Details Activity {/* Owners */} {isSmallDevice ? ( ) : ( <> )} {attributes && attributes.length > 0 && !isSmallDevice && ( )} {!isSmallDevice && ( { setTokenSearchQuery(e.target.value) }} value={tokenSearchQuery} /> )} {socketState !== null && } {!isSmallDevice && ( )} Floor {nativePrice ? ( {chainCurrency.symbol} ) : ( '-' )} Top Bid {topBidPrice ? `${topBidPrice?.toFixed(2) || 0} ${ chainCurrency.symbol }` : '-'} Count {Number(collection?.tokenCount)?.toLocaleString()} {isFetchingInitialData ? Array(10) .fill(null) .map((_, index) => ( )) : tokens.map((token, i) => ( { if ( playingElement && playingElement !== e.nativeEvent.target ) { playingElement.pause() } const element = (e.nativeEvent.target as HTMLAudioElement) || (e.nativeEvent.target as HTMLVideoElement) if (element) { setPlayingElement(element) } }} addToCartEnabled={ token.market?.floorAsk?.maker?.toLowerCase() !== address?.toLowerCase() } /> ))} {(hasNextPage || isFetchingPage) && !isFetchingInitialData && } {(hasNextPage || isFetchingPage) && !isFetchingInitialData && ( <> {Array(6) .fill(null) .map((_, index) => ( ))} )} {tokens.length == 0 && !isFetchingPage && ( No items found )} {isSmallDevice ? ( ) : ( )} {!isSmallDevice && ( )} ) : ( )} ) } export const getServerSideProps: GetServerSideProps<{ ssr: { collection?: paths['/collections/v5']['get']['responses']['200']['schema'] tokens?: paths['/tokens/v6']['get']['responses']['200']['schema'] hasAttributes: boolean } id: string | undefined }> = async ({ params, res }) => { const id = params?.contract?.toString() const { reservoirBaseUrl } = supportedChains.find((chain) => params?.chain === chain.routePrefix) || DefaultChain const headers: RequestInit = { headers: { 'x-api-key': process.env.RESERVOIR_API_KEY || '7f7c5cdf-98c7-5366-99e4-bb50eca3932a', }, } let collectionQuery: paths['/collections/v5']['get']['parameters']['query'] = { id, includeSalesCount: true, normalizeRoyalties: NORMALIZE_ROYALTIES, } const collectionsPromise = fetcher( `${reservoirBaseUrl}/collections/v5`, collectionQuery, headers ) let tokensQuery: paths['/tokens/v6']['get']['parameters']['query'] = { collection: id, sortBy: 'floorAskPrice', sortDirection: 'asc', limit: 20, normalizeRoyalties: NORMALIZE_ROYALTIES, includeDynamicPricing: true, includeAttributes: true, includeQuantity: true, includeLastSale: true, } const tokensPromise = fetcher( `${reservoirBaseUrl}/tokens/v6`, tokensQuery, headers ) const promises = await Promise.allSettled([ collectionsPromise, tokensPromise, ]).catch(() => {}) const collection: Props['ssr']['collection'] = promises?.[0].status === 'fulfilled' && promises[0].value.data ? (promises[0].value.data as Props['ssr']['collection']) : {} const tokens: Props['ssr']['tokens'] = promises?.[1].status === 'fulfilled' && promises[1].value.data ? (promises[1].value.data as Props['ssr']['tokens']) : {} const hasAttributes = tokens?.tokens?.some( (token) => (token?.token?.attributes?.length || 0) > 0 ) || false res.setHeader( 'Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60' ) return { props: { ssr: { collection, tokens, hasAttributes }, id }, } } export default CollectionPage