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 })