feat: [DetailsV2] Add Trait component content (#6460)

* hide trait container when asset has no traits

* add traits header row

* trait value rows and scroll behaviour

* row with placeholder values

* add random filler values and proper scrollbar styles

* working rarity graph

* bar border radius

* move rarity graph to its own file

* always show scrim

* working scrim and move traitrow to its own file

* cleanup

* remove padding

* move scrollbar right

* add snapshot tests

* add comment about randomly generated rarities

* cleanup

* only pass traits

* justify

* not important

* cleanup scrim styles

* remove comment

* add scroll state hook

* lint

* update test

* object over map

* remove spaces

* justify content

* add ticket

* add comments

* update snapshot

* correct padding

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
This commit is contained in:
Charles Bachmeier 2023-05-01 11:43:55 -04:00 committed by GitHub
parent 97312bb174
commit 6bc7cfc996
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 494 additions and 56 deletions

@ -47,7 +47,7 @@ export const DataPage = ({ asset }: { asset: GenieAsset }) => {
<DataPageHeader /> <DataPageHeader />
<ContentContainer> <ContentContainer>
<LeftColumn> <LeftColumn>
<DataPageTraits asset={asset} /> {!!asset.traits?.length && <DataPageTraits traits={asset.traits} />}
<DataPageDescription /> <DataPageDescription />
</LeftColumn> </LeftColumn>
<DataPageTable /> <DataPageTable />

@ -0,0 +1,12 @@
import { TEST_NFT_ASSET } from 'test-utils/nft/fixtures'
import { render } from 'test-utils/render'
import { DataPageTraits } from './DataPageTraits'
it('data page trait component does not load with asset with no traits', () => {
const { asFragment } = render(<DataPageTraits traits={TEST_NFT_ASSET.traits ?? []} />)
expect(asFragment()).toMatchSnapshot()
})
// TODO(NFT-1189): add test for trait component with asset with traits when rarity is not randomly generated
// while rarities are randomly generated, snapshots will never match

@ -1,23 +1,102 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { GenieAsset } from 'nft/types' import Column from 'components/Column'
import { ScrollBarStyles } from 'components/Common'
import Row from 'components/Row'
import { useSubscribeScrollState } from 'nft/hooks'
import { Trait } from 'nft/types'
import { useMemo } from 'react' import { useMemo } from 'react'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { opacify } from 'theme/utils'
import { Tab, TabbedComponent } from './TabbedComponent' import { Tab, TabbedComponent } from './TabbedComponent'
import { TraitRow } from './TraitRow'
const TraitsContentContainer = styled.div` const TraitsHeaderContainer = styled(Row)`
height: 492px; padding-right: 12px;
` `
const TraitsContent = () => { const TraitsHeader = styled(ThemedText.SubHeaderSmall)<{ $flex?: number; $justifyContent?: string }>`
return <TraitsContentContainer>Traits Content</TraitsContentContainer> display: flex;
line-height: 20px;
color: ${({ theme }) => theme.textSecondary};
flex: ${({ $flex }) => $flex ?? 1};
justify-content: ${({ $justifyContent }) => $justifyContent};
`
const TraitRowContainer = styled.div`
position: relative;
`
const TraitRowScrollableContainer = styled.div`
overflow-y: auto;
overflow-x: hidden;
max-height: 412px;
width: calc(100% + 6px);
${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 = ({ traits }: { traits?: Trait[] }) => {
const { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState()
// This is needed to prevent rerenders when handling scrolls
const traitRows = useMemo(() => {
return traits?.map((trait) => <TraitRow trait={trait} key={trait.trait_type + ':' + trait.trait_value} />)
}, [traits])
return (
<Column>
<TraitsHeaderContainer>
<TraitsHeader $flex={3}>
<Trans>Trait</Trans>
</TraitsHeader>
<TraitsHeader $flex={2}>
<Trans>Floor price</Trans>
</TraitsHeader>
<TraitsHeader>
<Trans>Quantity</Trans>
</TraitsHeader>
<TraitsHeader $flex={1.5} $justifyContent="flex-end">
<Trans>Rarity</Trans>
</TraitsHeader>
</TraitsHeaderContainer>
<TraitRowContainer>
{scrollProgress > 0 && <Scrim />}
<TraitRowScrollableContainer ref={scrollRef} onScroll={scrollHandler}>
{traitRows}
</TraitRowScrollableContainer>
{userCanScroll && scrollProgress !== 100 && <Scrim isBottom={true} />}
</TraitRowContainer>
</Column>
)
} }
enum TraitTabsKeys { enum TraitTabsKeys {
Traits = 'traits', Traits = 'traits',
} }
export const DataPageTraits = ({ asset }: { asset: GenieAsset }) => { export const DataPageTraits = ({ traits }: { traits: Trait[] }) => {
const TraitTabs: Map<string, Tab> = useMemo( const TraitTabs: Map<string, Tab> = useMemo(
() => () =>
new Map([ new Map([
@ -26,12 +105,12 @@ export const DataPageTraits = ({ asset }: { asset: GenieAsset }) => {
{ {
title: <Trans>Traits</Trans>, title: <Trans>Traits</Trans>,
key: TraitTabsKeys.Traits, key: TraitTabsKeys.Traits,
content: <TraitsContent />, content: <TraitsContent traits={traits} />,
count: asset.traits?.length, count: traits?.length,
}, },
], ],
]), ]),
[asset.traits?.length] [traits]
) )
return <TabbedComponent tabs={TraitTabs} /> return <TabbedComponent tabs={TraitTabs} />
} }

@ -0,0 +1,78 @@
import Row from 'components/Row'
import { Trait } from 'nft/types'
import styled from 'styled-components/macro'
import { colors } from 'theme/colors'
const RarityBar = styled.div<{ $color?: string }>`
background: ${({ $color, theme }) => $color ?? theme.backgroundOutline};
width: 2px;
height: 10px;
border-radius: 2px;
`
interface RarityValue {
threshold: number
color: string
}
enum RarityLevel {
VeryCommon = 'Very Common',
Common = 'Common',
Rare = 'Rare',
VeryRare = 'Very Rare',
ExtremelyRare = 'Extremely Rare',
}
const RarityLevels: { [key in RarityLevel]: RarityValue } = {
[RarityLevel.VeryCommon]: {
threshold: 0.8,
color: colors.gray500,
},
[RarityLevel.Common]: {
threshold: 0.6,
color: colors.green300,
},
[RarityLevel.Rare]: {
threshold: 0.4,
color: colors.blueVibrant,
},
[RarityLevel.VeryRare]: {
threshold: 0.2,
color: colors.purpleVibrant,
},
[RarityLevel.ExtremelyRare]: {
threshold: 0,
color: colors.magentaVibrant,
},
}
function getRarityLevel(rarity: number) {
switch (true) {
case rarity > RarityLevels[RarityLevel.VeryCommon].threshold:
return RarityLevels[RarityLevel.VeryCommon]
case rarity > RarityLevels[RarityLevel.Common].threshold:
return RarityLevels[RarityLevel.Common]
case rarity > RarityLevels[RarityLevel.Rare].threshold:
return RarityLevels[RarityLevel.Rare]
case rarity > RarityLevels[RarityLevel.VeryRare].threshold:
return RarityLevels[RarityLevel.VeryRare]
case rarity >= RarityLevels[RarityLevel.ExtremelyRare].threshold:
return RarityLevels[RarityLevel.ExtremelyRare]
default:
return RarityLevels[RarityLevel.VeryCommon]
}
}
export const RarityGraph = ({ trait, rarity }: { trait: Trait; rarity: number }) => {
const rarityLevel = getRarityLevel(rarity)
return (
<Row gap="1.68px" justify="flex-end">
{Array.from({ length: 20 }).map((_, index) => (
<RarityBar
key={trait.trait_value + '_bar_' + index}
$color={index * 0.05 <= 1 - rarity ? rarityLevel?.color : undefined}
/>
))}
</Row>
)
}

@ -0,0 +1,47 @@
import Column from 'components/Column'
import Row from 'components/Row'
import { Trait } from 'nft/types'
import { formatEth } from 'nft/utils'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { RarityGraph } from './RarityGraph'
const SubheaderTiny = styled.div`
font-size: 10px;
line-height: 16px;
font-weight: 600;
color: ${({ theme }) => theme.textSecondary};
`
const TraitValue = styled(Column)`
gap: 4px;
flex: 3;
`
const TraitRowValue = styled(ThemedText.BodySmall)<{ $flex?: number; $justifyContent?: string }>`
display: flex;
line-height: 20px;
padding-top: 20px;
flex: ${({ $flex }) => $flex ?? 1};
justify-content: ${({ $justifyContent }) => $justifyContent};
`
export const TraitRow = ({ trait }: { trait: Trait }) => {
// TODO: Replace with actual rarity, count, and floor price when BE supports
// rarity eventually should be number of items with this trait / total number of items, smaller rarity means more rare
const randomRarity = Math.random()
return (
<Row padding="12px 18px 12px 0px">
<TraitValue>
<SubheaderTiny>{trait.trait_type}</SubheaderTiny>
<ThemedText.BodyPrimary lineHeight="20px">{trait.trait_value}</ThemedText.BodyPrimary>
</TraitValue>
<TraitRowValue $flex={2}>{formatEth(randomRarity * 1000)} ETH</TraitRowValue>
<TraitRowValue>{Math.round(randomRarity * 10000)}</TraitRowValue>
<TraitRowValue $flex={1.5} $justifyContent="flex-end">
<RarityGraph trait={trait} rarity={randomRarity} />
</TraitRowValue>
</Row>
)
}

@ -61,7 +61,7 @@ exports[`placeholder containers load 1`] = `
justify-content: flex-start; justify-content: flex-start;
} }
.c17 { .c15 {
height: 568px; height: 568px;
} }
@ -82,32 +82,26 @@ exports[`placeholder containers load 1`] = `
} }
.c10 { .c10 {
color: #0D111C;
line-height: 24px;
cursor: default;
}
.c13 {
color: #0D111C; color: #0D111C;
line-height: 24px; line-height: 24px;
cursor: pointer; cursor: pointer;
} }
.c13:hover { .c10:hover {
opacity: 0.6; opacity: 0.6;
} }
.c14 { .c12 {
color: #98A1C0; color: #98A1C0;
line-height: 24px; line-height: 24px;
cursor: pointer; cursor: pointer;
} }
.c14:hover { .c12:hover {
opacity: 0.6; opacity: 0.6;
} }
.c16 { .c14 {
background: #D2D9EE; background: #D2D9EE;
border-radius: 4px; border-radius: 4px;
padding: 2px 4px; padding: 2px 4px;
@ -115,7 +109,7 @@ exports[`placeholder containers load 1`] = `
line-height: 12px; line-height: 12px;
} }
.c15 { .c13 {
height: 252px; height: 252px;
} }
@ -134,10 +128,6 @@ exports[`placeholder containers load 1`] = `
padding-left: 0px; padding-left: 0px;
} }
.c12 {
height: 492px;
}
.c1 { .c1 {
padding: 24px 64px; padding: 24px 64px;
height: 100vh; height: 100vh;
@ -202,28 +192,6 @@ exports[`placeholder containers load 1`] = `
> >
<div <div
class="c9 c10 css-rjqmed" class="c9 c10 css-rjqmed"
>
<div
class="c3 c11"
>
Traits
</div>
</div>
</div>
<div
class="c12"
>
Traits Content
</div>
</div>
<div
class="c7"
>
<div
class="c3 c4 c8"
>
<div
class="c9 c13 css-rjqmed"
> >
<div <div
class="c3 c11" class="c3 c11"
@ -232,7 +200,7 @@ exports[`placeholder containers load 1`] = `
</div> </div>
</div> </div>
<div <div
class="c9 c14 css-rjqmed" class="c9 c12 css-rjqmed"
> >
<div <div
class="c3 c11" class="c3 c11"
@ -242,7 +210,7 @@ exports[`placeholder containers load 1`] = `
</div> </div>
</div> </div>
<div <div
class="c15" class="c13"
> >
Description Content Description Content
</div> </div>
@ -255,7 +223,7 @@ exports[`placeholder containers load 1`] = `
class="c3 c4 c8" class="c3 c4 c8"
> >
<div <div
class="c9 c13 css-rjqmed" class="c9 c10 css-rjqmed"
> >
<div <div
class="c3 c11" class="c3 c11"
@ -264,28 +232,28 @@ exports[`placeholder containers load 1`] = `
</div> </div>
</div> </div>
<div <div
class="c9 c14 css-rjqmed" class="c9 c12 css-rjqmed"
> >
<div <div
class="c3 c11" class="c3 c11"
> >
Offers Offers
<div <div
class="c16 css-f8aq60" class="c14 css-f8aq60"
> >
10+ 10+
</div> </div>
</div> </div>
</div> </div>
<div <div
class="c9 c14 css-rjqmed" class="c9 c12 css-rjqmed"
> >
<div <div
class="c3 c11" class="c3 c11"
> >
Listings Listings
<div <div
class="c16 css-f8aq60" class="c14 css-f8aq60"
> >
10+ 10+
</div> </div>
@ -293,7 +261,7 @@ exports[`placeholder containers load 1`] = `
</div> </div>
</div> </div>
<div <div
class="c17" class="c15"
> >
Activity Content Activity Content
</div> </div>

@ -0,0 +1,230 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`data page trait component does not load with asset with no traits 1`] = `
<DocumentFragment>
.c1 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.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;
}
.c6 {
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;
}
.c4 {
color: #0D111C;
}
.c9 {
color: #7780A0;
}
.c7 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c0 {
background: #FFFFFF;
border-radius: 16px;
padding: 16px 20px;
width: 100%;
-webkit-align-self: flex-start;
-ms-flex-item-align: start;
align-self: flex-start;
}
.c3 {
gap: 32px;
margin-bottom: 12px;
width: 100;
}
.c5 {
color: #0D111C;
line-height: 24px;
cursor: default;
}
.c8 {
padding-right: 12px;
}
.c10 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
line-height: 20px;
color: #7780A0;
-webkit-flex: 3;
-ms-flex: 3;
flex: 3;
}
.c11 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
line-height: 20px;
color: #7780A0;
-webkit-flex: 2;
-ms-flex: 2;
flex: 2;
}
.c12 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
line-height: 20px;
color: #7780A0;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c13 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
line-height: 20px;
color: #7780A0;
-webkit-flex: 1.5;
-ms-flex: 1.5;
flex: 1.5;
-webkit-box-pack: end;
-webkit-justify-content: flex-end;
-ms-flex-pack: end;
justify-content: flex-end;
}
.c14 {
position: relative;
}
.c15 {
overflow-y: auto;
overflow-x: hidden;
max-height: 412px;
width: calc(100% + 6px);
-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%;
}
.c15::-webkit-scrollbar {
background: transparent;
width: 4px;
overflow-y: scroll;
}
.c15::-webkit-scrollbar-thumb {
background: #D2D9EE;
border-radius: 8px;
}
<div
class="c0"
>
<div
class="c1 c2 c3"
>
<div
class="c4 c5 css-rjqmed"
>
<div
class="c1 c6"
>
Traits
</div>
</div>
</div>
<div
class="c7"
>
<div
class="c1 c2 c8"
>
<div
class="c9 c10 css-1aekuku"
>
Trait
</div>
<div
class="c9 c11 css-1aekuku"
>
Floor price
</div>
<div
class="c9 c12 css-1aekuku"
>
Quantity
</div>
<div
class="c9 c13 css-1aekuku"
>
Rarity
</div>
</div>
<div
class="c14"
>
<div
class="c15"
/>
</div>
</div>
</div>
</DocumentFragment>
`;

@ -11,6 +11,7 @@ export * from './useProfilePageState'
export * from './useSelectAsset' export * from './useSelectAsset'
export * from './useSellAsset' export * from './useSellAsset'
export * from './useSendTransaction' export * from './useSendTransaction'
export * from './useSubscribeScrollState'
export * from './useSweep' export * from './useSweep'
export * from './useTransactionResponse' export * from './useTransactionResponse'
export * from './useWalletBalance' export * from './useWalletBalance'

@ -0,0 +1,22 @@
import { useState } from 'react'
// TODO(NFT-1190): update Bag component to use this hook
export function useSubscribeScrollState() {
const [userCanScroll, setUserCanScroll] = useState(false)
const [scrollProgress, setScrollProgress] = useState(0)
const scrollRef = (node: HTMLDivElement) => {
if (node !== null) {
const canScroll = node.scrollHeight > node.clientHeight
canScroll !== userCanScroll && setUserCanScroll(canScroll)
}
}
const scrollHandler = (event: React.UIEvent<HTMLDivElement>) => {
const scrollTop = event.currentTarget.scrollTop
const containerHeight = event.currentTarget.clientHeight
const scrollHeight = event.currentTarget.scrollHeight
setScrollProgress(scrollTop ? ((scrollTop + containerHeight) / scrollHeight) * 100 : 0)
}
return { scrollRef, scrollHandler, scrollProgress, userCanScroll }
}

@ -87,6 +87,7 @@ export const colors = {
magentaVibrant: '#FC72FF', magentaVibrant: '#FC72FF',
purple300: '#8440F2', purple300: '#8440F2',
purple900: '#1C0337', purple900: '#1C0337',
purpleVibrant: '#6100FF',
// TODO: add all other vibrant variations // TODO: add all other vibrant variations
networkEthereum: '#627EEA', networkEthereum: '#627EEA',
networkOptimism: '#FF0420', networkOptimism: '#FF0420',