From e180153c3a753143ec38f69a1019ad3830695490 Mon Sep 17 00:00:00 2001
From: aballerr
Date: Wed, 21 Sep 2022 07:22:05 -0400
Subject: [PATCH] chore: merge marketplace and traits (#4645)
* porting of filters
Co-authored-by: Jack Short
---
.../components/collection/CollectionNfts.tsx | 110 +++++++---
.../components/collection/FilterButton.tsx | 6 +-
src/nft/components/collection/Filters.tsx | 73 ++++++-
.../collection/MarketplaceSelect.tsx | 145 +++++++++++++
src/nft/components/collection/PriceRange.tsx | 86 ++++++++
.../components/collection/TraitSelect.css.ts | 12 ++
src/nft/components/collection/TraitSelect.tsx | 194 ++++++++++++++++++
src/nft/components/layout/Input.tsx | 55 +++++
src/nft/pages/collection/index.tsx | 32 +--
src/nft/utils/scrollToTop.ts | 3 +
10 files changed, 664 insertions(+), 52 deletions(-)
create mode 100644 src/nft/components/collection/MarketplaceSelect.tsx
create mode 100644 src/nft/components/collection/PriceRange.tsx
create mode 100644 src/nft/components/collection/TraitSelect.css.ts
create mode 100644 src/nft/components/collection/TraitSelect.tsx
create mode 100644 src/nft/components/layout/Input.tsx
create mode 100644 src/nft/utils/scrollToTop.ts
diff --git a/src/nft/components/collection/CollectionNfts.tsx b/src/nft/components/collection/CollectionNfts.tsx
index 39dc396a1c..d2608c3c71 100644
--- a/src/nft/components/collection/CollectionNfts.tsx
+++ b/src/nft/components/collection/CollectionNfts.tsx
@@ -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 (
- Loading from scroll...
: null}
- dataLength={collectionNfts.length}
- style={{ overflow: 'unset' }}
- >
- {collectionNfts.length > 0 ? (
-
- {collectionNfts.map((asset) => {
- return asset ? (
-
- ) : null
- })}
-
- ) : (
-
-
-
No NFTS found
-
- View full collection
-
+ <>
+
+
+
+ setFiltersExpanded(!isFiltersExpanded)}
+ collectionCount={collectionNfts?.[0]?.totalCount ?? 0}
+ />
+
+
+
+
+
Loading from scroll... : null}
+ dataLength={collectionNfts.length}
+ style={{ overflow: 'unset' }}
+ >
+ {collectionNfts.length > 0 ? (
+
+ {collectionNfts.map((asset) => {
+ return asset ? (
+
+ ) : null
+ })}
-
- )}
-
+ ) : (
+
+
+
No NFTS found
+
+ View full collection
+
+
+
+ )}
+
+ >
)
}
diff --git a/src/nft/components/collection/FilterButton.tsx b/src/nft/components/collection/FilterButton.tsx
index 3bf667f791..914a3dc1f2 100644
--- a/src/nft/components/collection/FilterButton.tsx
+++ b/src/nft/components/collection/FilterButton.tsx
@@ -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 = ({
•
)}
-
{results ? putCommas(results) : 0} results
+
+ {collectionCount > 0 ? putCommas(collectionCount) : 0} results
+
) : null}
diff --git a/src/nft/components/collection/Filters.tsx b/src/nft/components/collection/Filters.tsx
index ce5dce238a..5fc92e62f9 100644
--- a/src/nft/components/collection/Filters.tsx
+++ b/src/nft/components/collection/Filters.tsx
@@ -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
= 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 = (e) => {
+ e.currentTarget.placeholder = ''
+ }
+ const handleBlur: FocusEventHandler = (e) => {
+ e.currentTarget.placeholder = 'Search traits'
+ }
+
return (
@@ -45,6 +86,36 @@ export const Filters = () => {
+
+
+
+ Price
+
+
+
+
+
+ Traits
+
+
+
+ ) => 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]) => (
+
+ ))}
+
+
)
diff --git a/src/nft/components/collection/MarketplaceSelect.tsx b/src/nft/components/collection/MarketplaceSelect.tsx
new file mode 100644
index 0000000000..c89e1951f0
--- /dev/null
+++ b/src/nft/components/collection/MarketplaceSelect.tsx
@@ -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 (
+
+
+ {title}{' '}
+
+
+
+ {count}
+
+
+
+ )
+}
+
+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 (
+
+ {
+ e.preventDefault()
+ setOpen(!isOpen)
+ }}
+ >
+ Marketplaces
+
+
+
+
+
+ {Object.entries(marketPlaceItems).map(([value, title]) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/nft/components/collection/PriceRange.tsx b/src/nft/components/collection/PriceRange.tsx
new file mode 100644
index 0000000000..56893c86ff
--- /dev/null
+++ b/src/nft/components/collection/PriceRange.tsx
@@ -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 = (e) => {
+ setPlaceholderText(e.currentTarget.placeholder)
+ e.currentTarget.placeholder = ''
+ }
+
+ const handleBlur: FocusEventHandler = (e) => {
+ e.currentTarget.placeholder = placeholderText
+ setPlaceholderText('')
+ }
+
+ return (
+
+
+ ) => {
+ scrollToTop()
+ setMinPrice(isNumber(v.currentTarget.value) ? parseFloat(v.currentTarget.value) : '')
+ }}
+ onFocus={handleFocus}
+ value={minPrice}
+ onBlur={handleBlur}
+ />
+
+
+ ) => {
+ scrollToTop()
+ setMaxPrice(isNumber(v.currentTarget.value) ? parseFloat(v.currentTarget.value) : '')
+ }}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ />
+
+
+ )
+}
diff --git a/src/nft/components/collection/TraitSelect.css.ts b/src/nft/components/collection/TraitSelect.css.ts
new file mode 100644
index 0000000000..0a08c5d265
--- /dev/null
+++ b/src/nft/components/collection/TraitSelect.css.ts
@@ -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',
+ },
+ },
+})
diff --git a/src/nft/components/collection/TraitSelect.tsx b/src/nft/components/collection/TraitSelect.tsx
new file mode 100644
index 0000000000..1508b2744c
--- /dev/null
+++ b/src/nft/components/collection/TraitSelect.tsx
@@ -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) => {
+ 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 (
+
+ 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}
+
+
+
+ {!showFullTraitName && trait.trait_count}
+
+
+
+ )
+}
+
+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 ? (
+
+ {
+ e.preventDefault()
+ setOpen(!isOpen)
+ }}
+ >
+ {type}
+
+
+ {searchedTraits.length}
+
+
+
+
+
+
+
+ {searchedTraits.map((trait) => {
+ const isTraitSelected = selectedTraits.find(
+ ({ trait_type, trait_value }) =>
+ trait_type === trait.trait_type && String(trait_value) === String(trait.trait_value)
+ )
+
+ return (
+
+ )
+ })}
+
+
+ ) : null
+}
diff --git a/src/nft/components/layout/Input.tsx b/src/nft/components/layout/Input.tsx
new file mode 100644
index 0000000000..4953189314
--- /dev/null
+++ b/src/nft/components/layout/Input.tsx
@@ -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((props, ref) => (
+
+))
+
+Input.displayName = 'Input'
+
+export const NumericInput = forwardRef((props, ref) => {
+ return (
+ ) => {
+ v.currentTarget.value =
+ !!v.currentTarget.value && isNumber(v.currentTarget.value) && parseFloat(v.currentTarget.value) >= 0
+ ? v.currentTarget.value
+ : ''
+ }}
+ {...props}
+ />
+ )
+})
+
+NumericInput.displayName = 'Input'
diff --git a/src/nft/pages/collection/index.tsx b/src/nft/pages/collection/index.tsx
index c58d3a1643..118423f163 100644
--- a/src/nft/pages/collection/index.tsx
+++ b/src/nft/pages/collection/index.tsx
@@ -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 = {}
+ collectionStats?.marketplaceCount?.forEach(({ marketplace, count }) => {
+ marketCount[marketplace] = count
+ })
+ setMarketCount(marketCount)
+ }, [collectionStats?.marketplaceCount, setMarketCount])
+
return (
@@ -44,7 +54,9 @@ const Collection = () => {
)}
- {isFiltersExpanded && }
+ {isFiltersExpanded && (
+
+ )}
{/* @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)`),
}}
>
-
-
-
- setFiltersExpanded(!isFiltersExpanded)}
- />
-
-
-
-
{contractAddress && }
diff --git a/src/nft/utils/scrollToTop.ts b/src/nft/utils/scrollToTop.ts
new file mode 100644
index 0000000000..f2f1d06b98
--- /dev/null
+++ b/src/nft/utils/scrollToTop.ts
@@ -0,0 +1,3 @@
+const DESKTOP_OFFSET = 420
+
+export const scrollToTop = () => window.scrollTo({ top: DESKTOP_OFFSET })