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:
Charles Bachmeier 2023-02-06 13:00:29 -08:00 committed by GitHub
parent 5def0dd166
commit 9cac9f8299
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 325 additions and 114 deletions

@ -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;
}
`

@ -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 `}
/>
&nbsp;
<Trans>below the collections 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)}
&nbsp;
{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' }
)