mirror of
https://github.com/luxfi/market.git
synced 2026-09-24 00:25:57 +00:00
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
import {
|
|
GetStaticPaths,
|
|
GetStaticProps,
|
|
InferGetStaticPropsType,
|
|
NextPage,
|
|
} from 'next'
|
|
import { Text, Flex, Box, Grid } from '../../components/primitives'
|
|
import { paths } from '@reservoir0x/reservoir-sdk'
|
|
import Layout from 'components/Layout'
|
|
import fetcher, { basicFetcher } from 'utils/fetcher'
|
|
import { useIntersectionObserver } from 'usehooks-ts'
|
|
import { useMediaQuery } from 'react-responsive'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Avatar } from 'components/primitives/Avatar'
|
|
import Jazzicon, { jsNumberForAddress } from 'react-jazzicon'
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import { faCopy, faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'
|
|
import { TabsList, TabsTrigger, TabsContent } from 'components/primitives/Tab'
|
|
import * as Tabs from '@radix-ui/react-tabs'
|
|
import {
|
|
useCollectionActivity,
|
|
useTokens,
|
|
useUserCollections,
|
|
useUserTokens,
|
|
} from '@reservoir0x/reservoir-kit-ui'
|
|
import TokenCard from 'components/collections/TokenCard'
|
|
import { TokenFilters } from 'components/common/TokenFilters'
|
|
import { useMounted, useMarketplaceChain } from '../../hooks'
|
|
import { FilterButton } from 'components/common/FilterButton'
|
|
import { UserActivityTable } from 'components/profile/UserActivityTable'
|
|
import { MobileActivityFilters } from 'components/common/MobileActivityFilters'
|
|
import { ActivityFilters } from 'components/common/ActivityFilters'
|
|
import { MobileTokenFilters } from 'components/common/MobileTokenFilters'
|
|
import LoadingCard from 'components/common/LoadingCard'
|
|
import { NAVBAR_HEIGHT } from 'components/navbar'
|
|
import { DefaultChain } from 'utils/chains'
|
|
import { useENSResolver } from 'hooks'
|
|
import { COLLECTION_SET_ID, COMMUNITY, NORMALIZE_ROYALTIES } from 'pages/_app'
|
|
import Head from 'next/head'
|
|
import CopyText from 'components/common/CopyText'
|
|
|
|
type Props = InferGetStaticPropsType<typeof getStaticProps>
|
|
|
|
type ActivityTypes = Exclude<
|
|
NonNullable<
|
|
NonNullable<
|
|
Exclude<Parameters<typeof useCollectionActivity>['0'], boolean>
|
|
>['types']
|
|
>,
|
|
string
|
|
>
|
|
|
|
const IndexPage: NextPage<Props> = ({ address, ssr, ensName }) => {
|
|
const {
|
|
avatar: ensAvatar,
|
|
name: resolvedEnsName,
|
|
shortAddress,
|
|
} = useENSResolver(address)
|
|
ensName = resolvedEnsName ? resolvedEnsName : ensName
|
|
const [tokenFiltersOpen, setTokenFiltersOpen] = useState(true)
|
|
const [activityFiltersOpen, setActivityFiltersOpen] = useState(true)
|
|
const [filterCollection, setFilterCollection] = useState<string | undefined>(
|
|
undefined
|
|
)
|
|
const isSmallDevice = useMediaQuery({ maxWidth: 905 })
|
|
const [playingElement, setPlayingElement] = useState<
|
|
HTMLAudioElement | HTMLVideoElement | null
|
|
>()
|
|
const isMounted = useMounted()
|
|
const [activityTypes, setActivityTypes] = useState<ActivityTypes>(['sale'])
|
|
const marketplaceChain = useMarketplaceChain()
|
|
|
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const scrollToTop = () => {
|
|
let top = (scrollRef.current?.offsetTop || 0) - (NAVBAR_HEIGHT + 16)
|
|
window.scrollTo({ top: top })
|
|
}
|
|
|
|
const loadMoreRef = useRef<HTMLDivElement>(null)
|
|
const loadMoreObserver = useIntersectionObserver(loadMoreRef, {
|
|
rootMargin: '0px 0px 300px 0px',
|
|
})
|
|
|
|
const tokenQuery: Parameters<typeof useUserTokens>['1'] = {
|
|
limit: 20,
|
|
collection: filterCollection,
|
|
}
|
|
|
|
const collectionQuery: Parameters<typeof useUserCollections>['1'] = {
|
|
limit: 100,
|
|
}
|
|
|
|
if (COLLECTION_SET_ID) {
|
|
collectionQuery.collectionsSetId = COLLECTION_SET_ID
|
|
tokenQuery.collectionsSetId = COLLECTION_SET_ID
|
|
} else if (COMMUNITY) {
|
|
collectionQuery.community = COMMUNITY
|
|
tokenQuery.community = COMMUNITY
|
|
}
|
|
|
|
const ssrTokens = ssr.tokens[marketplaceChain.id]
|
|
? [ssr.tokens[marketplaceChain.id]]
|
|
: undefined
|
|
const {
|
|
data: tokens,
|
|
mutate,
|
|
fetchNextPage,
|
|
isFetchingInitialData,
|
|
hasNextPage,
|
|
isFetchingPage,
|
|
} = useUserTokens(address || '', tokenQuery, {
|
|
fallbackData: filterCollection ? undefined : ssrTokens,
|
|
})
|
|
|
|
const ssrCollections = ssr.collections[marketplaceChain.id]
|
|
? [ssr.collections[marketplaceChain.id]]
|
|
: undefined
|
|
|
|
const { data: collections } = useUserCollections(address, collectionQuery, {
|
|
fallbackData: filterCollection ? undefined : ssrCollections,
|
|
})
|
|
|
|
useEffect(() => {
|
|
const isVisible = !!loadMoreObserver?.isIntersecting
|
|
if (isVisible) {
|
|
fetchNextPage()
|
|
}
|
|
}, [loadMoreObserver?.isIntersecting])
|
|
|
|
if (!isMounted) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<Head>
|
|
<title>{`Profile - ${address}`}</title>
|
|
</Head>
|
|
<Flex
|
|
direction="column"
|
|
css={{
|
|
px: '$4',
|
|
pt: '$5',
|
|
pb: 0,
|
|
'@sm': {
|
|
px: '$5',
|
|
},
|
|
}}
|
|
>
|
|
<Flex align="center">
|
|
{ensAvatar ? (
|
|
<Avatar size="xxl" src={ensAvatar} />
|
|
) : (
|
|
<Jazzicon
|
|
diameter={64}
|
|
seed={jsNumberForAddress(address as string)}
|
|
/>
|
|
)}
|
|
<Flex direction="column" css={{ ml: '$4' }}>
|
|
<Text style="h5">{ensName ? ensName : shortAddress}</Text>
|
|
<CopyText text={address as string}>
|
|
<Flex align="center" css={{ cursor: 'pointer' }}>
|
|
<Text style="subtitle1" color="subtle" css={{ mr: '$3' }}>
|
|
{shortAddress}
|
|
</Text>
|
|
<Box css={{ color: '$gray10' }}>
|
|
<FontAwesomeIcon icon={faCopy} width={16} height={16} />
|
|
</Box>
|
|
</Flex>
|
|
</CopyText>
|
|
</Flex>
|
|
</Flex>
|
|
<Tabs.Root defaultValue="items">
|
|
<TabsList>
|
|
<TabsTrigger value="items">Items</TabsTrigger>
|
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="items">
|
|
<Flex
|
|
css={{
|
|
gap: tokenFiltersOpen ? '$5' : '',
|
|
position: 'relative',
|
|
}}
|
|
ref={scrollRef}
|
|
>
|
|
{isSmallDevice ? (
|
|
<MobileTokenFilters
|
|
collections={collections}
|
|
filterCollection={filterCollection}
|
|
setFilterCollection={setFilterCollection}
|
|
/>
|
|
) : (
|
|
<TokenFilters
|
|
open={tokenFiltersOpen}
|
|
setOpen={setTokenFiltersOpen}
|
|
collections={collections}
|
|
filterCollection={filterCollection}
|
|
setFilterCollection={setFilterCollection}
|
|
scrollToTop={scrollToTop}
|
|
/>
|
|
)}
|
|
<Box
|
|
css={{
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<Flex justify="between" css={{ marginBottom: '$4' }}>
|
|
{collections && collections.length > 0 && !isSmallDevice && (
|
|
<FilterButton
|
|
open={tokenFiltersOpen}
|
|
setOpen={setTokenFiltersOpen}
|
|
/>
|
|
)}
|
|
</Flex>
|
|
<Grid
|
|
css={{
|
|
gap: '$4',
|
|
width: '100%',
|
|
pb: '$6',
|
|
gridTemplateColumns:
|
|
'repeat(auto-fill, minmax(200px, 1fr))',
|
|
'@md': {
|
|
gridTemplateColumns:
|
|
'repeat(auto-fill, minmax(240px, 1fr))',
|
|
},
|
|
}}
|
|
>
|
|
{isFetchingInitialData
|
|
? Array(10)
|
|
.fill(null)
|
|
.map((_, index) => (
|
|
<LoadingCard key={`loading-card-${index}`} />
|
|
))
|
|
: tokens.map((token, i) => {
|
|
if (token)
|
|
return (
|
|
<TokenCard
|
|
key={i}
|
|
token={
|
|
token as ReturnType<typeof useTokens>['data'][0]
|
|
}
|
|
mutate={mutate}
|
|
rarityEnabled={false}
|
|
onMediaPlayed={(e) => {
|
|
if (
|
|
playingElement &&
|
|
playingElement !== e.nativeEvent.target
|
|
) {
|
|
playingElement.pause()
|
|
}
|
|
const element =
|
|
(e.nativeEvent.target as HTMLAudioElement) ||
|
|
(e.nativeEvent.target as HTMLVideoElement)
|
|
if (element) {
|
|
setPlayingElement(element)
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
<Box ref={loadMoreRef}>
|
|
{hasNextPage && !isFetchingInitialData && <LoadingCard />}
|
|
</Box>
|
|
{hasNextPage && (
|
|
<>
|
|
{Array(10)
|
|
.fill(null)
|
|
.map((_, index) => (
|
|
<LoadingCard key={`loading-card-${index}`} />
|
|
))}
|
|
</>
|
|
)}
|
|
</Grid>
|
|
{tokens.length === 0 && !isFetchingPage && (
|
|
<Flex
|
|
direction="column"
|
|
align="center"
|
|
css={{ py: '$6', gap: '$4', width: '100%' }}
|
|
>
|
|
<Text css={{ color: '$gray11' }}>
|
|
<FontAwesomeIcon icon={faMagnifyingGlass} size="2xl" />
|
|
</Text>
|
|
<Text css={{ color: '$gray11' }}>No items found</Text>
|
|
</Flex>
|
|
)}
|
|
</Box>
|
|
</Flex>
|
|
</TabsContent>
|
|
<TabsContent value="activity">
|
|
<Flex
|
|
css={{
|
|
gap: activityFiltersOpen ? '$5' : '',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{!isSmallDevice && (
|
|
<ActivityFilters
|
|
open={activityFiltersOpen}
|
|
setOpen={setActivityFiltersOpen}
|
|
activityTypes={activityTypes}
|
|
setActivityTypes={setActivityTypes}
|
|
/>
|
|
)}
|
|
<Box
|
|
css={{
|
|
flex: 1,
|
|
gap: '$4',
|
|
pb: '$5',
|
|
}}
|
|
>
|
|
{isSmallDevice ? (
|
|
<MobileActivityFilters
|
|
activityTypes={activityTypes}
|
|
setActivityTypes={setActivityTypes}
|
|
/>
|
|
) : (
|
|
<FilterButton
|
|
open={activityFiltersOpen}
|
|
setOpen={setActivityFiltersOpen}
|
|
/>
|
|
)}
|
|
<UserActivityTable
|
|
user={address}
|
|
activityTypes={activityTypes}
|
|
/>
|
|
</Box>
|
|
</Flex>
|
|
</TabsContent>
|
|
</Tabs.Root>
|
|
</Flex>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
export const getStaticPaths: GetStaticPaths = async () => {
|
|
return {
|
|
paths: [],
|
|
fallback: 'blocking',
|
|
}
|
|
}
|
|
|
|
type UserTokensSchema =
|
|
paths['/users/{user}/tokens/v6']['get']['responses']['200']['schema']
|
|
type UserCollectionsSchema =
|
|
paths['/users/{user}/collections/v2']['get']['responses']['200']['schema']
|
|
|
|
export const getStaticProps: GetStaticProps<{
|
|
ssr: {
|
|
tokens: Record<number, UserTokensSchema>
|
|
collections: Record<number, UserCollectionsSchema>
|
|
}
|
|
address: string | undefined
|
|
ensName: string | null
|
|
}> = async ({ params }) => {
|
|
let address = params?.address?.toString() || ''
|
|
const isEnsName = address.includes('.')
|
|
let ensName: null | string = null
|
|
|
|
if (isEnsName) {
|
|
ensName = address
|
|
const ensResponse = await basicFetcher(
|
|
`https://api.ensideas.com/ens/resolve/${address}`
|
|
)
|
|
const ensAddress = ensResponse?.data?.address
|
|
if (ensAddress) {
|
|
address = ensAddress
|
|
} else {
|
|
return {
|
|
notFound: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
const tokensQuery: paths['/users/{user}/tokens/v6']['get']['parameters']['query'] =
|
|
{
|
|
limit: 20,
|
|
normalizeRoyalties: NORMALIZE_ROYALTIES,
|
|
}
|
|
|
|
const collectionsQuery: paths['/users/{user}/collections/v2']['get']['parameters']['query'] =
|
|
{
|
|
limit: 100,
|
|
}
|
|
|
|
if (COLLECTION_SET_ID) {
|
|
tokensQuery.collectionsSetId = COLLECTION_SET_ID
|
|
collectionsQuery.collectionsSetId = COLLECTION_SET_ID
|
|
} else if (COMMUNITY) {
|
|
tokensQuery.community = COMMUNITY
|
|
collectionsQuery.community = COMMUNITY
|
|
}
|
|
|
|
const promises: ReturnType<typeof fetcher>[] = []
|
|
|
|
const headers = {
|
|
headers: {
|
|
'x-api-key': DefaultChain.apiKey || '',
|
|
},
|
|
}
|
|
const tokensPromise = fetcher(
|
|
`${DefaultChain.reservoirBaseUrl}/users/${address}/tokens/v6`,
|
|
tokensQuery,
|
|
headers
|
|
)
|
|
const collectionsPromise = fetcher(
|
|
`${DefaultChain.reservoirBaseUrl}/users/${address}/collections/v2`,
|
|
collectionsQuery,
|
|
headers
|
|
)
|
|
promises.push(tokensPromise)
|
|
promises.push(collectionsPromise)
|
|
|
|
const responses = await Promise.allSettled(promises)
|
|
const collections: Record<number, any> = {}
|
|
const tokens: Record<number, any> = {}
|
|
responses.forEach((response) => {
|
|
if (response.status === 'fulfilled') {
|
|
const url = new URL(response.value.response.url)
|
|
if (url.pathname.includes('collections')) {
|
|
collections[DefaultChain.id] = response.value.data
|
|
} else if (url.pathname.includes('tokens')) {
|
|
tokens[DefaultChain.id] = response.value.data
|
|
}
|
|
}
|
|
})
|
|
|
|
return {
|
|
props: { ssr: { tokens, collections }, address, ensName },
|
|
revalidate: 5,
|
|
}
|
|
}
|
|
|
|
export default IndexPage
|