manifold/web/components/contract-search.tsx
FRC 1f8c72b4c9
Overview page on groups (#961)
* Frontpage on groups

wip

* Fix James's nits
2022-10-03 00:02:31 +01:00

592 lines
16 KiB
TypeScript

/* eslint-disable react-hooks/exhaustive-deps */
import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract'
import { PAST_BETS, User } from 'common/user'
import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row'
import {
useEffect,
useLayoutEffect,
useRef,
useMemo,
ReactNode,
useState,
} from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows'
import {
historyStore,
urlParamStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button'
import { debounce, isEqual, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col'
import clsx from 'clsx'
import { safeLocalStorage } from 'web/lib/util/local'
import {
getIndexName,
searchClient,
searchIndexName,
} from 'web/lib/service/algolia'
import { useIsMobile } from 'web/hooks/use-is-mobile'
import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button'
import { Modal } from './layout/modal'
import { Title } from './title'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Closing soon', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const
export type Sort = typeof SORTS[number]['value']
export const PROB_SORTS = ['prob-descending', 'prob-ascending']
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
type SearchParameters = {
query: string
sort: Sort
openClosedFilter: 'open' | 'closed' | undefined
facetFilters: SearchOptions['facetFilters']
}
type AdditionalFilter = {
creatorId?: string
tag?: string
excludeContractIds?: string[]
groupSlug?: string
}
export function ContractSearch(props: {
user?: User | null
defaultSort?: Sort
defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter
highlightOptions?: CardHighlightOptions
onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean
cardUIOptions?: {
hideGroupLink?: boolean
hideQuickBet?: boolean
noLinkAvatar?: boolean
showProbChange?: boolean
}
headerClassName?: string
persistPrefix?: string
useQueryUrlParam?: boolean
isWholePage?: boolean
includeProbSorts?: boolean
noControls?: boolean
maxResults?: number
renderContracts?: (
contracts: Contract[] | undefined,
loadMore: () => void
) => ReactNode
autoFocus?: boolean
profile?: boolean | undefined
}) {
const {
user,
defaultSort,
defaultFilter,
defaultPill,
additionalFilter,
onContractClick,
hideOrderSelector,
cardUIOptions,
highlightOptions,
headerClassName,
persistPrefix,
useQueryUrlParam,
includeProbSorts,
isWholePage,
noControls,
maxResults,
renderContracts,
autoFocus,
profile,
} = props
const [state, setState] = usePersistentState(
{
numPages: 1,
pages: [] as Contract[][],
showTime: null as ShowTime | null,
showProbChange: false,
},
!persistPrefix
? undefined
: { key: `${persistPrefix}-search`, store: historyStore() }
)
const searchParams = useRef<SearchParameters | null>(null)
const searchParamsStore = historyStore<SearchParameters>()
const requestId = useRef(0)
useLayoutEffect(() => {
if (persistPrefix) {
const params = searchParamsStore.get(`${persistPrefix}-params`)
if (params !== undefined) {
searchParams.current = params
}
}
}, [])
const searchIndex = useMemo(
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
const performQuery = async (freshQuery?: boolean) => {
if (searchParams.current == null) {
return
}
const { query, sort, openClosedFilter, facetFilters } = searchParams.current
const id = ++requestId.current
const requestedPage = freshQuery ? 0 : state.pages.length
if (freshQuery || requestedPage < state.numPages) {
const index = query
? searchIndex
: searchClient.initIndex(getIndexName(sort))
const numericFilters = query
? []
: [
openClosedFilter === 'open' ? `closeTime > ${Date.now()}` : '',
openClosedFilter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f)
const results = await index.search(query, {
facetFilters,
numericFilters,
page: requestedPage,
hitsPerPage: 20,
advancedSyntax: true,
})
// if there's a more recent request, forget about this one
if (id === requestId.current) {
const newPage = results.hits as any as Contract[]
const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : null
const showProbChange = sort === 'daily-score'
const pages = freshQuery ? [newPage] : [...state.pages, newPage]
setState({ numPages: results.nbPages, pages, showTime, showProbChange })
if (freshQuery && isWholePage) window.scrollTo(0, 0)
}
}
}
const onSearchParametersChanged = useRef(
debounce((params) => {
if (!isEqual(searchParams.current, params)) {
if (persistPrefix) {
searchParamsStore.set(`${persistPrefix}-params`, params)
}
searchParams.current = params
performQuery(true)
}
}, 100)
).current
const updatedCardUIOptions = useMemo(() => {
if (cardUIOptions?.showProbChange === undefined && state.showProbChange)
return { ...cardUIOptions, showProbChange: true }
return cardUIOptions
}, [cardUIOptions, state.showProbChange])
const contracts = state.pages
.flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts =
state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} />
}
return (
<Col>
<ContractSearchControls
className={headerClassName}
defaultSort={defaultSort}
defaultFilter={defaultFilter}
defaultPill={defaultPill}
additionalFilter={additionalFilter}
persistPrefix={persistPrefix}
hideOrderSelector={hideOrderSelector}
useQueryUrlParam={useQueryUrlParam}
includeProbSorts={includeProbSorts}
user={user}
onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls}
autoFocus={autoFocus}
/>
{renderContracts ? (
renderContracts(renderedContracts, performQuery)
) : renderedContracts && renderedContracts.length === 0 && profile ? (
<p className="mx-2 text-gray-500">
This creator does not yet have any markets.
</p>
) : (
<ContractsGrid
contracts={renderedContracts}
loadMore={noControls ? undefined : performQuery}
showTime={state.showTime ?? undefined}
onContractClick={onContractClick}
highlightOptions={highlightOptions}
cardUIOptions={updatedCardUIOptions}
/>
)}
</Col>
)
}
function ContractSearchControls(props: {
className?: string
defaultSort?: Sort
defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter
persistPrefix?: string
hideOrderSelector?: boolean
includeProbSorts?: boolean
onSearchParametersChanged: (params: SearchParameters) => void
useQueryUrlParam?: boolean
user?: User | null
noControls?: boolean
autoFocus?: boolean
}) {
const {
className,
defaultSort,
defaultFilter,
defaultPill,
additionalFilter,
persistPrefix,
hideOrderSelector,
onSearchParametersChanged,
useQueryUrlParam,
user,
noControls,
autoFocus,
includeProbSorts,
} = props
const router = useRouter()
const [query, setQuery] = usePersistentState(
'',
!useQueryUrlParam
? undefined
: {
key: 'q',
store: urlParamStore(router),
}
)
const isMobile = useIsMobile()
const sortKey = `${persistPrefix}-search-sort`
const savedSort = safeLocalStorage()?.getItem(sortKey)
const [sort, setSort] = usePersistentState(
savedSort ?? defaultSort ?? 'score',
!useQueryUrlParam
? undefined
: {
key: 's',
store: urlParamStore(router),
}
)
const [filter, setFilter] = usePersistentState(
defaultFilter ?? 'open',
!useQueryUrlParam
? undefined
: {
key: 'f',
store: urlParamStore(router),
}
)
const [pill, setPill] = usePersistentState(
defaultPill ?? '',
!useQueryUrlParam
? undefined
: {
key: 'p',
store: urlParamStore(router),
}
)
useEffect(() => {
if (persistPrefix && sort) {
safeLocalStorage()?.setItem(sortKey, sort as string)
}
}, [persistPrefix, query, sort, sortKey])
const follows = useFollows(user?.id)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
)
const memberGroupSlugs =
memberGroups.length > 0
? memberGroups.map((g) => g.slug)
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.totalContracts > 0),
(group) => group.totalContracts
).reverse()
const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const personalFilters = user
? [
// Show contracts in groups that the user is a member of.
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Or, show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
// Subtract contracts you bet on, to show new ones.
`uniqueBettorIds:-${user.id}`,
]
: []
const additionalFilters = [
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
additionalFilter?.groupSlug
? `groupLinks.slug:${additionalFilter.groupSlug}`
: '',
]
const facetFilters = query
? additionalFilters
: [
...additionalFilters,
additionalFilter ? '' : 'visibility:public',
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
pill && pill !== 'personal' && pill !== 'your-bets'
? `groupLinks.slug:${pill}`
: '',
...(pill === 'personal' ? personalFilters : []),
pill === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f)
const openClosedFilter =
filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined
const selectPill = (pill: string | null) => () => {
setPill(pill ?? '')
track('select search category', { category: pill ?? 'all' })
}
const updateQuery = (newQuery: string) => {
setQuery(newQuery)
}
const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return
setFilter(newFilter)
track('select search filter', { filter: newFilter })
}
const selectSort = (newSort: Sort) => {
if (newSort === sort) return
setSort(newSort)
track('select search sort', { sort: newSort })
}
useEffect(() => {
onSearchParametersChanged({
query: query,
sort: sort as Sort,
openClosedFilter: openClosedFilter,
facetFilters: facetFilters,
})
}, [query, sort, openClosedFilter, JSON.stringify(facetFilters)])
if (noControls) {
return <></>
}
return (
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2">
<input
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query: query })}
placeholder={'Search'}
className="input input-bordered w-full"
autoFocus={autoFocus}
/>
{!isMobile && (
<SearchFilters
filter={filter}
selectFilter={selectFilter}
hideOrderSelector={hideOrderSelector}
selectSort={selectSort}
sort={sort}
className={'flex flex-row gap-2'}
includeProbSorts={includeProbSorts}
/>
)}
{isMobile && (
<>
<MobileSearchBar
children={
<SearchFilters
filter={filter}
selectFilter={selectFilter}
hideOrderSelector={hideOrderSelector}
selectSort={selectSort}
sort={sort}
className={'flex flex-col gap-4'}
includeProbSorts={includeProbSorts}
/>
}
/>
</>
)}
</Row>
{!additionalFilter && !query && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton key={'all'} selected={!pill} onSelect={selectPill(null)}>
All
</PillButton>
<PillButton
key={'personal'}
selected={pill === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={'your-bets'}
selected={pill === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your {PAST_BETS}
</PillButton>
)}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pill === slug}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
</Col>
)
}
export function SearchFilters(props: {
filter: string
selectFilter: (newFilter: filter) => void
hideOrderSelector: boolean | undefined
selectSort: (newSort: Sort) => void
sort: string
className?: string
includeProbSorts?: boolean
}) {
const {
filter,
selectFilter,
hideOrderSelector,
selectSort,
sort,
className,
includeProbSorts,
} = props
const sorts = includeProbSorts
? SORTS
: SORTS.filter((sort) => !PROB_SORTS.includes(sort.value))
return (
<div className={className}>
<select
className="select select-bordered"
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sorts.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</div>
)
}
export function MobileSearchBar(props: { children: ReactNode }) {
const { children } = props
const [openFilters, setOpenFilters] = useState(false)
return (
<>
<Button color="gray-white" onClick={() => setOpenFilters(true)}>
<AdjustmentsIcon className="my-auto h-7" />
</Button>
<Modal
open={openFilters}
setOpen={setOpenFilters}
position="top"
className="rounded-lg bg-white px-4 pb-4"
>
<Col>
<Title text="Filter Markets" />
{children}
</Col>
</Modal>
</>
)
}