chore: merge marketplace and traits (#4645)
* porting of filters Co-authored-by: Jack Short <john.short.tj@gmail.com>
This commit is contained in:
parent
e35f9e16a1
commit
e180153c3a
@ -1,10 +1,13 @@
|
||||
import clsx from 'clsx'
|
||||
import { Box } from 'nft/components/Box'
|
||||
import useDebounce from 'hooks/useDebounce'
|
||||
import { AnimatedBox, Box } from 'nft/components/Box'
|
||||
import { FilterButton } from 'nft/components/collection'
|
||||
import { CollectionAsset } from 'nft/components/collection/CollectionAsset'
|
||||
import * as styles from 'nft/components/collection/CollectionNfts.css'
|
||||
import { Row } from 'nft/components/Flex'
|
||||
import { Center } from 'nft/components/Flex'
|
||||
import { bodySmall, buttonTextMedium, header2 } from 'nft/css/common.css'
|
||||
import { useCollectionFilters } from 'nft/hooks'
|
||||
import { useCollectionFilters, useFiltersExpanded, useIsMobile } from 'nft/hooks'
|
||||
import { AssetsFetcher } from 'nft/queries'
|
||||
import { UniformHeight, UniformHeights } from 'nft/types'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@ -16,7 +19,17 @@ interface CollectionNftsProps {
|
||||
}
|
||||
|
||||
export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
|
||||
const traits = useCollectionFilters((state) => state.traits)
|
||||
const minPrice = useCollectionFilters((state) => state.minPrice)
|
||||
const maxPrice = useCollectionFilters((state) => state.maxPrice)
|
||||
const markets = useCollectionFilters((state) => state.markets)
|
||||
const buyNow = useCollectionFilters((state) => state.buyNow)
|
||||
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const debouncedMinPrice = useDebounce(minPrice, 500)
|
||||
const debouncedMaxPrice = useDebounce(maxPrice, 500)
|
||||
|
||||
const {
|
||||
data: collectionAssets,
|
||||
isSuccess: AssetsFetchSuccess,
|
||||
@ -26,15 +39,29 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
|
||||
[
|
||||
'collectionNfts',
|
||||
{
|
||||
traits,
|
||||
contractAddress,
|
||||
markets,
|
||||
notForSale: !buyNow,
|
||||
price: {
|
||||
low: debouncedMinPrice,
|
||||
high: debouncedMaxPrice,
|
||||
symbol: 'ETH',
|
||||
},
|
||||
},
|
||||
],
|
||||
async ({ pageParam = 0 }) => {
|
||||
return await AssetsFetcher({
|
||||
contractAddress,
|
||||
markets,
|
||||
notForSale: !buyNow,
|
||||
pageParam,
|
||||
traits,
|
||||
price: {
|
||||
low: debouncedMinPrice,
|
||||
high: debouncedMaxPrice,
|
||||
symbol: 'ETH',
|
||||
},
|
||||
})
|
||||
},
|
||||
{
|
||||
@ -67,38 +94,53 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
next={fetchNextPage}
|
||||
hasMore={hasNextPage ?? false}
|
||||
loader={hasNextPage ? <p>Loading from scroll...</p> : null}
|
||||
dataLength={collectionNfts.length}
|
||||
style={{ overflow: 'unset' }}
|
||||
>
|
||||
{collectionNfts.length > 0 ? (
|
||||
<div className={styles.assetList}>
|
||||
{collectionNfts.map((asset) => {
|
||||
return asset ? (
|
||||
<CollectionAsset
|
||||
key={asset.address + asset.tokenId}
|
||||
asset={asset}
|
||||
uniformHeight={uniformHeight}
|
||||
setUniformHeight={setUniformHeight}
|
||||
mediaShouldBePlaying={asset.tokenId === currentTokenPlayingMedia}
|
||||
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
|
||||
/>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Center width="full" color="darkGray" style={{ height: '60vh' }}>
|
||||
<div style={{ display: 'block', textAlign: 'center' }}>
|
||||
<p className={header2}>No NFTS found</p>
|
||||
<Box className={clsx(bodySmall, buttonTextMedium)} color="blue" cursor="pointer">
|
||||
View full collection
|
||||
</Box>
|
||||
<>
|
||||
<AnimatedBox position="sticky" top="72" width="full" zIndex="3">
|
||||
<Box backgroundColor="white08" width="full" paddingBottom="8" style={{ backdropFilter: 'blur(24px)' }}>
|
||||
<Row marginTop="12" gap="12">
|
||||
<FilterButton
|
||||
isMobile={isMobile}
|
||||
isFiltersExpanded={isFiltersExpanded}
|
||||
onClick={() => setFiltersExpanded(!isFiltersExpanded)}
|
||||
collectionCount={collectionNfts?.[0]?.totalCount ?? 0}
|
||||
/>
|
||||
</Row>
|
||||
</Box>
|
||||
</AnimatedBox>
|
||||
|
||||
<InfiniteScroll
|
||||
next={fetchNextPage}
|
||||
hasMore={hasNextPage ?? false}
|
||||
loader={hasNextPage ? <p>Loading from scroll...</p> : null}
|
||||
dataLength={collectionNfts.length}
|
||||
style={{ overflow: 'unset' }}
|
||||
>
|
||||
{collectionNfts.length > 0 ? (
|
||||
<div className={styles.assetList}>
|
||||
{collectionNfts.map((asset) => {
|
||||
return asset ? (
|
||||
<CollectionAsset
|
||||
key={asset.address + asset.tokenId}
|
||||
asset={asset}
|
||||
uniformHeight={uniformHeight}
|
||||
setUniformHeight={setUniformHeight}
|
||||
mediaShouldBePlaying={asset.tokenId === currentTokenPlayingMedia}
|
||||
setCurrentTokenPlayingMedia={setCurrentTokenPlayingMedia}
|
||||
/>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</Center>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
) : (
|
||||
<Center width="full" color="darkGray" style={{ height: '60vh' }}>
|
||||
<div style={{ display: 'block', textAlign: 'center' }}>
|
||||
<p className={header2}>No NFTS found</p>
|
||||
<Box className={clsx(bodySmall, buttonTextMedium)} color="blue" cursor="pointer">
|
||||
View full collection
|
||||
</Box>
|
||||
</div>
|
||||
</Center>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -12,11 +12,13 @@ export const FilterButton = ({
|
||||
isMobile,
|
||||
isFiltersExpanded,
|
||||
results,
|
||||
collectionCount = 0,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
isFiltersExpanded: boolean
|
||||
results?: number
|
||||
onClick: () => void
|
||||
collectionCount?: number
|
||||
}) => {
|
||||
const { minPrice, maxPrice, minRarity, maxRarity, traits, markets, buyNow } = useCollectionFilters((state) => ({
|
||||
minPrice: state.minPrice,
|
||||
@ -67,7 +69,9 @@ export const FilterButton = ({
|
||||
•
|
||||
</Box>
|
||||
)}
|
||||
<Box paddingLeft={!isFiltersExpanded ? '12' : '2'}>{results ? putCommas(results) : 0} results</Box>
|
||||
<Box paddingLeft={!isFiltersExpanded ? '12' : '2'}>
|
||||
{collectionCount > 0 ? putCommas(collectionCount) : 0} results
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
@ -1,21 +1,62 @@
|
||||
import { Box } from 'nft/components/Box'
|
||||
import * as styles from 'nft/components/collection/Filters.css'
|
||||
import { MarketplaceSelect } from 'nft/components/collection/MarketplaceSelect'
|
||||
import { PriceRange } from 'nft/components/collection/PriceRange'
|
||||
import { Column, Row } from 'nft/components/Flex'
|
||||
import { Radio } from 'nft/components/layout/Radio'
|
||||
import { useCollectionFilters } from 'nft/hooks'
|
||||
import { FocusEventHandler, FormEvent, useMemo, useState } from 'react'
|
||||
import { useReducer } from 'react'
|
||||
|
||||
export const Filters = () => {
|
||||
import { Trait } from '../../hooks/useCollectionFilters'
|
||||
import { groupBy } from '../../utils/groupBy'
|
||||
import { Input } from '../layout/Input'
|
||||
import { TraitSelect } from './TraitSelect'
|
||||
|
||||
export const Filters = ({
|
||||
traits,
|
||||
traitsByAmount,
|
||||
}: {
|
||||
traits: Trait[]
|
||||
traitsByAmount: {
|
||||
traitCount: number
|
||||
numWithTrait: number
|
||||
}[]
|
||||
}) => {
|
||||
const { buyNow, setBuyNow } = useCollectionFilters((state) => ({
|
||||
buyNow: state.buyNow,
|
||||
setBuyNow: state.setBuyNow,
|
||||
}))
|
||||
const traitsByGroup: Record<string, Trait[]> = useMemo(() => {
|
||||
if (traits) {
|
||||
let groupedTraits = groupBy(traits, 'trait_type')
|
||||
groupedTraits['Number of traits'] = []
|
||||
for (let i = 0; i < traitsByAmount.length; i++) {
|
||||
groupedTraits['Number of traits'].push({
|
||||
trait_type: 'Number of traits',
|
||||
trait_value: traitsByAmount[i].traitCount,
|
||||
trait_count: traitsByAmount[i].numWithTrait,
|
||||
})
|
||||
}
|
||||
groupedTraits = Object.assign({ 'Number of traits': null }, groupedTraits)
|
||||
return groupedTraits
|
||||
} else return {}
|
||||
}, [traits, traitsByAmount])
|
||||
|
||||
const [buyNowHovered, toggleBuyNowHover] = useReducer((state) => !state, false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const handleBuyNowToggle = () => {
|
||||
setBuyNow(!buyNow)
|
||||
}
|
||||
|
||||
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
e.currentTarget.placeholder = ''
|
||||
}
|
||||
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
e.currentTarget.placeholder = 'Search traits'
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={styles.container}>
|
||||
<Row width="full" justifyContent="space-between">
|
||||
@ -45,6 +86,36 @@ export const Filters = () => {
|
||||
</Box>
|
||||
<Radio hovered={buyNowHovered} checked={buyNow} onClick={handleBuyNowToggle} />
|
||||
</Row>
|
||||
<MarketplaceSelect />
|
||||
<Box marginTop="12" marginBottom="12">
|
||||
<Box as="span" fontSize="20">
|
||||
Price
|
||||
</Box>
|
||||
<PriceRange />
|
||||
</Box>
|
||||
<Box marginTop="12">
|
||||
<Box as="span" fontSize="20">
|
||||
Traits
|
||||
</Box>
|
||||
|
||||
<Column marginTop="12" marginBottom="60" gap={{ sm: '4' }}>
|
||||
<Input
|
||||
display={!traits?.length ? 'none' : undefined}
|
||||
value={search}
|
||||
onChange={(e: FormEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
|
||||
width="full"
|
||||
marginBottom="8"
|
||||
placeholder="Search traits"
|
||||
autoComplete="off"
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
style={{ border: '2px solid rgba(153, 161, 189, 0.24)', maxWidth: '300px' }}
|
||||
/>
|
||||
{Object.entries(traitsByGroup).map(([type, traits]) => (
|
||||
<TraitSelect key={type} {...{ type, traits, search }} />
|
||||
))}
|
||||
</Column>
|
||||
</Box>
|
||||
</Column>
|
||||
</Box>
|
||||
)
|
||||
|
145
src/nft/components/collection/MarketplaceSelect.tsx
Normal file
145
src/nft/components/collection/MarketplaceSelect.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import clsx from 'clsx'
|
||||
import { Box } from 'nft/components/Box'
|
||||
import * as styles from 'nft/components/collection/Filters.css'
|
||||
import { Column, Row } from 'nft/components/Flex'
|
||||
import { ChevronUpIcon } from 'nft/components/icons'
|
||||
import { subheadSmall } from 'nft/css/common.css'
|
||||
import { useCollectionFilters } from 'nft/hooks/useCollectionFilters'
|
||||
import { FormEvent, useEffect, useReducer, useState } from 'react'
|
||||
|
||||
import { Checkbox } from '../layout/Checkbox'
|
||||
|
||||
export const marketPlaceItems = {
|
||||
looksrare: 'LooksRare',
|
||||
nft20: 'NFT20',
|
||||
nftx: 'NFTX',
|
||||
opensea: 'OpenSea',
|
||||
x2y2: 'X2Y2',
|
||||
}
|
||||
|
||||
const MarketplaceItem = ({
|
||||
title,
|
||||
value,
|
||||
addMarket,
|
||||
removeMarket,
|
||||
isMarketSelected,
|
||||
count,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
addMarket: (market: string) => void
|
||||
removeMarket: (market: string) => void
|
||||
isMarketSelected: boolean
|
||||
count?: number
|
||||
}) => {
|
||||
const [isCheckboxSelected, setCheckboxSelected] = useState(false)
|
||||
const [hovered, toggleHover] = useReducer((state) => !state, false)
|
||||
useEffect(() => {
|
||||
setCheckboxSelected(isMarketSelected)
|
||||
}, [isMarketSelected])
|
||||
const handleCheckbox = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isCheckboxSelected) {
|
||||
addMarket(value)
|
||||
setCheckboxSelected(true)
|
||||
} else {
|
||||
removeMarket(value)
|
||||
setCheckboxSelected(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
key={value}
|
||||
justifyContent="space-between"
|
||||
maxWidth="full"
|
||||
overflowX={'hidden'}
|
||||
overflowY={'hidden'}
|
||||
fontWeight="normal"
|
||||
className={`${subheadSmall} ${styles.subRowHover}`}
|
||||
paddingLeft="12"
|
||||
paddingRight="12"
|
||||
cursor="pointer"
|
||||
style={{ paddingBottom: '21px', paddingTop: '21px', maxHeight: '44px' }}
|
||||
onMouseEnter={toggleHover}
|
||||
onMouseLeave={toggleHover}
|
||||
onClick={handleCheckbox}
|
||||
>
|
||||
<Box as="span" fontSize="14" fontWeight="normal">
|
||||
{title}{' '}
|
||||
</Box>
|
||||
<Checkbox checked={isCheckboxSelected} hovered={hovered} onChange={handleCheckbox}>
|
||||
<Box as="span" color="darkGray" marginLeft="4" paddingRight={'12'}>
|
||||
{count}
|
||||
</Box>
|
||||
</Checkbox>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export const MarketplaceSelect = () => {
|
||||
const {
|
||||
addMarket,
|
||||
removeMarket,
|
||||
markets: selectedMarkets,
|
||||
marketCount,
|
||||
} = useCollectionFilters(({ markets, marketCount, removeMarket, addMarket }) => ({
|
||||
markets,
|
||||
marketCount,
|
||||
removeMarket,
|
||||
addMarket,
|
||||
}))
|
||||
|
||||
const [isOpen, setOpen] = useState(!!selectedMarkets.length)
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="details"
|
||||
className={clsx(subheadSmall, !isOpen && styles.rowHover, isOpen && styles.detailsOpen)}
|
||||
borderRadius="12"
|
||||
open={isOpen}
|
||||
>
|
||||
<Box
|
||||
as="summary"
|
||||
className={clsx(isOpen && styles.summaryOpen, isOpen ? styles.rowHoverOpen : styles.rowHover)}
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
cursor="pointer"
|
||||
alignItems="center"
|
||||
fontSize="14"
|
||||
paddingTop="12"
|
||||
paddingLeft="12"
|
||||
paddingRight="12"
|
||||
paddingBottom={isOpen ? '8' : '12'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
Marketplaces
|
||||
<Box
|
||||
color="darkGray"
|
||||
transition="250"
|
||||
height="28"
|
||||
width="28"
|
||||
style={{
|
||||
transform: `rotate(${isOpen ? 0 : 180}deg)`,
|
||||
}}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</Box>
|
||||
</Box>
|
||||
<Column className={styles.filterDropDowns} paddingLeft="0">
|
||||
{Object.entries(marketPlaceItems).map(([value, title]) => (
|
||||
<MarketplaceItem
|
||||
key={value}
|
||||
title={title}
|
||||
value={value}
|
||||
count={marketCount?.[value] || 0}
|
||||
{...{ addMarket, removeMarket, isMarketSelected: selectedMarkets.includes(value) }}
|
||||
/>
|
||||
))}
|
||||
</Column>
|
||||
</Box>
|
||||
)
|
||||
}
|
86
src/nft/components/collection/PriceRange.tsx
Normal file
86
src/nft/components/collection/PriceRange.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useIsMobile } from 'nft/hooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FocusEventHandler, FormEvent } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useCollectionFilters } from '../../hooks/useCollectionFilters'
|
||||
import { isNumber } from '../../utils/numbers'
|
||||
import { scrollToTop } from '../../utils/scrollToTop'
|
||||
import { Row } from '../Flex'
|
||||
import { NumericInput } from '../layout/Input'
|
||||
|
||||
export const PriceRange = () => {
|
||||
const [placeholderText, setPlaceholderText] = useState('')
|
||||
const setMinPrice = useCollectionFilters((state) => state.setMinPrice)
|
||||
const setMaxPrice = useCollectionFilters((state) => state.setMaxPrice)
|
||||
const minPrice = useCollectionFilters((state) => state.minPrice)
|
||||
const maxPrice = useCollectionFilters((state) => state.maxPrice)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
setMinPrice('')
|
||||
setMaxPrice('')
|
||||
}, [location.pathname, setMinPrice, setMaxPrice])
|
||||
|
||||
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
setPlaceholderText(e.currentTarget.placeholder)
|
||||
e.currentTarget.placeholder = ''
|
||||
}
|
||||
|
||||
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
e.currentTarget.placeholder = placeholderText
|
||||
setPlaceholderText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gap="12" marginTop="12" color="blackBlue">
|
||||
<Row position="relative" style={{ flex: 1 }}>
|
||||
<NumericInput
|
||||
style={{
|
||||
width: isMobile ? '100%' : '142px',
|
||||
border: '2px solid rgba(153, 161, 189, 0.24)',
|
||||
}}
|
||||
borderRadius="12"
|
||||
padding="12"
|
||||
fontSize="14"
|
||||
color={{ placeholder: 'darkGray', default: 'blackBlue' }}
|
||||
backgroundColor="transparent"
|
||||
placeholder="Min"
|
||||
defaultValue={minPrice}
|
||||
onChange={(v: FormEvent<HTMLInputElement>) => {
|
||||
scrollToTop()
|
||||
setMinPrice(isNumber(v.currentTarget.value) ? parseFloat(v.currentTarget.value) : '')
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
value={minPrice}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</Row>
|
||||
<Row position="relative" style={{ flex: 1 }}>
|
||||
<NumericInput
|
||||
style={{
|
||||
width: isMobile ? '100%' : '142px',
|
||||
border: '2px solid rgba(153, 161, 189, 0.24)',
|
||||
}}
|
||||
borderColor={{ default: 'medGray', focus: 'darkGray' }}
|
||||
borderRadius="12"
|
||||
padding="12"
|
||||
fontSize="14"
|
||||
color={{ placeholder: 'darkGray', default: 'blackBlue' }}
|
||||
backgroundColor="transparent"
|
||||
placeholder="Max"
|
||||
defaultValue={maxPrice}
|
||||
value={maxPrice}
|
||||
onChange={(v: FormEvent<HTMLInputElement>) => {
|
||||
scrollToTop()
|
||||
setMaxPrice(isNumber(v.currentTarget.value) ? parseFloat(v.currentTarget.value) : '')
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
)
|
||||
}
|
12
src/nft/components/collection/TraitSelect.css.ts
Normal file
12
src/nft/components/collection/TraitSelect.css.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { style } from '@vanilla-extract/css'
|
||||
|
||||
export const list = style({
|
||||
overflowAnchor: 'none',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
})
|
194
src/nft/components/collection/TraitSelect.tsx
Normal file
194
src/nft/components/collection/TraitSelect.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import clsx from 'clsx'
|
||||
import useDebounce from 'hooks/useDebounce'
|
||||
import { pluralize } from 'nft/utils/roundAndPluralize'
|
||||
import { scrollToTop } from 'nft/utils/scrollToTop'
|
||||
import { useMemo } from 'react'
|
||||
import { FormEvent, MouseEvent } from 'react'
|
||||
import { useEffect, useLayoutEffect, useState } from 'react'
|
||||
|
||||
import { subheadSmall } from '../../css/common.css'
|
||||
import { Trait, useCollectionFilters } from '../../hooks/useCollectionFilters'
|
||||
import { Box } from '../Box'
|
||||
import { Column, Row } from '../Flex'
|
||||
import { ChevronUpIcon } from '../icons'
|
||||
import { Checkbox } from '../layout/Checkbox'
|
||||
import * as styles from './Filters.css'
|
||||
|
||||
const TraitItem = ({
|
||||
trait,
|
||||
addTrait,
|
||||
removeTrait,
|
||||
isTraitSelected,
|
||||
}: {
|
||||
trait: Trait
|
||||
addTrait: (trait: Trait) => void
|
||||
removeTrait: (trait: Trait) => void
|
||||
isTraitSelected: boolean
|
||||
}) => {
|
||||
const [isCheckboxSelected, setCheckboxSelected] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const handleHover = () => setHovered(!hovered)
|
||||
const toggleShowFullTraitName = useCollectionFilters((state) => state.toggleShowFullTraitName)
|
||||
|
||||
const { shouldShow, trait_value, trait_type } = useCollectionFilters((state) => state.showFullTraitName)
|
||||
const isEllipsisActive = (e: MouseEvent<HTMLElement>) => {
|
||||
if (e.currentTarget.offsetWidth < e.currentTarget.scrollWidth) {
|
||||
toggleShowFullTraitName({
|
||||
shouldShow: true,
|
||||
trait_value: trait.trait_value,
|
||||
trait_type: trait.trait_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
setCheckboxSelected(isTraitSelected)
|
||||
}, [isTraitSelected])
|
||||
|
||||
const handleCheckbox = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
scrollToTop()
|
||||
|
||||
if (!isCheckboxSelected) {
|
||||
addTrait(trait)
|
||||
setCheckboxSelected(true)
|
||||
} else {
|
||||
removeTrait(trait)
|
||||
setCheckboxSelected(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showFullTraitName = shouldShow && trait_type === trait.trait_type && trait_value === trait.trait_value
|
||||
|
||||
return (
|
||||
<Row
|
||||
key={trait.trait_value}
|
||||
maxWidth="full"
|
||||
overflowX={'hidden'}
|
||||
overflowY={'hidden'}
|
||||
fontWeight="normal"
|
||||
className={`${subheadSmall} ${styles.subRowHover}`}
|
||||
justifyContent="space-between"
|
||||
cursor="pointer"
|
||||
paddingLeft="12"
|
||||
paddingRight="12"
|
||||
style={{ paddingBottom: '21px', paddingTop: '21px', maxHeight: '44px' }}
|
||||
onMouseEnter={handleHover}
|
||||
onMouseLeave={handleHover}
|
||||
onClick={handleCheckbox}
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
maxWidth={!showFullTraitName ? '160' : 'full'}
|
||||
onMouseOver={(e) => isEllipsisActive(e)}
|
||||
onMouseLeave={() => toggleShowFullTraitName({ shouldShow: false, trait_type: '', trait_value: '' })}
|
||||
>
|
||||
{trait.trait_type === 'Number of traits'
|
||||
? `${trait.trait_value} trait${pluralize(Number(trait.trait_value))}`
|
||||
: trait.trait_value}
|
||||
</Box>
|
||||
<Checkbox checked={isCheckboxSelected} hovered={hovered} onChange={handleCheckbox}>
|
||||
<Box as="span" color="darkGray" minWidth={'8'} paddingTop={'2'} paddingRight={'12'} position={'relative'}>
|
||||
{!showFullTraitName && trait.trait_count}
|
||||
</Box>
|
||||
</Checkbox>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export const TraitSelect = ({ traits, type, search }: { traits: Trait[]; type: string; search: string }) => {
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
const addTrait = useCollectionFilters((state) => state.addTrait)
|
||||
const removeTrait = useCollectionFilters((state) => state.removeTrait)
|
||||
const selectedTraits = useCollectionFilters((state) => state.traits)
|
||||
|
||||
const [isOpen, setOpen] = useState(
|
||||
traits.some(({ trait_type, trait_value }) => {
|
||||
return selectedTraits.some((selectedTrait) => {
|
||||
return selectedTrait.trait_type === trait_type && selectedTrait.trait_value === String(trait_value)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const { isTypeIncluded, searchedTraits } = useMemo(() => {
|
||||
const isTypeIncluded = type.includes(debouncedSearch)
|
||||
const searchedTraits = traits.filter(
|
||||
(t) => isTypeIncluded || t.trait_value.toString().toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
)
|
||||
return { searchedTraits, isTypeIncluded }
|
||||
}, [debouncedSearch, traits, type])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (debouncedSearch && searchedTraits.length) {
|
||||
setOpen(true)
|
||||
return () => {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [searchedTraits, debouncedSearch, setOpen])
|
||||
|
||||
return searchedTraits.length || isTypeIncluded ? (
|
||||
<Box
|
||||
as="details"
|
||||
className={clsx(subheadSmall, !isOpen && styles.rowHover, isOpen && styles.detailsOpen)}
|
||||
borderRadius="12"
|
||||
open={isOpen}
|
||||
>
|
||||
<Box
|
||||
as="summary"
|
||||
className={clsx(isOpen && styles.summaryOpen, isOpen ? styles.rowHoverOpen : styles.rowHover)}
|
||||
display="flex"
|
||||
paddingTop="8"
|
||||
paddingRight="12"
|
||||
paddingBottom="8"
|
||||
paddingLeft="12"
|
||||
justifyContent="space-between"
|
||||
cursor="pointer"
|
||||
alignItems="center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
<Box display="flex" alignItems="center">
|
||||
<Box color="darkGray" display="inline-block" marginRight="12">
|
||||
{searchedTraits.length}
|
||||
</Box>
|
||||
<Box
|
||||
color="darkGray"
|
||||
display="inline-block"
|
||||
transition="250"
|
||||
height="28"
|
||||
width="28"
|
||||
style={{
|
||||
transform: `rotate(${isOpen ? 0 : 180}deg)`,
|
||||
}}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Column className={styles.filterDropDowns} paddingLeft="0">
|
||||
{searchedTraits.map((trait) => {
|
||||
const isTraitSelected = selectedTraits.find(
|
||||
({ trait_type, trait_value }) =>
|
||||
trait_type === trait.trait_type && String(trait_value) === String(trait.trait_value)
|
||||
)
|
||||
|
||||
return (
|
||||
<TraitItem
|
||||
isTraitSelected={!!isTraitSelected}
|
||||
key={trait.trait_value}
|
||||
{...{ trait, addTrait, removeTrait }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Column>
|
||||
</Box>
|
||||
) : null
|
||||
}
|
55
src/nft/components/layout/Input.tsx
Normal file
55
src/nft/components/layout/Input.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { FormEvent } from 'react'
|
||||
|
||||
import { Atoms } from '../../css/atoms'
|
||||
import { isNumber } from '../../utils/numbers'
|
||||
import { Box, BoxProps } from '../Box'
|
||||
|
||||
export const defaultInputStyle: Atoms = {
|
||||
borderColor: { default: 'medGray', focus: 'darkGray' },
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: '8',
|
||||
padding: '12',
|
||||
fontSize: '14',
|
||||
color: { placeholder: 'darkGray', default: 'blackBlue' },
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, BoxProps>((props, ref) => (
|
||||
<Box
|
||||
ref={ref}
|
||||
as="input"
|
||||
borderColor={{ default: 'medGray', focus: 'darkGray' }}
|
||||
borderWidth="1px"
|
||||
borderStyle="solid"
|
||||
borderRadius="12"
|
||||
padding="12"
|
||||
fontSize="14"
|
||||
color={{ placeholder: 'darkGray', default: 'blackBlue' }}
|
||||
backgroundColor="transparent"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export const NumericInput = forwardRef<HTMLInputElement, BoxProps>((props, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
as="input"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
onInput={(v: FormEvent<HTMLInputElement>) => {
|
||||
v.currentTarget.value =
|
||||
!!v.currentTarget.value && isNumber(v.currentTarget.value) && parseFloat(v.currentTarget.value) >= 0
|
||||
? v.currentTarget.value
|
||||
: ''
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
NumericInput.displayName = 'Input'
|
@ -1,9 +1,10 @@
|
||||
import { AnimatedBox, Box } from 'nft/components/Box'
|
||||
import { CollectionNfts, CollectionStats, FilterButton, Filters } from 'nft/components/collection'
|
||||
import { CollectionNfts, CollectionStats, Filters } from 'nft/components/collection'
|
||||
import { Column, Row } from 'nft/components/Flex'
|
||||
import { useFiltersExpanded, useIsMobile } from 'nft/hooks'
|
||||
import { useCollectionFilters, useFiltersExpanded, useIsMobile } from 'nft/hooks'
|
||||
import * as styles from 'nft/pages/collection/index.css'
|
||||
import { CollectionStatsFetcher } from 'nft/queries'
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useSpring } from 'react-spring/web'
|
||||
@ -14,7 +15,8 @@ const Collection = () => {
|
||||
const { contractAddress } = useParams()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
|
||||
const [isFiltersExpanded] = useFiltersExpanded()
|
||||
const setMarketCount = useCollectionFilters((state) => state.setMarketCount)
|
||||
|
||||
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
|
||||
CollectionStatsFetcher(contractAddress as string)
|
||||
@ -25,6 +27,14 @@ const Collection = () => {
|
||||
gridWidthOffset: isFiltersExpanded ? FILTER_WIDTH : 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const marketCount: Record<string, number> = {}
|
||||
collectionStats?.marketplaceCount?.forEach(({ marketplace, count }) => {
|
||||
marketCount[marketplace] = count
|
||||
})
|
||||
setMarketCount(marketCount)
|
||||
}, [collectionStats?.marketplaceCount, setMarketCount])
|
||||
|
||||
return (
|
||||
<Column width="full">
|
||||
<Box width="full" height="160">
|
||||
@ -44,7 +54,9 @@ const Collection = () => {
|
||||
)}
|
||||
<Row alignItems="flex-start" position="relative" paddingX="48">
|
||||
<Box position="sticky" top="72" width="0">
|
||||
{isFiltersExpanded && <Filters />}
|
||||
{isFiltersExpanded && (
|
||||
<Filters traitsByAmount={collectionStats?.numTraitsByAmount ?? []} traits={collectionStats?.traits ?? []} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* @ts-ignore: https://github.com/microsoft/TypeScript/issues/34933 */}
|
||||
@ -54,18 +66,6 @@ const Collection = () => {
|
||||
width: gridWidthOffset.interpolate((x) => `calc(100% - ${x as number}px)`),
|
||||
}}
|
||||
>
|
||||
<AnimatedBox position="sticky" top="72" width="full" zIndex="3">
|
||||
<Box backgroundColor="white08" width="full" paddingBottom="8" style={{ backdropFilter: 'blur(24px)' }}>
|
||||
<Row marginTop="12" gap="12">
|
||||
<FilterButton
|
||||
isMobile={isMobile}
|
||||
isFiltersExpanded={isFiltersExpanded}
|
||||
onClick={() => setFiltersExpanded(!isFiltersExpanded)}
|
||||
/>
|
||||
</Row>
|
||||
</Box>
|
||||
</AnimatedBox>
|
||||
|
||||
{contractAddress && <CollectionNfts contractAddress={contractAddress} />}
|
||||
</AnimatedBox>
|
||||
</Row>
|
||||
|
3
src/nft/utils/scrollToTop.ts
Normal file
3
src/nft/utils/scrollToTop.ts
Normal file
@ -0,0 +1,3 @@
|
||||
const DESKTOP_OFFSET = 420
|
||||
|
||||
export const scrollToTop = () => window.scrollTo({ top: DESKTOP_OFFSET })
|
Loading…
Reference in New Issue
Block a user