diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx new file mode 100644 index 00000000..980f8d97 --- /dev/null +++ b/web/components/contract-search.tsx @@ -0,0 +1,191 @@ +import algoliasearch from 'algoliasearch/lite' +import { + InstantSearch, + SearchBox, + SortBy, + useInfiniteHits, + useRange, + useRefinementList, + useSortBy, + useToggleRefinement, +} from 'react-instantsearch-hooks-web' +import { Contract } from '../../common/contract' +import { + Sort, + useInitialQueryAndSort, + useUpdateQueryAndSort, +} from '../hooks/use-sort-and-query-params' +import { ContractsGrid } from './contract/contracts-list' +import { Row } from './layout/row' +import { useEffect, useState } from 'react' +import { Spacer } from './layout/spacer' +import { useRouter } from 'next/router' + +const searchClient = algoliasearch( + 'GJQPAYENIF', + '75c28fc084a80e1129d427d470cf41a3' +) + +const sortIndexes = [ + { label: 'Newest', value: 'contracts-newest' }, + { label: 'Oldest', value: 'contracts-oldest' }, + { label: 'Most traded', value: 'contracts-most-traded' }, + { label: '24h volume', value: 'contracts-24-hour-vol' }, + { label: 'Closing soon', value: 'contracts-closing-soon' }, + { label: 'Resolved', value: 'contracts-resolved' }, +] + +export function ContractSearch(props: { + querySortOptions?: { + defaultSort: Sort + filter?: { + creatorId?: string + tag?: string + } + shouldLoadFromStorage?: boolean + } +}) { + const { querySortOptions } = props + + const { initialSort } = useInitialQueryAndSort(querySortOptions) + + const sort = sortIndexes + .map(({ value }) => value) + .includes(`contracts-${initialSort ?? ''}`) + ? initialSort + : querySortOptions?.defaultSort + + console.log('sort', sort) + if (!sort) return <> + return ( + + + + + + + + ) +} + +export function ContractSearchInner(props: { + querySortOptions?: { + defaultSort: Sort + filter?: { + creatorId?: string + tag?: string + } + shouldLoadFromStorage?: boolean + } +}) { + const { querySortOptions } = props + const { initialQuery } = useInitialQueryAndSort(querySortOptions) + + const { query, setQuery, setSort } = useUpdateQueryAndSort({ + shouldLoadFromStorage: true, + }) + + useEffect(() => { + console.log('initial query', initialQuery) + setQuery(initialQuery) + }, [initialQuery]) + + const { currentRefinement: index } = useSortBy({ + items: [], + }) + + useEffect(() => { + console.log('setting query', query) + setQuery(query) + }, [query]) + + useEffect(() => { + console.log('effect sort', 'curr', index) + const sort = index.split('contracts-')[1] as Sort + if (sort) { + setSort(sort) + } + }, [index]) + + const creatorId = querySortOptions?.filter?.creatorId + useFilterCreator(creatorId) + + const tag = querySortOptions?.filter?.tag + useFilterTag(tag) + + if (!creatorId) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useFilterClosed(index) + + // eslint-disable-next-line react-hooks/rules-of-hooks + useFilterResolved(index) + } + + const { showMore, hits, isLastPage } = useInfiniteHits() + const contracts = hits as any as Contract[] + + const router = useRouter() + const hasLoaded = contracts.length > 0 || router.isReady + + return ( +
+ + + {hasLoaded && ( + + )} +
+ ) +} + +const useFilterCreator = (creatorId: string | undefined) => { + const { refine } = useRefinementList({ attribute: 'creatorId' }) + useEffect(() => { + if (creatorId) refine(creatorId) + }, [creatorId, refine]) +} + +const useFilterTag = (tag: string | undefined) => { + const { refine } = useRefinementList({ attribute: 'lowercaseTags' }) + useEffect(() => { + if (tag) refine(`${tag.toLowerCase()} OR ManifoldMarkets`) + }, [tag, refine]) +} + +const useFilterClosed = (index: string) => { + const [now] = useState(Date.now()) + useRange({ + attribute: 'closeTime', + min: index === 'contracts-resolved' ? 0 : now, + }) +} + +const useFilterResolved = (index: string) => { + const { refine: refineResolved } = useToggleRefinement({ + attribute: 'isResolved', + on: true, + off: false, + }) + useEffect(() => { + console.log( + 'effect', + 'curr', + index, + 'update', + index === 'contracts-resolved' + ) + refineResolved({ isRefined: index !== 'contracts-resolved' }) + }, [index]) +} diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index dab8613d..dbbb9a10 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -1,41 +1,24 @@ import _ from 'lodash' -import Link from 'next/link' -import clsx from 'clsx' -import { useEffect, useState } from 'react' -import { - contractMetrics, - Contract, - listContracts, - getBinaryProb, -} from '../../lib/firebase/contracts' +import { Contract } from '../../lib/firebase/contracts' import { User } from '../../lib/firebase/users' import { Col } from '../layout/col' import { SiteLink } from '../site-link' import { ContractCard } from './contract-card' -import { - Sort, - useQueryAndSortParams, -} from '../../hooks/use-sort-and-query-params' -import { Answer } from '../../../common/answer' -import { LoadingIndicator } from '../loading-indicator' +import { ContractSearch } from '../contract-search' export function ContractsGrid(props: { contracts: Contract[] - showHotVolume?: boolean + loadMore: () => void + hasMore: boolean showCloseTime?: boolean }) { - const { showCloseTime } = props - const PAGE_SIZE = 100 - const [page, setPage] = useState(1) - + const { showCloseTime, hasMore, loadMore } = props const [resolvedContracts, activeContracts] = _.partition( props.contracts, (c) => c.isResolved ) - const allContracts = [...activeContracts, ...resolvedContracts] - const showMore = allContracts.length > PAGE_SIZE * page - const contracts = allContracts.slice(0, PAGE_SIZE * page) + const contracts = [...activeContracts, ...resolvedContracts] if (contracts.length === 0) { return ( @@ -49,317 +32,38 @@ export function ContractsGrid(props: { } return ( - <> + - {/* Show a link that increases the page num when clicked */} - {showMore && ( + {hasMore && ( )} - - ) -} - -const MAX_GROUPED_CONTRACTS_DISPLAYED = 6 - -function CreatorContractsGrid(props: { contracts: Contract[] }) { - const { contracts } = props - - const byCreator = _.groupBy(contracts, (contract) => contract.creatorId) - const creator7DayVol = _.mapValues(byCreator, (contracts) => - _.sumBy(contracts, (contract) => contract.volume7Days) - ) - const creatorIds = _.sortBy( - Object.keys(byCreator), - (creatorId) => -1 * creator7DayVol[creatorId] - ) - - let numContracts = 0 - let maxIndex = 0 - for (; maxIndex < creatorIds.length; maxIndex++) { - numContracts += Math.min( - MAX_GROUPED_CONTRACTS_DISPLAYED, - byCreator[creatorIds[maxIndex]].length - ) - if (numContracts > MAX_CONTRACTS_DISPLAYED) break - } - - const creatorIdsSubset = creatorIds.slice(0, maxIndex) - - return ( - - {creatorIdsSubset.map((creatorId) => { - const { creatorUsername, creatorName } = byCreator[creatorId][0] - - return ( - - - {creatorName} - - - - - {byCreator[creatorId].length > MAX_GROUPED_CONTRACTS_DISPLAYED ? ( - - e.stopPropagation()} - > - See all - - - ) : ( -
- )} - - ) - })} ) } -function TagContractsGrid(props: { contracts: Contract[] }) { - const { contracts } = props - - const contractTags = _.flatMap(contracts, (contract) => { - const { tags } = contract - return tags.map((tag) => ({ - tag, - contract, - })) - }) - const groupedByTag = _.groupBy(contractTags, ({ tag }) => tag) - const byTag = _.mapValues(groupedByTag, (contractTags) => - contractTags.map(({ contract }) => contract) - ) - const tag7DayVol = _.mapValues(byTag, (contracts) => - _.sumBy(contracts, (contract) => contract.volume7Days) - ) - const tags = _.sortBy( - Object.keys(byTag), - (creatorId) => -1 * tag7DayVol[creatorId] - ) - - let numContracts = 0 - let maxIndex = 0 - for (; maxIndex < tags.length; maxIndex++) { - numContracts += Math.min( - MAX_GROUPED_CONTRACTS_DISPLAYED, - byTag[tags[maxIndex]].length - ) - if (numContracts > MAX_CONTRACTS_DISPLAYED) break - } - - const tagsSubset = tags.slice(0, maxIndex) - - return ( - - {tagsSubset.map((tag) => { - return ( - - - #{tag} - - - - - {byTag[tag].length > MAX_GROUPED_CONTRACTS_DISPLAYED ? ( - - e.stopPropagation()} - > - See all - - - ) : ( -
- )} - - ) - })} - - ) -} - -const MAX_CONTRACTS_DISPLAYED = 99 - -export function SearchableGrid(props: { - contracts: Contract[] | undefined - byOneCreator?: boolean - querySortOptions?: { - defaultSort: Sort - shouldLoadFromStorage?: boolean - } -}) { - const { contracts, byOneCreator, querySortOptions } = props - - const { query, setQuery, sort, setSort } = - useQueryAndSortParams(querySortOptions) - - const queryWords = query.toLowerCase().split(' ') - function check(corpus: String) { - return queryWords.every((word) => corpus.toLowerCase().includes(word)) - } - - let matches = (contracts ?? []).filter( - (c) => - check(c.question) || - check(c.description) || - check(c.creatorName) || - check(c.creatorUsername) || - check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) || - check( - ((c as any).answers ?? []) - .map((answer: Answer) => answer.text) - .join(' ') - ) - ) - - if (sort === 'newest' || sort === 'all') { - matches.sort((a, b) => b.createdTime - a.createdTime) - } else if (sort === 'resolved') { - matches = _.sortBy( - matches, - (contract) => -1 * (contract.resolutionTime ?? 0) - ) - } else if (sort === 'oldest') { - matches.sort((a, b) => a.createdTime - b.createdTime) - } else if (sort === 'close-date' || sort === 'closed') { - matches = _.sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) - matches = _.sortBy( - matches, - (contract) => - (sort === 'closed' ? -1 : 1) * (contract.closeTime ?? Infinity) - ) - const hideClosed = sort === 'closed' - matches = matches.filter( - ({ closeTime }) => closeTime && closeTime > Date.now() !== hideClosed - ) - } else if (sort === 'most-traded') { - matches.sort((a, b) => b.volume - a.volume) - } else if (sort === '24-hour-vol') { - // Use lodash for stable sort, so previous sort breaks all ties. - matches = _.sortBy(matches, ({ volume7Days }) => -1 * volume7Days) - matches = _.sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) - } else if (sort === 'creator' || sort === 'tag') { - matches.sort((a, b) => b.volume7Days - a.volume7Days) - } else if (sort === 'most-likely') { - matches = _.sortBy(matches, (contract) => -getBinaryProb(contract)) - } else if (sort === 'least-likely') { - // Exclude non-binary contracts - matches = matches.filter((contract) => getBinaryProb(contract) !== 0) - matches = _.sortBy(matches, (contract) => getBinaryProb(contract)) - } - - if (sort !== 'all') { - // Filter for (or filter out) resolved contracts - matches = matches.filter((c) => - sort === 'resolved' ? c.resolution : !c.resolution - ) - - // Filter out closed contracts. - if (sort !== 'closed' && sort !== 'resolved') { - matches = matches.filter((c) => !c.closeTime || c.closeTime > Date.now()) - } - } - - return ( -
- {/* Show a search input next to a sort dropdown */} -
- setQuery(e.target.value)} - placeholder="Search markets" - className="input input-bordered w-full" - /> - -
- - {contracts === undefined ? ( - - ) : sort === 'tag' ? ( - - ) : !byOneCreator && sort === 'creator' ? ( - - ) : ( - - )} -
- ) -} - export function CreatorContractsList(props: { creator: User }) { const { creator } = props - const [contracts, setContracts] = useState('loading') - - useEffect(() => { - if (creator?.id) { - // TODO: stream changes from firestore - listContracts(creator.id).then(setContracts) - } - }, [creator]) - - if (contracts === 'loading') return <> return ( - diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index 642a4e90..72c899ad 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,6 +1,7 @@ import _ from 'lodash' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' +import { useSearchBox } from 'react-instantsearch-hooks-web' const MARKETS_SORT = 'markets_sort' @@ -11,12 +12,12 @@ export type Sort = | 'oldest' | 'most-traded' | '24-hour-vol' - | 'close-date' + | 'closing-soon' | 'closed' | 'resolved' | 'all' -export function useQueryAndSortParams(options?: { +export function useInitialQueryAndSort(options?: { defaultSort: Sort shouldLoadFromStorage?: boolean }) { @@ -26,24 +27,58 @@ export function useQueryAndSortParams(options?: { }) const router = useRouter() - const { s: sort, q: query } = router.query as { - q?: string - s?: Sort + const [initialSort, setInitialSort] = useState(undefined) + const [initialQuery, setInitialQuery] = useState('') + + useEffect(() => { + // If there's no sort option, then set the one from localstorage + if (router.isReady) { + const { s: sort, q: query } = router.query as { + q?: string + s?: Sort + } + + setInitialQuery(query ?? '') + + if (!sort && shouldLoadFromStorage) { + console.log('ready loading from storage ', sort ?? defaultSort) + const localSort = localStorage.getItem(MARKETS_SORT) as Sort + if (localSort) { + router.query.s = localSort + // Use replace to not break navigating back. + router.replace(router, undefined, { shallow: true }) + } + setInitialSort(localSort ?? defaultSort) + } else { + console.log('ready setting to ', sort ?? defaultSort) + setInitialSort(sort ?? defaultSort) + } + } + }, [defaultSort, router.isReady, shouldLoadFromStorage]) + + return { + initialSort, + initialQuery, } +} + +export function useUpdateQueryAndSort(props: { + shouldLoadFromStorage: boolean +}) { + const { shouldLoadFromStorage } = props + const router = useRouter() const setSort = (sort: Sort | undefined) => { - router.query.s = sort - router.push(router, undefined, { shallow: true }) - if (shouldLoadFromStorage) { - localStorage.setItem(MARKETS_SORT, sort || '') + if (sort !== router.query.s) { + router.query.s = sort + router.push(router, undefined, { shallow: true }) + if (shouldLoadFromStorage) { + localStorage.setItem(MARKETS_SORT, sort || '') + } } } - const [queryState, setQueryState] = useState(query) - - useEffect(() => { - setQueryState(query) - }, [query]) + const { query, refine } = useSearchBox() // Debounce router query update. const pushQuery = useMemo( @@ -60,26 +95,13 @@ export function useQueryAndSortParams(options?: { ) const setQuery = (query: string | undefined) => { - setQueryState(query) + refine(query ?? '') pushQuery(query) } - useEffect(() => { - // If there's no sort option, then set the one from localstorage - if (router.isReady && !sort && shouldLoadFromStorage) { - const localSort = localStorage.getItem(MARKETS_SORT) as Sort - if (localSort) { - router.query.s = localSort - // Use replace to not break navigating back. - router.replace(router, undefined, { shallow: true }) - } - } - }) - return { - sort: sort ?? defaultSort, - query: queryState ?? '', setSort, setQuery, + query, } } diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 18329b12..1c0c2b9b 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -18,6 +18,13 @@ export default function Document() { rel="stylesheet" /> + +