feat: [DetailsV2] Offer and Listing Tables (#6515)

* added home icon, basic content container with scroll behaviour

* add more struct

* add timeUntil util, add main structure of generic component, basic mock data

* propagate asset

* actual fake data

* working scroll

* proper alignment

* 1155 quantity

* small window sizes

* more action buttons

* cleanup

* update snapshot

* add tests

* add new test files

* add outline and hide usd price for certain screen sizes

* use sell order data

* update tests

* fetch multiple listings

* better price width on select screens

* mobile icon for approve

* bottom padding on mobile

* update snapshot

* use test objs in tests

* update query

* add border between rows

* update page padding

* breakpoint overlap

* simplified sellOrder check

* external link

* upstream button and better mobile padding

* add file and update tests

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
This commit is contained in:
Charles Bachmeier 2023-05-15 12:14:09 -04:00 committed by GitHub
parent 57274a800d
commit 42e3af7b5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 2897 additions and 125 deletions

@ -49,7 +49,7 @@ gql`
} }
description description
} }
listings(first: 1) { listings(first: 25) {
edges { edges {
node { node {
address address

@ -9,13 +9,14 @@ import { DataPageHeader } from './DataPageHeader'
import { DataPageTable } from './DataPageTable' import { DataPageTable } from './DataPageTable'
import { DataPageTraits } from './DataPageTraits' import { DataPageTraits } from './DataPageTraits'
const DataPageContainer = styled(Column)` const DataPagePaddingContainer = styled.div`
padding: 24px 64px; padding: 24px 64px;
height: 100vh; height: 100vh;
width: 100%; width: 100%;
gap: 36px;
max-width: ${({ theme }) => theme.maxWidth}; @media screen and (max-width: ${BREAKPOINTS.md}px) {
margin: 0 auto; height: 100%;
}
@media screen and (max-width: ${BREAKPOINTS.sm}px) { @media screen and (max-width: ${BREAKPOINTS.sm}px) {
padding: 24px 48px; padding: 24px 48px;
@ -26,6 +27,14 @@ const DataPageContainer = styled(Column)`
} }
` `
const DataPageContainer = styled(Column)`
height: 100%;
width: 100%;
gap: 36px;
max-width: ${({ theme }) => theme.maxWidth};
margin: 0 auto;
`
const ContentContainer = styled(Row)` const ContentContainer = styled(Row)`
gap: 24px; gap: 24px;
padding-bottom: 45px; padding-bottom: 45px;
@ -50,7 +59,7 @@ export const DataPage = ({ asset }: { asset: GenieAsset }) => {
{!!asset.traits?.length && <DataPageTraits asset={asset} />} {!!asset.traits?.length && <DataPageTraits asset={asset} />}
<DataPageDescription /> <DataPageDescription />
</LeftColumn> </LeftColumn>
<DataPageTable /> <DataPageTable asset={asset} />
</ContentContainer> </ContentContainer>
</DataPageContainer> </DataPageContainer>
) )

@ -0,0 +1,23 @@
import { TEST_NFT_ASSET, TEST_OFFER, TEST_SELL_ORDER } from 'test-utils/nft/fixtures'
import { render } from 'test-utils/render'
import { ListingsTableContent } from './ListingsTableContent'
import { OffersTableContent } from './OffersTableContent'
it('data page offers table content loads with a given asset', () => {
const assetWithOffer = {
...TEST_NFT_ASSET,
offers: [TEST_OFFER],
}
const { asFragment } = render(<OffersTableContent asset={assetWithOffer} />)
expect(asFragment()).toMatchSnapshot()
})
it('data page listings table content loads with a given asset', () => {
const assetWithOrder = {
...TEST_NFT_ASSET,
sellorders: [TEST_SELL_ORDER],
}
const { asFragment } = render(<ListingsTableContent asset={assetWithOrder} />)
expect(asFragment()).toMatchSnapshot()
})

@ -1,45 +1,50 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { GenieAsset } from 'nft/types'
import { useMemo } from 'react'
import { ActivityTableContent } from './ActivityTableContent' import { ActivityTableContent } from './ActivityTableContent'
import { ListingsTableContent } from './ListingsTableContent' import { ListingsTableContent } from './ListingsTableContent'
import { OffersTableContent } from './OffersTableContent' import { OffersTableContent } from './OffersTableContent'
import { Tab, TabbedComponent } from './TabbedComponent' import { Tab, TabbedComponent } from './TabbedComponent'
enum TableTabsKeys { export enum TableTabsKeys {
Activity = 'activity', Activity = 'activity',
Offers = 'offers', Offers = 'offers',
Listings = 'listings', Listings = 'listings',
} }
const TableTabs: Map<string, Tab> = new Map([ export const DataPageTable = ({ asset }: { asset: GenieAsset }) => {
[ const TableTabs: Map<string, Tab> = useMemo(
TableTabsKeys.Activity, () =>
{ new Map([
title: <Trans>Activity</Trans>, [
key: TableTabsKeys.Activity, TableTabsKeys.Activity,
content: <ActivityTableContent />, {
}, title: <Trans>Activity</Trans>,
], key: TableTabsKeys.Activity,
[ content: <ActivityTableContent />,
TableTabsKeys.Offers, },
{ ],
title: <Trans>Offers</Trans>, [
key: TableTabsKeys.Offers, TableTabsKeys.Offers,
content: <OffersTableContent />, {
count: 11, // TODO Replace Placeholder with real data title: <Trans>Offers</Trans>,
}, key: TableTabsKeys.Offers,
], content: <OffersTableContent asset={asset} />,
[ count: 11, // TODO Replace Placeholder with real data
TableTabsKeys.Listings, },
{ ],
title: <Trans>Listings</Trans>, [
key: TableTabsKeys.Listings, TableTabsKeys.Listings,
content: <ListingsTableContent />, {
count: 11, // TODO Replace Placeholder with real data title: <Trans>Listings</Trans>,
}, key: TableTabsKeys.Listings,
], content: <ListingsTableContent asset={asset} />,
]) count: asset.sellorders?.length,
},
export const DataPageTable = () => { ],
]),
[asset]
)
return <TabbedComponent tabs={TableTabs} /> return <TabbedComponent tabs={TableTabs} />
} }

@ -7,8 +7,8 @@ import { GenieAsset } from 'nft/types'
import { useMemo } from 'react' import { useMemo } from 'react'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { BREAKPOINTS, ThemedText } from 'theme' import { BREAKPOINTS, ThemedText } from 'theme'
import { opacify } from 'theme/utils'
import { Scrim } from './shared'
import { Tab, TabbedComponent } from './TabbedComponent' import { Tab, TabbedComponent } from './TabbedComponent'
import { TraitRow } from './TraitRow' import { TraitRow } from './TraitRow'
@ -45,26 +45,6 @@ const TraitRowScrollableContainer = styled.div`
${ScrollBarStyles} ${ScrollBarStyles}
` `
// Scrim that fades out the top and bottom of the scrollable container, isBottom changes the direction and placement of the fade
const Scrim = styled.div<{ isBottom?: boolean }>`
position: absolute;
height: 88px;
left: 0px;
right: 6px;
${({ isBottom }) =>
isBottom
? 'bottom: 0px'
: `
top: 0px;
transform: matrix(1, 0, 0, -1, 0, 0);
`};
background: ${({ theme }) =>
`linear-gradient(180deg, ${opacify(0, theme.backgroundSurface)} 0%, ${theme.backgroundSurface} 100%)`};
display: flex;
`
const TraitsContent = ({ asset }: { asset: GenieAsset }) => { const TraitsContent = ({ asset }: { asset: GenieAsset }) => {
const { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState() const { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState()

@ -1,5 +1,25 @@
import { TableContentContainer } from './shared' import { Trans } from '@lingui/macro'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { AddToBagIcon } from 'nft/components/icons'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset } from 'nft/types'
import { useTheme } from 'styled-components/macro'
export const ListingsTableContent = () => { import { TableTabsKeys } from './DataPageTable'
return <TableContentContainer>Listings Content</TableContentContainer> import { TableContentComponent } from './TableContentComponent'
import { ContentRow, HeaderRow } from './TableRowComponent'
export const ListingsTableContent = ({ asset }: { asset: GenieAsset }) => {
const isMobile = useIsMobile()
const theme = useTheme()
const headers = <HeaderRow type={TableTabsKeys.Listings} is1155={asset.tokenType === NftStandard.Erc1155} />
const contentRows = (asset.sellorders || []).map((offer, index) => (
<ContentRow
key={'offer_' + index}
content={offer}
buttonCTA={isMobile ? <AddToBagIcon color={theme.textSecondary} /> : <Trans>Add to Bag</Trans>}
is1155={asset.tokenType === NftStandard.Erc1155}
/>
))
return <TableContentComponent headerRow={headers} contentRows={contentRows} type={TableTabsKeys.Offers} />
} }

@ -23,6 +23,7 @@ const DetailsBackground = styled.div<{ backgroundImage: string }>`
const DetailsContentContainer = styled.div` const DetailsContentContainer = styled.div`
z-index: ${Z_INDEX.hover}; z-index: ${Z_INDEX.hover};
width: 100%;
` `
export const NftDetails = ({ asset, collection }: NftDetailsProps) => { export const NftDetails = ({ asset, collection }: NftDetailsProps) => {

@ -1,5 +1,28 @@
import { TableContentContainer } from './shared' import { Trans } from '@lingui/macro'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset } from 'nft/types'
import { Check } from 'react-feather'
import { useTheme } from 'styled-components/macro'
import { TEST_OFFER } from 'test-utils/nft/fixtures'
export const OffersTableContent = () => { import { TableTabsKeys } from './DataPageTable'
return <TableContentContainer>Offers Content</TableContentContainer> import { TableContentComponent } from './TableContentComponent'
import { ContentRow, HeaderRow } from './TableRowComponent'
export const OffersTableContent = ({ asset }: { asset: GenieAsset }) => {
// TODO(NFT-1189) Replace with real offer data when BE supports
const mockOffers = new Array(11).fill(TEST_OFFER)
const isMobile = useIsMobile()
const theme = useTheme()
const headers = <HeaderRow type={TableTabsKeys.Offers} is1155={asset.tokenType === NftStandard.Erc1155} />
const contentRows = mockOffers.map((offer, index) => (
<ContentRow
key={'offer_' + index}
content={offer}
buttonCTA={isMobile ? <Check color={theme.textSecondary} height="20px" width="20px" /> : <Trans>Accept</Trans>}
is1155={asset.tokenType === NftStandard.Erc1155}
/>
))
return <TableContentComponent headerRow={headers} contentRows={contentRows} type={TableTabsKeys.Offers} />
} }

@ -0,0 +1,55 @@
import { ScrollBarStyles } from 'components/Common'
import { useSubscribeScrollState } from 'nft/hooks'
import styled from 'styled-components/macro'
import { TableTabsKeys } from './DataPageTable'
import { Scrim } from './shared'
const TableRowsContainer = styled.div`
position: relative;
`
const TableRowScrollableContainer = styled.div`
overflow-y: auto;
overflow-x: hidden;
max-height: 264px;
${ScrollBarStyles}
`
const TableHeaderRowContainer = styled.div<{ userCanScroll: boolean }>`
margin-right: ${({ userCanScroll }) => (userCanScroll ? '11px' : '0')};
`
const TableRowContainer = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline};
&:last-child {
border-bottom: none;
}
`
interface TableContentComponentProps {
headerRow: React.ReactNode
contentRows: React.ReactNode[]
type: TableTabsKeys
}
export const TableContentComponent = ({ headerRow, contentRows, type }: TableContentComponentProps) => {
const { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState()
return (
<>
<TableHeaderRowContainer userCanScroll={userCanScroll}>{headerRow}</TableHeaderRowContainer>
<TableRowsContainer>
{scrollProgress > 0 && <Scrim />}
<TableRowScrollableContainer ref={scrollRef} onScroll={scrollHandler}>
{contentRows.map((row, index) => (
<TableRowContainer key={type + '_row_' + index}>{row}</TableRowContainer>
))}
</TableRowScrollableContainer>
{userCanScroll && scrollProgress !== 100 && <Scrim isBottom={true} />}
</TableRowsContainer>
</>
)
}

@ -0,0 +1,156 @@
import { Trans } from '@lingui/macro'
import { formatCurrencyAmount, NumberType } from '@uniswap/conedison/format'
import { useWeb3React } from '@web3-react/core'
import { OpacityHoverState } from 'components/Common'
import Row from 'components/Row'
import { OrderType } from 'graphql/data/__generated__/types-and-hooks'
import { useScreenSize } from 'hooks/useScreenSize'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
import { HomeSearchIcon } from 'nft/components/icons'
import { Offer, SellOrder } from 'nft/types'
import { formatEth, getMarketplaceIcon, timeUntil } from 'nft/utils'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ExternalLink, ThemedText } from 'theme'
import { shortenAddress } from 'utils'
import { TableTabsKeys } from './DataPageTable'
const TableCell = styled.div<{ $flex?: number; $justifyContent?: string; $color?: string; hideOnSmall?: boolean }>`
display: flex;
flex: ${({ $flex }) => $flex ?? 1};
justify-content: ${({ $justifyContent }) => $justifyContent};
color: ${({ $color }) => $color};
flex-shrink: 0;
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
display: ${({ hideOnSmall }) => (hideOnSmall ? 'none' : 'flex')};
}
`
const ActionButton = styled.div`
cursor: pointer;
white-space: nowrap;
${OpacityHoverState}
`
const USDPrice = styled(ThemedText.BodySmall)`
color: ${({ theme }) => theme.textSecondary};
line-height: 20px;
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
display: none;
}
@media screen and (min-width: ${BREAKPOINTS.lg}px) and (max-width: ${BREAKPOINTS.xl - 1}px) {
display: none;
}
`
const Link = styled(ExternalLink)`
height: 20px;
`
const PriceCell = ({ price }: { price: number }) => {
const { chainId } = useWeb3React()
const nativeCurrency = useNativeCurrency(chainId)
const parsedAmount = tryParseCurrencyAmount(price.toString(), nativeCurrency)
const usdValue = useStablecoinValue(parsedAmount)
return (
<Row gap="8px">
<ThemedText.LabelSmall color="textPrimary" lineHeight="16px">
{formatEth(price)}
</ThemedText.LabelSmall>
<USDPrice>{formatCurrencyAmount(usdValue, NumberType.FiatTokenPrice)}</USDPrice>
</Row>
)
}
export const HeaderRow = ({ type, is1155 }: { type: TableTabsKeys; is1155?: boolean }) => {
const screenSize = useScreenSize()
const isMobile = !screenSize['sm']
const isLargeScreen = screenSize['lg'] && !screenSize['xl']
const reducedPriceWidth = isMobile || isLargeScreen
return (
<Row gap="12px" padding="6px 6px 6px 0px">
<HomeSearchIcon />
<TableCell $flex={reducedPriceWidth ? 1 : 1.75}>
<ThemedText.SubHeaderSmall color="textSecondary">
<Trans>Price</Trans>
</ThemedText.SubHeaderSmall>
</TableCell>
{is1155 && (
<TableCell $flex={0.5}>
<ThemedText.SubHeaderSmall color="textSecondary">
<Trans>Quantity</Trans>
</ThemedText.SubHeaderSmall>
</TableCell>
)}
{(type === TableTabsKeys.Offers || is1155) && (
<TableCell hideOnSmall={true}>
<ThemedText.SubHeaderSmall color="textSecondary">
{type === TableTabsKeys.Offers ? <Trans>From</Trans> : <Trans>Seller</Trans>}
</ThemedText.SubHeaderSmall>
</TableCell>
)}
<TableCell $justifyContent="flex-end">
<ThemedText.SubHeaderSmall color="textSecondary">
<Trans>Expires in</Trans>
</ThemedText.SubHeaderSmall>
</TableCell>
{/* An empty cell is needed in the headers for proper vertical alignment with the action buttons */}
<TableCell $flex={isMobile ? 0.25 : 1}>&nbsp;</TableCell>
</Row>
)
}
export const ContentRow = ({
content,
buttonCTA,
is1155,
}: {
content: Offer | SellOrder
buttonCTA: React.ReactNode
is1155?: boolean
}) => {
const screenSize = useScreenSize()
const isMobile = !screenSize['sm']
const date = content.endAt && new Date(content.endAt)
const isSellOrder = 'type' in content && content.type === OrderType.Listing
const reducedPriceWidth = isMobile || (screenSize['lg'] && !screenSize['xl'])
return (
<Row gap="12px" padding="16px 6px 16px 0px">
<Link href={content.marketplaceUrl}>{getMarketplaceIcon(content.marketplace, '20')}</Link>
{content.price && (
<TableCell $flex={reducedPriceWidth ? 1 : 1.75}>
<PriceCell price={content.price.value} />
</TableCell>
)}
{is1155 && (
<TableCell $flex={0.5} $justifyContent="center">
<ThemedText.SubHeaderSmall color="textPrimary">{content.quantity}</ThemedText.SubHeaderSmall>
</TableCell>
)}
{(!isSellOrder || is1155) && (
<TableCell hideOnSmall={true}>
<Link href={`https://etherscan.io/address/${content.maker}`}>
<ThemedText.LabelSmall color="textPrimary">{shortenAddress(content.maker)}</ThemedText.LabelSmall>
</Link>
</TableCell>
)}
<TableCell $justifyContent="flex-end">
<ThemedText.LabelSmall color="textPrimary">
{date ? timeUntil(date) : <Trans>Never</Trans>}
</ThemedText.LabelSmall>
</TableCell>
<TableCell $flex={isMobile ? 0.25 : 1} $justifyContent="center">
<ActionButton>
<ThemedText.LabelSmall color="textSecondary">{buttonCTA}</ThemedText.LabelSmall>
</ActionButton>
</TableCell>
</Row>
)
}

@ -142,6 +142,7 @@ exports[`placeholder containers load 1`] = `
.c18 { .c18 {
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #D2D9EE;
border-radius: 16px; border-radius: 16px;
padding: 16px 20px; padding: 16px 20px;
width: 100%; width: 100%;
@ -287,8 +288,7 @@ exports[`placeholder containers load 1`] = `
} }
.c1 { .c1 {
padding: 24px 64px; height: 100%;
height: 100vh;
width: 100%; width: 100%;
gap: 36px; gap: 36px;
max-width: 1200px; max-width: 1200px;
@ -320,18 +320,6 @@ exports[`placeholder containers load 1`] = `
} }
} }
@media screen and (max-width:640px) {
.c1 {
padding: 24px 48px;
}
}
@media screen and (max-width:396px) {
.c1 {
padding: 24px 20px;
}
}
@media screen and (max-width:1024px) { @media screen and (max-width:1024px) {
.c16 { .c16 {
-webkit-flex-wrap: wrap; -webkit-flex-wrap: wrap;
@ -469,11 +457,6 @@ exports[`placeholder containers load 1`] = `
class="c2 c21" class="c2 c21"
> >
Listings Listings
<div
class="c24 css-f8aq60"
>
10+
</div>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

@ -67,6 +67,7 @@ exports[`data page trait component does not load with asset with no traits 1`] =
.c0 { .c0 {
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #D2D9EE;
border-radius: 16px; border-radius: 16px;
padding: 16px 20px; padding: 16px 20px;
width: 100%; width: 100%;

@ -1,7 +1,9 @@
import styled, { css } from 'styled-components/macro' import styled, { css } from 'styled-components/macro'
import { opacify } from 'theme/utils'
export const containerStyles = css` export const containerStyles = css`
background: ${({ theme }) => theme.backgroundSurface}; background: ${({ theme }) => theme.backgroundSurface};
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px; border-radius: 16px;
padding: 16px 20px; padding: 16px 20px;
width: 100%; width: 100%;
@ -11,3 +13,24 @@ export const containerStyles = css`
export const TableContentContainer = styled.div` export const TableContentContainer = styled.div`
height: 568px; height: 568px;
` `
// Scrim that fades out the top and bottom of the scrollable container, isBottom changes the direction and placement of the fade
export const Scrim = styled.div<{ isBottom?: boolean }>`
position: absolute;
pointer-events: none;
height: 88px;
left: 0px;
right: 6px;
${({ isBottom }) =>
isBottom
? 'bottom: 0px'
: `
top: 0px;
transform: matrix(1, 0, 0, -1, 0, 0);
`};
background: ${({ theme }) =>
`linear-gradient(180deg, ${opacify(0, theme.backgroundSurface)} 0%, ${theme.backgroundSurface} 100%)`};
display: flex;
`

@ -1351,6 +1351,35 @@ export const UniswapMagentaIcon = (props: SVGProps) => (
</svg> </svg>
) )
export const HomeSearchIcon = (props: SVGProps) => (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M17.898 7.57097L11.7212 2.49102C10.7237 1.67268 9.27795 1.67185 8.28045 2.49102L2.10379 7.57016C1.83796 7.78932 1.79877 8.18268 2.01794 8.45018C2.2371 8.71768 2.63213 8.75437 2.89796 8.53604L3.54209 8.00605V15.0002C3.54209 17.0152 4.65209 18.1252 6.66709 18.1252H13.3338C15.3488 18.1252 16.4588 17.0152 16.4588 15.0002V8.00605L17.1029 8.53604C17.2195 8.63187 17.3604 8.67845 17.5004 8.67845C17.6804 8.67845 17.8596 8.601 17.9829 8.451C18.2029 8.1835 18.1638 7.79014 17.898 7.57097ZM15.2088 15.0002C15.2088 16.3143 14.6479 16.8752 13.3338 16.8752H6.66709C5.35292 16.8752 4.79209 16.3143 4.79209 15.0002V6.97852L9.07462 3.45771C9.61045 3.01688 10.3913 3.01688 10.9271 3.45771L15.2096 6.97934V15.0002H15.2088ZM6.45875 10.7643C6.45875 12.4493 7.82958 13.8202 9.51458 13.8202C10.1312 13.8202 10.7038 13.6335 11.1838 13.3176L12.4746 14.6085C12.5962 14.7302 12.7563 14.7918 12.9163 14.7918C13.0763 14.7918 13.2363 14.731 13.358 14.6085C13.6021 14.3644 13.6021 13.9685 13.358 13.7243L12.0663 12.4326C12.3813 11.9518 12.568 11.3794 12.568 10.7627C12.568 9.07854 11.1971 7.70688 9.51295 7.70688C7.82962 7.70854 6.45875 9.07933 6.45875 10.7643ZM11.3196 10.7643C11.3196 11.7602 10.5096 12.5702 9.51458 12.5702C8.51875 12.5702 7.70875 11.7602 7.70875 10.7643C7.70875 9.7685 8.51875 8.9585 9.51458 8.9585C10.5096 8.9585 11.3196 9.7685 11.3196 10.7643Z"
fill="#7780A0"
/>
</svg>
)
export const AddToBagIcon = (props: SVGProps) => (
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8.51389 18.25H5.44444C4.6467 18.25 4 17.653 4 16.9167V7.58333C4 6.84695 4.6467 6.25 5.44444 6.25H14.5556C15.3533 6.25 16 6.84695 16 7.58333V10.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 6.25L7 5.45C7 4.60131 7.31607 3.78737 7.87868 3.18726C8.44129 2.58714 9.20435 2.25 10 2.25C10.7956 2.25 11.5587 2.58714 12.1213 3.18726C12.6839 3.78737 13 4.60131 13 5.45L13 6.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M11 15.25H17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 12.25L14 18.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
export const HandHoldingDollarIcon = (props: SVGProps) => ( export const HandHoldingDollarIcon = (props: SVGProps) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path <path

@ -1,4 +1,4 @@
import { MediaType, NftStandard } from 'graphql/data/__generated__/types-and-hooks' import { MediaType, NftMarketplace, NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { SortBy } from 'nft/hooks' import { SortBy } from 'nft/hooks'
import { SellOrder } from '../sell' import { SellOrder } from '../sell'
@ -79,6 +79,21 @@ export interface Trait {
trait_count?: number trait_count?: number
order?: any order?: any
} }
export interface Offer {
createdAt: number
endAt?: number
id: string
maker: string
marketplace: NftMarketplace
marketplaceUrl: string
price: {
currency?: string
value: number
}
quantity?: number
}
export interface GenieAsset { export interface GenieAsset {
id?: string // This would be a random id created and assigned by front end id?: string // This would be a random id created and assigned by front end
address: string address: string

@ -13,6 +13,6 @@ export * from './numbers'
export * from './pooledAssets' export * from './pooledAssets'
export * from './putCommas' export * from './putCommas'
export * from './roundAndPluralize' export * from './roundAndPluralize'
export * from './timeSince' export * from './time'
export * from './transactionResponse' export * from './transactionResponse'
export * from './updatedAssets' export * from './updatedAssets'

@ -0,0 +1,58 @@
import { i18n } from '@lingui/core'
import { DEFAULT_LOCALE } from 'constants/locales'
import catalog from 'locales/en-US'
import { en } from 'make-plural'
import { timeUntil } from './time'
describe('timeUntil', () => {
const originalDate = new Date('2023-06-01T00:00:00.000Z')
i18n.load({
[DEFAULT_LOCALE]: catalog.messages,
})
i18n.loadLocaleData({
[DEFAULT_LOCALE]: { plurals: en },
})
i18n.activate(DEFAULT_LOCALE)
test('returns undefined when date is in the past', () => {
const pastDate = new Date('2022-01-01T00:00:00.000Z')
expect(timeUntil(pastDate, originalDate)).toBeUndefined()
})
test('returns the correct time until in months', () => {
const futureDate = new Date('2023-09-01T00:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('3 months')
})
test('returns the correct time until in weeks', () => {
const futureDate = new Date('2023-06-20T00:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('2 weeks')
})
test('returns the correct time until in days', () => {
const futureDate = new Date('2023-06-03T12:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('2 days')
})
test('returns the correct time untwil in hours', () => {
const futureDate = new Date('2023-06-01T05:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('5 hours')
})
test('returns the correct time until in minutes', () => {
const futureDate = new Date('2023-06-01T00:05:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('5 minutes')
})
test('returns the correct time until in seconds', () => {
const futureDate = new Date('2023-06-01T00:00:05.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('5 seconds')
})
test('returns 99+ months for large intervals', () => {
const futureDate = new Date('2123-01-01T00:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('99+ months')
})
})

98
src/nft/utils/time.ts Normal file

@ -0,0 +1,98 @@
import { plural, t } from '@lingui/macro'
import ms from 'ms.macro'
import { roundAndPluralize } from './roundAndPluralize'
const SECOND = ms`1s`
const MINUTE = ms`1m`
const HOUR = ms`1h`
const DAY = ms`1d`
const WEEK = ms`7d`
const MONTH = ms`30d`
interface TimePeriod {
milliseconds: number
pluralLabel: (i: number) => string
}
const timePeriods: TimePeriod[] = [
{
milliseconds: MONTH,
pluralLabel: (i: number) =>
plural(i, {
one: 'month',
other: 'months',
}),
},
{
milliseconds: WEEK,
pluralLabel: (i: number) =>
plural(i, {
one: 'week',
other: 'weeks',
}),
},
{
milliseconds: DAY,
pluralLabel: (i: number) =>
plural(i, {
one: 'day',
other: 'days',
}),
},
{
milliseconds: HOUR,
pluralLabel: (i: number) =>
plural(i, {
one: 'hour',
other: 'hours',
}),
},
{
milliseconds: MINUTE,
pluralLabel: (i: number) =>
plural(i, {
one: 'minute',
other: 'minutes',
}),
},
{
milliseconds: SECOND,
pluralLabel: (i: number) =>
plural(i, {
one: 'second',
other: 'seconds',
}),
},
]
export function timeUntil(date: Date, originalDate?: Date): string | undefined {
const referenceDate = originalDate ?? new Date()
const milliseconds = date.getTime() - referenceDate.getTime()
if (milliseconds < 0) return undefined
const monthInterval = milliseconds / MONTH
if (monthInterval >= 100) return `99+ ${t`months`}`
for (const period of timePeriods) {
const interval = milliseconds / period.milliseconds
if (interval >= 1) {
return `${Math.floor(interval)} ${period.pluralLabel(interval)}`
}
}
return undefined
}
export const timeLeft = (targetDate: Date): string => {
const countDown = new Date(targetDate).getTime() - new Date().getTime()
const days = Math.floor(countDown / DAY)
const hours = Math.floor((countDown % DAY) / HOUR)
const minutes = Math.floor((countDown % HOUR) / MINUTE)
return `${days !== 0 ? roundAndPluralize(days, 'day') : ''} ${
hours !== 0 ? roundAndPluralize(hours, 'hour') : ''
} ${roundAndPluralize(minutes, 'minute')}`
}

@ -1,39 +0,0 @@
import { roundAndPluralize } from './roundAndPluralize'
export function timeSince(date: Date, min?: boolean) {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
let interval = seconds / 31536000
if (interval > 1) return roundAndPluralize(interval, min ? 'yr' : 'year')
interval = seconds / 2592000
if (interval > 1) return roundAndPluralize(interval, min ? 'mth' : 'month')
interval = seconds / 86400
if (interval > 1) return roundAndPluralize(interval, 'day')
interval = seconds / 3600
if (interval > 1) return roundAndPluralize(interval, min ? 'hr' : 'hour')
interval = seconds / 60
if (interval > 1) return roundAndPluralize(interval, 'min')
return roundAndPluralize(interval, 'sec')
}
const MINUTE = 1000 * 60
const HOUR = MINUTE * 60
const DAY = 24 * HOUR
export const timeLeft = (targetDate: Date): string => {
const countDown = new Date(targetDate).getTime() - new Date().getTime()
const days = Math.floor(countDown / DAY)
const hours = Math.floor((countDown % DAY) / HOUR)
const minutes = Math.floor((countDown % HOUR) / MINUTE)
return `${days !== 0 ? roundAndPluralize(days, 'day') : ''} ${
hours !== 0 ? roundAndPluralize(hours, 'hour') : ''
} ${roundAndPluralize(minutes, 'minute')}`
}

@ -6,7 +6,7 @@ import {
OrderStatus, OrderStatus,
OrderType, OrderType,
} from 'graphql/data/__generated__/types-and-hooks' } from 'graphql/data/__generated__/types-and-hooks'
import { ActivityEvent, CollectionInfoForAsset, GenieAsset, Markets, SellOrder, WalletAsset } from 'nft/types' import { ActivityEvent, CollectionInfoForAsset, GenieAsset, Markets, Offer, SellOrder, WalletAsset } from 'nft/types'
export const TEST_NFT_ASSET: GenieAsset = { export const TEST_NFT_ASSET: GenieAsset = {
id: 'TmZ0QXNzZXQ6MHhlZDVhZjM4ODY1MzU2N2FmMmYzODhlNjIyNGRjN2M0YjMyNDFjNTQ0XzMzMTg=', id: 'TmZ0QXNzZXQ6MHhlZDVhZjM4ODY1MzU2N2FmMmYzODhlNjIyNGRjN2M0YjMyNDFjNTQ0XzMzMTg=',
@ -238,3 +238,17 @@ export const TEST_SELL_ORDER: SellOrder = {
type: OrderType.Listing, type: OrderType.Listing,
protocolParameters: {}, protocolParameters: {},
} }
export const TEST_OFFER: Offer = {
createdAt: 1683561510000,
endAt: 1699528045000,
id: 'TmZ0T3JkZXI6MHgyOWQ3ZWJjYTY1NjY2NWMxYTUyYTkyZjgzMGU0MTNlMzk0ZGI2YjRmXzY4MTVfMHg3OWVhNDQ5YzMzNzVlZDFhOWQ3ZDk5ZjgwNjgyMDllYTc0OGM2ZDQyXzQ5NzAwMDAwMDAwMDAwMDAwMDAwMF9vcGVuc2VhX01vbiBNYXkgMDggMjAyMyAxNTo1ODozMCBHTVQrMDAwMCAoQ29vcmRpbmF0ZWQgVW5pdmVyc2FsIFRpbWUp',
maker: '0x79ea449c3375ed1a9d7d99f8068209ea748c6d42',
marketplace: NftMarketplace.Opensea,
marketplaceUrl: 'https://opensea.io/assets/0x29d7ebca656665c1a52a92f830e413e394db6b4f/6815',
price: {
currency: 'ETH',
value: 123.456,
},
quantity: 1,
}