feat: add phase0 searchbar (#4377)

* feat: add phase0 searchbar

* exhaustive deps

* use router Link'

* use correct navigate for tokens

* useLocation

* add util function for organizing search results

* fix mobile navbar link

* remove exhausted depedencies

* split suggestion rows to their own file

* add new file

* use pathname instead of hash

* use imageholder classname

* fallback update

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
This commit is contained in:
Charles Bachmeier 2022-08-18 14:19:03 -07:00 committed by GitHub
parent 819302b51f
commit c6b4cc8e01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 735 additions and 6 deletions

@ -11,6 +11,7 @@ import { ChainSwitcher } from './ChainSwitcher'
import { MenuDropdown } from './MenuDropdown'
import { MobileSideBar } from './MobileSidebar'
import * as styles from './Navbar.css'
import { SearchBar } from './SearchBar'
interface MenuItemProps {
href: string
@ -45,7 +46,7 @@ const MobileNavbar = () => {
</Box>
<Box className={styles.rightSideMobileContainer}>
<Row gap="16">
{/* TODO add Searchbar */}
<SearchBar />
<MobileSideBar />
</Row>
</Box>
@ -92,7 +93,9 @@ const Navbar = () => {
</MenuItem>
</Row>
</Box>
<Box className={styles.middleContainer}>{/* TODO add Searchbar */}</Box>
<Box className={styles.middleContainer}>
<SearchBar />
</Box>
<Box className={styles.rightSideContainer}>
<Row gap="12">
<MenuDropdown />

@ -0,0 +1,160 @@
import { style } from '@vanilla-extract/css'
import { buttonTextSmall, subhead, subheadSmall } from 'nft/css/common.css'
import { breakpoints, sprinkles, vars } from '../../nft/css/sprinkles.css'
const DESKTOP_NAVBAR_WIDTH = '360px'
const baseSearchStyle = style([
sprinkles({
borderStyle: 'solid',
borderColor: 'lightGrayButton',
borderWidth: '1px',
paddingY: '12',
width: { mobile: 'viewWidth' },
}),
{
'@media': {
[`screen and (min-width: ${breakpoints.tabletSm}px)`]: {
width: DESKTOP_NAVBAR_WIDTH,
},
},
},
])
export const searchBar = style([
baseSearchStyle,
sprinkles({
height: 'full',
color: 'placeholder',
paddingX: '16',
cursor: 'pointer',
}),
])
export const searchBarInput = style([
sprinkles({
padding: '0',
fontWeight: 'normal',
fontSize: '16',
color: { default: 'blackBlue', placeholder: 'placeholder' },
border: 'none',
background: 'none',
}),
{ lineHeight: '24px' },
])
export const searchBarDropdown = style([
baseSearchStyle,
sprinkles({
position: 'absolute',
left: '0',
top: '48',
borderBottomLeftRadius: '12',
borderBottomRightRadius: '12',
background: 'white',
}),
{
borderTop: 'none',
},
])
export const suggestionRow = style([
sprinkles({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingY: '8',
paddingX: '16',
}),
{
':hover': {
cursor: 'pointer',
background: vars.color.lightGrayContainer,
},
textDecoration: 'none',
},
])
export const suggestionImage = sprinkles({
width: '36',
height: '36',
borderRadius: 'round',
marginRight: '8',
})
export const suggestionPrimaryContainer = style([
sprinkles({
alignItems: 'flex-start',
width: 'full',
}),
])
export const suggestionSecondaryContainer = sprinkles({
textAlign: 'right',
alignItems: 'flex-end',
})
export const primaryText = style([
subhead,
sprinkles({
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
color: 'blackBlue',
}),
{
lineHeight: '24px',
},
])
export const secondaryText = style([
buttonTextSmall,
sprinkles({
color: 'darkGray',
}),
{
lineHeight: '20px',
},
])
export const imageHolder = style([
suggestionImage,
sprinkles({
background: 'loading',
flexShrink: '0',
}),
])
export const suggestionIcon = sprinkles({
display: 'flex',
flexShrink: '0',
})
export const magnifyingGlassIcon = style([
sprinkles({
width: '20',
height: '20',
marginRight: '12',
}),
])
export const sectionHeader = style([
subheadSmall,
sprinkles({
color: 'darkGray',
}),
{
lineHeight: '20px',
},
])
export const notFoundContainer = style([
sectionHeader,
sprinkles({
paddingY: '4',
paddingLeft: '16',
marginTop: '20',
}),
])

@ -0,0 +1,345 @@
import useDebounce from 'hooks/useDebounce'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useWindowSize } from 'hooks/useWindowSize'
import { organizeSearchResults } from 'lib/utils/searchBar'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { Overlay } from 'nft/components/modals/Overlay'
import { subheadSmall } from 'nft/css/common.css'
import { breakpoints } from 'nft/css/sprinkles.css'
import { useSearchHistory } from 'nft/hooks'
// import { fetchSearchCollections, fetchTrendingCollections } from 'nft/queries'
import { fetchSearchTokens } from 'nft/queries/genie/SearchTokensFetcher'
import { fetchTrendingTokens } from 'nft/queries/genie/TrendingTokensFetcher'
import { FungibleToken, GenieCollection, TrendingCollection } from 'nft/types'
import { ChangeEvent, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useQuery } from 'react-query'
import { useLocation } from 'react-router-dom'
import {
ChevronLeftIcon,
ClockIcon,
MagnifyingGlassIcon,
NavMagnifyingGlassIcon,
TrendingArrow,
} from '../../nft/components/icons'
import { NavIcon } from './NavIcon'
import * as styles from './SearchBar.css'
import { CollectionRow, SkeletonRow, TokenRow } from './SuggestionRow'
interface SearchBarDropdownSectionProps {
toggleOpen: () => void
suggestions: (GenieCollection | FungibleToken)[]
header: string
headerIcon?: JSX.Element
hoveredIndex: number | undefined
startingIndex: number
setHoveredIndex: (index: number | undefined) => void
}
export const SearchBarDropdownSection = ({
toggleOpen,
suggestions,
header,
headerIcon = undefined,
hoveredIndex,
startingIndex,
setHoveredIndex,
}: SearchBarDropdownSectionProps) => {
return (
<Column gap="12">
<Row paddingX="16" paddingY="4" gap="8" color="grey300" className={subheadSmall} style={{ lineHeight: '20px' }}>
{headerIcon ? headerIcon : null}
<Box>{header}</Box>
</Row>
<Column gap="12">
{suggestions?.map((suggestion, index) =>
isCollection(suggestion) ? (
<CollectionRow
key={suggestion.address}
collection={suggestion as GenieCollection}
isHovered={hoveredIndex === index + startingIndex}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
index={index + startingIndex}
/>
) : (
<TokenRow
key={suggestion.address}
token={suggestion as FungibleToken}
isHovered={hoveredIndex === index + startingIndex}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
index={index + startingIndex}
/>
)
)}
</Column>
</Column>
)
}
interface SearchBarDropdownProps {
toggleOpen: () => void
tokens: FungibleToken[]
collections: GenieCollection[]
hasInput: boolean
}
export const SearchBarDropdown = ({ toggleOpen, tokens, collections, hasInput }: SearchBarDropdownProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | undefined>(undefined)
const searchHistory = useSearchHistory(
(state: { history: (FungibleToken | GenieCollection)[] }) => state.history
).slice(0, 2)
const { pathname } = useLocation()
const isNFTPage = pathname.includes('/nfts')
const isTokenPage = pathname.includes('/tokens')
const tokenSearchResults =
tokens.length > 0 ? (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={isNFTPage ? collections.length : 0}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={tokens}
header={'Tokens'}
/>
) : (
<Box className={styles.notFoundContainer}>No tokens found.</Box>
)
const collectionSearchResults =
collections.length > 0 ? (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={isNFTPage ? 0 : tokens.length}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={collections}
header={'NFT Collections'}
/>
) : null
// TODO Trending NFT Results implmented here
const trendingCollections = [] as TrendingCollection[]
const { data: trendingTokenResults } = useQuery([], () => fetchTrendingTokens(4), {
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
})
const trendingTokens = useMemo(() => {
// TODO reimplement this logic with NFT search
// return trendingTokenResults?.slice(0, isTokenPage ? 3 : 2)
return trendingTokenResults?.slice(0, 4)
}, [trendingTokenResults])
const totalSuggestions = hasInput
? tokens.length + collections.length
: Math.min(searchHistory.length, 2) +
(isNFTPage || !isTokenPage ? trendingCollections?.length ?? 0 : 0) +
(isTokenPage || !isNFTPage ? trendingTokens?.length ?? 0 : 0)
// Close the modal on escape
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
event.preventDefault()
if (!hoveredIndex) {
setHoveredIndex(totalSuggestions - 1)
} else {
setHoveredIndex(hoveredIndex - 1)
}
} else if (event.key === 'ArrowDown') {
if (hoveredIndex && hoveredIndex === totalSuggestions - 1) {
setHoveredIndex(0)
} else {
setHoveredIndex((hoveredIndex ?? -1) + 1)
}
}
}
document.addEventListener('keydown', keyDownHandler)
return () => {
document.removeEventListener('keydown', keyDownHandler)
}
}, [toggleOpen, hoveredIndex, totalSuggestions])
return (
<Box className={styles.searchBarDropdown}>
{hasInput ? (
// Empty or Up to 8 combined tokens and nfts
<Column gap="20">
{isNFTPage ? (
<>
{collectionSearchResults}
{tokenSearchResults}
</>
) : (
<>
{tokenSearchResults}
{collectionSearchResults}
</>
)}
</Column>
) : (
// Recent Searches, Trending Tokens, Trending Collections
<Column gap="20">
{searchHistory.length > 0 && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={0}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={searchHistory}
header={'Recent searches'}
headerIcon={<ClockIcon />}
/>
)}
{(trendingTokens?.length ?? 0) > 0 && !isNFTPage && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={searchHistory.length}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={trendingTokens ?? []}
header={'Popular tokens'}
headerIcon={<TrendingArrow />}
/>
)}
{(trendingCollections?.length ?? 0) > 0 && !isTokenPage && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={searchHistory.length + (isNFTPage ? 0 : trendingTokens?.length ?? 0)}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={trendingCollections as unknown as GenieCollection[]}
header={'Trending NFT collections'}
headerIcon={<TrendingArrow />}
/>
)}
</Column>
)}
</Box>
)
}
function isCollection(suggestion: GenieCollection | FungibleToken | TrendingCollection) {
return (suggestion as FungibleToken).decimals === undefined
}
export const SearchBar = () => {
const [isOpen, toggleOpen] = useReducer((state: boolean) => !state, false)
const [searchValue, setSearchValue] = useState('')
const debouncedSearchValue = useDebounce(searchValue, 300)
const searchRef = useRef<HTMLDivElement>(null)
const { pathname } = useLocation()
const { width: windowWidth } = useWindowSize()
useOnClickOutside(searchRef, () => {
isOpen && toggleOpen()
})
// TODO NFT Search Results implmented here
// eslint-disable-next-line react-hooks/exhaustive-deps
const collections = [] as GenieCollection[]
const collectionsAreLoading = false
const { data: tokens, isLoading: tokensAreLoading } = useQuery(
['searchTokens', debouncedSearchValue],
() => fetchSearchTokens(debouncedSearchValue),
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}
)
const isNFTPage = pathname.includes('/nfts')
const [reducedTokens, reducedCollections] = organizeSearchResults(isNFTPage, tokens ?? [], collections ?? [])
useEffect(() => {
const escapeKeyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
event.preventDefault()
toggleOpen()
}
}
document.addEventListener('keydown', escapeKeyDownHandler)
return () => {
document.removeEventListener('keydown', escapeKeyDownHandler)
}
}, [isOpen, toggleOpen, collections])
// clear searchbar when changing pages
useEffect(() => {
setSearchValue('')
}, [pathname])
const isMobile = useMemo(() => windowWidth && windowWidth <= breakpoints.tabletSm, [windowWidth])
return (
<>
<Box
position={{ mobile: isOpen ? 'absolute' : 'relative', tabletSm: 'relative' }}
top={{ mobile: '0', tabletSm: 'unset' }}
left={{ mobile: '0', tabletSm: 'unset' }}
width={{ mobile: isOpen ? 'viewWidth' : 'auto', tabletSm: 'auto' }}
ref={searchRef}
style={{ zIndex: '1000' }}
>
<Row
className={styles.searchBar}
borderRadius={isOpen ? undefined : '12'}
borderTopRightRadius={isOpen && !isMobile ? '12' : undefined}
borderTopLeftRadius={isOpen && !isMobile ? '12' : undefined}
display={{ mobile: isOpen ? 'flex' : 'none', desktopXl: 'flex' }}
justifyContent={isOpen ? 'flex-start' : 'center'}
background={isOpen ? 'white' : 'lightGrayContainer'}
onFocus={() => !isOpen && toggleOpen()}
onClick={() => !isOpen && toggleOpen()}
>
<Box display={{ mobile: 'none', tabletSm: 'flex' }}>
<MagnifyingGlassIcon className={styles.magnifyingGlassIcon} />
</Box>
<Box display={{ mobile: 'flex', tabletSm: 'none' }} color="blackBlue" onClick={toggleOpen}>
<ChevronLeftIcon className={styles.magnifyingGlassIcon} />
</Box>
<Box
as="input"
placeholder="Search tokens"
width={isOpen ? 'full' : '120'}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
!isOpen && toggleOpen()
setSearchValue(event.target.value)
}}
className={styles.searchBarInput}
value={searchValue}
/>
</Row>
<Box display={{ mobile: isOpen ? 'none' : 'flex', desktopXl: 'none' }}>
<NavIcon onClick={toggleOpen}>
<NavMagnifyingGlassIcon width={28} height={28} />
</NavIcon>
</Box>
{isOpen &&
(searchValue.length > 0 && (tokensAreLoading || collectionsAreLoading) ? (
<SkeletonRow />
) : (
<SearchBarDropdown
toggleOpen={toggleOpen}
tokens={reducedTokens}
collections={reducedCollections}
hasInput={searchValue.length > 0}
/>
))}
</Box>
{isOpen && <Overlay />}
</>
)
}

@ -0,0 +1,200 @@
import clsx from 'clsx'
import uriToHttp from 'lib/utils/uriToHttp'
import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { vars } from 'nft/css/sprinkles.css'
import { useSearchHistory } from 'nft/hooks'
// import { fetchSearchCollections, fetchTrendingCollections } from 'nft/queries'
import { FungibleToken, GenieCollection } from 'nft/types'
import { ethNumberStandardFormatter } from 'nft/utils/currency'
import { putCommas } from 'nft/utils/putCommas'
import { useCallback, useEffect, useReducer, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { TokenWarningRedIcon, VerifiedIcon } from '../../nft/components/icons'
import * as styles from './SearchBar.css'
interface CollectionRowProps {
collection: GenieCollection
isHovered: boolean
setHoveredIndex: (index: number | undefined) => void
toggleOpen: () => void
index: number
}
export const CollectionRow = ({ collection, isHovered, setHoveredIndex, toggleOpen, index }: CollectionRowProps) => {
const [brokenImage, setBrokenImage] = useState(false)
const [loaded, setLoaded] = useState(false)
const addToSearchHistory = useSearchHistory(
(state: { addItem: (item: FungibleToken | GenieCollection) => void }) => state.addItem
)
const navigate = useNavigate()
const handleClick = useCallback(() => {
addToSearchHistory(collection)
toggleOpen()
}, [addToSearchHistory, collection, toggleOpen])
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'Enter' && isHovered) {
event.preventDefault()
navigate(`/nfts/collection/${collection.address}`)
handleClick()
}
}
document.addEventListener('keydown', keyDownHandler)
return () => {
document.removeEventListener('keydown', keyDownHandler)
}
}, [toggleOpen, isHovered, collection, navigate, handleClick])
return (
<Link
to={`/nfts/collection/${collection.address}`}
onClick={handleClick}
onMouseEnter={() => !isHovered && setHoveredIndex(index)}
onMouseLeave={() => isHovered && setHoveredIndex(undefined)}
className={styles.suggestionRow}
style={{ background: isHovered ? vars.color.lightGrayButton : 'none' }}
>
<Row style={{ width: '68%' }}>
{!brokenImage && collection.imageUrl ? (
<Box
as="img"
src={collection.imageUrl}
alt={collection.name}
className={clsx(loaded ? styles.suggestionImage : styles.imageHolder)}
onError={() => setBrokenImage(true)}
onLoad={() => setLoaded(true)}
/>
) : (
<Box className={styles.imageHolder} />
)}
<Column className={styles.suggestionPrimaryContainer}>
<Row gap="4" width="full">
<Box className={styles.primaryText}>{collection.name}</Box>
{collection.isVerified && <VerifiedIcon className={styles.suggestionIcon} />}
</Row>
<Box className={styles.secondaryText}>{putCommas(collection.stats.total_supply)} items</Box>
</Column>
</Row>
{collection.floorPrice && (
<Column className={styles.suggestionSecondaryContainer}>
<Row gap="4">
<Box className={styles.primaryText}>{ethNumberStandardFormatter(collection.floorPrice)} ETH</Box>
</Row>
<Box className={styles.secondaryText}>Floor</Box>
</Column>
)}
</Link>
)
}
interface TokenRowProps {
token: FungibleToken
isHovered: boolean
setHoveredIndex: (index: number | undefined) => void
toggleOpen: () => void
index: number
}
export const TokenRow = ({ token, isHovered, setHoveredIndex, toggleOpen, index }: TokenRowProps) => {
const [brokenImage, setBrokenImage] = useState(false)
const [loaded, setLoaded] = useState(false)
const addToSearchHistory = useSearchHistory(
(state: { addItem: (item: FungibleToken | GenieCollection) => void }) => state.addItem
)
const navigate = useNavigate()
const handleClick = useCallback(() => {
addToSearchHistory(token)
toggleOpen()
}, [addToSearchHistory, toggleOpen, token])
// Close the modal on escape
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'Enter' && isHovered) {
event.preventDefault()
navigate(`/tokens/${token.address}`)
handleClick()
}
}
document.addEventListener('keydown', keyDownHandler)
return () => {
document.removeEventListener('keydown', keyDownHandler)
}
}, [toggleOpen, isHovered, token, navigate, handleClick])
return (
<Link
// TODO connect with explore token URI
to={`/tokens/${token.address}`}
onClick={handleClick}
onMouseEnter={() => !isHovered && setHoveredIndex(index)}
onMouseLeave={() => isHovered && setHoveredIndex(undefined)}
className={styles.suggestionRow}
style={{ background: isHovered ? vars.color.lightGrayButton : 'none' }}
>
<Row>
{!brokenImage && token.logoURI ? (
<Box
as="img"
src={token.logoURI.includes('ipfs://') ? uriToHttp(token.logoURI)[0] : token.logoURI}
alt={token.name}
className={clsx(loaded ? styles.suggestionImage : styles.imageHolder)}
onError={() => setBrokenImage(true)}
onLoad={() => setLoaded(true)}
/>
) : (
<Box className={styles.imageHolder} />
)}
<Column className={styles.suggestionPrimaryContainer}>
<Row gap="4" width="full">
<Box className={styles.primaryText}>{token.name}</Box>
{token.onDefaultList ? (
<VerifiedIcon className={styles.suggestionIcon} />
) : (
<TokenWarningRedIcon className={styles.suggestionIcon} />
)}
</Row>
<Box className={styles.secondaryText}>{token.symbol}</Box>
</Column>
</Row>
<Column className={styles.suggestionSecondaryContainer}>
{token.priceUsd && (
<Row gap="4">
<Box className={styles.primaryText}>{ethNumberStandardFormatter(token.priceUsd, true)}</Box>
</Row>
)}
{token.price24hChange && (
<Box className={styles.secondaryText} color={token.price24hChange >= 0 ? 'green400' : 'red400'}>
{token.price24hChange.toFixed(2)}%
</Box>
)}
</Column>
</Link>
)
}
export const SkeletonRow = () => {
const [isHovered, toggleHovered] = useReducer((s) => !s, false)
return (
<Box className={styles.searchBarDropdown}>
<Row
background={isHovered ? 'lightGrayButton' : 'none'}
onMouseEnter={toggleHovered}
onMouseLeave={toggleHovered}
className={styles.suggestionRow}
>
<Row>
<Box className={styles.imageHolder} />
<Box borderRadius="round" height="16" width="160" background="loading" />
</Row>
</Row>
</Box>
)
}

@ -0,0 +1,20 @@
import { FungibleToken, GenieCollection } from 'nft/types'
/**
* Organizes the number of Token and NFT results to be shown to a user depending on if they're in the NFT or Token experience
* If not an nft page show up to 5 tokens, else up to 3. Max total suggestions of 8
* @param isNFTPage boolean if user is currently on an nft page
* @param tokenResults array of FungibleToken results
* @param collectionResults array of NFT Collection results
* @returns an array of Fungible Tokens and an array of NFT Collections with correct number of results to be shown
*/
export function organizeSearchResults(
isNFTPage: boolean,
tokenResults: FungibleToken[],
collectionResults: GenieCollection[]
): [FungibleToken[], GenieCollection[]] {
const reducedTokens =
tokenResults?.slice(0, isNFTPage ? 3 : collectionResults.length < 3 ? 8 - collectionResults.length : 5) ?? []
const reducedCollections = collectionResults.slice(0, 8 - reducedTokens.length)
return [reducedTokens, reducedCollections]
}

@ -6,15 +6,15 @@ export const overlay = style([
sprinkles({
top: '0',
left: '0',
width: 'full',
height: 'full',
width: 'viewWidth',
height: 'viewHeight',
position: 'fixed',
display: 'block',
background: 'black',
zIndex: '3',
}),
{
opacity: 0.75,
zIndex: 10,
opacity: 0.72,
overflow: 'hidden',
},
])

@ -143,6 +143,7 @@ export const vars = createGlobalTheme(':root', {
transculent: '#7F7F7F',
transparent: 'transparent',
none: 'none',
loading: '#7C85A24D',
// new uniswap colors:
blue400: '#4C82FB',