import { faChevronLeft, faMagnifyingGlass, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Flex, Text, Button, Select, FormatCryptoCurrency, Input, } from 'components/primitives' import { ComponentPropsWithoutRef, Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState, } from 'react' import { Currency, Listing, ListModal } from '@reservoir0x/reservoir-kit-ui' import expirationOptions from 'utils/defaultExpirationOptions' import { ExpirationOption } from 'types/ExpirationOption' import { UserToken } from 'pages/portfolio/[[...address]]' import CryptoCurrencyIcon from 'components/primitives/CryptoCurrencyIcon' import { useChainCurrency, useMarketplaceChain } from 'hooks' import BatchListModal from 'components/portfolio/BatchListModal' import { useMediaQuery } from 'react-responsive' import { BatchListingsTableHeading } from './BatchListingsTableHeading' import { BatchListingsTableRow } from './BatchListingsTableRow' import useOnChainRoyalties, { OnChainRoyaltyReturnType, } from 'hooks/useOnChainRoyalties' import { formatUnits } from 'viem' export type BatchListing = { token: UserToken price: string quantity: number expirationOption: ExpirationOption orderbook: Listing['orderbook'] orderKind: Listing['orderKind'] marketplaceFee?: number } type ListingCurrencies = ComponentPropsWithoutRef< typeof ListModal >['currencies'] export type Marketplace = { name: string imageUrl: string orderbook: string orderKind: string } type Props = { selectedItems: UserToken[] setSelectedItems: Dispatch> setShowListingPage: Dispatch> } const MINIMUM_AMOUNT = 0.000001 const marketplaces = [ { name: 'Reservoir', imageUrl: 'https://api.reservoir.tools/redirect/sources/reservoir/logo/v2', orderbook: 'reservoir', orderKind: 'seaport-v1.4', }, { name: 'OpenSea', imageUrl: 'https://api.reservoir.tools/redirect/sources/opensea/logo/v2', orderbook: 'opensea', orderKind: 'seaport-v1.4', }, ] const BatchListings: FC = ({ selectedItems, setSelectedItems, setShowListingPage, }) => { const [listings, setListings] = useState([]) const [selectedMarketplaces, setSelectedMarketplaces] = useState< Marketplace[] >([marketplaces[0]]) const [globalPrice, setGlobalPrice] = useState('') const [globalExpirationOption, setGlobalExpirationOption] = useState(expirationOptions[5]) const [totalProfit, setTotalProfit] = useState(0) const [listButtonDisabled, setListButtonDisabled] = useState(true) const isLargeDevice = useMediaQuery({ minWidth: 1400 }) const chain = useMarketplaceChain() const chainCurrency = useChainCurrency() const defaultCurrency = { contract: chainCurrency.address, symbol: chainCurrency.symbol, } const currencies: ListingCurrencies = chain.listingCurrencies const [currency, setCurrency] = useState( currencies && currencies[0] ? currencies[0] : defaultCurrency ) const royaltyQuery: NonNullable< Parameters['0']['tokens'] > = useMemo( () => listings .filter((listing) => listing?.token?.token !== undefined) .map((listing) => ({ contract: listing.token.token?.contract as string, tokenId: listing.token.token?.tokenId as string, })), [listings] ) const { data: onChainRoyalties } = useOnChainRoyalties({ tokens: royaltyQuery, chainId: chain.id, enabled: royaltyQuery.length > 0, }) const onChainRoyaltiesMap = useMemo( () => onChainRoyalties?.reduce((royalties, royaltyData, i) => { if ( royaltyData.status === 'success' && (royaltyData.result as any)[0] && (royaltyData.result as any)[1] ) { const royaltyBpsList = ( royaltyData.result as OnChainRoyaltyReturnType )[1] const id = `${royaltyQuery[i].contract}:${royaltyQuery[i].tokenId}` const totalRoyalty = royaltyBpsList ? royaltyBpsList.reduce((total, feeBps) => { total += parseFloat( formatUnits(feeBps, currency.decimals || 18) ) return total }, 0) : 0 if (totalRoyalty) { royalties[id] = (totalRoyalty / 1) * 10000 } } return royalties }, {} as Record) || {}, [onChainRoyalties, chainCurrency] ) const displayQuantity = useCallback(() => { return listings.some((listing) => listing?.token?.token?.kind === 'erc1155') }, [listings]) let gridTemplateColumns = displayQuantity() ? isLargeDevice ? '1.1fr .5fr 2.6fr .8fr repeat(2, .7fr) .5fr .3fr' : '1.3fr .6fr 1.6fr 1fr repeat(2, .9fr) .6fr .3fr' : isLargeDevice ? '1.1fr 2.7fr 1fr repeat(2, .7fr) .5fr .3fr' : '1.3fr 1.8fr 1.2fr repeat(2, .9fr) .6fr .3fr' const generateListings = useCallback(() => { const listings = selectedItems.flatMap((item) => { return selectedMarketplaces.map((marketplace) => { const listing: BatchListing = { token: item, quantity: 1, price: globalPrice || '0', expirationOption: globalExpirationOption, //@ts-ignore orderbook: marketplace.orderbook, //@ts-ignore orderKind: marketplace.orderKind, } return listing }) }) return listings }, [selectedItems, selectedMarketplaces]) useEffect(() => { setListings(generateListings()) }, [selectedItems, selectedMarketplaces, generateListings]) useEffect(() => { const maxProfit = selectedItems.reduce((total, item) => { const itemId = `${item.token?.contract}:${item.token?.tokenId}` const itemListings = listings.filter( (listing) => `${listing.token.token?.contract}:${listing.token.token?.tokenId}` === itemId ) const onChainRoyaltyBps = onChainRoyaltiesMap[itemId] const profits = itemListings.map((listing) => { const listingCreatorRoyalties = (Number(listing.price) * listing.quantity * (onChainRoyaltyBps || listing?.token?.token?.collection?.royaltiesBps || 0)) / 10000 const profit = Number(listing.price) * listing.quantity - (listing.marketplaceFee || 0) - listingCreatorRoyalties return profit }) const highestProfit = Math.max(...profits) return total + highestProfit }, 0) setTotalProfit(maxProfit) }, [listings, onChainRoyaltiesMap, selectedMarketplaces, globalPrice]) useEffect(() => { const hasInvalidPrice = listings.some( (listing) => listing.price === undefined || listing.price === '' || Number(listing.price) < MINIMUM_AMOUNT ) setListButtonDisabled(hasInvalidPrice) }, [listings]) const removeMarketplaceListings = useCallback( (orderbook: string) => { let updatedListings = listings.filter( (listing) => listing.orderbook === orderbook ) setListings(updatedListings) }, [listings] ) const addMarketplaceListings = useCallback( (orderbook: string, orderKind: string) => { setListings((prevListings) => { const updatedListings = [...prevListings] selectedItems.forEach((item) => { const existingListingIndex = updatedListings.findIndex( (listing) => listing.token === item && listing.orderbook === orderbook ) if (existingListingIndex === -1) { const newListing: BatchListing = { token: item, quantity: 1, price: globalPrice || '0', expirationOption: globalExpirationOption, //@ts-ignore orderbook: orderbook, //@ts-ignore orderKind: orderKind, } updatedListings.push(newListing) } }) return updatedListings }) }, [selectedItems, globalPrice, globalExpirationOption] ) const handleMarketplaceSelection = useCallback( (marketplace: Marketplace) => { const isSelected = selectedMarketplaces.some( (selected) => selected.orderbook === marketplace.orderbook ) if (isSelected) { setSelectedMarketplaces((prevSelected) => prevSelected.filter( (selected) => selected.orderbook !== marketplace.orderbook ) ) removeMarketplaceListings(marketplace.orderbook as string) } else { setSelectedMarketplaces((prevSelected) => [ ...prevSelected, marketplace, ]) addMarketplaceListings( marketplace.orderbook as string, marketplace.orderKind as string ) } }, [selectedMarketplaces, addMarketplaceListings, removeMarketplaceListings] ) const updateListing = useCallback((updatedListing: BatchListing) => { setListings((prevListings) => { return prevListings.map((listing) => { if ( listing.token === updatedListing.token && listing.orderbook === updatedListing.orderbook ) { return updatedListing } return listing }) }) }, []) const applyFloorPrice = useCallback(() => { setListings((prevListings) => { return prevListings.map((listing) => { if (listing.token.token?.collection?.floorAskPrice?.amount?.native) { return { ...listing, price: listing.token.token?.collection?.floorAskPrice?.amount?.native.toString(), } } return listing }) }) setCurrency(defaultCurrency) }, [listings]) const applyTopTraitPrice = useCallback(() => { setListings((prevListings) => { return prevListings.map((listing) => { if (listing.token.token?.attributes) { // Find the highest floor price let topTraitPrice = Math.max( ...listing.token.token.attributes.map( (attribute) => attribute.floorAskPrice ?? 0 ) ) if (topTraitPrice && topTraitPrice > 0) { return { ...listing, price: topTraitPrice.toString(), } } } return listing }) }) setCurrency(defaultCurrency) }, [listings]) return ( List for Sale Select Marketplaces {marketplaces.map((marketplace) => { const isSelected = selectedMarketplaces.some( (selected) => selected.orderbook === marketplace.orderbook ) return ( handleMarketplaceSelection(marketplace)} > {marketplace.name} {marketplace.name} ) })} Apply to All {isLargeDevice ? ( ) : null} { setGlobalPrice(e.target.value) }} /> {listings.length > 0 ? ( {listings.map((listing, i) => ( ))} Max Profit { setShowListingPage(false) setSelectedItems([]) setListings([]) }} /> ) : ( No items selected )} ) } export default BatchListings