feat: add listing modal (#4663)

* add listing modal

* add new files

* remove useeffect

* re-add useeffect and includes array

* position relative

* add listing datatype

* use pluralize

* readable const

* clsx

* parseFloat 0 default

* don't use any

* cant use months for ms

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
This commit is contained in:
Charles Bachmeier 2022-09-20 12:45:22 -05:00 committed by GitHub
parent 3d4b077b89
commit ee97d8d902
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1083 additions and 14 deletions

@ -0,0 +1,253 @@
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import ms from 'ms.macro'
import { Box } from 'nft/components/Box'
import { Row } from 'nft/components/Flex'
import { ArrowRightIcon, HazardIcon, LoadingIcon, XMarkIcon } from 'nft/components/icons'
import { bodySmall } from 'nft/css/common.css'
import { useNFTList, useSellAsset } from 'nft/hooks'
import { Listing, ListingStatus, WalletAsset } from 'nft/types'
import { pluralize } from 'nft/utils/roundAndPluralize'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as styles from './ListingModal.css'
import { getListings } from './utils'
interface ListingButtonProps {
onClick: () => void
buttonText: string
showWarningOverride?: boolean
}
export const ListingButton = ({ onClick, buttonText, showWarningOverride = false }: ListingButtonProps) => {
const sellAssets = useSellAsset((state) => state.sellAssets)
const addMarketplaceWarning = useSellAsset((state) => state.addMarketplaceWarning)
const removeAllMarketplaceWarnings = useSellAsset((state) => state.removeAllMarketplaceWarnings)
const listingStatus = useNFTList((state) => state.listingStatus)
const setListingStatus = useNFTList((state) => state.setListingStatus)
const setListings = useNFTList((state) => state.setListings)
const setCollectionsRequiringApproval = useNFTList((state) => state.setCollectionsRequiringApproval)
const [showWarning, setShowWarning] = useState(false)
const [canContinue, setCanContinue] = useState(false)
const warningRef = useRef<HTMLDivElement>(null)
useOnClickOutside(warningRef, () => {
setShowWarning(false)
})
useEffect(() => {
const [newCollectionsToApprove, newListings] = getListings(sellAssets)
setListings(newListings)
setCollectionsRequiringApproval(newCollectionsToApprove)
setListingStatus(ListingStatus.DEFINED)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sellAssets])
const [
noMarketplacesSelected,
missingExpiration,
invalidExpiration,
overMaxExpiration,
listingsMissingPrice,
listingsBelowFloor,
listingsAboveSellOrderFloor,
invalidPrices,
] = useMemo(() => {
const noMarketplacesSelected = sellAssets.some(
(asset: WalletAsset) => asset.marketplaces === undefined || asset.marketplaces.length === 0
)
const missingExpiration = sellAssets.some((asset) => {
return asset.expirationTime != null && asset.expirationTime - Date.now() < ms`60 seconds`
})
const invalidExpiration = sellAssets.some((asset) => {
return asset.expirationTime != null && isNaN(asset.expirationTime)
})
const overMaxExpiration = sellAssets.some((asset) => {
return asset.expirationTime != null && asset.expirationTime - Date.now() > ms`180 days`
})
const listingsMissingPrice: [WalletAsset, Listing][] = []
const listingsBelowFloor: [WalletAsset, Listing][] = []
const listingsAboveSellOrderFloor: [WalletAsset, Listing][] = []
const invalidPrices: [WalletAsset, Listing][] = []
for (const asset of sellAssets) {
if (asset.newListings) {
for (const listing of asset.newListings) {
if (!listing.price) listingsMissingPrice.push([asset, listing])
else if (isNaN(parseFloat(listing.price)) || parseFloat(listing.price) < 0)
invalidPrices.push([asset, listing])
else if (parseFloat(listing.price) < asset.floorPrice && !listing.overrideFloorPrice)
listingsBelowFloor.push([asset, listing])
else if (asset.floor_sell_order_price && parseFloat(listing.price) > asset.floor_sell_order_price)
listingsAboveSellOrderFloor.push([asset, listing])
}
}
}
const continueCheck = listingsBelowFloor.length === 0 && listingsAboveSellOrderFloor.length === 0
setCanContinue(continueCheck)
return [
noMarketplacesSelected,
missingExpiration,
invalidExpiration,
overMaxExpiration,
listingsMissingPrice,
listingsBelowFloor,
listingsAboveSellOrderFloor,
invalidPrices,
]
}, [sellAssets])
const [disableListButton, warningMessage] = useMemo(() => {
const disableListButton =
noMarketplacesSelected ||
missingExpiration ||
invalidExpiration ||
overMaxExpiration ||
invalidPrices.length > 0 ||
listingsMissingPrice.length > 0
const warningMessage = noMarketplacesSelected
? 'No marketplaces selected'
: missingExpiration
? 'Set duration'
: invalidExpiration
? 'Invalid duration'
: overMaxExpiration
? 'Max duration is 6 months'
: listingsMissingPrice.length > 0
? `${listingsMissingPrice.length} item price${pluralize(listingsMissingPrice.length)} not set`
: invalidPrices.length > 0
? `${invalidPrices.length} price${pluralize(invalidPrices.length)} are invalid`
: listingsBelowFloor.length > 0
? `${listingsBelowFloor.length} item${pluralize(listingsBelowFloor.length)} listed below floor`
: listingsAboveSellOrderFloor.length > 0
? `${listingsAboveSellOrderFloor.length} item${pluralize(listingsAboveSellOrderFloor.length)} already listed`
: ''
return [disableListButton, warningMessage]
}, [
noMarketplacesSelected,
missingExpiration,
invalidExpiration,
overMaxExpiration,
listingsMissingPrice,
invalidPrices,
listingsBelowFloor,
listingsAboveSellOrderFloor,
])
useEffect(() => {
setShowWarning(false)
}, [warningMessage])
const addWarningMessages = () => {
removeAllMarketplaceWarnings()
if (!missingExpiration && !noMarketplacesSelected) {
if (listingsMissingPrice.length > 0) {
for (const [asset, listing] of listingsMissingPrice) {
addMarketplaceWarning(asset, {
message: 'PLEASE SET A PRICE',
marketplace: listing.marketplace,
})
}
} else if (invalidPrices.length > 0) {
for (const [asset, listing] of invalidPrices) {
!listing.overrideFloorPrice &&
addMarketplaceWarning(asset, {
message: `INVALID PRICE`,
marketplace: listing.marketplace,
})
}
}
}
setShowWarning(true)
}
const warningWrappedClick = () => {
if ((!disableListButton && canContinue) || showWarningOverride) onClick()
else addWarningMessages()
}
return (
<Box position="relative" width="full">
{!showWarningOverride && showWarning && warningMessage.length > 0 && (
<Row
className={`${bodySmall} ${styles.warningTooltip}`}
transition="250"
onClick={() => setShowWarning(false)}
color="darkGray"
zIndex="3"
borderRadius="4"
backgroundColor="white"
height={!disableListButton ? '64' : '36'}
maxWidth="276"
position="absolute"
left="24"
bottom="52"
flexWrap={!disableListButton ? 'wrap' : 'nowrap'}
style={{ maxWidth: !disableListButton ? '225px' : '' }}
ref={warningRef}
>
<HazardIcon />
<Box marginLeft="4" marginRight="8">
{warningMessage}
</Box>
{!!disableListButton ? (
<Box paddingTop="6">
<XMarkIcon fill="darkGray" height="20" width="20" />
</Box>
) : (
<Row
marginLeft="72"
cursor="pointer"
color="genieBlue"
onClick={() => {
setShowWarning(false)
setCanContinue(true)
onClick()
}}
>
Continue
<ArrowRightIcon height="20" width="20" />
</Row>
)}
</Row>
)}
<Box
as="button"
border="none"
backgroundColor="genieBlue"
cursor={
[ListingStatus.APPROVED, ListingStatus.PENDING, ListingStatus.SIGNING].includes(listingStatus) ||
disableListButton
? 'default'
: 'pointer'
}
color="explicitWhite"
className={styles.button}
onClick={() => listingStatus !== ListingStatus.APPROVED && warningWrappedClick()}
type="button"
style={{
opacity:
![ListingStatus.DEFINED, ListingStatus.FAILED, ListingStatus.CONTINUE].includes(listingStatus) ||
disableListButton
? 0.3
: 1,
}}
>
{listingStatus === ListingStatus.SIGNING || listingStatus === ListingStatus.PENDING ? (
<Row gap="8">
<LoadingIcon stroke="white" height="20" width="20" />
{listingStatus === ListingStatus.PENDING ? 'Pending' : 'Proceed in wallet'}
</Row>
) : listingStatus === ListingStatus.APPROVED ? (
'Complete!'
) : listingStatus === ListingStatus.PAUSED ? (
'Paused'
) : listingStatus === ListingStatus.FAILED ? (
'Try again'
) : listingStatus === ListingStatus.CONTINUE ? (
'Continue'
) : (
buttonText
)}
</Box>
</Box>
)
}

@ -0,0 +1,79 @@
import { style } from '@vanilla-extract/css'
import { sprinkles } from 'nft/css/sprinkles.css'
export const chevron = style([
sprinkles({
height: '28',
width: '28',
transition: '250',
marginLeft: 'auto',
marginRight: '0',
}),
])
export const chevronDown = style({
transform: 'rotate(180deg)',
})
export const sectionDivider = style([
sprinkles({
borderRadius: '20',
marginTop: '8',
width: 'full',
borderWidth: '0.5px',
borderStyle: 'solid',
borderColor: 'medGray',
}),
])
export const button = style([
sprinkles({
height: '40',
width: 'full',
textAlign: 'center',
fontWeight: 'medium',
fontSize: '14',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'flex-end',
borderRadius: '12',
}),
{
lineHeight: '18px',
},
])
export const listingModalIcon = style([
sprinkles({
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'white',
}),
{
boxSizing: 'border-box',
marginLeft: '-2px',
},
])
export const warningTooltip = style([
sprinkles({
paddingTop: '8',
paddingRight: '8',
paddingBottom: '8',
paddingLeft: '12',
}),
{
boxShadow: '0px 4px 16px rgba(10, 10, 59, 0.2)',
},
])
export const listingSectionBorder = style([
sprinkles({
padding: '8',
borderRadius: '8',
borderColor: 'medGray',
borderStyle: 'solid',
borderWidth: '1px',
}),
])

@ -0,0 +1,237 @@
import { addressesByNetwork, SupportedChainId } from '@looksrare/sdk'
import { useWeb3React } from '@web3-react/core'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { ChevronLeftIcon, XMarkIcon } from 'nft/components/icons'
import { caption, headlineSmall, subhead, subheadSmall } from 'nft/css/common.css'
import { themeVars } from 'nft/css/sprinkles.css'
import { useBag, useIsMobile, useNFTList, useSellAsset } from 'nft/hooks'
import { logListing, looksRareNonceFetcher } from 'nft/queries'
import { AssetRow, CollectionRow, ListingRow, ListingStatus } from 'nft/types'
import { pluralize } from 'nft/utils/roundAndPluralize'
import { Dispatch, useEffect, useMemo, useRef, useState } from 'react'
import { ListingButton } from './ListingButton'
import * as styles from './ListingModal.css'
import { ListingSection } from './ListingSection'
import { approveCollectionRow, getTotalEthValue, pauseRow, resetRow, signListingRow, verifyStatus } from './utils'
const ListingModal = () => {
const { provider } = useWeb3React()
const sellAssets = useSellAsset((state) => state.sellAssets)
const signer = provider?.getSigner()
const listings = useNFTList((state) => state.listings)
const setListings = useNFTList((state) => state.setListings)
const collectionsRequiringApproval = useNFTList((state) => state.collectionsRequiringApproval)
const setCollectionsRequiringApproval = useNFTList((state) => state.setCollectionsRequiringApproval)
const [openIndex, setOpenIndex] = useState(0)
const listingStatus = useNFTList((state) => state.listingStatus)
const setListingStatus = useNFTList((state) => state.setListingStatus)
const [allCollectionsApproved, setAllCollectionsApproved] = useState(false)
const looksRareNonce = useNFTList((state) => state.looksRareNonce)
const setLooksRareNonce = useNFTList((state) => state.setLooksRareNonce)
const getLooksRareNonce = useNFTList((state) => state.getLooksRareNonce)
const toggleCart = useBag((state) => state.toggleBag)
const looksRareNonceRef = useRef(looksRareNonce)
const isMobile = useIsMobile()
useEffect(() => {
useNFTList.subscribe((state) => (looksRareNonceRef.current = state.looksRareNonce))
}, [])
const totalEthListingValue = useMemo(() => getTotalEthValue(sellAssets), [sellAssets])
// when all collections have been approved, auto start the signing process
useEffect(() => {
collectionsRequiringApproval?.length &&
setAllCollectionsApproved(
collectionsRequiringApproval.every((collection: CollectionRow) => collection.status === ListingStatus.APPROVED)
)
if (
allCollectionsApproved &&
(listingStatus === ListingStatus.PENDING || listingStatus === ListingStatus.CONTINUE)
) {
signListings()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionsRequiringApproval, allCollectionsApproved])
// handles the modal wide listing state based on conglomeration of the wallet, collection, and listing states
const startListingFlow = async () => {
if (!signer) return
setListingStatus(ListingStatus.SIGNING)
const addresses = addressesByNetwork[SupportedChainId.MAINNET]
const signerAddress = await signer.getAddress()
const nonce = await looksRareNonceFetcher(signerAddress)
setLooksRareNonce(nonce ?? 0)
if (!collectionsRequiringApproval?.some((collection) => collection.status === ListingStatus.PAUSED)) {
setListingStatus(ListingStatus.SIGNING)
setOpenIndex(1)
}
const looksRareAddress = addresses.TRANSFER_MANAGER_ERC721
// for all unqiue collection, marketplace combos -> approve collections
for (const collectionRow of collectionsRequiringApproval) {
verifyStatus(collectionRow.status) &&
approveCollectionRow(
collectionRow,
collectionsRequiringApproval,
setCollectionsRequiringApproval,
signer,
looksRareAddress,
pauseAllRows
)
}
}
const signListings = async () => {
if (!signer || !provider) return
setListingStatus(ListingStatus.SIGNING)
setOpenIndex(2)
// sign listings
for (const listing of listings) {
verifyStatus(listing.status) &&
(await signListingRow(
listing,
listings,
setListings,
signer,
provider,
getLooksRareNonce,
setLooksRareNonce,
pauseAllRows
))
}
const allListingsSigned = listings.every((listing: ListingRow) => listing.status === ListingStatus.APPROVED)
const paused = listings.some((listing: ListingRow) => listing.status === ListingStatus.PAUSED)
if (allListingsSigned) {
setOpenIndex(0)
setListingStatus(ListingStatus.APPROVED)
} else if (!paused) {
setListingStatus(ListingStatus.FAILED)
}
await logListing(listings, (await signer?.getAddress()) ?? '')
}
const pauseAllRows = () => {
for (const collection of collectionsRequiringApproval) {
pauseRow(collection, collectionsRequiringApproval, setCollectionsRequiringApproval as Dispatch<AssetRow[]>)
}
for (const listing of listings) {
pauseRow(listing, listings, setListings as Dispatch<AssetRow[]>)
}
}
const resetAllRows = () => {
for (const collection of collectionsRequiringApproval) {
resetRow(collection, collectionsRequiringApproval, setCollectionsRequiringApproval as Dispatch<AssetRow[]>)
}
for (const listing of listings) {
resetRow(listing, listings, setListings as Dispatch<AssetRow[]>)
}
}
const clickStopListing = () => {
pauseAllRows()
}
const clickStartListingFlow = () => {
resetAllRows()
allCollectionsApproved ? signListings() : startListingFlow()
}
const showSuccessScreen = useMemo(() => listingStatus === ListingStatus.APPROVED, [listingStatus])
return (
<Column paddingTop="20" paddingBottom="20" paddingLeft="12" paddingRight="12">
<Row className={headlineSmall} marginBottom="10">
{isMobile && !showSuccessScreen && (
<Box paddingTop="4" marginRight="4" onClick={toggleCart}>
<ChevronLeftIcon height={28} width={28} />
</Box>
)}
{showSuccessScreen ? 'Success!' : `Listing ${sellAssets.length} NFTs`}
<Box
as="button"
border="none"
color="darkGray"
backgroundColor="white"
marginLeft="auto"
marginRight="0"
paddingRight="0"
display={{ sm: 'flex', md: 'none' }}
cursor="pointer"
onClick={toggleCart}
>
<XMarkIcon height={28} width={28} fill={themeVars.colors.blackBlue} />
</Box>
</Row>
<Column overflowX="hidden" overflowY="auto" style={{ maxHeight: '60vh' }}>
{showSuccessScreen ? (
<ListingSection
sectionTitle={`Listed ${listings.length} item${pluralize(listings.length)} for sale`}
rows={listings}
index={0}
openIndex={openIndex}
isSuccessScreen={true}
/>
) : (
<>
<ListingSection
sectionTitle={`Approve ${collectionsRequiringApproval.length} collection${pluralize(
collectionsRequiringApproval.length
)}`}
title="COLLECTIONS"
rows={collectionsRequiringApproval}
index={1}
openIndex={openIndex}
/>
<ListingSection
sectionTitle={`Confirm ${listings.length} listing${pluralize(listings.length)}`}
caption="Now you can sign to list each item"
title="NFTS"
rows={listings}
index={2}
openIndex={openIndex}
/>
</>
)}
</Column>
<hr className={styles.sectionDivider} />
<Row className={subhead} marginTop="12" marginBottom={showSuccessScreen ? '8' : '20'}>
Return if sold
<Row className={subheadSmall} marginLeft="auto" marginRight="0">
{totalEthListingValue}
&nbsp;ETH
</Row>
</Row>
{showSuccessScreen ? (
<Box as="span" className={caption} color="darkGray">
Status:{' '}
<Box as="span" color="green200">
Confirmed
</Box>
</Box>
) : (
<ListingButton onClick={clickStartListingFlow} buttonText={'Start listing'} showWarningOverride={isMobile} />
)}
{(listingStatus === ListingStatus.PENDING || listingStatus === ListingStatus.SIGNING) && (
<Box
as="button"
border="none"
backgroundColor="white"
cursor="pointer"
color="orange"
className={styles.button}
onClick={clickStopListing}
type="button"
>
Stop listing
</Box>
)}
</Column>
)
}
export default ListingModal

@ -0,0 +1,188 @@
import clsx from 'clsx'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { ApprovedCheckmarkIcon, ChevronUpIcon, FailedListingIcon, LoadingIcon } from 'nft/components/icons'
import { badge, bodySmall, buttonTextSmall, subhead } from 'nft/css/common.css'
import { useSellAsset } from 'nft/hooks'
import { AssetRow, CollectionRow, ListingRow, ListingStatus } from 'nft/types'
import { formatEthPrice, numberToWei } from 'nft/utils/currency'
import { useEffect, useState } from 'react'
import * as styles from './ListingModal.css'
export const ListingSection = ({
sectionTitle,
caption = undefined,
title = undefined,
rows,
index,
openIndex,
isSuccessScreen = false,
}: {
sectionTitle: string
caption?: string
title?: string
rows: AssetRow[]
index: number
openIndex: number
isSuccessScreen?: boolean
}) => {
const [isOpen, setIsOpen] = useState(false)
const notAllApproved = rows.some((row: AssetRow) => row.status !== ListingStatus.APPROVED)
const sellAssets = useSellAsset((state) => state.sellAssets)
const removeAssetMarketplace = useSellAsset((state) => state.removeAssetMarketplace)
const removeRow = (row: any) => {
// collections
if (index === 1) {
for (const asset of sellAssets)
if (asset.asset_contract.address === row.collectionAddress) removeAssetMarketplace(asset, row.marketplace)
}
// listings
else removeAssetMarketplace(row.asset, row.marketplace)
}
useEffect(() => {
setIsOpen(index === openIndex)
}, [index, openIndex])
function getListingRowPrice(row: AssetRow): number | undefined {
const listingRow = row as ListingRow
const newListings = listingRow.asset.newListings
return parseFloat(
newListings?.find((listing) => listing.marketplace.name === listingRow.marketplace.name)?.price ?? '0'
)
}
const allApproved = !notAllApproved && rows.length > 0 && !isSuccessScreen
return (
<Row
flexWrap="wrap"
className={subhead}
marginTop="10"
marginBottom="10"
onClick={() => rows.length > 0 && setIsOpen(!isOpen)}
color={allApproved ? 'green' : 'blackBlue'}
>
{allApproved && <ApprovedCheckmarkIcon style={{ marginRight: '8px' }} />}
{sectionTitle}
{!isSuccessScreen && <ChevronUpIcon className={clsx(`${isOpen ? '' : styles.chevronDown} ${styles.chevron}`)} />}
{(isOpen || isSuccessScreen) && (
<Column
gap="12"
width="full"
paddingTop={isSuccessScreen ? '28' : 'auto'}
className={clsx(!isSuccessScreen && styles.listingSectionBorder)}
>
{caption && (
<Box color="blackBlue" fontWeight="normal" className={caption}>
{caption}
</Box>
)}
{title && (
<Box color="darkGray" className={badge}>
{title}
</Box>
)}
<Column gap="8">
{rows.map((row: AssetRow, index) => {
return (
<Column key={index} gap="8">
<Row>
{row.images.map((image, index) => {
return (
<Box
as="img"
height="20"
width="20"
borderRadius={index === 0 && (row as CollectionRow).collectionAddress ? 'round' : '4'}
style={{ zIndex: 2 - index }}
className={styles.listingModalIcon}
src={image}
alt={row.name}
key={index}
/>
)
})}
<Box
marginLeft="8"
marginRight="auto"
fontWeight="normal"
color="blackBlue"
textOverflow="ellipsis"
overflow="hidden"
maxWidth={{
sm: 'max',
md:
row.status === ListingStatus.REJECTED || row.status === ListingStatus.FAILED ? '120' : 'full',
}}
className={bodySmall}
>
{row.name}
</Box>
{isSuccessScreen ? (
getListingRowPrice(row) &&
`${formatEthPrice(numberToWei(getListingRowPrice(row) ?? 0).toString())} ETH`
) : row.status === ListingStatus.APPROVED ? (
<ApprovedCheckmarkIcon height="20" width="20" />
) : row.status === ListingStatus.FAILED || row.status === ListingStatus.REJECTED ? (
<Row gap="4">
<Box fontWeight="normal" fontSize="14" color="darkGray">
{row.status}
</Box>
<FailedListingIcon />
</Row>
) : (
row.status === ListingStatus.SIGNING && <LoadingIcon height="20" width="20" stroke="#4673FA" />
)}
</Row>
{(row.status === ListingStatus.FAILED || row.status === ListingStatus.REJECTED) && (
<Row gap="8" justifyContent="center">
<Box
width="120"
as="button"
className={buttonTextSmall}
borderRadius="12"
border="none"
color="red400"
height="32"
cursor="pointer"
style={{ backgroundColor: '#FA2B391A' }}
onClick={async (e) => {
e.stopPropagation()
removeRow(row)
}}
>
Remove
</Box>
<Box
width="120"
as="button"
className={buttonTextSmall}
borderRadius="12"
border="none"
color="genieBlue"
height="32"
cursor="pointer"
style={{ backgroundColor: '#4C82FB29' }}
onClick={async (e) => {
e.stopPropagation()
if (row.callback) {
await row.callback()
}
}}
>
Try again
</Box>
</Row>
)}
</Column>
)
})}
</Column>
</Column>
)}
</Row>
)
}

@ -11,6 +11,7 @@ import { useLocation } from 'react-router-dom'
import * as styles from './ListingTag.css'
const CartSellAssetRow = lazy(() => import('./TagAssetRow'))
const ListingModal = lazy(() => import('./ListingModal'))
const Cart = () => {
const { pathname } = useLocation()
@ -44,7 +45,9 @@ const Cart = () => {
marginLeft="0"
justifyContent="flex-start"
>
{sellPageState === SellPageStateType.LISTING ? null : (
{sellPageState === SellPageStateType.LISTING ? (
<ListingModal />
) : (
<>
<BagHeader bagQuantity={sellAssets.length} />
<Column

@ -0,0 +1,305 @@
import { BigNumber } from '@ethersproject/bignumber'
import type { JsonRpcSigner, Web3Provider } from '@ethersproject/providers'
import { ConsiderationInputItem } from '@opensea/seaport-js/lib/types'
import {
INVERSE_BASIS_POINTS,
OPENSEA_CROSS_CHAIN_CONDUIT,
OPENSEA_DEFAULT_FEE,
OPENSEA_FEE_ADDRESS,
} from 'nft/queries/openSea'
import { AssetRow, CollectionRow, ListingMarket, ListingRow, ListingStatus, WalletAsset } from 'nft/types'
import { approveCollection, signListing } from 'nft/utils/listNfts'
import { Dispatch } from 'react'
export const updateStatus = ({
listing,
newStatus,
rows,
setRows,
callback,
}: {
listing: AssetRow
newStatus: ListingStatus
rows: AssetRow[]
setRows: Dispatch<AssetRow[]>
callback?: () => Promise<void>
}) => {
const rowsCopy = [...rows]
const index = rows.findIndex((n) => n === listing)
listing.status = newStatus
if (callback) listing.callback = callback
rowsCopy[index] = listing
setRows(rowsCopy)
}
export async function approveCollectionRow(
collectionRow: CollectionRow,
collectionsRequiringApproval: CollectionRow[],
setCollectionsRequiringApproval: Dispatch<CollectionRow[]>,
signer: JsonRpcSigner,
looksRareAddress: string,
pauseAllRows: () => void
) {
updateStatus({
listing: collectionRow,
newStatus: ListingStatus.SIGNING,
rows: collectionsRequiringApproval,
setRows: setCollectionsRequiringApproval as Dispatch<AssetRow[]>,
callback: () =>
approveCollectionRow(
collectionRow,
collectionsRequiringApproval,
setCollectionsRequiringApproval,
signer,
looksRareAddress,
pauseAllRows
),
})
const { marketplace, collectionAddress } = collectionRow
const spender =
marketplace.name === 'OpenSea'
? OPENSEA_CROSS_CHAIN_CONDUIT
: marketplace.name === 'Rarible'
? process.env.REACT_APP_LOOKSRARE_MARKETPLACE_CONTRACT
: marketplace.name === 'X2Y2'
? process.env.REACT_APP_X2Y2_TRANSFER_CONTRACT
: looksRareAddress
await approveCollection(spender ?? '', collectionAddress, signer, (newStatus: ListingStatus) =>
updateStatus({
listing: collectionRow,
newStatus,
rows: collectionsRequiringApproval,
setRows: setCollectionsRequiringApproval as Dispatch<AssetRow[]>,
})
)
if (collectionRow.status === ListingStatus.REJECTED || collectionRow.status === ListingStatus.FAILED) pauseAllRows()
}
export async function signListingRow(
listing: ListingRow,
listings: ListingRow[],
setListings: Dispatch<ListingRow[]>,
signer: JsonRpcSigner,
provider: Web3Provider,
getLooksRareNonce: () => number,
setLooksRareNonce: (nonce: number) => void,
pauseAllRows: () => void
) {
const looksRareNonce = getLooksRareNonce()
updateStatus({
listing,
newStatus: ListingStatus.SIGNING,
rows: listings,
setRows: setListings as Dispatch<AssetRow[]>,
callback: () => {
return signListingRow(
listing,
listings,
setListings,
signer,
provider,
getLooksRareNonce,
setLooksRareNonce,
pauseAllRows
)
},
})
const { asset, marketplace } = listing
const res = await signListing(marketplace, asset, signer, provider, looksRareNonce, (newStatus: ListingStatus) =>
updateStatus({
listing,
newStatus,
rows: listings,
setRows: setListings as Dispatch<AssetRow[]>,
})
)
if (listing.status === ListingStatus.REJECTED) pauseAllRows()
else {
res && listing.marketplace.name === 'LooksRare' && setLooksRareNonce(looksRareNonce + 1)
const newStatus = res ? ListingStatus.APPROVED : ListingStatus.FAILED
updateStatus({
listing,
newStatus,
rows: listings,
setRows: setListings as Dispatch<AssetRow[]>,
})
}
}
export const getTotalEthValue = (sellAssets: WalletAsset[]) => {
const total = sellAssets.reduce((total, asset: WalletAsset) => {
if (asset.newListings?.length) {
const maxListing = asset.newListings.reduce((a, b) =>
parseFloat(a.price ?? '0') > parseFloat(b.price ?? '0') ? a : b
)
return (
total +
parseFloat(maxListing.price ?? '0') -
parseFloat(maxListing.price ?? '0') * (maxListing.marketplace.fee / 100 + asset.creatorPercentage)
)
}
return total
}, 0)
return total ? Math.round(total * 100 + Number.EPSILON) / 100 : 0
}
export const getListings = (sellAssets: WalletAsset[]): [CollectionRow[], ListingRow[]] => {
const newCollectionsToApprove: CollectionRow[] = []
const newListings: ListingRow[] = []
sellAssets.forEach((asset) => {
asset.marketplaces?.forEach((marketplace: ListingMarket) => {
const newListing = {
images: [asset.image_preview_url, marketplace.icon],
name: asset.name || `#${asset.tokenId}`,
status: ListingStatus.DEFINED,
asset,
marketplace,
}
newListings.push(newListing)
if (
!newCollectionsToApprove.some(
(collectionRow: CollectionRow) =>
collectionRow.collectionAddress === asset.asset_contract.address &&
collectionRow.marketplace.name === marketplace.name
)
) {
const newCollectionRow = {
images: [asset.asset_contract.image_url, marketplace.icon],
name: asset.asset_contract.name,
status: ListingStatus.DEFINED,
collectionAddress: asset.asset_contract.address,
marketplace,
}
newCollectionsToApprove.push(newCollectionRow)
}
})
})
return [newCollectionsToApprove, newListings]
}
export type ListingState = {
allListingsPending: boolean
allListingsDefined: boolean
allListingsApproved: boolean
allCollectionsPending: boolean
allCollectionsDefined: boolean
anyActiveSigning: boolean
anyActiveFailures: boolean
anyActiveRejections: boolean
anyPaused: boolean
}
export const getListingState = (
collectionsRequiringApproval: CollectionRow[],
listings: ListingRow[]
): ListingState => {
let allListingsPending = true
let allListingsDefined = true
let allListingsApproved = true
let allCollectionsPending = true
let allCollectionsDefined = true
let anyActiveSigning = false
let anyActiveFailures = false
let anyActiveRejections = false
let anyPaused = false
if (collectionsRequiringApproval.length === 0) {
allCollectionsDefined = allCollectionsPending = false
}
for (const collection of collectionsRequiringApproval) {
if (collection.status !== ListingStatus.PENDING) allCollectionsPending = false
if (collection.status !== ListingStatus.DEFINED) allCollectionsDefined = false
if (collection.status === ListingStatus.SIGNING) anyActiveSigning = true
else if (collection.status === ListingStatus.FAILED) anyActiveFailures = true
else if (collection.status === ListingStatus.REJECTED) anyActiveRejections = true
else if (collection.status === ListingStatus.PAUSED) anyPaused = true
}
if (listings.length === 0) {
allListingsApproved = allListingsDefined = allListingsPending = false
}
for (const listing of listings) {
if (listing.status !== ListingStatus.PENDING) allListingsPending = false
if (listing.status !== ListingStatus.DEFINED) allListingsDefined = false
if (listing.status !== ListingStatus.APPROVED) allListingsApproved = false
if (listing.status === ListingStatus.SIGNING) anyActiveSigning = true
else if (listing.status === ListingStatus.FAILED) anyActiveFailures = true
else if (listing.status === ListingStatus.REJECTED) anyActiveRejections = true
else if (listing.status === ListingStatus.PAUSED) anyPaused = true
}
return {
allListingsPending,
allListingsDefined,
allListingsApproved,
allCollectionsPending,
allCollectionsDefined,
anyActiveSigning,
anyActiveFailures,
anyActiveRejections,
anyPaused,
}
}
export const verifyStatus = (status: ListingStatus) => {
return status !== ListingStatus.PAUSED && status !== ListingStatus.APPROVED
}
export const pauseRow = (row: AssetRow, rows: AssetRow[], setRows: Dispatch<AssetRow[]>) => {
if (row.status === ListingStatus.PENDING || row.status === ListingStatus.DEFINED)
updateStatus({
listing: row,
newStatus: ListingStatus.PAUSED,
rows,
setRows,
})
}
export const resetRow = (row: AssetRow, rows: AssetRow[], setRows: Dispatch<AssetRow[]>) => {
if (
row.status === ListingStatus.PAUSED ||
row.status === ListingStatus.FAILED ||
row.status === ListingStatus.REJECTED
)
updateStatus({
listing: row,
newStatus: ListingStatus.DEFINED,
rows,
setRows,
})
}
const createConsiderationItem = (basisPoints: string, recipient: string): ConsiderationInputItem => {
return {
amount: basisPoints,
recipient,
}
}
export const getConsiderationItems = (
asset: WalletAsset,
price: BigNumber,
signerAddress: string
): {
sellerFee: ConsiderationInputItem
openseaFee: ConsiderationInputItem
creatorFee?: ConsiderationInputItem
} => {
const openSeaBasisPoints = OPENSEA_DEFAULT_FEE * INVERSE_BASIS_POINTS
const creatorFeeBasisPoints = asset.creatorPercentage * INVERSE_BASIS_POINTS
const sellerBasisPoints = INVERSE_BASIS_POINTS - openSeaBasisPoints - creatorFeeBasisPoints
const openseaFee = price.mul(BigNumber.from(openSeaBasisPoints)).div(BigNumber.from(INVERSE_BASIS_POINTS)).toString()
const creatorFee = price
.mul(BigNumber.from(creatorFeeBasisPoints))
.div(BigNumber.from(INVERSE_BASIS_POINTS))
.toString()
const sellerFee = price.mul(BigNumber.from(sellerBasisPoints)).div(BigNumber.from(INVERSE_BASIS_POINTS)).toString()
return {
sellerFee: createConsiderationItem(sellerFee, signerAddress),
openseaFee: createConsiderationItem(openseaFee, OPENSEA_FEE_ADDRESS),
creatorFee:
creatorFeeBasisPoints > 0 ? createConsiderationItem(creatorFee, asset.asset_contract.payout_address) : undefined,
}
}

@ -249,7 +249,7 @@ const flexAlignment = [
const overflow = ['hidden', 'inherit', 'scroll', 'visible', 'auto'] as const
const borderWidth = ['0px', '1px', '1.5px', '2px', '4px']
const borderWidth = ['0px', '0.5px', '1px', '1.5px', '2px', '4px']
const borderStyle = ['none', 'solid'] as const

@ -1,10 +1,10 @@
export * from './useBag'
export * from './useCollectionFilters'
export * from './useFiltersExpanded'
export * from './useGenieList'
export * from './useIsMobile'
export * from './useIsTablet'
export * from './useMarketplaceSelect'
export * from './useNFTList'
export * from './useNFTSelect'
export * from './useSearchHistory'
export * from './useSelectAsset'

@ -1,10 +1,10 @@
import { AssetRow, ListingMarket } from '../../types'
import { ListingMarket, ListingRow } from 'nft/types'
interface Listing extends AssetRow {
interface Listing extends ListingRow {
marketplaces: ListingMarket[]
}
export const logListing = async (listings: AssetRow[], userAddress: string): Promise<boolean> => {
export const logListing = async (listings: ListingRow[], userAddress: string): Promise<boolean> => {
const url = `${process.env.REACT_APP_GENIE_API_URL}/logGenieList`
const listingsConsolidated: Listing[] = listings.map((el) => ({ ...el, marketplaces: [] }))
const marketplacesById: Record<string, ListingMarket[]> = {}

@ -28,6 +28,12 @@ export interface SellOrder {
tokenReserves?: number
}
export interface Listing {
price?: string
marketplace: ListingMarket
overrideFloorPrice?: boolean
}
export interface WalletAsset {
id?: string
image_url: string
@ -60,11 +66,7 @@ export interface WalletAsset {
// Used for creating new listings
expirationTime?: number
marketAgnosticPrice?: string
newListings?: {
price?: string
marketplace: ListingMarket
overrideFloorPrice?: boolean
}[]
newListings?: Listing[]
marketplaces?: ListingMarket[]
listingWarnings?: ListingWarning[]
}
@ -88,19 +90,19 @@ export enum ListingStatus {
SIGNING = 'Signing',
}
export interface ListingRow {
export interface AssetRow {
images: string[]
name: string
status: ListingStatus
callback?: () => Promise<void>
}
export interface AssetRow extends ListingRow {
export interface ListingRow extends AssetRow {
asset: WalletAsset
marketplace: ListingMarket
}
export interface CollectionRow extends ListingRow {
export interface CollectionRow extends AssetRow {
collectionAddress: string
marketplace: ListingMarket
}

@ -3,3 +3,5 @@ export const roundAndPluralize = (i: number, word: string) => {
return `${rounded} ${word}${rounded === 1 ? '' : 's'}`
}
export const pluralize = (number: number) => (number !== 1 ? 's' : '')