From 49c5cbbf3b620b66cf2cb142f4afb7f01ba89ee6 Mon Sep 17 00:00:00 2001 From: Charles Bachmeier Date: Fri, 16 Sep 2022 10:55:26 -0700 Subject: [PATCH] feat: Add sell page filters sidebar (#4630) * working sell filters * split filters into own file * include new file * fix eslint warnings * update filter button param and fix rerender bug * de morgon's law * usecallback * move max_padding * extend htmlinputelement for checkbox * styles cleanup * simplify checkbox sprinkles * add null check to collectionfilteritem * remove x axis scrollbar on collections * update fitlerbutton logic * scrollbar width Co-authored-by: Charles Bachmeier --- .../components/collection/FilterButton.tsx | 10 +- .../common/Loading/LoadingSparkle.css.ts | 4 +- .../common/SortDropdown/SortDropdown.css.ts | 9 + .../common/SortDropdown/SortDropdown.tsx | 240 ++++++++++++++ .../components/common/SortDropdown/index.ts | 1 + src/nft/components/layout/Checkbox.css.ts | 49 +++ src/nft/components/layout/Checkbox.tsx | 37 +++ .../components/sell/select/FilterSidebar.tsx | 239 ++++++++++++++ src/nft/components/sell/select/SelectPage.tsx | 312 ++++++++++++++++-- src/nft/pages/sell/sell.css.ts | 2 +- 10 files changed, 864 insertions(+), 39 deletions(-) create mode 100644 src/nft/components/common/SortDropdown/SortDropdown.css.ts create mode 100644 src/nft/components/common/SortDropdown/SortDropdown.tsx create mode 100644 src/nft/components/common/SortDropdown/index.ts create mode 100644 src/nft/components/layout/Checkbox.css.ts create mode 100644 src/nft/components/layout/Checkbox.tsx create mode 100644 src/nft/components/sell/select/FilterSidebar.tsx diff --git a/src/nft/components/collection/FilterButton.tsx b/src/nft/components/collection/FilterButton.tsx index 04a3e7d0ff..3bf667f791 100644 --- a/src/nft/components/collection/FilterButton.tsx +++ b/src/nft/components/collection/FilterButton.tsx @@ -3,8 +3,9 @@ import { Box } from 'nft/components/Box' import * as styles from 'nft/components/collection/FilterButton.css' import { Row } from 'nft/components/Flex' import { FilterIcon } from 'nft/components/icons' -import { useCollectionFilters } from 'nft/hooks' +import { useCollectionFilters, useWalletCollections } from 'nft/hooks' import { putCommas } from 'nft/utils/putCommas' +import { useLocation } from 'react-router-dom' export const FilterButton = ({ onClick, @@ -26,8 +27,13 @@ export const FilterButton = ({ markets: state.markets, buyNow: state.buyNow, })) + const collectionFilters = useWalletCollections((state) => state.collectionFilters) + const { pathname } = useLocation() + const isSellPage = pathname.startsWith('/nfts/sell') - const showFilterBadge = minPrice || maxPrice || minRarity || maxRarity || traits.length || markets.length || buyNow + const showFilterBadge = isSellPage + ? collectionFilters.length > 0 + : minPrice || maxPrice || minRarity || maxRarity || traits.length || markets.length || buyNow return ( { + const [isOpen, toggleOpen] = useReducer((s) => !s, false) + const [isReversed, toggleReversed] = useReducer((s) => !s, false) + const [selectedIndex, setSelectedIndex] = useState(0) + + const [maxWidth, setMaxWidth] = useState(0) + + const ref = useRef(null) + useOnClickOutside(ref, () => isOpen && toggleOpen()) + + useEffect(() => setMaxWidth(0), [dropDownOptions]) + + const reversable = useMemo( + () => dropDownOptions[selectedIndex].reverseOnClick || dropDownOptions[selectedIndex].reverseIndex, + [selectedIndex, dropDownOptions] + ) + + return ( + + + + {!isOpen && reversable && ( + { + e.stopPropagation() + + if (dropDownOptions[selectedIndex].reverseOnClick) { + dropDownOptions[selectedIndex].reverseOnClick?.() + toggleReversed() + } else { + dropDownOptions[dropDownOptions[selectedIndex].reverseIndex ?? 1 - 1].onClick() + setSelectedIndex(dropDownOptions[selectedIndex].reverseIndex ?? 1 - 1) + } + }} + > + {dropDownOptions[selectedIndex].reverseOnClick && (isReversed ? : )} + {dropDownOptions[selectedIndex].reverseIndex && + (selectedIndex > (dropDownOptions[selectedIndex].reverseIndex ?? 1) - 1 ? ( + + ) : ( + + ))} + + )} + + {mini ? miniPrompt : isOpen ? 'Sort by' : dropDownOptions[selectedIndex].displayText} + + + + + + + {!maxWidth + ? [ + dropDownOptions.reduce((acc, curr) => { + return curr.displayText.length >= acc.displayText.length ? curr : acc + }, dropDownOptions[0]), + ].map((option, index) => { + return + }) + : isOpen && + dropDownOptions.map((option, index) => { + return ( + { + dropDownOptions[index].onClick() + setSelectedIndex(index) + toggleOpen() + isReversed && toggleReversed() + }} + /> + ) + })} + + + ) +} + +const DropDownItem = ({ + option, + index, + onClick, + mini, +}: { + option: DropDownOption + index: number + onClick?: () => void + mini?: boolean +}) => { + return ( + + {option.icon && ( + + {option.icon} + + )} + + {option.displayText} + + + ) +} + +const MAX_PADDING = 52 + +const LargestItem = ({ + option, + index, + setMaxWidth, +}: { + option: DropDownOption + index: number + setMaxWidth: (width: number) => void +}) => { + const maxWidthRef = useRef(null) + + useLayoutEffect(() => { + if (maxWidthRef && maxWidthRef.current) { + setMaxWidth(Math.ceil(maxWidthRef.current.getBoundingClientRect().width) + MAX_PADDING) + } + }) + + return ( + + + + ) +} diff --git a/src/nft/components/common/SortDropdown/index.ts b/src/nft/components/common/SortDropdown/index.ts new file mode 100644 index 0000000000..b24555040f --- /dev/null +++ b/src/nft/components/common/SortDropdown/index.ts @@ -0,0 +1 @@ +export * from './SortDropdown' diff --git a/src/nft/components/layout/Checkbox.css.ts b/src/nft/components/layout/Checkbox.css.ts new file mode 100644 index 0000000000..d99eb7631b --- /dev/null +++ b/src/nft/components/layout/Checkbox.css.ts @@ -0,0 +1,49 @@ +import { style } from '@vanilla-extract/css' +import { sprinkles } from 'nft/css/sprinkles.css' + +export const input = style([ + sprinkles({ position: 'absolute' }), + { + top: '-24px', + selectors: { + '&[type="checkbox"]': { + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', + }, + }, + }, +]) + +export const checkbox = style([ + sprinkles({ + display: 'inline-block', + marginRight: '1', + borderRadius: '4', + height: '24', + width: '24', + borderStyle: 'solid', + borderWidth: '2px', + }), +]) + +export const checkMark = sprinkles({ + display: 'none', + height: '24', + width: '24', + color: 'blue400', +}) + +export const checkMarkActive = style([ + sprinkles({ + display: 'inline-block', + color: 'blue400', + position: 'absolute', + top: '0', + right: '1', + }), +]) diff --git a/src/nft/components/layout/Checkbox.tsx b/src/nft/components/layout/Checkbox.tsx new file mode 100644 index 0000000000..b3e2501676 --- /dev/null +++ b/src/nft/components/layout/Checkbox.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx' +import { Box } from 'nft/components/Box' +import { ApprovedCheckmarkIcon } from 'nft/components/icons' +import React from 'react' + +import * as styles from './Checkbox.css' + +interface CheckboxProps extends React.InputHTMLAttributes { + hovered: boolean + children: React.ReactNode +} + +export const Checkbox: React.FC = ({ hovered, children, ...props }: CheckboxProps) => { + return ( + + {children} + + ) +} diff --git a/src/nft/components/sell/select/FilterSidebar.tsx b/src/nft/components/sell/select/FilterSidebar.tsx new file mode 100644 index 0000000000..35c9f576ca --- /dev/null +++ b/src/nft/components/sell/select/FilterSidebar.tsx @@ -0,0 +1,239 @@ +import { AnimatedBox, Box } from 'nft/components/Box' +import { Column, Row } from 'nft/components/Flex' +import { XMarkIcon } from 'nft/components/icons' +import { Checkbox } from 'nft/components/layout/Checkbox' +import { buttonTextSmall, headlineSmall } from 'nft/css/common.css' +import { themeVars } from 'nft/css/sprinkles.css' +import { useFiltersExpanded, useIsMobile, useWalletCollections } from 'nft/hooks' +import { WalletCollection } from 'nft/types' +import { Dispatch, FormEvent, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react' +import { useSpring } from 'react-spring/web' + +import * as styles from './SelectPage.css' + +export const FilterSidebar = ({ SortDropdown }: { SortDropdown: () => JSX.Element }) => { + const collectionFilters = useWalletCollections((state) => state.collectionFilters) + const setCollectionFilters = useWalletCollections((state) => state.setCollectionFilters) + + const walletCollections = useWalletCollections((state) => state.walletCollections) + const listFilter = useWalletCollections((state) => state.listFilter) + const setListFilter = useWalletCollections((state) => state.setListFilter) + + const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded() + const isMobile = useIsMobile() + + const { sidebarX } = useSpring({ + sidebarX: isFiltersExpanded ? 0 : -360, + }) + return ( + // @ts-ignore + `translateX(${x}px)`) }} + > + + + + Filters + + {isMobile && ( + setFiltersExpanded(false)} + > + + + )} + + + + + {isMobile && ( + + + + )} + + + + ) +} + +const CollectionSelect = ({ + collections, + collectionFilters, + setCollectionFilters, +}: { + collections: WalletCollection[] + collectionFilters: Array + setCollectionFilters: (address: string) => void +}) => { + const [collectionSearchText, setCollectionSearchText] = useState('') + const [displayCollections, setDisplayCollections] = useState(collections) + + useEffect(() => { + if (collectionSearchText) { + const filtered = collections.filter((collection) => + collection.name?.toLowerCase().includes(collectionSearchText.toLowerCase()) + ) + setDisplayCollections(filtered) + } else { + setDisplayCollections(collections) + } + }, [collectionSearchText, collections]) + + return ( + <> + + Collections + + + + + + {displayCollections?.map((collection, index) => ( + + ))} + + + + + ) +} + +const CollectionFilterSearch = ({ + collectionSearchText, + setCollectionSearchText, +}: { + collectionSearchText: string + setCollectionSearchText: Dispatch> +}) => { + return ( + ) => setCollectionSearchText(e.currentTarget.value)} + /> + ) +} + +const CollectionItem = ({ + collection, + collectionFilters, + setCollectionFilters, +}: { + collection: WalletCollection + collectionFilters: Array + setCollectionFilters: (address: string) => void +}) => { + const [isCheckboxSelected, setCheckboxSelected] = useState(false) + const [hovered, toggleHovered] = useReducer((state) => { + return !state + }, false) + const isChecked = useCallback( + (address: string) => { + return collectionFilters.some((collection) => collection === address) + }, + [collectionFilters] + ) + const handleCheckbox = () => { + setCheckboxSelected(!isCheckboxSelected) + setCollectionFilters(collection.address) + } + return ( + + + + {collection.name}{' '} + + + + {collection.count} + + + + ) +} + +const statusArray = ['All', 'Unlisted', 'Listed'] + +const ListStatusFilterButtons = ({ + listFilter, + setListFilter, +}: { + listFilter: string + setListFilter: (value: string) => void +}) => { + return ( + <> + {statusArray.map((value, index) => ( + setListFilter(value)} + width="max" + padding="14" + cursor="pointer" + > + {value} + + ))} + + ) +} diff --git a/src/nft/components/sell/select/SelectPage.tsx b/src/nft/components/sell/select/SelectPage.tsx index 1abae004c1..6c19834784 100644 --- a/src/nft/components/sell/select/SelectPage.tsx +++ b/src/nft/components/sell/select/SelectPage.tsx @@ -1,20 +1,49 @@ import clsx from 'clsx' import { AnimatedBox, Box } from 'nft/components/Box' import { assetList } from 'nft/components/collection/CollectionNfts.css' +import { FilterButton } from 'nft/components/collection/FilterButton' import { LoadingSparkle } from 'nft/components/common/Loading/LoadingSparkle' +import { SortDropdown } from 'nft/components/common/SortDropdown' import { Center, Column, Row } from 'nft/components/Flex' -import { VerifiedIcon } from 'nft/components/icons' +import { + BagFillIcon, + ClockIconFilled, + CrossIcon, + NonRarityIconFilled, + PaintPaletteIconFilled, + TagFillIcon, + VerifiedIcon, +} from 'nft/components/icons' +import { FilterSidebar } from 'nft/components/sell/select/FilterSidebar' import { subhead, subheadSmall } from 'nft/css/common.css' -import { useBag, useIsMobile, useSellAsset, useSellPageState, useWalletBalance, useWalletCollections } from 'nft/hooks' +import { vars } from 'nft/css/sprinkles.css' +import { + useBag, + useFiltersExpanded, + useIsMobile, + useSellAsset, + useSellPageState, + useWalletBalance, + useWalletCollections, +} from 'nft/hooks' import { fetchMultipleCollectionStats, fetchWalletAssets, OSCollectionsFetcher } from 'nft/queries' -import { SellPageStateType, WalletAsset } from 'nft/types' +import { DropDownOption, SellPageStateType, WalletAsset, WalletCollection } from 'nft/types' import { Dispatch, FormEvent, SetStateAction, useEffect, useMemo, useReducer, useState } from 'react' import InfiniteScroll from 'react-infinite-scroll-component' import { useInfiniteQuery, useQuery } from 'react-query' import { Link } from 'react-router-dom' +import { useSpring } from 'react-spring/web' import * as styles from './SelectPage.css' +enum SortBy { + FloorPrice, + LastPrice, + DateAcquired, + DateCreated, + DateListed, +} + const formatEth = (price: number) => { if (price > 1000000) { return `${Math.round(price / 1000000)}M` @@ -32,6 +61,8 @@ function roundFloorPrice(price?: number, n?: number) { export const SelectPage = () => { const { address } = useWalletBalance() const collectionFilters = useWalletCollections((state) => state.collectionFilters) + const setCollectionFilters = useWalletCollections((state) => state.setCollectionFilters) + const clearCollectionFilters = useWalletCollections((state) => state.clearCollectionFilters) const { data: ownerCollections } = useQuery( ['ownerCollections', address], @@ -79,11 +110,17 @@ export const SelectPage = () => { const setWalletAssets = useWalletCollections((state) => state.setWalletAssets) const displayAssets = useWalletCollections((state) => state.displayAssets) const setDisplayAssets = useWalletCollections((state) => state.setDisplayAssets) + const walletCollections = useWalletCollections((state) => state.walletCollections) const setWalletCollections = useWalletCollections((state) => state.setWalletCollections) + const listFilter = useWalletCollections((state) => state.listFilter) const sellAssets = useSellAsset((state) => state.sellAssets) const reset = useSellAsset((state) => state.reset) const setSellPageState = useSellPageState((state) => state.setSellPageState) + const [sortBy, setSortBy] = useState(SortBy.DateAcquired) + const [orderByASC, setOrderBy] = useState(true) const [searchText, setSearchText] = useState('') + const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded() + const isMobile = useIsMobile() useEffect(() => { setWalletAssets(ownerAssets?.flat() ?? []) @@ -93,8 +130,6 @@ export const SelectPage = () => { ownerCollections && setWalletCollections(ownerCollections) }, [ownerCollections, setWalletCollections]) - const listFilter = useWalletCollections((state) => state.listFilter) - useEffect(() => { if (searchText) { const filtered = walletAssets.filter((asset) => asset.name?.toLowerCase().includes(searchText.toLowerCase())) @@ -115,47 +150,169 @@ export const SelectPage = () => { } }, [collectionStats, ownerCollections, setWalletCollections]) + useEffect(() => { + const sorted = displayAssets && [...displayAssets] + if (sortBy === SortBy.FloorPrice && orderByASC) sorted?.sort((a, b) => (b.floorPrice || 0) - (a.floorPrice || 0)) + else if (sortBy === SortBy.FloorPrice && !orderByASC) + sorted?.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)) + else if (sortBy === SortBy.LastPrice && orderByASC) sorted?.sort((a, b) => b.lastPrice - a.lastPrice) + else if (sortBy === SortBy.LastPrice && !orderByASC) sorted?.sort((a, b) => a.lastPrice - b.lastPrice) + else if (sortBy === SortBy.DateCreated && orderByASC) + sorted?.sort( + (a, b) => new Date(a.asset_contract.created_date).getTime() - new Date(b.asset_contract.created_date).getTime() + ) + else if (sortBy === SortBy.DateCreated && !orderByASC) + sorted?.sort( + (a, b) => new Date(b.asset_contract.created_date).getTime() - new Date(a.asset_contract.created_date).getTime() + ) + else if (sortBy === SortBy.DateAcquired && orderByASC) + sorted?.sort((a, b) => new Date(a.date_acquired).getTime() - new Date(b.date_acquired).getTime()) + else if (sortBy === SortBy.DateAcquired && !orderByASC) + sorted?.sort((a, b) => new Date(b.date_acquired).getTime() - new Date(a.date_acquired).getTime()) + else if (sortBy === SortBy.DateListed && orderByASC) sorted?.sort((a, b) => +b.listing_date - +a.listing_date) + else if (sortBy === SortBy.DateListed && !orderByASC) sorted?.sort((a, b) => +a.listing_date - +b.listing_date) + setDisplayAssets(sorted, listFilter) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortBy, orderByASC, listFilter]) + + useEffect(() => { + if (ownerCollections?.length && collectionStats?.length) { + const ownerCollectionsCopy = [...ownerCollections] + for (const collection of ownerCollectionsCopy) { + const floorPrice = collectionStats.find((stat) => stat.address === collection.address)?.floorPrice + collection.floorPrice = floorPrice ? Math.round(floorPrice * 1000 + Number.EPSILON) / 1000 : 0 //round to at most 3 digits + } + setWalletCollections(ownerCollectionsCopy) + } + }, [collectionStats, ownerCollections, setWalletCollections]) + + const { gridX, gridWidthOffset } = useSpring({ + gridX: isFiltersExpanded ? 300 : -16, + gridWidthOffset: isFiltersExpanded ? 300 /* right padding */ : 0, + }) + + const sortDropDownOptions: DropDownOption[] = useMemo( + () => [ + { + displayText: 'Floor price', + onClick: () => { + setOrderBy(false) + setSortBy(SortBy.FloorPrice) + }, + icon: , + reverseOnClick: () => setOrderBy(!orderByASC), + }, + { + displayText: 'Last price', + onClick: () => { + setOrderBy(false) + setSortBy(SortBy.LastPrice) + }, + icon: , + reverseOnClick: () => setOrderBy(!orderByASC), + }, + { + displayText: 'Date acquired', + onClick: () => { + setOrderBy(false) + setSortBy(SortBy.DateAcquired) + }, + icon: , + reverseOnClick: () => setOrderBy(!orderByASC), + }, + { + displayText: 'Date created', + onClick: () => { + setOrderBy(false) + setSortBy(SortBy.DateCreated) + }, + icon: , + reverseOnClick: () => setOrderBy(!orderByASC), + }, + { + displayText: 'Date listed', + onClick: () => { + setOrderBy(false) + setSortBy(SortBy.DateListed) + }, + icon: , + reverseOnClick: () => setOrderBy(!orderByASC), + }, + ], + [orderByASC] + ) + + const SortWalletAssetsDropdown = () => + return ( - // Column style is temporary while we move over the filters bar that adjust width - + - - - - - - - - - ) : null - } - dataLength={displayAssets.length} - style={{ overflow: 'unset' }} + + + {(!isMobile || !isFiltersExpanded) && ( + // @ts-ignore + `translate(${Number(x) - (!isMobile && isFiltersExpanded ? 300 : 0)}px)` + ), + width: gridWidthOffset.interpolate((x) => `calc(100% - ${x}px)`), + }} > -
- {displayAssets && displayAssets.length - ? displayAssets.map((asset, index) => ) - : null} -
-
-
+ + setFiltersExpanded(!isFiltersExpanded)} + /> + {!isMobile && } + + + + + + + + + + ) : null + } + dataLength={displayAssets.length} + style={{ overflow: 'unset' }} + > +
+ {displayAssets && displayAssets.length + ? displayAssets.map((asset, index) => ) + : null} +
+
+ + )}
{sellAssets.length > 0 && ( { const resetSellAssets = useSellAsset((state) => state.reset) useEffect(() => { - if (!isAllSelected) resetSellAssets() if (isAllSelected) { displayAssets.forEach((asset) => selectSellAsset(asset)) + } else { + resetSellAssets() } - }, [displayAssets, isAllSelected, resetSellAssets, selectSellAsset]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAllSelected, resetSellAssets, selectSellAsset]) const toggleAllSelected = () => { setIsAllSelected(!isAllSelected) @@ -356,6 +515,89 @@ const SelectAllButton = () => { ) } +const CollectionFiltersRow = ({ + collections, + collectionFilters, + setCollectionFilters, + clearCollectionFilters, +}: { + collections: WalletCollection[] + collectionFilters: Array + setCollectionFilters: (address: string) => void + clearCollectionFilters: Dispatch> +}) => { + const getCollection = (collectionAddress: string) => { + return collections?.find((collection) => collection.address === collectionAddress) + } + return ( + + {collectionFilters && + collectionFilters.map((collectionAddress, index) => ( + + ))} + {collectionFilters?.length ? ( + clearCollectionFilters()} + > + Clear all + + ) : null} + + ) +} + +const CollectionFilterItem = ({ + collection, + setCollectionFilters, +}: { + collection: WalletCollection | undefined + setCollectionFilters: (address: string) => void +}) => { + if (!collection) return null + return ( + + + + {collection?.name} + + setCollectionFilters(collection.address)} + > + + + + ) +} + const CollectionSearch = ({ searchText, setSearchText, diff --git a/src/nft/pages/sell/sell.css.ts b/src/nft/pages/sell/sell.css.ts index 8300fa9553..3bb4af7329 100644 --- a/src/nft/pages/sell/sell.css.ts +++ b/src/nft/pages/sell/sell.css.ts @@ -21,7 +21,7 @@ export const mobileSellWrapper = style([ top: { sm: '0', md: 'unset' }, zIndex: { sm: '3', md: 'auto' }, height: { sm: 'full', md: 'auto' }, - width: { sm: 'full', md: 'auto' }, + width: 'full', overflowY: 'scroll', }), {