diff --git a/common/envs/constants.ts b/common/envs/constants.ts index b87948a7..dc753021 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -2,7 +2,7 @@ import { DEV_CONFIG } from './dev' import { EnvConfig, PROD_CONFIG } from './prod' import { THEOREMONE_CONFIG } from './theoremone' -const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD' +export const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD' const CONFIGS = { PROD: PROD_CONFIG, diff --git a/common/util/format.ts b/common/util/format.ts index 765c34d9..3310d902 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -9,9 +9,7 @@ const formatter = new Intl.NumberFormat('en-US', { export function formatMoney(amount: number) { const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case - return ( - ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') - ) + return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') } export function formatWithCommas(amount: number) { diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx new file mode 100644 index 00000000..b46bee6e --- /dev/null +++ b/web/components/contract-search.tsx @@ -0,0 +1,221 @@ +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' +import { ENV } from 'common/envs/constants' + +const searchClient = algoliasearch( + 'GJQPAYENIF', + '75c28fc084a80e1129d427d470cf41a3' +) + +const indexPrefix = ENV === 'DEV' ? 'dev-' : '' +console.log('env', ENV, indexPrefix) + +const sortIndexes = [ + { label: 'Newest', value: indexPrefix + 'contracts-newest' }, + { label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, + { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, + { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, + { label: 'Close date', value: indexPrefix + 'contracts-close-date' }, + { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, +] + +type filter = 'open' | 'closed' | 'resolved' | 'all' + +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(`${indexPrefix}contracts-${initialSort ?? ''}`) + ? initialSort + : querySortOptions?.defaultSort + + const [filter, setFilter] = useState('open') + + if (!sort) return <> + return ( + + + + + + + + + + + ) +} + +export function ContractSearchInner(props: { + querySortOptions?: { + defaultSort: Sort + filter?: { + creatorId?: string + tag?: string + } + shouldLoadFromStorage?: boolean + } + filter: filter +}) { + const { querySortOptions, filter } = 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) + + useFilterClosed( + filter === 'closed' + ? true + : filter === 'all' || filter === 'resolved' + ? undefined + : false + ) + useFilterResolved( + filter === 'resolved' ? true : filter === 'all' ? undefined : false + ) + + 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()) + }, [tag, refine]) +} + +const useFilterClosed = (value: boolean | undefined) => { + const [now] = useState(Date.now()) + useRange({ + attribute: 'closeTime', + min: value === false ? now : undefined, + max: value ? now : undefined, + }) +} + +const useFilterResolved = (value: boolean | undefined) => { + // Note (James): I don't know why this works. + const { refine: refineResolved } = useToggleRefinement({ + attribute: value === undefined ? 'non-existant-field' : 'isResolved', + on: true, + off: value === undefined ? undefined : false, + }) + useEffect(() => { + refineResolved({ isRefined: !value }) + }, [value]) +} diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index 76cc8752..705de807 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -1,41 +1,19 @@ import _ from 'lodash' -import Link from 'next/link' -import clsx from 'clsx' -import { useEffect, useState } from 'react' -import { - contractMetrics, - Contract, - listContracts, - getBinaryProb, -} from 'web/lib/firebase/contracts' -import { User } from 'web/lib/firebase/users' +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 'web/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 [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, showCloseTime, hasMore, loadMore } = props if (contracts.length === 0) { return ( @@ -49,306 +27,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) +export function CreatorContractsList(props: { creator: User }) { + const { creator } = props 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: { contracts: Contract[] }) { - const { contracts } = props - return ( - diff --git a/web/components/feed-create.tsx b/web/components/feed-create.tsx index 1f308355..6d192336 100644 --- a/web/components/feed-create.tsx +++ b/web/components/feed-create.tsx @@ -1,3 +1,4 @@ +import _ from 'lodash' import { SparklesIcon, XIcon } from '@heroicons/react/solid' import { Avatar } from './avatar' import { useEffect, useRef, useState } from 'react' @@ -9,8 +10,7 @@ import { Contract, MAX_QUESTION_LENGTH } from 'common/contract' import { Col } from './layout/col' import clsx from 'clsx' import { Row } from './layout/row' -import { ENV_CONFIG } from 'common/envs/constants' -import _ from 'lodash' +import { ENV_CONFIG } from '../../common/envs/constants' import { SiteLink } from './site-link' export function FeedPromo(props: { hotContracts: Contract[] }) { @@ -50,7 +50,8 @@ export function FeedPromo(props: { hotContracts: Contract[] }) { {}} + hasMore={false} /> ) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index a36f754c..89750b28 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -201,7 +201,7 @@ export function UserPage(props: { tabs={[ { title: 'Markets', - content: , + content: , tabIcon: (
{usersContracts.length} diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index 642a4e90..7a9abb85 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,22 +1,20 @@ 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' export type Sort = - | 'creator' - | 'tag' | 'newest' | '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 +24,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 +92,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/package.json b/web/package.json index d29a01b5..6e9d6f7d 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@heroicons/react": "1.0.5", "@nivo/core": "0.74.0", "@nivo/line": "0.74.0", + "algoliasearch": "4.13.0", "clsx": "1.1.1", "cors": "^2.8.5", "daisyui": "1.16.4", @@ -31,9 +32,10 @@ "lodash": "4.17.21", "next": "12.1.2", "react": "17.0.2", - "react-confetti": "^6.0.1", + "react-confetti": "6.0.1", "react-dom": "17.0.2", - "react-expanding-textarea": "2.3.5" + "react-expanding-textarea": "2.3.5", + "react-instantsearch-hooks-web": "6.24.1" }, "devDependencies": { "@tailwindcss/forms": "0.4.0", diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 461103bd..93d5d774 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -18,6 +18,13 @@ export default function Document() { rel="stylesheet" /> + +