chore: Refactor ListingButton with deprecation of Listing V1 (#6023)

* remove unused wrapper

* remove old listing modal

* simplify button styling

* deprecate outdated listing logic and move button to styled components

* remove unused linting protection

* remove unused functions from sellAssets hook

* undo and save this refactor for different PR

* remove more unused items

* hook dependencies

* more trash cleanup

* styled continue button

* slight continue button tweaks

* cleanup

* add new standard hover state

* no mixed conditionals

* lint

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
This commit is contained in:
Charles Bachmeier 2023-02-27 10:42:30 -08:00 committed by GitHub
parent b1e6d0ab7a
commit 1df9da9eff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 197 additions and 1011 deletions

@ -1,4 +1,5 @@
import { BigNumber } from '@ethersproject/bignumber'
import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { NFTEventName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
@ -6,12 +7,10 @@ import { GqlRoutingVariant, useGqlRoutingFlag } from 'featureFlags/flags/gqlRout
import { useNftRouteLazyQuery } from 'graphql/data/__generated__/types-and-hooks'
import { useIsNftDetailsPage, useIsNftPage, useIsNftProfilePage } from 'hooks/useIsNftPage'
import { BagFooter } from 'nft/components/bag/BagFooter'
import ListingModal from 'nft/components/bag/profile/ListingModal'
import { Box } from 'nft/components/Box'
import { Portal } from 'nft/components/common/Portal'
import { Column } from 'nft/components/Flex'
import { Overlay } from 'nft/components/modals/Overlay'
import { buttonTextMedium, commonButtonStyles } from 'nft/css/common.css'
import {
useBag,
useIsMobile,
@ -89,6 +88,24 @@ const DetailsPageBackground = styled.div`
height: 100%;
`
const ContinueButton = styled.div`
background: ${({ theme }) => theme.accentAction};
color: ${({ theme }) => theme.accentTextLightPrimary};
margin: 32px 28px 16px;
padding: 10px 0px;
border-radius: 12px;
text-align: center;
font-size: 16px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
transition: ${({ theme }) => theme.transition.duration.medium};
:hover {
opacity: ${({ theme }) => theme.opacity.hover};
}
`
const ScrollingIndicator = ({ top, show }: SeparatorProps) => (
<Box
marginX="24"
@ -113,10 +130,7 @@ const Bag = () => {
shallow
)
const { profilePageState, setProfilePageState } = useProfilePageState(
({ setProfilePageState, state }) => ({ profilePageState: state, setProfilePageState }),
shallow
)
const { setProfilePageState } = useProfilePageState(({ setProfilePageState }) => ({ setProfilePageState }))
const {
bagStatus,
@ -396,48 +410,34 @@ const Bag = () => {
return (
<Portal>
<BagContainer data-testid="nft-bag" raiseZIndex={isMobile || isModalOpen} isProfilePage={isProfilePage}>
{!(isProfilePage && profilePageState === ProfilePageStateType.LISTING) ? (
<>
<BagHeader
numberOfAssets={isProfilePage ? sellAssets.length : itemsInBag.length}
closeBag={handleCloseBag}
resetFlow={isProfilePage ? resetSellAssets : reset}
isProfilePage={isProfilePage}
/>
{shouldRenderEmptyState && <EmptyState />}
<ScrollingIndicator top show={userCanScroll && scrollProgress > 0} />
<Column ref={scrollRef} className={styles.assetsContainer} onScroll={scrollHandler} gap="12">
{isProfilePage ? <ProfileBagContent /> : <BagContent />}
</Column>
{hasAssetsToShow && !isProfilePage && (
<BagFooter totalEthPrice={totalEthPrice} fetchAssets={fetchAssets} eventProperties={eventProperties} />
)}
{isSellingAssets && isProfilePage && (
<Box
marginTop="32"
marginX="28"
marginBottom="16"
paddingY="10"
className={`${buttonTextMedium} ${commonButtonStyles}`}
backgroundColor="accentAction"
color="white"
textAlign="center"
onClick={() => {
toggleBag()
setProfilePageState(ProfilePageStateType.LISTING)
sendAnalyticsEvent(NFTEventName.NFT_PROFILE_PAGE_START_SELL, {
list_quantity: sellAssets.length,
collection_addresses: sellAssets.map((asset) => asset.asset_contract.address),
token_ids: sellAssets.map((asset) => asset.tokenId),
})
}}
>
Continue
</Box>
)}
</>
) : (
<ListingModal />
<BagHeader
numberOfAssets={isProfilePage ? sellAssets.length : itemsInBag.length}
closeBag={handleCloseBag}
resetFlow={isProfilePage ? resetSellAssets : reset}
isProfilePage={isProfilePage}
/>
{shouldRenderEmptyState && <EmptyState />}
<ScrollingIndicator top show={userCanScroll && scrollProgress > 0} />
<Column ref={scrollRef} className={styles.assetsContainer} onScroll={scrollHandler} gap="12">
{isProfilePage ? <ProfileBagContent /> : <BagContent />}
</Column>
{hasAssetsToShow && !isProfilePage && (
<BagFooter totalEthPrice={totalEthPrice} fetchAssets={fetchAssets} eventProperties={eventProperties} />
)}
{isSellingAssets && isProfilePage && (
<ContinueButton
onClick={() => {
toggleBag()
setProfilePageState(ProfilePageStateType.LISTING)
sendAnalyticsEvent(NFTEventName.NFT_PROFILE_PAGE_START_SELL, {
list_quantity: sellAssets.length,
collection_addresses: sellAssets.map((asset) => asset.asset_contract.address),
token_ids: sellAssets.map((asset) => asset.tokenId),
})
}}
>
<Trans>Continue</Trans>
</ContinueButton>
)}
</BagContainer>

@ -1,269 +0,0 @@
import { Plural, t, Trans } from '@lingui/macro'
import ms from 'ms.macro'
import { Box } from 'nft/components/Box'
import { BelowFloorWarningModal } from 'nft/components/profile/list/Modal/BelowFloorWarningModal'
import { useNFTList, useSellAsset } from 'nft/hooks'
import { Listing, ListingStatus, WalletAsset } from 'nft/types'
import { pluralize } from 'nft/utils/roundAndPluralize'
import { useEffect, useMemo, useState } from 'react'
import { useTheme } from 'styled-components/macro'
import shallow from 'zustand/shallow'
import * as styles from './ListingModal.css'
import { getListings } from './utils'
const BELOW_FLOOR_PRICE_THRESHOLD = 0.8
interface ListingButtonProps {
onClick: () => void
buttonText: string
showWarningOverride?: boolean
}
export const ListingButton = ({ onClick, buttonText, showWarningOverride = false }: ListingButtonProps) => {
const {
addMarketplaceWarning,
sellAssets,
removeAllMarketplaceWarnings,
showResolveIssues,
toggleShowResolveIssues,
issues,
setIssues,
} = useSellAsset(
({
addMarketplaceWarning,
sellAssets,
removeAllMarketplaceWarnings,
showResolveIssues,
toggleShowResolveIssues,
issues,
setIssues,
}) => ({
addMarketplaceWarning,
sellAssets,
removeAllMarketplaceWarnings,
showResolveIssues,
toggleShowResolveIssues,
issues,
setIssues,
}),
shallow
)
const { listingStatus, setListingStatus, setListings, setCollectionsRequiringApproval } = useNFTList(
({ listingStatus, setListingStatus, setListings, setCollectionsRequiringApproval }) => ({
listingStatus,
setListingStatus,
setListings,
setCollectionsRequiringApproval,
}),
shallow
)
const [showWarning, setShowWarning] = useState(false)
const [canContinue, setCanContinue] = useState(false)
const theme = useTheme()
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)
const missingExpiration = sellAssets.some((asset) => {
return (
asset.expirationTime != null &&
(isNaN(asset.expirationTime) || asset.expirationTime * 1000 - 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 * 1000 - 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(listing.price) || listing.price < 0) invalidPrices.push([asset, listing])
else if (
listing.price < (asset?.floorPrice ?? 0) * BELOW_FLOOR_PRICE_THRESHOLD &&
!listing.overrideFloorPrice
)
listingsBelowFloor.push([asset, listing])
else if (asset.floor_sell_order_price && listing.price >= asset.floor_sell_order_price)
listingsAboveSellOrderFloor.push([asset, listing])
}
}
}
// set number of issues
const foundIssues =
Number(missingExpiration) +
Number(overMaxExpiration) +
listingsMissingPrice.length +
listingsAboveSellOrderFloor.length
setIssues(foundIssues)
!foundIssues && showResolveIssues && toggleShowResolveIssues()
// Only show Resolve Issue text if there was a user submitted error (ie not when page loads with no prices set)
if ((missingExpiration || overMaxExpiration || listingsAboveSellOrderFloor.length) && !showResolveIssues)
toggleShowResolveIssues()
const continueCheck = listingsBelowFloor.length === 0 && listingsAboveSellOrderFloor.length === 0
setCanContinue(continueCheck)
return [
noMarketplacesSelected,
missingExpiration,
invalidExpiration,
overMaxExpiration,
listingsMissingPrice,
listingsBelowFloor,
listingsAboveSellOrderFloor,
invalidPrices,
]
}, [sellAssets, setIssues, showResolveIssues, toggleShowResolveIssues])
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) {
if (issues) !showResolveIssues && toggleShowResolveIssues()
else if (listingsBelowFloor.length) setShowWarning(true)
else onClick()
} else addWarningMessages()
}
return (
<>
<Box position="relative">
<Box
as="button"
border="none"
backgroundColor={showResolveIssues ? 'accentFailure' : 'accentAction'}
cursor={
[ListingStatus.APPROVED, ListingStatus.PENDING, ListingStatus.SIGNING].includes(listingStatus) ||
disableListButton
? 'default'
: 'pointer'
}
className={styles.button}
onClick={() => listingStatus !== ListingStatus.APPROVED && warningWrappedClick()}
type="button"
style={{
color: showResolveIssues ? theme.accentTextLightPrimary : theme.white,
opacity:
![ListingStatus.DEFINED, ListingStatus.FAILED, ListingStatus.CONTINUE].includes(listingStatus) ||
(disableListButton && !showResolveIssues)
? 0.3
: 1,
}}
>
{listingStatus === ListingStatus.SIGNING ? (
<Trans>Proceed in wallet</Trans>
) : listingStatus === ListingStatus.PENDING ? (
<Trans>Pending</Trans>
) : listingStatus === ListingStatus.APPROVED ? (
<Trans>Complete!</Trans>
) : listingStatus === ListingStatus.PAUSED ? (
<Trans>Paused</Trans>
) : listingStatus === ListingStatus.FAILED ? (
<Trans>Try again</Trans>
) : listingStatus === ListingStatus.CONTINUE ? (
<Trans>Continue</Trans>
) : showResolveIssues ? (
<Plural value={issues !== 1 ? 2 : 1} _1="Resolve issue" other={t`Resolve ${issues} issues`} />
) : (
buttonText
)}
</Box>
</Box>
{showWarning && (
<BelowFloorWarningModal
listingsBelowFloor={listingsBelowFloor}
closeModal={() => setShowWarning(false)}
startListing={onClick}
/>
)}
</>
)
}

@ -1,79 +0,0 @@
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)',
cursor: 'pointer',
})
export const sectionDivider = style([
sprinkles({
borderRadius: '20',
marginTop: '8',
width: 'full',
borderWidth: '0.5px',
borderStyle: 'solid',
borderColor: 'backgroundOutline',
}),
])
export const button = style([
sprinkles({
paddingX: { sm: '12', md: '16' },
paddingY: { sm: '10', md: '16' },
textAlign: 'center',
fontWeight: 'semibold',
fontSize: { sm: '16', md: '20' },
lineHeight: { sm: '20', md: '24' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'flex-end',
borderRadius: '12',
}),
])
export const listingModalIcon = style([
sprinkles({
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'backgroundSurface',
}),
{
boxSizing: 'border-box',
marginLeft: '-2px',
marginRight: '4px',
},
])
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: 'backgroundOutline',
borderStyle: 'solid',
borderWidth: '1px',
}),
])

@ -1,322 +0,0 @@
import { sendAnalyticsEvent, Trace, useTrace } from '@uniswap/analytics'
import { InterfaceModalName, NFTEventName } from '@uniswap/analytics-events'
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 { fetchPrice } from 'nft/utils/fetchPrice'
import { pluralize } from 'nft/utils/roundAndPluralize'
import { Dispatch, useEffect, useMemo, useRef, useState } from 'react'
import shallow from 'zustand/shallow'
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 {
listingStatus,
setListingStatus,
setListings,
setCollectionsRequiringApproval,
setListingStatusAndCallback,
setCollectionStatusAndCallback,
looksRareNonce,
setLooksRareNonce,
getLooksRareNonce,
collectionsRequiringApproval,
listings,
} = useNFTList(
({
listingStatus,
setListingStatus,
setListings,
setCollectionsRequiringApproval,
setListingStatusAndCallback,
setCollectionStatusAndCallback,
looksRareNonce,
setLooksRareNonce,
getLooksRareNonce,
collectionsRequiringApproval,
listings,
}) => ({
listingStatus,
setListingStatus,
setListings,
setCollectionsRequiringApproval,
setListingStatusAndCallback,
setCollectionStatusAndCallback,
looksRareNonce,
setLooksRareNonce,
getLooksRareNonce,
collectionsRequiringApproval,
listings,
}),
shallow
)
const signer = provider?.getSigner()
const [openIndex, setOpenIndex] = useState(0)
const [allCollectionsApproved, setAllCollectionsApproved] = useState(false)
const toggleCart = useBag((state) => state.toggleBag)
const looksRareNonceRef = useRef(looksRareNonce)
const isMobile = useIsMobile()
const trace = useTrace({ modal: InterfaceModalName.NFT_LISTING })
useEffect(() => {
useNFTList.subscribe((state) => (looksRareNonceRef.current = state.looksRareNonce))
}, [])
const totalEthListingValue = useMemo(() => getTotalEthValue(sellAssets), [sellAssets])
const [ethPriceInUSD, setEthPriceInUSD] = useState(0)
useEffect(() => {
fetchPrice().then((price) => {
setEthPriceInUSD(price || 0)
})
}, [])
const startListingEventProperties = {
collection_addresses: sellAssets.map((asset) => asset.asset_contract.address),
token_ids: sellAssets.map((asset) => asset.tokenId),
marketplaces: Array.from(new Set(listings.map((asset) => asset.marketplace.name))),
list_quantity: listings.length,
usd_value: ethPriceInUSD * totalEthListingValue,
...trace,
}
// 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 ||
listingStatus === ListingStatus.SIGNING)
) {
resetAllRows()
signListings()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionsRequiringApproval, allCollectionsApproved])
const allCollectionsApprovedOrPaused = useMemo(
() =>
collectionsRequiringApproval.every(
(collection: CollectionRow) =>
collection.status === ListingStatus.APPROVED || collection.status === ListingStatus.PAUSED
),
[collectionsRequiringApproval]
)
const allListingsApprovedOrPaused = useMemo(
() =>
listings.every(
(listing: ListingRow) => listing.status === ListingStatus.APPROVED || listing.status === ListingStatus.PAUSED
),
[listings]
)
// go back to a ready state after a successful retry
useEffect(() => {
if (listingStatus === ListingStatus.SIGNING && allCollectionsApprovedOrPaused && allListingsApprovedOrPaused) {
resetAllRows()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allCollectionsApprovedOrPaused, allListingsApprovedOrPaused])
// handles the modal wide listing state based on conglomeration of the wallet, collection, and listing states
const startListingFlow = async () => {
if (!signer) return
sendAnalyticsEvent(NFTEventName.NFT_SELL_START_LISTING, { ...startListingEventProperties })
setListingStatus(ListingStatus.SIGNING)
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)
}
// for all unique collection, marketplace combos -> approve collections
for (const collectionRow of collectionsRequiringApproval) {
verifyStatus(collectionRow.status) &&
(isMobile
? await approveCollectionRow(collectionRow, signer, setCollectionStatusAndCallback, pauseAllRows)
: approveCollectionRow(collectionRow, signer, setCollectionStatusAndCallback, 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,
signer,
provider,
getLooksRareNonce,
setLooksRareNonce,
setListingStatusAndCallback,
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)
}
sendAnalyticsEvent(NFTEventName.NFT_LISTING_COMPLETED, {
signatures_approved: listings.filter((asset) => asset.status === ListingStatus.APPROVED),
list_quantity: listings.length,
usd_value: ethPriceInUSD * totalEthListingValue,
...trace,
})
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 (
<Trace modal={InterfaceModalName.NFT_LISTING}>
<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="textSecondary"
backgroundColor="backgroundSurface"
marginLeft="auto"
marginRight="0"
paddingRight="0"
display={{ sm: 'flex', md: 'none' }}
cursor="pointer"
onClick={toggleCart}
>
<XMarkIcon height={28} width={28} fill={themeVars.colors.textPrimary} />
</Box>
</Row>
<Column overflowX="hidden" overflowY="auto" style={{ maxHeight: '60vh' }}>
{showSuccessScreen ? (
<Trace
name={NFTEventName.NFT_LISTING_COMPLETED}
properties={{ list_quantity: listings.length, usd_value: ethPriceInUSD * totalEthListingValue, ...trace }}
shouldLogImpression
>
<ListingSection
sectionTitle={`Listed ${listings.length} item${pluralize(listings.length)} for sale`}
rows={listings}
index={0}
openIndex={openIndex}
isSuccessScreen={true}
/>
</Trace>
) : (
<>
<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="textSecondary">
Status:{' '}
<Box as="span" color="accentSuccess">
Confirmed
</Box>
</Box>
) : (
<ListingButton onClick={clickStartListingFlow} buttonText="Start listing" showWarningOverride={isMobile} />
)}
{(listingStatus === ListingStatus.PENDING || listingStatus === ListingStatus.SIGNING) && (
<Box
as="button"
border="none"
backgroundColor="backgroundSurface"
cursor="pointer"
color="orange"
className={styles.button}
onClick={clickStopListing}
type="button"
>
Stop listing
</Box>
)}
</Column>
</Trace>
)
}
export default ListingModal

@ -1,187 +0,0 @@
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 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 ? 'accentSuccess' : 'textPrimary'}
>
{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="textPrimary" fontWeight="normal" className={caption}>
{caption}
</Box>
)}
{title && (
<Box color="textSecondary" 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="textPrimary"
textOverflow="ellipsis"
overflow="hidden"
whiteSpace="nowrap"
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="textSecondary">
{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="accentAction"
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>
)
}

@ -2,30 +2,8 @@ import type { JsonRpcSigner, Web3Provider } from '@ethersproject/providers'
import { addressesByNetwork, SupportedChainId } from '@looksrare/sdk'
import { LOOKSRARE_MARKETPLACE_CONTRACT, X2Y2_TRANSFER_CONTRACT } from 'nft/queries'
import { OPENSEA_CROSS_CHAIN_CONDUIT } from 'nft/queries/openSea'
import { AssetRow, CollectionRow, ListingMarket, ListingRow, ListingStatus, WalletAsset } from 'nft/types'
import { CollectionRow, ListingMarket, ListingRow, ListingStatus, WalletAsset } from 'nft/types'
import { approveCollection, LOOKS_RARE_CREATOR_BASIS_POINTS, signListing } from 'nft/utils/listNfts'
import { Dispatch } from 'react'
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,
@ -211,27 +189,3 @@ export const getListingState = (
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,
})
}

@ -304,30 +304,6 @@ export const ClockIcon = () => (
</svg>
)
export const LoadingIcon = (props: SVGProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
width="100px"
height="100px"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
{...props}
>
<circle cx="50" cy="50" fill="none" strokeWidth="10" r="35" strokeDasharray="164.93361431346415 56.97787143782138">
<animateTransform
attributeName="transform"
type="rotate"
repeatCount="indefinite"
dur="1s"
values="0 50 50;360 50 50"
keyTimes="0;1"
{...props}
></animateTransform>
</circle>
</svg>
)
export const ApprovedCheckmarkIcon = (props: SVGProps) => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
@ -337,15 +313,6 @@ export const ApprovedCheckmarkIcon = (props: SVGProps) => (
</svg>
)
export const FailedListingIcon = (props: SVGProps) => (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M9.9933 16.2444C13.5529 16.2444 16.4909 13.3064 16.4909 9.75307C16.4909 6.19978 13.5466 3.26172 9.98703 3.26172C6.43373 3.26172 3.50195 6.19978 3.50195 9.75307C3.50195 13.3064 6.44001 16.2444 9.9933 16.2444ZM8.12877 12.3207C7.78976 12.3207 7.62653 12.1324 7.62653 11.8624V7.63742C7.62653 7.36747 7.78976 7.17913 8.12877 7.17913H8.80678C9.14579 7.17913 9.30901 7.36747 9.30901 7.63742V11.8624C9.30901 12.1324 9.14579 12.3207 8.80678 12.3207H8.12877ZM11.1798 12.3207C10.8471 12.3207 10.6776 12.1324 10.6776 11.8624V7.63742C10.6776 7.36747 10.8471 7.17913 11.1798 7.17913H11.8641C12.1906 7.17913 12.3538 7.36747 12.3538 7.63742V11.8624C12.3538 12.1324 12.1906 12.3207 11.8641 12.3207H11.1798Z"
fill={themeVars.colors.textSecondary}
/>
</svg>
)
export const FilterIcon = (props: SVGProps) => (
<svg width="20" height="20" viewBox="1 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path

@ -1,11 +1,11 @@
import { t, Trans } from '@lingui/macro'
import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent, useTrace } from '@uniswap/analytics'
import { InterfaceModalName, NFTEventName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import Column from 'components/Column'
import Row from 'components/Row'
import { ListingButton } from 'nft/components/bag/profile/ListingButton'
import { approveCollectionRow, getListingState, getTotalEthValue, verifyStatus } from 'nft/components/bag/profile/utils'
import { ListingButton } from 'nft/components/profile/list/ListingButton'
import { useIsMobile, useNFTList, useProfilePageState, useSellAsset } from 'nft/hooks'
import { LIST_PAGE_MARGIN, LIST_PAGE_MARGIN_MOBILE } from 'nft/pages/profile/shared'
import { looksRareNonceFetcher } from 'nft/queries'
@ -212,7 +212,6 @@ export const ListPage = () => {
)
const totalEthListingValue = useMemo(() => getTotalEthValue(sellAssets), [sellAssets])
const anyListingsMissingPrice = useMemo(() => !!listings.find((listing) => !listing.price), [listings])
const [showListModal, toggleShowListModal] = useReducer((s) => !s, false)
const [selectedMarkets, setSelectedMarkets] = useState([ListingMarkets[0]]) // default marketplace: x2y2
const [ethPriceInUSD, setEthPriceInUSD] = useState(0)
@ -269,7 +268,7 @@ export const ListPage = () => {
}
}
const handleV2Click = () => {
const showModalAndStartListing = () => {
toggleShowListModal()
startListingFlow()
}
@ -322,11 +321,7 @@ export const ListPage = () => {
<UsdValue>{formatUsdPrice(totalEthListingValue * ethPriceInUSD)}</UsdValue>
)}
</ProceedsWrapper>
<ListingButton
onClick={handleV2Click}
buttonText={anyListingsMissingPrice && !isMobile ? t`Set prices to continue` : t`Start listing`}
showWarningOverride={true}
/>
<ListingButton onClick={showModalAndStartListing} />
</ProceedsAndButtonWrapper>
</FloatingConfirmationBar>
<Overlay />

@ -0,0 +1,144 @@
import { Plural, t, Trans } from '@lingui/macro'
import { BaseButton } from 'components/Button'
import ms from 'ms.macro'
import { BelowFloorWarningModal } from 'nft/components/profile/list/Modal/BelowFloorWarningModal'
import { useIsMobile, useNFTList, useSellAsset } from 'nft/hooks'
import { Listing, ListingStatus, WalletAsset } from 'nft/types'
import { useEffect, useMemo, useState } from 'react'
import styled from 'styled-components/macro'
import { BREAKPOINTS } from 'theme'
import shallow from 'zustand/shallow'
import { getListings } from '../../bag/profile/utils'
const BELOW_FLOOR_PRICE_THRESHOLD = 0.8
const StyledListingButton = styled(BaseButton)<{ showResolveIssues: boolean; missingPrices: boolean }>`
background: ${({ showResolveIssues, theme }) => (showResolveIssues ? theme.accentFailure : theme.accentAction)};
color: ${({ theme }) => theme.accentTextLightPrimary};
font-weight: 600;
font-size: 20px;
line-height: 24px;
padding: 16px;
border-radius: 12px;
width: min-content;
border: none;
cursor: ${({ missingPrices }) => (missingPrices ? 'auto' : 'pointer')};
opacity: ${({ showResolveIssues, missingPrices }) => !showResolveIssues && missingPrices && '0.3'};
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
font-size: 16px;
line-height: 20px;
padding: 10px 12px;
}
`
export const ListingButton = ({ onClick }: { onClick: () => void }) => {
const { sellAssets, showResolveIssues, toggleShowResolveIssues, issues, setIssues } = useSellAsset(
({ sellAssets, showResolveIssues, toggleShowResolveIssues, issues, setIssues }) => ({
sellAssets,
showResolveIssues,
toggleShowResolveIssues,
issues,
setIssues,
}),
shallow
)
const { setListingStatus, setListings, setCollectionsRequiringApproval } = useNFTList(
({ setListingStatus, setListings, setCollectionsRequiringApproval }) => ({
setListingStatus,
setListings,
setCollectionsRequiringApproval,
}),
shallow
)
const [showWarning, setShowWarning] = useState(false)
const isMobile = useIsMobile()
// instantiate listings and collections to approve when user's modify input data
useEffect(() => {
const [newCollectionsToApprove, newListings] = getListings(sellAssets)
setListings(newListings)
setCollectionsRequiringApproval(newCollectionsToApprove)
setListingStatus(ListingStatus.DEFINED)
}, [sellAssets, setCollectionsRequiringApproval, setListingStatus, setListings])
// Find issues with item listing data
const [listingsMissingPrice, listingsBelowFloor] = useMemo(() => {
const missingExpiration = sellAssets.some((asset) => {
return (
asset.expirationTime != null &&
(isNaN(asset.expirationTime) || asset.expirationTime * 1000 - Date.now() < ms`60 seconds`)
)
})
const overMaxExpiration = sellAssets.some((asset) => {
return asset.expirationTime != null && asset.expirationTime * 1000 - 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(listing.price) || listing.price < 0) invalidPrices.push([asset, listing])
else if (
listing.price < (asset?.floorPrice ?? 0) * BELOW_FLOOR_PRICE_THRESHOLD &&
!listing.overrideFloorPrice
)
listingsBelowFloor.push([asset, listing])
else if (asset.floor_sell_order_price && listing.price >= asset.floor_sell_order_price)
listingsAboveSellOrderFloor.push([asset, listing])
}
}
}
// set number of issues
const foundIssues =
Number(missingExpiration) +
Number(overMaxExpiration) +
listingsMissingPrice.length +
listingsAboveSellOrderFloor.length
setIssues(foundIssues)
!foundIssues && showResolveIssues && toggleShowResolveIssues()
// Only show Resolve Issue text if there was a user submitted error (ie not when page loads with no prices set)
if ((missingExpiration || overMaxExpiration || listingsAboveSellOrderFloor.length) && !showResolveIssues)
toggleShowResolveIssues()
return [listingsMissingPrice, listingsBelowFloor]
}, [sellAssets, setIssues, showResolveIssues, toggleShowResolveIssues])
const warningWrappedClick = () => {
if (issues) !showResolveIssues && toggleShowResolveIssues()
else if (listingsBelowFloor.length) setShowWarning(true)
else onClick()
}
return (
<>
<StyledListingButton
onClick={warningWrappedClick}
missingPrices={!!listingsMissingPrice.length}
showResolveIssues={showResolveIssues}
>
{showResolveIssues ? (
<Plural value={issues !== 1 ? 2 : 1} _1="Resolve issue" other={t`Resolve ${issues} issues`} />
) : listingsMissingPrice.length && !isMobile ? (
<Trans>Set prices to continue</Trans>
) : (
<Trans>Start listing</Trans>
)}
</StyledListingButton>
{showWarning && (
<BelowFloorWarningModal
listingsBelowFloor={listingsBelowFloor}
closeModal={() => setShowWarning(false)}
startListing={onClick}
/>
)}
</>
)
}

@ -18,27 +18,10 @@ export const subheadSmall = sprinkles({ fontWeight: 'medium', fontSize: '14', li
export const body = sprinkles({ fontWeight: 'normal', fontSize: '16', lineHeight: '24' })
export const bodySmall = sprinkles({ fontWeight: 'normal', fontSize: '14', lineHeight: '20' })
export const caption = sprinkles({ fontWeight: 'normal', fontSize: '12', lineHeight: '16' })
export const badge = sprinkles({ fontWeight: 'semibold', fontSize: '10', lineHeight: '12' })
export const buttonTextMedium = sprinkles({ fontWeight: 'semibold', fontSize: '16', lineHeight: '20' })
export const buttonTextSmall = sprinkles({ fontWeight: 'semibold', fontSize: '14', lineHeight: '16' })
export const commonButtonStyles = style([
sprinkles({
borderRadius: '12',
transition: '250',
}),
{
border: 'none',
':hover': {
cursor: 'pointer',
},
':disabled': {
cursor: 'auto',
},
},
])
const magicalGradient = style({
selectors: {
'&::before': {