feat: [ListV2] error and warning states (#5921)
* update error message for price inputs * add grid and button warning states * add list below floor warning modal * only warn for prices 20% below floor * highlight unentered price in red on button press * missing dependency * updated modal name and mobile height * add new file * fix column presence * rookie mistake * bulk zustand imports * below floor threshold * move issue check higher * cleanup mouseEvent * rename color var --------- Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
This commit is contained in:
parent
5def0dd166
commit
9cac9f8299
@ -1,18 +1,25 @@
|
||||
import { Plural, t } from '@lingui/macro'
|
||||
import { NftListV2Variant, useNftListV2Flag } from 'featureFlags/flags/nftListV2'
|
||||
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 { BelowFloorWarningModal } from 'nft/components/profile/list/Modal/BelowFloorWarningModal'
|
||||
import { bodySmall } from 'nft/css/common.css'
|
||||
import { themeVars } from 'nft/css/sprinkles.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 { 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
|
||||
@ -20,18 +27,46 @@ interface ListingButtonProps {
|
||||
}
|
||||
|
||||
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 {
|
||||
addMarketplaceWarning,
|
||||
sellAssets,
|
||||
removeAllMarketplaceWarnings,
|
||||
showResolveIssues,
|
||||
toggleShowResolveIssues,
|
||||
} = useSellAsset(
|
||||
({
|
||||
addMarketplaceWarning,
|
||||
sellAssets,
|
||||
removeAllMarketplaceWarnings,
|
||||
showResolveIssues,
|
||||
toggleShowResolveIssues,
|
||||
}) => ({
|
||||
addMarketplaceWarning,
|
||||
sellAssets,
|
||||
removeAllMarketplaceWarnings,
|
||||
showResolveIssues,
|
||||
toggleShowResolveIssues,
|
||||
}),
|
||||
shallow
|
||||
)
|
||||
const { listingStatus, setListingStatus, setListings, setCollectionsRequiringApproval } = useNFTList(
|
||||
({ listingStatus, setListingStatus, setListings, setCollectionsRequiringApproval }) => ({
|
||||
listingStatus,
|
||||
setListingStatus,
|
||||
setListings,
|
||||
setCollectionsRequiringApproval,
|
||||
}),
|
||||
shallow
|
||||
)
|
||||
|
||||
const isNftListV2 = useNftListV2Flag() === NftListV2Variant.Enabled
|
||||
const [showWarning, setShowWarning] = useState(false)
|
||||
const [canContinue, setCanContinue] = useState(false)
|
||||
const [issues, setIssues] = useState(0)
|
||||
const theme = useTheme()
|
||||
const warningRef = useRef<HTMLDivElement>(null)
|
||||
useOnClickOutside(warningRef, () => {
|
||||
setShowWarning(false)
|
||||
!isNftListV2 && setShowWarning(false)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@ -71,13 +106,30 @@ export const ListingButton = ({ onClick, buttonText, showWarningOverride = false
|
||||
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) && !listing.overrideFloorPrice)
|
||||
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
|
||||
if (isNftListV2) {
|
||||
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 [
|
||||
@ -90,7 +142,7 @@ export const ListingButton = ({ onClick, buttonText, showWarningOverride = false
|
||||
listingsAboveSellOrderFloor,
|
||||
invalidPrices,
|
||||
]
|
||||
}, [sellAssets])
|
||||
}, [isNftListV2, sellAssets, showResolveIssues, toggleShowResolveIssues])
|
||||
|
||||
const [disableListButton, warningMessage] = useMemo(() => {
|
||||
const disableListButton =
|
||||
@ -158,11 +210,15 @@ export const ListingButton = ({ onClick, buttonText, showWarningOverride = false
|
||||
}
|
||||
|
||||
const warningWrappedClick = () => {
|
||||
if ((!disableListButton && canContinue) || showWarningOverride) onClick()
|
||||
else addWarningMessages()
|
||||
if ((!disableListButton && canContinue) || showWarningOverride) {
|
||||
if (issues && isNftListV2 && !showResolveIssues) toggleShowResolveIssues()
|
||||
else if (listingsBelowFloor.length) setShowWarning(true)
|
||||
else onClick()
|
||||
} else addWarningMessages()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box position="relative" width="full">
|
||||
{!showWarningOverride && showWarning && warningMessage.length > 0 && (
|
||||
<Row
|
||||
@ -210,21 +266,21 @@ export const ListingButton = ({ onClick, buttonText, showWarningOverride = false
|
||||
<Box
|
||||
as="button"
|
||||
border="none"
|
||||
backgroundColor="accentAction"
|
||||
backgroundColor={showResolveIssues ? 'accentFailure' : 'accentAction'}
|
||||
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={{
|
||||
color: showResolveIssues ? theme.accentTextDarkPrimary : theme.white,
|
||||
opacity:
|
||||
![ListingStatus.DEFINED, ListingStatus.FAILED, ListingStatus.CONTINUE].includes(listingStatus) ||
|
||||
disableListButton
|
||||
(disableListButton && !showResolveIssues)
|
||||
? 0.3
|
||||
: 1,
|
||||
}}
|
||||
@ -242,10 +298,20 @@ export const ListingButton = ({ onClick, buttonText, showWarningOverride = false
|
||||
'Try again'
|
||||
) : listingStatus === ListingStatus.CONTINUE ? (
|
||||
'Continue'
|
||||
) : 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -314,6 +314,7 @@ export const ListPage = () => {
|
||||
<ListingButton
|
||||
onClick={handleV2Click}
|
||||
buttonText={anyListingsMissingPrice && !isMobile ? t`Set prices to continue` : t`Start listing`}
|
||||
showWarningOverride={true}
|
||||
/>
|
||||
</ListingButtonWrapper>
|
||||
</ProceedsAndButtonWrapper>
|
||||
|
@ -22,7 +22,7 @@ const PastPriceInfo = styled(Column)`
|
||||
display: none;
|
||||
flex: 1;
|
||||
|
||||
@media screen and (min-width: ${BREAKPOINTS.xxl}px) {
|
||||
@media screen and (min-width: ${BREAKPOINTS.xl}px) {
|
||||
display: flex;
|
||||
}
|
||||
`
|
||||
|
124
src/nft/components/profile/list/Modal/BelowFloorWarningModal.tsx
Normal file
124
src/nft/components/profile/list/Modal/BelowFloorWarningModal.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { Plural, t, Trans } from '@lingui/macro'
|
||||
import { ButtonPrimary } from 'components/Button'
|
||||
import Column from 'components/Column'
|
||||
import { Portal } from 'nft/components/common/Portal'
|
||||
import { Overlay } from 'nft/components/modals/Overlay'
|
||||
import { Listing, WalletAsset } from 'nft/types'
|
||||
import React from 'react'
|
||||
import { AlertTriangle, X } from 'react-feather'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { BREAKPOINTS, ThemedText } from 'theme'
|
||||
import { Z_INDEX } from 'theme/zIndex'
|
||||
|
||||
const ModalWrapper = styled(Column)`
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 420px;
|
||||
z-index: ${Z_INDEX.modal};
|
||||
background: ${({ theme }) => theme.backgroundSurface};
|
||||
border-radius: 20px;
|
||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
box-shadow: ${({ theme }) => theme.deepShadow};
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
const CloseIconWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
const CloseIcon = styled(X)`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
`
|
||||
|
||||
const HazardIconWrap = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 32px 120px;
|
||||
`
|
||||
|
||||
const ContinueButton = styled(ButtonPrimary)`
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
margin-top: 12px;
|
||||
`
|
||||
|
||||
const EditListings = styled.span`
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
color: ${({ theme }) => theme.accentAction};
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 12px 16px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
`
|
||||
|
||||
export const BelowFloorWarningModal = ({
|
||||
listingsBelowFloor,
|
||||
closeModal,
|
||||
startListing,
|
||||
}: {
|
||||
listingsBelowFloor: [WalletAsset, Listing][]
|
||||
closeModal: () => void
|
||||
startListing: () => void
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const clickContinue = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
startListing()
|
||||
closeModal()
|
||||
}
|
||||
return (
|
||||
<Portal>
|
||||
<ModalWrapper>
|
||||
<CloseIconWrapper>
|
||||
<CloseIcon width={24} height={24} onClick={closeModal} />{' '}
|
||||
</CloseIconWrapper>
|
||||
<HazardIconWrap>
|
||||
<AlertTriangle height={90} width={90} color={theme.accentCritical} />
|
||||
</HazardIconWrap>
|
||||
<ThemedText.HeadlineSmall lineHeight="28px" textAlign="center">
|
||||
<Trans>Low listing price</Trans>
|
||||
</ThemedText.HeadlineSmall>
|
||||
<ThemedText.BodyPrimary textAlign="center">
|
||||
<Plural
|
||||
value={listingsBelowFloor.length !== 1 ? 2 : 1}
|
||||
_1={t`One NFT is listed ${(
|
||||
(1 - (listingsBelowFloor[0][1].price ?? 0) / (listingsBelowFloor[0][0].floorPrice ?? 0)) *
|
||||
100
|
||||
).toFixed(0)}% `}
|
||||
other={t`${listingsBelowFloor.length} NFTs are listed significantly `}
|
||||
/>
|
||||
|
||||
<Trans>below the collection’s floor price. Are you sure you want to continue?</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
<ContinueButton onClick={clickContinue}>
|
||||
<Trans>Continue</Trans>
|
||||
</ContinueButton>
|
||||
<EditListings onClick={closeModal}>
|
||||
<Trans>Edit listings</Trans>
|
||||
</EditListings>
|
||||
</ModalWrapper>
|
||||
<Overlay onClick={closeModal} />
|
||||
</Portal>
|
||||
)
|
||||
}
|
@ -8,6 +8,7 @@ import { useSellAsset } from 'nft/hooks'
|
||||
import { ListingWarning, WalletAsset } from 'nft/types'
|
||||
import { formatEth } from 'nft/utils/currency'
|
||||
import { Dispatch, FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { AlertTriangle } from 'react-feather'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { BREAKPOINTS } from 'theme'
|
||||
import { colors } from 'theme/colors'
|
||||
@ -42,7 +43,11 @@ const GlobalPriceIcon = styled.div`
|
||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||
`
|
||||
|
||||
const WarningMessage = styled(Row)<{ warningType: WarningType }>`
|
||||
const WarningRow = styled(Row)`
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const WarningMessage = styled(Row)<{ $color: string }>`
|
||||
top: 52px;
|
||||
width: max-content;
|
||||
position: absolute;
|
||||
@ -50,17 +55,16 @@ const WarningMessage = styled(Row)<{ warningType: WarningType }>`
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
color: ${({ warningType, theme }) => (warningType === WarningType.BELOW_FLOOR ? colors.red400 : theme.textSecondary)};
|
||||
color: ${({ $color }) => $color};
|
||||
|
||||
@media screen and (min-width: ${BREAKPOINTS.md}px) {
|
||||
right: unset;
|
||||
}
|
||||
`
|
||||
|
||||
const WarningAction = styled.div<{ warningType: WarningType }>`
|
||||
margin-left: 8px;
|
||||
const WarningAction = styled.div`
|
||||
cursor: pointer;
|
||||
color: ${({ warningType, theme }) => (warningType === WarningType.BELOW_FLOOR ? theme.accentAction : colors.red400)};
|
||||
color: ${({ theme }) => theme.accentAction};
|
||||
`
|
||||
|
||||
enum WarningType {
|
||||
@ -73,10 +77,10 @@ const getWarningMessage = (warning: WarningType) => {
|
||||
let message = <></>
|
||||
switch (warning) {
|
||||
case WarningType.BELOW_FLOOR:
|
||||
message = <Trans>LISTING BELOW FLOOR </Trans>
|
||||
message = <Trans>below floor price.</Trans>
|
||||
break
|
||||
case WarningType.ALREADY_LISTED:
|
||||
message = <Trans>ALREADY LISTED FOR </Trans>
|
||||
message = <Trans>Already listed at</Trans>
|
||||
break
|
||||
}
|
||||
return message
|
||||
@ -107,6 +111,7 @@ export const PriceTextInput = ({
|
||||
const [warningType, setWarningType] = useState(WarningType.NONE)
|
||||
const removeMarketplaceWarning = useSellAsset((state) => state.removeMarketplaceWarning)
|
||||
const removeSellAsset = useSellAsset((state) => state.removeSellAsset)
|
||||
const showResolveIssues = useSellAsset((state) => state.showResolveIssues)
|
||||
const inputRef = useRef() as React.MutableRefObject<HTMLInputElement>
|
||||
const theme = useTheme()
|
||||
|
||||
@ -121,9 +126,16 @@ export const PriceTextInput = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listPrice])
|
||||
|
||||
const borderColor =
|
||||
warningType !== WarningType.NONE && !focused
|
||||
const percentBelowFloor = (1 - (listPrice ?? 0) / (asset.floorPrice ?? 0)) * 100
|
||||
|
||||
const warningColor =
|
||||
showResolveIssues && !listPrice
|
||||
? colors.red400
|
||||
: warningType !== WarningType.NONE && !focused
|
||||
? (warningType === WarningType.BELOW_FLOOR && percentBelowFloor >= 20) ||
|
||||
warningType === WarningType.ALREADY_LISTED
|
||||
? colors.red400
|
||||
: theme.accentWarning
|
||||
: isGlobalPrice
|
||||
? theme.accentAction
|
||||
: listPrice != null
|
||||
@ -132,7 +144,7 @@ export const PriceTextInput = ({
|
||||
|
||||
return (
|
||||
<PriceTextInputWrapper>
|
||||
<InputWrapper borderColor={borderColor}>
|
||||
<InputWrapper borderColor={warningColor}>
|
||||
<NumericInput
|
||||
as="input"
|
||||
pattern="[0-9]"
|
||||
@ -164,27 +176,27 @@ export const PriceTextInput = ({
|
||||
</GlobalPriceIcon>
|
||||
)}
|
||||
</InputWrapper>
|
||||
<WarningMessage warningType={warningType}>
|
||||
<WarningMessage $color={warningColor}>
|
||||
{warning
|
||||
? warning.message
|
||||
: warningType !== WarningType.NONE && (
|
||||
<>
|
||||
<WarningRow>
|
||||
<AlertTriangle height={16} width={16} color={warningColor} />
|
||||
<span>
|
||||
{warningType === WarningType.BELOW_FLOOR && `${percentBelowFloor.toFixed(0)}% `}
|
||||
{getWarningMessage(warningType)}
|
||||
|
||||
{warningType === WarningType.BELOW_FLOOR
|
||||
? formatEth(asset?.floorPrice ?? 0)
|
||||
: formatEth(asset?.floor_sell_order_price ?? 0)}
|
||||
ETH
|
||||
{warningType === WarningType.ALREADY_LISTED && `${formatEth(asset?.floor_sell_order_price ?? 0)} ETH`}
|
||||
</span>
|
||||
<WarningAction
|
||||
warningType={warningType}
|
||||
onClick={() => {
|
||||
warningType === WarningType.ALREADY_LISTED && removeSellAsset(asset)
|
||||
setWarningType(WarningType.NONE)
|
||||
}}
|
||||
>
|
||||
{warningType === WarningType.BELOW_FLOOR ? <Trans>DISMISS</Trans> : <Trans>REMOVE ITEM</Trans>}
|
||||
{warningType === WarningType.BELOW_FLOOR ? <Trans>Dismiss</Trans> : <Trans>Remove item</Trans>}
|
||||
</WarningAction>
|
||||
</>
|
||||
</WarningRow>
|
||||
)}
|
||||
</WarningMessage>
|
||||
</PriceTextInputWrapper>
|
||||
|
@ -5,6 +5,7 @@ import { ListingMarket, ListingWarning, WalletAsset } from '../types'
|
||||
|
||||
interface SellAssetState {
|
||||
sellAssets: WalletAsset[]
|
||||
showResolveIssues: boolean
|
||||
selectSellAsset: (asset: WalletAsset) => void
|
||||
removeSellAsset: (asset: WalletAsset) => void
|
||||
reset: () => void
|
||||
@ -16,12 +17,14 @@ interface SellAssetState {
|
||||
addMarketplaceWarning: (asset: WalletAsset, warning: ListingWarning) => void
|
||||
removeMarketplaceWarning: (asset: WalletAsset, warning: ListingWarning, setGlobalOverride?: boolean) => void
|
||||
removeAllMarketplaceWarnings: () => void
|
||||
toggleShowResolveIssues: () => void
|
||||
}
|
||||
|
||||
export const useSellAsset = create<SellAssetState>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
sellAssets: [],
|
||||
showResolveIssues: false,
|
||||
selectSellAsset: (asset) =>
|
||||
set(({ sellAssets }) => {
|
||||
if (sellAssets.length === 0) return { sellAssets: [asset] }
|
||||
@ -153,6 +156,11 @@ export const useSellAsset = create<SellAssetState>()(
|
||||
return { sellAssets: assetsCopy }
|
||||
})
|
||||
},
|
||||
toggleShowResolveIssues: () => {
|
||||
set(({ showResolveIssues }) => {
|
||||
return { showResolveIssues: !showResolveIssues }
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ name: 'useSelectAsset' }
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user