import { Dispatch, FC, SetStateAction, useEffect, useRef, useContext, useState, forwardRef, useImperativeHandle, useMemo, } from 'react' import { useMediaQuery } from 'react-responsive' import { Text, Flex, TableCell, TableRow, HeaderRow, Tooltip, FormatCryptoCurrency, Button, Box, Grid, } from '../primitives' import { List, AcceptBid } from 'components/buttons' import Image from 'next/image' import { useIntersectionObserver } from 'usehooks-ts' import LoadingSpinner from '../common/LoadingSpinner' import { EditListingModal, EditListingStep, useReservoirClient, useTokens, useUserTokens, } from '@reservoir0x/reservoir-kit-ui' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faBolt, faEdit, faEllipsis, faGasPump, faMagnifyingGlass, faRefresh, } from '@fortawesome/free-solid-svg-icons' import Link from 'next/link' import { MutatorCallback } from 'swr' import { Address } from 'wagmi' import { useMarketplaceChain } from 'hooks' import { NAVBAR_HEIGHT } from 'components/navbar' import Checkbox from 'components/primitives/Checkbox' import { UserToken } from 'pages/portfolio/[[...address]]' import { ChainContext } from 'context/ChainContextProvider' import { Dropdown, DropdownMenuItem } from 'components/primitives/Dropdown' import { PortfolioSortingOption } from 'components/common/PortfolioSortDropdown' import CancelListing from 'components/buttons/CancelListing' import { ToastContext } from 'context/ToastContextProvider' import fetcher from 'utils/fetcher' import { DATE_REGEX, timeTill } from 'utils/till' import { spin } from 'components/common/LoadingSpinner' import { formatDollar, formatNumber } from 'utils/numbers' import { OpenSeaVerified } from 'components/common/OpenSeaVerified' import { ItemView } from './ViewToggle' import PortfolioTokenCard from './PortfolioTokenCard' import optimizeImage from 'utils/optimizeImage' type Props = { address: Address | undefined filterCollection: string | undefined sortBy: PortfolioSortingOption isLoading?: boolean hideSpam: boolean selectedItems: UserToken[] isOwner: boolean itemView: ItemView acceptModalOpen?: boolean setSelectedItems: Dispatch> } const ownerDesktopTemplateColumns = '1.25fr repeat(3, .75fr) 1.5fr' const desktopTemplateColumns = '1.25fr repeat(3, .75fr)' export type TokenTableRef = { mutate: any } export const TokenTable = forwardRef( ( { address, isLoading, sortBy, filterCollection, selectedItems, isOwner, itemView, setSelectedItems, hideSpam, }, ref ) => { const loadMoreRef = useRef(null) const loadMoreObserver = useIntersectionObserver(loadMoreRef, {}) const client = useReservoirClient() const [acceptBidModalOpen, setAcceptBidModalOpen] = useState(false) const [playingElement, setPlayingElement] = useState< HTMLAudioElement | HTMLVideoElement | null >() let tokenQuery: Parameters['1'] = { limit: 20, sortBy: sortBy, collection: filterCollection, includeTopBid: true, includeRawData: true, includeAttributes: true, excludeSpam: hideSpam, } const { chain } = useContext(ChainContext) if (chain.collectionSetId) { tokenQuery.collectionsSetId = chain.collectionSetId } else if (chain.community) { tokenQuery.community = chain.community } const { data: tokens, fetchNextPage, mutate, setSize, isFetchingPage, isValidating, } = useUserTokens(address, tokenQuery, { revalidateOnMount: true, fallbackData: [], }) console.log(tokens) useEffect(() => { mutate() return () => { setSize(1) } }, []) useEffect(() => { const isVisible = !!loadMoreObserver?.isIntersecting if (isVisible) { fetchNextPage() } }, [loadMoreObserver?.isIntersecting]) useEffect(() => { const eventListener: Parameters< NonNullable>['addEventListener'] >['0'] = (event, chainId) => { switch (event.name) { case 'accept_offer_complete': { if (!acceptBidModalOpen && !selectedItems.length) { mutate() setSelectedItems([]) } break } } } client?.addEventListener(eventListener) return () => { client?.removeEventListener(eventListener) } }, [client]) useEffect(() => { if (acceptBidModalOpen) { setSelectedItems([]) } }, [acceptBidModalOpen]) useImperativeHandle(ref, () => ({ mutate, })) return ( <> {!isValidating && !isFetchingPage && tokens && tokens.length === 0 ? ( No items found ) : ( {isLoading ? null : itemView === 'list' ? ( <> {tokens.map((token, i) => { if (!token) return null return ( { setAcceptBidModalOpen(open) }} /> ) })} ) : ( {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) } }} /> ))} )}
)} {isValidating && ( )} ) } ) type TokenTableRowProps = { token: ReturnType['data'][0] mutate?: MutatorCallback selectedItems: UserToken[] setSelectedItems: Dispatch> onAcceptBidModalOpened: (open: boolean) => void isOwner: boolean } const TokenTableRow: FC = ({ token, selectedItems, isOwner, onAcceptBidModalOpened, mutate, setSelectedItems, }) => { const { routePrefix, proxyApi } = useMarketplaceChain() const { addToast } = useContext(ToastContext) const [isRefreshing, setIsRefreshing] = useState(false) const isSmallDevice = useMediaQuery({ maxWidth: 900 }) const addSelectedItem = (item: UserToken) => { setSelectedItems([...selectedItems, item]) } const removeSelectedItem = (item: UserToken) => { setSelectedItems( selectedItems.filter( (selectedItem) => selectedItem?.token?.tokenId !== item?.token?.tokenId || selectedItem?.token?.contract !== item?.token?.contract ) ) } const isSelectedItem = (item: UserToken) => { return selectedItems.some( (selectedItem) => selectedItem?.token?.tokenId === item?.token?.tokenId && selectedItem?.token?.contract === item?.token?.contract ) } const [acceptBidModalOpen, setAcceptBidModalOpen] = useState(false) const imageSrc = useMemo(() => { return token?.token?.tokenId ? token?.token?.imageSmall || optimizeImage(token?.token?.collection?.imageUrl, 250) : optimizeImage(token?.token?.collection?.imageUrl, 250) }, [ token?.token?.tokenId, token?.token?.imageSmall, token?.token?.collection?.imageUrl, ]) const isOracleOrder = token?.ownership?.floorAsk?.isNativeOffChainCancellable const contract = token.token?.collection?.id ? token.token?.collection.id?.split(':')[0] : undefined if (isSmallDevice) { return ( {imageSrc && ( src} src={imageSrc} alt={`${token?.token?.name}`} width={36} height={36} /> )} {token?.token?.collection?.name} {token?.token?.name || `#${token?.token?.tokenId}`} Floor Top Offer {token?.token?.topBid?.price?.amount?.decimal && isOwner ? ( { if (open !== acceptBidModalOpen) { onAcceptBidModalOpened(open as boolean) } setAcceptBidModalOpen(open) }, ]} buttonCss={{ width: '100%', maxWidth: '300px', justifyContent: 'center', px: '20px', backgroundColor: '$primary9', color: 'white', '&:hover': { backgroundColor: '$primary10', }, }} buttonChildren={ Sell } /> ) : null} {isOwner ? ( ['data'][0]} mutate={mutate} buttonCss={{ width: '100%', maxWidth: '300px', justifyContent: 'center', px: '20px', backgroundColor: '$gray3', color: '$gray12', '&:hover': { backgroundColor: '$gray4', }, }} buttonChildren="List" /> ) : null} } contentProps={{ asChild: true, forceMount: true }} > { if (isRefreshing) { e.preventDefault() return } setIsRefreshing(true) fetcher( `${window.location.origin}/${proxyApi}/tokens/refresh/v1`, undefined, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ token: `${contract}:${token.token?.tokenId}`, }), } ) .then(({ data, response }) => { if (response.status === 200) { addToast?.({ title: 'Refresh token', description: 'Request to refresh this token was accepted.', }) } else { throw data } setIsRefreshing(false) }) .catch((e) => { const ratelimit = DATE_REGEX.exec(e?.message)?.[0] addToast?.({ title: 'Refresh token failed', description: ratelimit ? `This token was recently refreshed. The next available refresh is ${timeTill( ratelimit )}.` : `This token was recently refreshed. Please try again later.`, }) setIsRefreshing(false) throw e }) }} > Refresh {isOracleOrder && token?.ownership?.floorAsk?.id && token?.token?.tokenId && token?.token?.collection?.id ? ( Edit Listing } listingId={token?.ownership?.floorAsk?.id} tokenId={token?.token?.tokenId} collectionId={token?.token?.collection?.id} onClose={(data, currentStep) => { if (mutate && currentStep == EditListingStep.Complete) mutate() }} /> ) : null} {token?.ownership?.floorAsk?.id ? ( {!isOracleOrder ? ( Cancelling this order requires gas. } > Cancel ) : ( Cancel )} } /> ) : null} ) } return ( {isOwner ? ( { if (checked) { addSelectedItem(token) } else { removeSelectedItem(token) } }} /> ) : null} {imageSrc && ( src} src={imageSrc} alt={`${token?.token?.name}`} width={48} height={48} /> )} {token?.token?.collection?.name} {token?.token?.kind === 'erc1155' && token?.ownership?.tokenCount && ( x{formatNumber(token?.ownership?.tokenCount, 0, true)} )} {token?.token?.name || `#${token?.token?.tokenId}`} Total Listed Price You Get } > {token.ownership?.floorAsk?.source?.icon ? ( Listing Source Icon ) : null} Total Offer You Get } > {/* TODO: Replace this when the api is patched */} {(token.token?.topBid as any)?.source?.icon ? ( Listing Source Icon ) : null} {token?.token?.topBid?.price?.amount?.usd ? ( {formatDollar( token?.token?.topBid?.price?.amount?.usd as number )} ) : null} {isOwner && ( {token?.token?.topBid?.price?.amount?.decimal ? ( { if (open !== acceptBidModalOpen) { onAcceptBidModalOpened(open as boolean) } setAcceptBidModalOpen(open) }, ]} tokenId={token.token.tokenId} collectionId={token?.token?.contract} buttonCss={{ px: '32px', backgroundColor: '$primary9', color: 'white', '&:hover': { backgroundColor: '$primary10', }, }} buttonChildren={ Sell } mutate={mutate} /> ) : null} ['data'][0]} buttonCss={{ px: '42px', backgroundColor: '$gray3', color: '$gray12', '&:hover': { backgroundColor: '$gray4', }, }} buttonChildren="List" mutate={mutate} /> } contentProps={{ asChild: true, forceMount: true }} > { if (isRefreshing) { e.preventDefault() return } setIsRefreshing(true) fetcher( `${window.location.origin}/${proxyApi}/tokens/refresh/v1`, undefined, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ token: `${contract}:${token.token?.tokenId}`, }), } ) .then(({ data, response }) => { if (response.status === 200) { addToast?.({ title: 'Refresh token', description: 'Request to refresh this token was accepted.', }) } else { throw data } setIsRefreshing(false) }) .catch((e) => { const ratelimit = DATE_REGEX.exec(e?.message)?.[0] addToast?.({ title: 'Refresh token failed', description: ratelimit ? `This token was recently refreshed. The next available refresh is ${timeTill( ratelimit )}.` : `This token was recently refreshed. Please try again later.`, }) setIsRefreshing(false) throw e }) }} > Refresh {isOracleOrder && token?.ownership?.floorAsk?.id && token?.token?.tokenId && token?.token?.collection?.id ? ( Edit Listing } listingId={token?.ownership?.floorAsk?.id} tokenId={token?.token?.tokenId} collectionId={token?.token?.collection?.id} onClose={(data, currentStep) => { if (mutate && currentStep == EditListingStep.Complete) mutate() }} /> ) : null} {token?.ownership?.floorAsk?.id ? ( {!isOracleOrder ? ( Cancelling this order requires gas. } > Cancel ) : ( Cancel )} } /> ) : null} )} ) } const TableHeading: FC<{ isOwner: boolean }> = ({ isOwner }) => ( Items Listed Price Floor Top Offer {isOwner ? : null} )