Commit bb92a9ee authored by aballerr's avatar aballerr Committed by GitHub

feat: consolidating fee columns (#5713)

* adding in floor price

* consolidating fee columns

* adding in tooltip

* adding in floor price

* current table progress

* better flex experience

* improved mobile listing experience

* improving the mobile experience

* cleaned up mobile experience

* reverting some file changes

* improving mobile experience

* some final adjustments

* updating text color to be line with task

* updating to address design comments

* resolving lint errors

* updating fees logic

* fixing linting issues

* removing generated files

* removing generated files

* removing generated files

* updating margin left

* Adding in trans components

* fixingl inting errors

* fixing minus icon placement

* i18n sort dropdown prompt

* move tooltip to its own file

* remove unused styles

* move NFTListRow to its own file

* move PriceTextInput to its own file

* adjust column settings

* add more translations

* Move MarketplaceRow to its own file

* warning message on small screens
Co-authored-by: default avatarCharles Bachmeier <charlie@genie.xyz>
parent 4936ec5c
// eslint-disable-next-line no-restricted-imports
import { t } from '@lingui/macro'
import { MouseoverTooltip } from 'components/Tooltip'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { body, bodySmall } from 'nft/css/common.css'
import { useSellAsset } from 'nft/hooks'
import { ListingMarket, ListingWarning, WalletAsset } from 'nft/types'
import { LOOKS_RARE_CREATOR_BASIS_POINTS } from 'nft/utils'
import { formatEth, formatUsdPrice } from 'nft/utils/currency'
import { fetchPrice } from 'nft/utils/fetchPrice'
import { Dispatch, useEffect, useMemo, useState } from 'react'
import * as styles from './ListPage.css'
import { SetPriceMethod } from './NFTListingsGrid'
import { PriceTextInput } from './PriceTextInput'
import { RoyaltyTooltip } from './RoyaltyTooltip'
const getRoyalty = (listingMarket: ListingMarket, asset: WalletAsset) => {
// LooksRare is a unique case where royalties for creators are a flat 0.5% or 50 basis points
const baseFee = listingMarket.name === 'LooksRare' ? LOOKS_RARE_CREATOR_BASIS_POINTS : asset.basisPoints ?? 0
return baseFee * 0.01
}
interface MarketplaceRowProps {
globalPriceMethod?: SetPriceMethod
globalPrice?: number
setGlobalPrice: Dispatch<number | undefined>
selectedMarkets: ListingMarket[]
removeMarket?: () => void
asset: WalletAsset
showMarketplaceLogo: boolean
expandMarketplaceRows?: boolean
}
export const MarketplaceRow = ({
globalPriceMethod,
globalPrice,
setGlobalPrice,
selectedMarkets,
removeMarket = undefined,
asset,
showMarketplaceLogo,
expandMarketplaceRows,
}: MarketplaceRowProps) => {
const [listPrice, setListPrice] = useState<number>()
const [globalOverride, setGlobalOverride] = useState(false)
const showGlobalPrice = globalPriceMethod === SetPriceMethod.SAME_PRICE && !globalOverride && globalPrice
const setAssetListPrice = useSellAsset((state) => state.setAssetListPrice)
const removeAssetMarketplace = useSellAsset((state) => state.removeAssetMarketplace)
const [hovered, setHovered] = useState(false)
const handleHover = () => setHovered(!hovered)
const price = showGlobalPrice ? globalPrice : listPrice
const fees = useMemo(() => {
if (selectedMarkets.length === 1) {
return getRoyalty(selectedMarkets[0], asset) + selectedMarkets[0].fee
} else {
let max = 0
for (const selectedMarket of selectedMarkets) {
const fee = selectedMarket.fee + getRoyalty(selectedMarket, asset)
max = Math.max(fee, max)
}
return max
}
}, [asset, selectedMarkets])
const feeInEth = price && (price * fees) / 100
const userReceives = price && feeInEth && price - feeInEth
useMemo(() => {
for (const market of selectedMarkets) {
if (market && asset && asset.basisPoints) {
market.royalty = (market.name === 'LooksRare' ? LOOKS_RARE_CREATOR_BASIS_POINTS : asset.basisPoints) * 0.01
}
}
}, [asset, selectedMarkets])
useEffect(() => {
if (globalPriceMethod === SetPriceMethod.FLOOR_PRICE) {
setListPrice(asset?.floorPrice)
setGlobalPrice(asset.floorPrice)
} else if (globalPriceMethod === SetPriceMethod.PREV_LISTING) {
setListPrice(asset.lastPrice)
setGlobalPrice(asset.lastPrice)
} else if (globalPriceMethod === SetPriceMethod.SAME_PRICE)
listPrice && !globalPrice ? setGlobalPrice(listPrice) : setListPrice(globalPrice)
setGlobalOverride(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [globalPriceMethod])
useEffect(() => {
if (selectedMarkets.length)
for (const marketplace of selectedMarkets) setAssetListPrice(asset, listPrice, marketplace)
else setAssetListPrice(asset, listPrice)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listPrice])
useEffect(() => {
let price: number | undefined = undefined
if (globalOverride) {
if (!listPrice) setListPrice(globalPrice)
price = listPrice ? listPrice : globalPrice
} else {
price = globalPrice
}
if (selectedMarkets.length) for (const marketplace of selectedMarkets) setAssetListPrice(asset, price, marketplace)
else setAssetListPrice(asset, price)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [globalOverride])
useEffect(() => {
if (globalPriceMethod === SetPriceMethod.SAME_PRICE && !globalOverride) {
if (selectedMarkets.length)
for (const marketplace of selectedMarkets) setAssetListPrice(asset, globalPrice, marketplace)
else setAssetListPrice(asset, globalPrice)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [globalPrice])
let warning: ListingWarning | undefined = undefined
if (asset.listingWarnings && asset.listingWarnings?.length > 0) {
if (showMarketplaceLogo) {
for (const listingWarning of asset.listingWarnings) {
if (listingWarning.marketplace.name === selectedMarkets[0].name) warning = listingWarning
}
} else {
warning = asset.listingWarnings[0]
}
}
return (
<Row transition="500" marginLeft="0">
<Column
className={bodySmall}
color="textSecondary"
textAlign="left"
flex="1"
display={{ sm: 'none', xxl: 'flex' }}
>
{asset.floorPrice ? `${asset.floorPrice.toFixed(3)} ETH` : '-'}
</Column>
<Column
className={bodySmall}
color="textSecondary"
textAlign="left"
flex="1"
display={{ sm: 'none', xxl: 'flex' }}
>
{asset.lastPrice ? `${asset.lastPrice.toFixed(3)} ETH` : '-'}
</Column>
<Row flex="2">
{showMarketplaceLogo && (
<Column
position="relative"
cursor="pointer"
onMouseEnter={handleHover}
onMouseLeave={handleHover}
onClick={(e) => {
e.stopPropagation()
removeAssetMarketplace(asset, selectedMarkets[0])
removeMarket && removeMarket()
}}
>
<Box className={styles.removeMarketplace} visibility={hovered ? 'visible' : 'hidden'} position="absolute">
<Box as="img" width="32" src="/nft/svgs/minusCircle.svg" alt="Remove item" />
</Box>
<Box
as="img"
alt={selectedMarkets[0].name}
width="28"
height="28"
borderRadius="4"
objectFit="cover"
src={selectedMarkets[0].icon}
marginRight="16"
/>
</Column>
)}
{globalPriceMethod === SetPriceMethod.SAME_PRICE && !globalOverride ? (
<PriceTextInput
listPrice={globalPrice}
setListPrice={setGlobalPrice}
isGlobalPrice={true}
setGlobalOverride={setGlobalOverride}
globalOverride={globalOverride}
warning={warning}
asset={asset}
shrink={expandMarketplaceRows}
/>
) : (
<PriceTextInput
listPrice={listPrice}
setListPrice={setListPrice}
isGlobalPrice={false}
setGlobalOverride={setGlobalOverride}
globalOverride={globalOverride}
warning={warning}
asset={asset}
shrink={expandMarketplaceRows}
/>
)}
</Row>
<Column flex="1" display={{ sm: 'none', lg: 'flex' }}>
<Box className={body} color="textSecondary" width="full" textAlign="right">
<MouseoverTooltip
text={
<Row>
<Box width="full" fontSize="14">
{selectedMarkets.map((selectedMarket, index) => {
return <RoyaltyTooltip selectedMarket={selectedMarket} key={index} />
})}
</Box>
</Row>
}
placement="left"
>
{fees > 0 ? `${fees}${selectedMarkets.length > 1 ? t`% max` : '%'}` : '--%'}
</MouseoverTooltip>
</Box>
</Column>
<Column flex="1.5" display={{ sm: 'none', lg: 'flex' }}>
<Column width="full">
<EthPriceDisplay ethPrice={userReceives} />
</Column>
</Column>
</Row>
)
}
const EthPriceDisplay = ({ ethPrice = 0 }: { ethPrice?: number }) => {
const [ethConversion, setEthConversion] = useState(3000)
useEffect(() => {
fetchPrice().then((price) => {
setEthConversion(price ?? 0)
})
}, [])
return (
<Column width="full">
<Row width="full" justifyContent="flex-end" color={ethPrice !== 0 ? 'textPrimary' : 'textSecondary'}>
{ethPrice !== 0 ? (
<>
<Column>
<Box className={body} color="textPrimary" textAlign="right" marginLeft="12" marginRight="0">
{formatEth(ethPrice)} ETH
</Box>
<Box className={body} color="textSecondary" textAlign="right" marginLeft="12" marginRight="0">
{formatUsdPrice(ethPrice * ethConversion)}
</Box>
</Column>
</>
) : (
'- ETH'
)}
</Row>
</Column>
)
}
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { RowsCollpsedIcon, RowsExpandedIcon, VerifiedIcon } from 'nft/components/icons'
import { bodySmall, subhead } from 'nft/css/common.css'
import { useSellAsset } from 'nft/hooks'
import { ListingMarket, WalletAsset } from 'nft/types'
import { Dispatch, useEffect, useState } from 'react'
import styled from 'styled-components/macro'
import { MarketplaceRow } from './MarketplaceRow'
import { SetPriceMethod } from './NFTListingsGrid'
const IconWrap = styled.div<{ hovered: boolean }>`
position: absolute;
left: 50%;
top: 30px;
transform: translateX(-50%);
width: 32px;
visibility: ${({ hovered }) => (hovered ? 'visible' : 'hidden')};
`
const StyledImg = styled.img`
width: 32px;
height: 32px;
`
interface NFTListRowProps {
asset: WalletAsset
globalPriceMethod?: SetPriceMethod
setGlobalPrice: Dispatch<number | undefined>
globalPrice?: number
selectedMarkets: ListingMarket[]
}
/**
* NFTListRow is the outermost row wrapper for an NFT Listing, which shows either the composite of multiple marketplaces listings
* or can be expanded to show listings per marketplace
*/
export const NFTListRow = ({
asset,
globalPriceMethod,
globalPrice,
setGlobalPrice,
selectedMarkets,
}: NFTListRowProps) => {
const [expandMarketplaceRows, setExpandMarketplaceRows] = useState(false)
const removeAsset = useSellAsset((state) => state.removeSellAsset)
const [localMarkets, setLocalMarkets] = useState([])
const [hovered, setHovered] = useState(false)
const handleHover = () => setHovered(!hovered)
useEffect(() => {
setLocalMarkets(JSON.parse(JSON.stringify(selectedMarkets)))
selectedMarkets.length < 2 && setExpandMarketplaceRows(false)
}, [selectedMarkets])
return (
<Row marginY="24">
<Row flexWrap="nowrap" flex={{ sm: '2', md: '1.5' }} marginTop="0" marginBottom="auto" minWidth="0">
<Box
transition="500"
style={{
maxWidth: localMarkets.length > 1 ? '28px' : '0',
opacity: localMarkets.length > 1 ? '1' : '0',
}}
cursor="pointer"
marginRight="8"
onClick={() => setExpandMarketplaceRows(!expandMarketplaceRows)}
>
{expandMarketplaceRows ? <RowsExpandedIcon /> : <RowsCollpsedIcon />}
</Box>
<Box
position="relative"
cursor="pointer"
onMouseEnter={handleHover}
onMouseLeave={handleHover}
width="48"
height="48"
marginRight="8"
onClick={() => {
removeAsset(asset)
}}
>
<IconWrap hovered={hovered}>
<StyledImg src="/nft/svgs/minusCircle.svg" alt="Remove item" />
</IconWrap>
<Box
as="img"
alt={asset.name}
width="48"
height="48"
borderRadius="8"
marginRight="8"
transition="500"
src={asset.imageUrl || '/nft/svgs/image-placeholder.svg'}
/>
</Box>
<Column gap="4" minWidth="0">
<Box paddingRight="8" overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" className={subhead}>
{asset.name ? asset.name : `#${asset.tokenId}`}
</Box>
<Box
paddingRight="8"
color="textSecondary"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
className={bodySmall}
>
{asset.collection?.name}
{asset.collectionIsVerified && <VerifiedIcon style={{ marginBottom: '-5px' }} />}
</Box>
</Column>
</Row>
<Column flex={{ sm: '1', md: '3' }} gap="24">
{expandMarketplaceRows ? (
localMarkets.map((market, index) => {
return (
<MarketplaceRow
globalPriceMethod={globalPriceMethod}
globalPrice={globalPrice}
setGlobalPrice={setGlobalPrice}
selectedMarkets={[market]}
removeMarket={() => localMarkets.splice(index, 1)}
asset={asset}
showMarketplaceLogo={true}
key={index}
expandMarketplaceRows={expandMarketplaceRows}
/>
)
})
) : (
<MarketplaceRow
globalPriceMethod={globalPriceMethod}
globalPrice={globalPrice}
setGlobalPrice={setGlobalPrice}
selectedMarkets={localMarkets}
asset={asset}
showMarketplaceLogo={false}
/>
)}
</Column>
</Row>
)
}
import { Trans } from '@lingui/macro'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { AttachPriceIcon, EditPriceIcon } from 'nft/components/icons'
import { NumericInput } from 'nft/components/layout/Input'
import { badge, body } from 'nft/css/common.css'
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'
enum WarningType {
BELOW_FLOOR,
ALREADY_LISTED,
NONE,
}
const getWarningMessage = (warning: WarningType) => {
let message = <></>
switch (warning) {
case WarningType.BELOW_FLOOR:
message = <Trans>LISTING BELOW FLOOR </Trans>
break
case WarningType.ALREADY_LISTED:
message = <Trans>ALREADY LISTED FOR </Trans>
break
}
return message
}
interface PriceTextInputProps {
listPrice?: number
setListPrice: Dispatch<number | undefined>
isGlobalPrice: boolean
setGlobalOverride: Dispatch<boolean>
globalOverride: boolean
warning?: ListingWarning
asset: WalletAsset
shrink?: boolean
}
export const PriceTextInput = ({
listPrice,
setListPrice,
isGlobalPrice,
setGlobalOverride,
globalOverride,
warning,
asset,
shrink,
}: PriceTextInputProps) => {
const [focused, setFocused] = useState(false)
const [warningType, setWarningType] = useState(WarningType.NONE)
const removeMarketplaceWarning = useSellAsset((state) => state.removeMarketplaceWarning)
const removeSellAsset = useSellAsset((state) => state.removeSellAsset)
const inputRef = useRef() as React.MutableRefObject<HTMLInputElement>
useEffect(() => {
inputRef.current.value = listPrice !== undefined ? `${listPrice}` : ''
setWarningType(WarningType.NONE)
if (!warning && listPrice) {
if (listPrice < (asset?.floorPrice ?? 0)) setWarningType(WarningType.BELOW_FLOOR)
else if (asset.floor_sell_order_price && listPrice >= asset.floor_sell_order_price)
setWarningType(WarningType.ALREADY_LISTED)
} else if (warning && listPrice && listPrice >= 0) removeMarketplaceWarning(asset, warning)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listPrice])
return (
<Column gap="12" position="relative">
<Row
color="textTertiary"
height="44"
width="min"
padding="4"
borderRadius="8"
borderWidth="2px"
borderStyle="solid"
marginRight="auto"
borderColor={
warningType !== WarningType.NONE && !focused
? 'orange'
: isGlobalPrice
? 'accentAction'
: listPrice != null
? 'textSecondary'
: 'blue400'
}
>
<NumericInput
as="input"
pattern="[0-9]"
borderStyle="none"
className={body}
color={{ placeholder: 'textSecondary', default: 'textPrimary' }}
placeholder="0"
marginRight="0"
marginLeft="14"
backgroundColor="none"
style={{ width: shrink ? '54px' : '68px' }}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
}}
ref={inputRef}
onChange={(v: FormEvent<HTMLInputElement>) => {
if (!listPrice && v.currentTarget.value.includes('.') && parseFloat(v.currentTarget.value) === 0) {
return
}
const val = parseFloat(v.currentTarget.value)
setListPrice(isNaN(val) ? undefined : val)
}}
/>
<Box color={listPrice && listPrice >= 0 ? 'textPrimary' : 'textSecondary'} marginRight="16">
&nbsp;ETH
</Box>
<Box
cursor="pointer"
display={isGlobalPrice || globalOverride ? 'block' : 'none'}
position="absolute"
style={{ marginTop: '-36px', marginLeft: '124px' }}
backgroundColor="backgroundSurface"
onClick={() => setGlobalOverride(!globalOverride)}
>
{globalOverride ? <AttachPriceIcon /> : <EditPriceIcon />}
</Box>
</Row>
<Row
top="52"
width="max"
className={badge}
color={warningType === WarningType.BELOW_FLOOR && !focused ? 'orange' : 'textSecondary'}
position="absolute"
right={{ sm: '0', md: 'unset' }}
>
{warning
? warning.message
: warningType !== WarningType.NONE && (
<>
{getWarningMessage(warningType)}
&nbsp;
{warningType === WarningType.BELOW_FLOOR
? formatEth(asset?.floorPrice ?? 0)
: formatEth(asset?.floor_sell_order_price ?? 0)}
ETH
<Box
color={warningType === WarningType.BELOW_FLOOR ? 'accentAction' : 'orange'}
marginLeft="8"
cursor="pointer"
onClick={() => {
warningType === WarningType.ALREADY_LISTED && removeSellAsset(asset)
setWarningType(WarningType.NONE)
}}
>
{warningType === WarningType.BELOW_FLOOR ? <Trans>DISMISS</Trans> : <Trans>REMOVE ITEM</Trans>}
</Box>
</>
)}
</Row>
</Column>
)
}
import { Trans } from '@lingui/macro'
import { ListingMarket } from 'nft/types'
// eslint-disable-next-line no-restricted-imports
import styled from 'styled-components/macro'
const FeeWrap = styled.div`
margin-bottom: 4px;
color: ${({ theme }) => theme.textPrimary};
`
const RoyaltyContainer = styled.div`
margin-bottom: 8px;
`
export const RoyaltyTooltip = ({ selectedMarket }: { selectedMarket: ListingMarket }) => {
return (
<RoyaltyContainer key={selectedMarket.name}>
<FeeWrap>
{selectedMarket.name}: {selectedMarket.fee}%
</FeeWrap>
<FeeWrap>
<Trans>Creator royalties</Trans>: {selectedMarket.royalty}%
</FeeWrap>
</RoyaltyContainer>
)
}
...@@ -6,6 +6,7 @@ export interface ListingMarket { ...@@ -6,6 +6,7 @@ export interface ListingMarket {
name: string name: string
fee: number fee: number
icon: string icon: string
royalty?: number
} }
export interface ListingWarning { export interface ListingWarning {
marketplace: ListingMarket marketplace: ListingMarket
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment