/* 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(null) const searchParamsStore = historyStore() 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 } return ( {renderContracts ? ( renderContracts(renderedContracts, performQuery) ) : renderedContracts && renderedContracts.length === 0 && profile ? (

This creator does not yet have any markets.

) : ( )} ) } 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 ( updateQuery(e.target.value)} onBlur={trackCallback('search', { query: query })} placeholder={'Search'} className="input input-bordered w-full" autoFocus={autoFocus} /> {!isMobile && ( )} {isMobile && ( <> } /> )} {!additionalFilter && !query && ( All {user ? 'For you' : 'Featured'} {user && ( Your {PAST_BETS} )} {pillGroups.map(({ name, slug }) => { return ( {name} ) })} )} ) } 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 (
{!hideOrderSelector && ( )}
) } export function MobileSearchBar(props: { children: ReactNode }) { const { children } = props const [openFilters, setOpenFilters] = useState(false) return ( <> {children} </Col> </Modal> </> ) }