Commit b63e9538 authored by aballerr's avatar aballerr Committed by GitHub

chore: Merging in search, sort (#4675)

* Porting over search and porting over sort
parent ef8fba1d
import clsx from 'clsx'
import useDebounce from 'hooks/useDebounce'
import { AnimatedBox, Box } from 'nft/components/Box'
import { FilterButton } from 'nft/components/collection'
import { CollectionSearch, 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 { SortDropdown } from 'nft/components/common/SortDropdown'
import { Center, Row } from 'nft/components/Flex'
import { NonRarityIcon, RarityIcon } from 'nft/components/icons'
import { bodySmall, buttonTextMedium, header2 } from 'nft/css/common.css'
import { useCollectionFilters, useFiltersExpanded, useIsMobile } from 'nft/hooks'
import { vars } from 'nft/css/sprinkles.css'
import {
CollectionFilters,
initialCollectionFilterState,
SortBy,
useCollectionFilters,
useFiltersExpanded,
useIsMobile,
} from 'nft/hooks'
import { AssetsFetcher } from 'nft/queries'
import { UniformHeight, UniformHeights } from 'nft/types'
import { useEffect, useMemo, useState } from 'react'
import { DropDownOption, GenieCollection, UniformHeight, UniformHeights } from 'nft/types'
import { getRarityStatus } from 'nft/utils/asset'
import { applyFiltersFromURL, syncLocalFiltersWithURL } from 'nft/utils/urlParams'
import { useEffect, useMemo, useRef, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { useInfiniteQuery } from 'react-query'
import { useLocation } from 'react-router-dom'
interface CollectionNftsProps {
contractAddress: string
collectionStats: GenieCollection
rarityVerified?: boolean
}
export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNftsProps) => {
const rarityStatusCache = new Map<string, boolean>()
export const CollectionNfts = ({ contractAddress, collectionStats, rarityVerified }: 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 sortBy = useCollectionFilters((state) => state.sortBy)
const searchByNameText = useCollectionFilters((state) => state.search)
const setMarketCount = useCollectionFilters((state) => state.setMarketCount)
const setSortBy = useCollectionFilters((state) => state.setSortBy)
const buyNow = useCollectionFilters((state) => state.buyNow)
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
const isMobile = useIsMobile()
const debouncedMinPrice = useDebounce(minPrice, 500)
const debouncedMaxPrice = useDebounce(maxPrice, 500)
const debouncedSearchByNameText = useDebounce(searchByNameText, 500)
const {
data: collectionAssets,
isSuccess: AssetsFetchSuccess,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery(
......@@ -44,18 +63,37 @@ export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNf
contractAddress,
markets,
notForSale: !buyNow,
price: {
low: debouncedMinPrice,
high: debouncedMaxPrice,
symbol: 'ETH',
},
sortBy,
searchByNameText,
debouncedMinPrice,
debouncedMaxPrice,
searchText: debouncedSearchByNameText,
},
],
async ({ pageParam = 0 }) => {
let sort = undefined
switch (sortBy) {
case SortBy.HighToLow: {
sort = { currentEthPrice: 'desc' }
break
}
case SortBy.RareToCommon: {
sort = { 'rarity.providers.0.rank': 1 }
break
}
case SortBy.CommonToRare: {
sort = { 'rarity.providers.0.rank': -1 }
break
}
default:
}
return await AssetsFetcher({
contractAddress,
sort,
markets,
notForSale: !buyNow,
searchText: debouncedSearchByNameText,
pageParam,
traits,
price: {
......@@ -78,6 +116,9 @@ export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNf
const [uniformHeight, setUniformHeight] = useState<UniformHeight>(UniformHeights.unset)
const [currentTokenPlayingMedia, setCurrentTokenPlayingMedia] = useState<string | undefined>()
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
const oldStateRef = useRef<CollectionFilters | null>(null)
const isMobile = useIsMobile()
const collectionNfts = useMemo(() => {
if (!collectionAssets || !AssetsFetchSuccess) return undefined
......@@ -85,14 +126,88 @@ export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNf
return collectionAssets.pages.flat()
}, [collectionAssets, AssetsFetchSuccess])
const hasRarity = getRarityStatus(rarityStatusCache, collectionStats?.address, collectionNfts)
const sortDropDownOptions: DropDownOption[] = useMemo(
() =>
hasRarity
? [
{
displayText: 'Low to High',
onClick: () => setSortBy(SortBy.LowToHigh),
icon: <NonRarityIcon width="28" height="28" color={vars.color.blue400} />,
reverseIndex: 2,
},
{
displayText: 'High to Low',
onClick: () => setSortBy(SortBy.HighToLow),
icon: <NonRarityIcon width="28" height="28" color={vars.color.blue400} />,
reverseIndex: 1,
},
{
displayText: 'Rare to Common',
onClick: () => setSortBy(SortBy.RareToCommon),
icon: <RarityIcon width="28" height="28" color={vars.color.blue400} />,
reverseIndex: 4,
},
{
displayText: 'Common to Rare',
onClick: () => setSortBy(SortBy.CommonToRare),
icon: <RarityIcon width="28" height="28" color={vars.color.blue400} />,
reverseIndex: 3,
},
]
: [
{
displayText: 'Low to High',
onClick: () => setSortBy(SortBy.LowToHigh),
icon: <NonRarityIcon width="28" height="28" color={vars.color.blue400} />,
reverseIndex: 2,
},
{
displayText: 'High to Low',
onClick: () => setSortBy(SortBy.HighToLow),
icon: <NonRarityIcon width="28" height="28" color={vars.color.blue400} />,
reverseIndex: 1,
},
],
[hasRarity, setSortBy]
)
useEffect(() => {
setUniformHeight(UniformHeights.unset)
return () => {
useCollectionFilters.setState(initialCollectionFilterState)
}
}, [contractAddress])
if (!collectionNfts) {
// TODO: collection unavailable page
return <div>No CollectionAssets</div>
useEffect(() => {
const marketCount: any = {}
collectionStats?.marketplaceCount?.forEach(({ marketplace, count }) => {
marketCount[marketplace] = count
})
setMarketCount(marketCount)
oldStateRef.current = useCollectionFilters.getState()
}, [collectionStats?.marketplaceCount, setMarketCount])
const location = useLocation()
// Applying filters from URL to local state
useEffect(() => {
if (collectionStats?.traits) {
const modifiedQuery = applyFiltersFromURL(location, collectionStats)
requestAnimationFrame(() => {
useCollectionFilters.setState(modifiedQuery as any)
})
useCollectionFilters.subscribe((state) => {
if (JSON.stringify(oldStateRef.current) !== JSON.stringify(state)) {
syncLocalFiltersWithURL(state)
oldStateRef.current = state
}
})
}
}, [collectionStats, location])
return (
<>
......@@ -105,18 +220,19 @@ export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNf
onClick={() => setFiltersExpanded(!isFiltersExpanded)}
collectionCount={collectionNfts?.[0]?.totalCount ?? 0}
/>
<SortDropdown dropDownOptions={sortDropDownOptions} />
<CollectionSearch />
</Row>
</Box>
</AnimatedBox>
<InfiniteScroll
next={fetchNextPage}
hasMore={hasNextPage ?? false}
loader={hasNextPage ? <p>Loading from scroll...</p> : null}
dataLength={collectionNfts.length}
dataLength={collectionNfts?.length ?? 0}
style={{ overflow: 'unset' }}
>
{collectionNfts.length > 0 ? (
{collectionNfts && collectionNfts.length > 0 ? (
<div className={styles.assetList}>
{collectionNfts.map((asset) => {
return asset ? (
......@@ -134,6 +250,7 @@ export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNf
})}
</div>
) : (
!isLoading && (
<Center width="full" color="darkGray" style={{ height: '60vh' }}>
<div style={{ display: 'block', textAlign: 'center' }}>
<p className={header2}>No NFTS found</p>
......@@ -142,6 +259,7 @@ export const CollectionNfts = ({ contractAddress, rarityVerified }: CollectionNf
</Box>
</div>
</Center>
)
)}
</InfiniteScroll>
</>
......
import { Box } from 'nft/components/Box'
import { useCollectionFilters } from 'nft/hooks/useCollectionFilters'
import { FormEvent } from 'react'
export const CollectionSearch = () => {
const setSearchByNameText = useCollectionFilters((state) => state.setSearch)
const searchByNameText = useCollectionFilters((state) => state.search)
return (
<Box
as="input"
borderColor={{ default: 'medGray', focus: 'genieBlue' }}
borderWidth="1px"
borderStyle="solid"
borderRadius="12"
padding="12"
backgroundColor="white"
fontSize="16"
height="44"
color={{ placeholder: 'darkGray', default: 'blackBlue' }}
value={searchByNameText}
placeholder={'Search by name'}
onChange={(e: FormEvent<HTMLInputElement>) => {
setSearchByNameText(e.currentTarget.value)
}}
/>
)
}
......@@ -5,11 +5,11 @@ 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 { Trait } from 'nft/hooks/useCollectionFilters'
import { groupBy } from 'nft/utils/groupBy'
import { FocusEventHandler, FormEvent, useMemo, useState } from 'react'
import { useReducer } from 'react'
import { Trait } from '../../hooks/useCollectionFilters'
import { groupBy } from '../../utils/groupBy'
import { Input } from '../layout/Input'
import { TraitSelect } from './TraitSelect'
......
import { Row } from 'nft/components/Flex'
import { NumericInput } from 'nft/components/layout/Input'
import { useIsMobile } from 'nft/hooks'
import { useCollectionFilters } from 'nft/hooks/useCollectionFilters'
import { isNumber } from 'nft/utils/numbers'
import { scrollToTop } from 'nft/utils/scrollToTop'
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)
......
import clsx from 'clsx'
import useDebounce from 'hooks/useDebounce'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { ChevronUpIcon } from 'nft/components/icons'
import { Checkbox } from 'nft/components/layout/Checkbox'
import { subheadSmall } from 'nft/css/common.css'
import { Trait, useCollectionFilters } from 'nft/hooks/useCollectionFilters'
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 = ({
......
export { CollectionNfts } from './CollectionNfts'
export { CollectionSearch } from './CollectionSearch'
export { CollectionStats } from './CollectionStats'
export { FilterButton } from './FilterButton'
export { Filters } from './Filters'
......@@ -45,3 +45,9 @@ export const selectedActivitySwitcherToggle = style([
},
},
])
export const noCollectionAssets = sprinkles({
display: 'flex',
justifyContent: 'center',
marginTop: '40',
})
......@@ -20,7 +20,7 @@ const Collection = () => {
const setMarketCount = useCollectionFilters((state) => state.setMarketCount)
const isBagExpanded = useBag((state) => state.bagExpanded)
const { data: collectionStats } = useQuery(['collectionStats', contractAddress], () =>
const { data: collectionStats, isLoading } = useQuery(['collectionStats', contractAddress], () =>
CollectionStatsFetcher(contractAddress as string)
)
......@@ -45,6 +45,9 @@ const Collection = () => {
return (
<Column width="full">
{collectionStats && contractAddress ? (
<>
{' '}
<Box width="full" height="160">
<Box
as="img"
......@@ -54,7 +57,6 @@ const Collection = () => {
className={`${styles.bannerImage}`}
/>
</Box>
{collectionStats && (
<Row paddingLeft="32" paddingRight="32">
<CollectionStats stats={collectionStats} isMobile={isMobile} />
......@@ -63,7 +65,10 @@ const Collection = () => {
<Row alignItems="flex-start" position="relative" paddingX="48">
<Box position="sticky" top="72" width="0">
{isFiltersExpanded && (
<Filters traitsByAmount={collectionStats?.numTraitsByAmount ?? []} traits={collectionStats?.traits ?? []} />
<Filters
traitsByAmount={collectionStats?.numTraitsByAmount ?? []}
traits={collectionStats?.traits ?? []}
/>
)}
</Box>
......@@ -74,11 +79,18 @@ const Collection = () => {
width: gridWidthOffset.interpolate((x) => `calc(100% - ${x as number}px)`),
}}
>
{contractAddress && (
<CollectionNfts contractAddress={contractAddress} rarityVerified={collectionStats?.rarityVerified} />
)}
<CollectionNfts
collectionStats={collectionStats}
contractAddress={contractAddress}
rarityVerified={collectionStats?.rarityVerified}
/>
</AnimatedBox>
</Row>
</>
) : (
// TODO: Put no collection asset page here
!isLoading && <div className={styles.noCollectionAssets}>No collection assets exist at this address</div>
)}
</Column>
)
}
......
import { DetailsOrigin, GenieAsset } from 'nft/types'
export function getRarityStatus(
rarityStatusCache: Map<string, boolean>,
id: string,
assets?: (GenieAsset | undefined)[]
) {
if (rarityStatusCache.has(id)) {
return rarityStatusCache.get(id)
}
const hasRarity = assets && Array.from(assets).reduce((reducer, asset) => !!(reducer || asset?.rarity), false)
if (hasRarity) {
rarityStatusCache.set(id, hasRarity)
}
return hasRarity
}
export const getAssetHref = (asset: GenieAsset, origin?: DetailsOrigin) => {
return `/nfts/asset/${asset.address}/${asset.tokenId}${origin ? `?origin=${origin}` : ''}`
}
import { CollectionFilters, initialCollectionFilterState, SortByPointers, Trait } from 'nft/hooks'
import { GenieCollection } from 'nft/types'
import qs from 'query-string'
import { Location } from 'react-router-dom'
const trimTraitStr = (trait: string) => {
return trait.substring(1, trait.length - 1)
}
const urlParamsUtils = {
removeDefaults: (query: Record<string, any>) => {
const clonedQuery: Record<string, any> = { ...query }
// Leveraging default values & not showing them on URL
for (const key in clonedQuery) {
const valueInQuery = clonedQuery[key]
const initialValue = initialCollectionFilterState[key as keyof typeof initialCollectionFilterState]
if (JSON.stringify(valueInQuery) === JSON.stringify(initialValue)) {
delete clonedQuery[key]
}
}
// Doing this one manually due to name mismatch - "all" in url, "buyNow" in state
if (clonedQuery['all'] !== initialCollectionFilterState.buyNow) {
delete clonedQuery['all']
}
const defaultSortByPointer = SortByPointers[initialCollectionFilterState.sortBy]
if (clonedQuery['sort'] === defaultSortByPointer) {
delete clonedQuery['sort']
}
return clonedQuery
},
// Making values in our URL more state-friendly
buildQuery: (query: Record<string, any>, collectionStats: GenieCollection) => {
const clonedQuery: Record<string, any> = { ...query }
const filters = ['traits', 'markets']
filters.forEach((key) => {
if (!clonedQuery[key]) {
clonedQuery[key] = []
}
/*
query-string package treats arrays with one value as a string.
Here we're making sure that we have an array, not a string. Example:
const foo = 'hey' // => ['hey']
*/
if (clonedQuery[key] && typeof clonedQuery[key] === 'string') {
clonedQuery[key] = [clonedQuery[key]]
}
})
try {
const { buyNow: initialBuyNow, search: initialSearchText } = initialCollectionFilterState
Object.entries(SortByPointers).forEach(([key, value]) => {
if (value === clonedQuery['sort']) {
clonedQuery['sortBy'] = Number(key)
}
})
clonedQuery['buyNow'] = !(clonedQuery['all'] === undefined ? !initialBuyNow : clonedQuery['all'])
clonedQuery['search'] = clonedQuery['search'] === undefined ? initialSearchText : String(clonedQuery['search'])
/*
Handling an edge case caused by query-string's bad array parsing, when user
only selects one trait and reloads the page.
Here's the general data-structure for our traits in URL:
`traits=("trait_type","trait_value"),("trait_type","trait_value")`
Expected behavior: When user selects one trait, there should be an array
containing one element.
Actual behavior: It creates an array with two elements, first element being
trait_type & the other trait_value. This causes confusion since we don't know
whether user has selected two traits (cause we have two elements in our array)
or it's only one.
Using this block of code, we'll identify if that's the case.
*/
if (clonedQuery['traits'].length === 2) {
const [trait_type, trait_value] = clonedQuery['traits'] as [string, string]
const fullTrait = `${trait_type}${trait_value}`
if (!fullTrait.includes(',')) {
if (
trait_type.startsWith('(') &&
!trait_type.endsWith(')') &&
trait_value.endsWith(')') &&
!trait_value.startsWith('(')
)
clonedQuery['traits'] = [`${trait_type},${trait_value}`]
}
}
clonedQuery['traits'] = clonedQuery['traits'].map((queryTrait: string) => {
const modifiedTrait = trimTraitStr(queryTrait.replace(/(")/g, ''))
const [trait_type, trait_value] = modifiedTrait.split(',')
const traitInStats = collectionStats.traits.find(
(item) => item.trait_type === trait_type && item.trait_value === trait_value
)
/*
For most cases, `traitInStats` is assigned. In case the trait
does not exist in our store, e.g "Number of traits", we have to
manually create an object for it.
*/
const trait = traitInStats ?? { trait_type, trait_value, trait_count: 0 }
return trait as Trait
})
} catch (err) {
clonedQuery['traits'] = []
}
return clonedQuery
},
}
export const syncLocalFiltersWithURL = (state: CollectionFilters) => {
const urlFilterItems = [
'markets',
'maxPrice',
'maxRarity',
'minPrice',
'minRarity',
'traits',
'all',
'search',
'sort',
] as const
const query: Record<string, any> = {}
urlFilterItems.forEach((key) => {
switch (key) {
case 'traits':
const traits = state.traits.map(({ trait_type, trait_value }) => `("${trait_type}","${trait_value}")`)
query['traits'] = traits
break
case 'all':
query['all'] = !state.buyNow
break
case 'sort':
query['sort'] = SortByPointers[state.sortBy]
break
default:
query[key] = state[key]
break
}
})
const modifiedQuery = urlParamsUtils.removeDefaults(query)
// Applying local state changes to URL
const url = window.location.href.split('?')[0]
const stringifiedQuery = qs.stringify(modifiedQuery, { arrayFormat: 'comma' })
// Using pushState on purpose here. router.push() will trigger re-renders & API calls.
window.history.pushState({}, ``, `${url}${stringifiedQuery && `?${stringifiedQuery}`}`)
}
export const applyFiltersFromURL = (location: Location, collectionStats: GenieCollection) => {
if (!location.search) return
const query = qs.parse(location.search, {
arrayFormat: 'comma',
parseNumbers: true,
parseBooleans: true,
}) as {
maxPrice: string
maxRarity: string
minPrice: string
minRarity: string
search: string
sort: string
sortBy: number
all: boolean
buyNow: boolean
traits: string[]
markets: string[]
}
const modifiedQuery = urlParamsUtils.buildQuery(query, collectionStats)
return modifiedQuery
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment