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 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 { CollectionAsset } from 'nft/components/collection/CollectionAsset'
|
||||||
import * as styles from 'nft/components/collection/CollectionNfts.css'
|
import * as styles from 'nft/components/collection/CollectionNfts.css'
|
||||||
|
import { Row } from 'nft/components/Flex'
|
||||||
import { Center } from 'nft/components/Flex'
|
import { Center } from 'nft/components/Flex'
|
||||||
import { bodySmall, buttonTextMedium, header2 } from 'nft/css/common.css'
|
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 { AssetsFetcher } from 'nft/queries'
|
||||||
import { UniformHeight, UniformHeights } from 'nft/types'
|
import { UniformHeight, UniformHeights } from 'nft/types'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@ -16,7 +19,17 @@ interface CollectionNftsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CollectionNfts = ({ contractAddress }: 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 buyNow = useCollectionFilters((state) => state.buyNow)
|
||||||
|
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
const debouncedMinPrice = useDebounce(minPrice, 500)
|
||||||
|
const debouncedMaxPrice = useDebounce(maxPrice, 500)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: collectionAssets,
|
data: collectionAssets,
|
||||||
isSuccess: AssetsFetchSuccess,
|
isSuccess: AssetsFetchSuccess,
|
||||||
@ -26,15 +39,29 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
|
|||||||
[
|
[
|
||||||
'collectionNfts',
|
'collectionNfts',
|
||||||
{
|
{
|
||||||
|
traits,
|
||||||
contractAddress,
|
contractAddress,
|
||||||
|
markets,
|
||||||
notForSale: !buyNow,
|
notForSale: !buyNow,
|
||||||
|
price: {
|
||||||
|
low: debouncedMinPrice,
|
||||||
|
high: debouncedMaxPrice,
|
||||||
|
symbol: 'ETH',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
async ({ pageParam = 0 }) => {
|
async ({ pageParam = 0 }) => {
|
||||||
return await AssetsFetcher({
|
return await AssetsFetcher({
|
||||||
contractAddress,
|
contractAddress,
|
||||||
|
markets,
|
||||||
notForSale: !buyNow,
|
notForSale: !buyNow,
|
||||||
pageParam,
|
pageParam,
|
||||||
|
traits,
|
||||||
|
price: {
|
||||||
|
low: debouncedMinPrice,
|
||||||
|
high: debouncedMaxPrice,
|
||||||
|
symbol: 'ETH',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -67,6 +94,20 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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
|
<InfiniteScroll
|
||||||
next={fetchNextPage}
|
next={fetchNextPage}
|
||||||
hasMore={hasNextPage ?? false}
|
hasMore={hasNextPage ?? false}
|
||||||
@ -100,5 +141,6 @@ export const CollectionNfts = ({ contractAddress }: CollectionNftsProps) => {
|
|||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -12,11 +12,13 @@ export const FilterButton = ({
|
|||||||
isMobile,
|
isMobile,
|
||||||
isFiltersExpanded,
|
isFiltersExpanded,
|
||||||
results,
|
results,
|
||||||
|
collectionCount = 0,
|
||||||
}: {
|
}: {
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
isFiltersExpanded: boolean
|
isFiltersExpanded: boolean
|
||||||
results?: number
|
results?: number
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
collectionCount?: number
|
||||||
}) => {
|
}) => {
|
||||||
const { minPrice, maxPrice, minRarity, maxRarity, traits, markets, buyNow } = useCollectionFilters((state) => ({
|
const { minPrice, maxPrice, minRarity, maxRarity, traits, markets, buyNow } = useCollectionFilters((state) => ({
|
||||||
minPrice: state.minPrice,
|
minPrice: state.minPrice,
|
||||||
@ -67,7 +69,9 @@ export const FilterButton = ({
|
|||||||
•
|
•
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -1,21 +1,62 @@
|
|||||||
import { Box } from 'nft/components/Box'
|
import { Box } from 'nft/components/Box'
|
||||||
import * as styles from 'nft/components/collection/Filters.css'
|
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 { Column, Row } from 'nft/components/Flex'
|
||||||
import { Radio } from 'nft/components/layout/Radio'
|
import { Radio } from 'nft/components/layout/Radio'
|
||||||
import { useCollectionFilters } from 'nft/hooks'
|
import { useCollectionFilters } from 'nft/hooks'
|
||||||
|
import { FocusEventHandler, FormEvent, useMemo, useState } from 'react'
|
||||||
import { useReducer } 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) => ({
|
const { buyNow, setBuyNow } = useCollectionFilters((state) => ({
|
||||||
buyNow: state.buyNow,
|
buyNow: state.buyNow,
|
||||||
setBuyNow: state.setBuyNow,
|
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 [buyNowHovered, toggleBuyNowHover] = useReducer((state) => !state, false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const handleBuyNowToggle = () => {
|
const handleBuyNowToggle = () => {
|
||||||
setBuyNow(!buyNow)
|
setBuyNow(!buyNow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
e.currentTarget.placeholder = ''
|
||||||
|
}
|
||||||
|
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
e.currentTarget.placeholder = 'Search traits'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={styles.container}>
|
<Box className={styles.container}>
|
||||||
<Row width="full" justifyContent="space-between">
|
<Row width="full" justifyContent="space-between">
|
||||||
@ -45,6 +86,36 @@ export const Filters = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
<Radio hovered={buyNowHovered} checked={buyNow} onClick={handleBuyNowToggle} />
|
<Radio hovered={buyNowHovered} checked={buyNow} onClick={handleBuyNowToggle} />
|
||||||
</Row>
|
</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>
|
</Column>
|
||||||
</Box>
|
</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 { 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 { 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 * as styles from 'nft/pages/collection/index.css'
|
||||||
import { CollectionStatsFetcher } from 'nft/queries'
|
import { CollectionStatsFetcher } from 'nft/queries'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { useQuery } from 'react-query'
|
import { useQuery } from 'react-query'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useSpring } from 'react-spring/web'
|
import { useSpring } from 'react-spring/web'
|
||||||
@ -14,7 +15,8 @@ const Collection = () => {
|
|||||||
const { contractAddress } = useParams()
|
const { contractAddress } = useParams()
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
|
const [isFiltersExpanded] = useFiltersExpanded()
|
||||||
|
const setMarketCount = useCollectionFilters((state) => state.setMarketCount)
|
||||||
|
|
||||||
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
|
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
|
||||||
CollectionStatsFetcher(contractAddress as string)
|
CollectionStatsFetcher(contractAddress as string)
|
||||||
@ -25,6 +27,14 @@ const Collection = () => {
|
|||||||
gridWidthOffset: isFiltersExpanded ? FILTER_WIDTH : 0,
|
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 (
|
return (
|
||||||
<Column width="full">
|
<Column width="full">
|
||||||
<Box width="full" height="160">
|
<Box width="full" height="160">
|
||||||
@ -44,7 +54,9 @@ const Collection = () => {
|
|||||||
)}
|
)}
|
||||||
<Row alignItems="flex-start" position="relative" paddingX="48">
|
<Row alignItems="flex-start" position="relative" paddingX="48">
|
||||||
<Box position="sticky" top="72" width="0">
|
<Box position="sticky" top="72" width="0">
|
||||||
{isFiltersExpanded && <Filters />}
|
{isFiltersExpanded && (
|
||||||
|
<Filters traitsByAmount={collectionStats?.numTraitsByAmount ?? []} traits={collectionStats?.traits ?? []} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* @ts-ignore: https://github.com/microsoft/TypeScript/issues/34933 */}
|
{/* @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)`),
|
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} />}
|
{contractAddress && <CollectionNfts contractAddress={contractAddress} />}
|
||||||
</AnimatedBox>
|
</AnimatedBox>
|
||||||
</Row>
|
</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