From 42e3af7b5c386de17ba43fff6510492d320d5da7 Mon Sep 17 00:00:00 2001 From: Charles Bachmeier Date: Mon, 15 May 2023 12:14:09 -0400 Subject: [PATCH] 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 --- src/graphql/data/nft/Details.ts | 2 +- .../components/details/detailsV2/DataPage.tsx | 19 +- .../details/detailsV2/DataPageTable.test.tsx | 23 + .../details/detailsV2/DataPageTable.tsx | 67 +- .../details/detailsV2/DataPageTraits.tsx | 22 +- .../detailsV2/ListingsTableContent.tsx | 26 +- .../details/detailsV2/NftDetails.tsx | 1 + .../details/detailsV2/OffersTableContent.tsx | 29 +- .../detailsV2/TableContentComponent.tsx | 55 + .../details/detailsV2/TableRowComponent.tsx | 156 ++ .../__snapshots__/DataPage.test.tsx.snap | 21 +- .../__snapshots__/DataPageTable.test.tsx.snap | 2318 +++++++++++++++++ .../DataPageTraits.test.tsx.snap | 1 + .../components/details/detailsV2/shared.ts | 23 + src/nft/components/icons.tsx | 29 + src/nft/types/common/common.ts | 17 +- src/nft/utils/index.ts | 2 +- src/nft/utils/time.test.ts | 58 + src/nft/utils/time.ts | 98 + src/nft/utils/timeSince.ts | 39 - src/test-utils/nft/fixtures.ts | 16 +- 21 files changed, 2897 insertions(+), 125 deletions(-) create mode 100644 src/nft/components/details/detailsV2/DataPageTable.test.tsx create mode 100644 src/nft/components/details/detailsV2/TableContentComponent.tsx create mode 100644 src/nft/components/details/detailsV2/TableRowComponent.tsx create mode 100644 src/nft/components/details/detailsV2/__snapshots__/DataPageTable.test.tsx.snap create mode 100644 src/nft/utils/time.test.ts create mode 100644 src/nft/utils/time.ts delete mode 100644 src/nft/utils/timeSince.ts diff --git a/src/graphql/data/nft/Details.ts b/src/graphql/data/nft/Details.ts index f34e8c198b..a1833efb37 100644 --- a/src/graphql/data/nft/Details.ts +++ b/src/graphql/data/nft/Details.ts @@ -49,7 +49,7 @@ gql` } description } - listings(first: 1) { + listings(first: 25) { edges { node { address diff --git a/src/nft/components/details/detailsV2/DataPage.tsx b/src/nft/components/details/detailsV2/DataPage.tsx index c89318f2ec..24b9783499 100644 --- a/src/nft/components/details/detailsV2/DataPage.tsx +++ b/src/nft/components/details/detailsV2/DataPage.tsx @@ -9,13 +9,14 @@ import { DataPageHeader } from './DataPageHeader' import { DataPageTable } from './DataPageTable' import { DataPageTraits } from './DataPageTraits' -const DataPageContainer = styled(Column)` +const DataPagePaddingContainer = styled.div` padding: 24px 64px; height: 100vh; width: 100%; - gap: 36px; - max-width: ${({ theme }) => theme.maxWidth}; - margin: 0 auto; + + @media screen and (max-width: ${BREAKPOINTS.md}px) { + height: 100%; + } @media screen and (max-width: ${BREAKPOINTS.sm}px) { 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)` gap: 24px; padding-bottom: 45px; @@ -50,7 +59,7 @@ export const DataPage = ({ asset }: { asset: GenieAsset }) => { {!!asset.traits?.length && } - + ) diff --git a/src/nft/components/details/detailsV2/DataPageTable.test.tsx b/src/nft/components/details/detailsV2/DataPageTable.test.tsx new file mode 100644 index 0000000000..c9fc63996c --- /dev/null +++ b/src/nft/components/details/detailsV2/DataPageTable.test.tsx @@ -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() + 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() + expect(asFragment()).toMatchSnapshot() +}) diff --git a/src/nft/components/details/detailsV2/DataPageTable.tsx b/src/nft/components/details/detailsV2/DataPageTable.tsx index 3592e1014d..5c4637d8ff 100644 --- a/src/nft/components/details/detailsV2/DataPageTable.tsx +++ b/src/nft/components/details/detailsV2/DataPageTable.tsx @@ -1,45 +1,50 @@ import { Trans } from '@lingui/macro' +import { GenieAsset } from 'nft/types' +import { useMemo } from 'react' import { ActivityTableContent } from './ActivityTableContent' import { ListingsTableContent } from './ListingsTableContent' import { OffersTableContent } from './OffersTableContent' import { Tab, TabbedComponent } from './TabbedComponent' -enum TableTabsKeys { +export enum TableTabsKeys { Activity = 'activity', Offers = 'offers', Listings = 'listings', } -const TableTabs: Map = new Map([ - [ - TableTabsKeys.Activity, - { - title: Activity, - key: TableTabsKeys.Activity, - content: , - }, - ], - [ - TableTabsKeys.Offers, - { - title: Offers, - key: TableTabsKeys.Offers, - content: , - count: 11, // TODO Replace Placeholder with real data - }, - ], - [ - TableTabsKeys.Listings, - { - title: Listings, - key: TableTabsKeys.Listings, - content: , - count: 11, // TODO Replace Placeholder with real data - }, - ], -]) - -export const DataPageTable = () => { +export const DataPageTable = ({ asset }: { asset: GenieAsset }) => { + const TableTabs: Map = useMemo( + () => + new Map([ + [ + TableTabsKeys.Activity, + { + title: Activity, + key: TableTabsKeys.Activity, + content: , + }, + ], + [ + TableTabsKeys.Offers, + { + title: Offers, + key: TableTabsKeys.Offers, + content: , + count: 11, // TODO Replace Placeholder with real data + }, + ], + [ + TableTabsKeys.Listings, + { + title: Listings, + key: TableTabsKeys.Listings, + content: , + count: asset.sellorders?.length, + }, + ], + ]), + [asset] + ) return } diff --git a/src/nft/components/details/detailsV2/DataPageTraits.tsx b/src/nft/components/details/detailsV2/DataPageTraits.tsx index 80701b094c..f24ae549c7 100644 --- a/src/nft/components/details/detailsV2/DataPageTraits.tsx +++ b/src/nft/components/details/detailsV2/DataPageTraits.tsx @@ -7,8 +7,8 @@ import { GenieAsset } from 'nft/types' import { useMemo } from 'react' import styled from 'styled-components/macro' import { BREAKPOINTS, ThemedText } from 'theme' -import { opacify } from 'theme/utils' +import { Scrim } from './shared' import { Tab, TabbedComponent } from './TabbedComponent' import { TraitRow } from './TraitRow' @@ -45,26 +45,6 @@ const TraitRowScrollableContainer = styled.div` ${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 { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState() diff --git a/src/nft/components/details/detailsV2/ListingsTableContent.tsx b/src/nft/components/details/detailsV2/ListingsTableContent.tsx index 374b57eb95..ced9994146 100644 --- a/src/nft/components/details/detailsV2/ListingsTableContent.tsx +++ b/src/nft/components/details/detailsV2/ListingsTableContent.tsx @@ -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 = () => { - return Listings Content +import { TableTabsKeys } from './DataPageTable' +import { TableContentComponent } from './TableContentComponent' +import { ContentRow, HeaderRow } from './TableRowComponent' + +export const ListingsTableContent = ({ asset }: { asset: GenieAsset }) => { + const isMobile = useIsMobile() + const theme = useTheme() + const headers = + const contentRows = (asset.sellorders || []).map((offer, index) => ( + : Add to Bag} + is1155={asset.tokenType === NftStandard.Erc1155} + /> + )) + return } diff --git a/src/nft/components/details/detailsV2/NftDetails.tsx b/src/nft/components/details/detailsV2/NftDetails.tsx index 8f56c1c94d..93f3756af7 100644 --- a/src/nft/components/details/detailsV2/NftDetails.tsx +++ b/src/nft/components/details/detailsV2/NftDetails.tsx @@ -23,6 +23,7 @@ const DetailsBackground = styled.div<{ backgroundImage: string }>` const DetailsContentContainer = styled.div` z-index: ${Z_INDEX.hover}; + width: 100%; ` export const NftDetails = ({ asset, collection }: NftDetailsProps) => { diff --git a/src/nft/components/details/detailsV2/OffersTableContent.tsx b/src/nft/components/details/detailsV2/OffersTableContent.tsx index 14c302d75b..1fc90508ec 100644 --- a/src/nft/components/details/detailsV2/OffersTableContent.tsx +++ b/src/nft/components/details/detailsV2/OffersTableContent.tsx @@ -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 = () => { - return Offers Content +import { TableTabsKeys } from './DataPageTable' +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 = + const contentRows = mockOffers.map((offer, index) => ( + : Accept} + is1155={asset.tokenType === NftStandard.Erc1155} + /> + )) + return } diff --git a/src/nft/components/details/detailsV2/TableContentComponent.tsx b/src/nft/components/details/detailsV2/TableContentComponent.tsx new file mode 100644 index 0000000000..79a713af51 --- /dev/null +++ b/src/nft/components/details/detailsV2/TableContentComponent.tsx @@ -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 ( + <> + {headerRow} + + {scrollProgress > 0 && } + + {contentRows.map((row, index) => ( + {row} + ))} + + {userCanScroll && scrollProgress !== 100 && } + + + ) +} diff --git a/src/nft/components/details/detailsV2/TableRowComponent.tsx b/src/nft/components/details/detailsV2/TableRowComponent.tsx new file mode 100644 index 0000000000..6f3939b0d6 --- /dev/null +++ b/src/nft/components/details/detailsV2/TableRowComponent.tsx @@ -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 ( + + + {formatEth(price)} + + {formatCurrencyAmount(usdValue, NumberType.FiatTokenPrice)} + + ) +} + +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 ( + + + + + Price + + + {is1155 && ( + + + Quantity + + + )} + {(type === TableTabsKeys.Offers || is1155) && ( + + + {type === TableTabsKeys.Offers ? From : Seller} + + + )} + + + Expires in + + + {/* An empty cell is needed in the headers for proper vertical alignment with the action buttons */} +   + + ) +} + +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 ( + + {getMarketplaceIcon(content.marketplace, '20')} + {content.price && ( + + + + )} + {is1155 && ( + + {content.quantity} + + )} + {(!isSellOrder || is1155) && ( + + + {shortenAddress(content.maker)} + + + )} + + + {date ? timeUntil(date) : Never} + + + + + {buttonCTA} + + + + ) +} diff --git a/src/nft/components/details/detailsV2/__snapshots__/DataPage.test.tsx.snap b/src/nft/components/details/detailsV2/__snapshots__/DataPage.test.tsx.snap index a959f678aa..052e89b8cd 100644 --- a/src/nft/components/details/detailsV2/__snapshots__/DataPage.test.tsx.snap +++ b/src/nft/components/details/detailsV2/__snapshots__/DataPage.test.tsx.snap @@ -142,6 +142,7 @@ exports[`placeholder containers load 1`] = ` .c18 { background: #FFFFFF; + border: 1px solid #D2D9EE; border-radius: 16px; padding: 16px 20px; width: 100%; @@ -287,8 +288,7 @@ exports[`placeholder containers load 1`] = ` } .c1 { - padding: 24px 64px; - height: 100vh; + height: 100%; width: 100%; gap: 36px; 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) { .c16 { -webkit-flex-wrap: wrap; @@ -469,11 +457,6 @@ exports[`placeholder containers load 1`] = ` class="c2 c21" > Listings -
- 10+ -
diff --git a/src/nft/components/details/detailsV2/__snapshots__/DataPageTable.test.tsx.snap b/src/nft/components/details/detailsV2/__snapshots__/DataPageTable.test.tsx.snap new file mode 100644 index 0000000000..19e8986ce1 --- /dev/null +++ b/src/nft/components/details/detailsV2/__snapshots__/DataPageTable.test.tsx.snap @@ -0,0 +1,2318 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`data page listings table content loads with a given asset 1`] = ` + + .c1 { + box-sizing: border-box; + margin: 0; + min-width: 0; + padding: 6px 6px 6px 0px; +} + +.c2 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + padding: 6px 6px 6px 0px; + gap: 12px; +} + +.c4 { + color: #7780A0; +} + +.c0 { + margin-right: 0; +} + +.c3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c5 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +@media screen and (max-width:640px) { + .c3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c5 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + +} + +@media screen and (max-width:640px) { + +} + +@media screen and (min-width:1024px) and (max-width:1279px) { + +} + +
+
+ + + +
+
+ Price +
+
+
+
+ Expires in +
+
+
+   +
+
+
+ .c3 { + box-sizing: border-box; + margin: 0; + min-width: 0; + padding: 16px 6px 16px 0px; +} + +.c8 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c4 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + padding: 16px 6px 16px 0px; + gap: 12px; +} + +.c9 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + +.c15 { + color: #7780A0; +} + +.c10 { + color: #0D111C; +} + +.c5 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FB118E; + stroke: #FB118E; + font-weight: 500; +} + +.c5:hover { + opacity: 0.6; +} + +.c5:active { + opacity: 0.4; +} + +.c0 { + position: relative; +} + +.c1 { + overflow-y: auto; + overflow-x: hidden; + max-height: 264px; + -webkit-scrollbar-width: thin; + -moz-scrollbar-width: thin; + -ms-scrollbar-width: thin; + scrollbar-width: thin; + -webkit-scrollbar-color: #D2D9EE transparent; + -moz-scrollbar-color: #D2D9EE transparent; + -ms-scrollbar-color: #D2D9EE transparent; + scrollbar-color: #D2D9EE transparent; + height: 100%; +} + +.c1::-webkit-scrollbar { + background: transparent; + width: 4px; + overflow-y: scroll; +} + +.c1::-webkit-scrollbar-thumb { + background: #D2D9EE; + border-radius: 8px; +} + +.c2 { + border-bottom: 1px solid #D2D9EE; +} + +.c2:last-child { + border-bottom: none; +} + +.c7 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c13 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c14 { + cursor: pointer; + white-space: nowrap; + -webkit-transition: opacity 250ms ease; + transition: opacity 250ms ease; +} + +.c14:hover { + opacity: 0.6; +} + +.c14:active { + opacity: 0.4; +} + +.c11 { + color: #7780A0; + line-height: 20px; +} + +.c6 { + height: 20px; +} + +@media screen and (max-width:640px) { + .c7 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c13 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c11 { + display: none; + } +} + +@media screen and (min-width:1024px) and (max-width:1279px) { + .c11 { + display: none; + } +} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 100M +
+
+ - +
+
+
+
+
+ 5 months +
+
+
+
+
+ Add to Bag +
+
+
+
+
+
+
+
+`; + +exports[`data page offers table content loads with a given asset 1`] = ` + + .c1 { + box-sizing: border-box; + margin: 0; + min-width: 0; + padding: 6px 6px 6px 0px; +} + +.c2 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + padding: 6px 6px 6px 0px; + gap: 12px; +} + +.c4 { + color: #7780A0; +} + +.c0 { + margin-right: 0; +} + +.c3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c5 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +@media screen and (max-width:640px) { + .c3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c5 { + display: none; + } +} + +@media screen and (max-width:640px) { + .c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + +} + +@media screen and (max-width:640px) { + +} + +@media screen and (min-width:1024px) and (max-width:1279px) { + +} + +
+
+ + + +
+
+ Price +
+
+
+
+ From +
+
+
+
+ Expires in +
+
+
+   +
+
+
+ .c3 { + box-sizing: border-box; + margin: 0; + min-width: 0; + padding: 16px 6px 16px 0px; +} + +.c8 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c4 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + padding: 16px 6px 16px 0px; + gap: 12px; +} + +.c9 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + +.c16 { + color: #7780A0; +} + +.c10 { + color: #0D111C; +} + +.c5 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FB118E; + stroke: #FB118E; + font-weight: 500; +} + +.c5:hover { + opacity: 0.6; +} + +.c5:active { + opacity: 0.4; +} + +.c0 { + position: relative; +} + +.c1 { + overflow-y: auto; + overflow-x: hidden; + max-height: 264px; + -webkit-scrollbar-width: thin; + -moz-scrollbar-width: thin; + -ms-scrollbar-width: thin; + scrollbar-width: thin; + -webkit-scrollbar-color: #D2D9EE transparent; + -moz-scrollbar-color: #D2D9EE transparent; + -ms-scrollbar-color: #D2D9EE transparent; + scrollbar-color: #D2D9EE transparent; + height: 100%; +} + +.c1::-webkit-scrollbar { + background: transparent; + width: 4px; + overflow-y: scroll; +} + +.c1::-webkit-scrollbar-thumb { + background: #D2D9EE; + border-radius: 8px; +} + +.c2 { + border-bottom: 1px solid #D2D9EE; +} + +.c2:last-child { + border-bottom: none; +} + +.c7 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c13 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c14 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c15 { + cursor: pointer; + white-space: nowrap; + -webkit-transition: opacity 250ms ease; + transition: opacity 250ms ease; +} + +.c15:hover { + opacity: 0.6; +} + +.c15:active { + opacity: 0.4; +} + +.c11 { + color: #7780A0; + line-height: 20px; +} + +.c6 { + height: 20px; +} + +@media screen and (max-width:640px) { + .c7 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c12 { + display: none; + } +} + +@media screen and (max-width:640px) { + .c13 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c14 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + } +} + +@media screen and (max-width:640px) { + .c11 { + display: none; + } +} + +@media screen and (min-width:1024px) and (max-width:1279px) { + .c11 { + display: none; + } +} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ 123.456 +
+
+ - +
+
+
+ +
+
+ 5 months +
+
+
+
+
+ Accept +
+
+
+
+
+
+
+
+`; diff --git a/src/nft/components/details/detailsV2/__snapshots__/DataPageTraits.test.tsx.snap b/src/nft/components/details/detailsV2/__snapshots__/DataPageTraits.test.tsx.snap index a416764c69..8d4a93d749 100644 --- a/src/nft/components/details/detailsV2/__snapshots__/DataPageTraits.test.tsx.snap +++ b/src/nft/components/details/detailsV2/__snapshots__/DataPageTraits.test.tsx.snap @@ -67,6 +67,7 @@ exports[`data page trait component does not load with asset with no traits 1`] = .c0 { background: #FFFFFF; + border: 1px solid #D2D9EE; border-radius: 16px; padding: 16px 20px; width: 100%; diff --git a/src/nft/components/details/detailsV2/shared.ts b/src/nft/components/details/detailsV2/shared.ts index 8ff2e797a1..c84791fb50 100644 --- a/src/nft/components/details/detailsV2/shared.ts +++ b/src/nft/components/details/detailsV2/shared.ts @@ -1,7 +1,9 @@ import styled, { css } from 'styled-components/macro' +import { opacify } from 'theme/utils' export const containerStyles = css` background: ${({ theme }) => theme.backgroundSurface}; + border: 1px solid ${({ theme }) => theme.backgroundOutline}; border-radius: 16px; padding: 16px 20px; width: 100%; @@ -11,3 +13,24 @@ export const containerStyles = css` export const TableContentContainer = styled.div` 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; +` diff --git a/src/nft/components/icons.tsx b/src/nft/components/icons.tsx index d9b357bf59..a1453b52eb 100644 --- a/src/nft/components/icons.tsx +++ b/src/nft/components/icons.tsx @@ -1351,6 +1351,35 @@ export const UniswapMagentaIcon = (props: SVGProps) => ( ) +export const HomeSearchIcon = (props: SVGProps) => ( + + + +) + +export const AddToBagIcon = (props: SVGProps) => ( + + + + + + +) export const HandHoldingDollarIcon = (props: SVGProps) => ( { + 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') + }) +}) diff --git a/src/nft/utils/time.ts b/src/nft/utils/time.ts new file mode 100644 index 0000000000..072372ab11 --- /dev/null +++ b/src/nft/utils/time.ts @@ -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')}` +} diff --git a/src/nft/utils/timeSince.ts b/src/nft/utils/timeSince.ts deleted file mode 100644 index cdd8b26536..0000000000 --- a/src/nft/utils/timeSince.ts +++ /dev/null @@ -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')}` -} diff --git a/src/test-utils/nft/fixtures.ts b/src/test-utils/nft/fixtures.ts index f0d78e3813..e0f903cb04 100644 --- a/src/test-utils/nft/fixtures.ts +++ b/src/test-utils/nft/fixtures.ts @@ -6,7 +6,7 @@ import { OrderStatus, OrderType, } 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 = { id: 'TmZ0QXNzZXQ6MHhlZDVhZjM4ODY1MzU2N2FmMmYzODhlNjIyNGRjN2M0YjMyNDFjNTQ0XzMzMTg=', @@ -238,3 +238,17 @@ export const TEST_SELL_ORDER: SellOrder = { type: OrderType.Listing, 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, +}