diff --git a/common/contest.ts b/common/contest.ts new file mode 100644 index 00000000..b432e372 --- /dev/null +++ b/common/contest.ts @@ -0,0 +1,23 @@ +import { GROUP_CHAT_SLUG } from 'common/group' + +export const CONTEST_DATA = { + 'cause-exploration-prize': { + link: 'https://www.causeexplorationprizes.com/', + description: + 'Open Philanthropy’s contest to find ideas for the best ways to use their resources, with focus on new areas to support, health development, and worldview investigations.', + }, +} + +export const CONTEST_SLUGS = Object.keys(CONTEST_DATA) + +export function contestPath( + contestSlug: string, + subpath?: + | 'edit' + | 'markets' + | 'about' + | typeof GROUP_CHAT_SLUG + | 'leaderboards' +) { + return `/contest/${contestSlug}${subpath ? `/${subpath}` : ''}` +} diff --git a/web/components/submission-search.tsx b/web/components/submission-search.tsx new file mode 100644 index 00000000..d7c101c3 --- /dev/null +++ b/web/components/submission-search.tsx @@ -0,0 +1,362 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import algoliasearch from 'algoliasearch/lite' + +import { Contract } from 'common/contract' +import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' +import { + ContractHighlightOptions, + ContractsGrid, +} from './contract/contracts-list' +import { Row } from './layout/row' +import { useEffect, useMemo, useState } from 'react' +import { Spacer } from './layout/spacer' +import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' +import { useUser } from 'web/hooks/use-user' +import { useFollows } from 'web/hooks/use-follows' +import { track, trackCallback } from 'web/lib/service/analytics' +import ContractSearchFirestore from 'web/pages/contract-search-firestore' +import { useMemberGroups } from 'web/hooks/use-group' +import { Group, NEW_USER_GROUP_SLUGS } from 'common/group' +import { PillButton } from './buttons/pill-button' +import { range, sortBy } from 'lodash' +import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' +import { Col } from './layout/col' +import SubmissionSearchFirestore from 'web/pages/submission-search-firestore' +import { SubmissionsGrid } from './submission/submission-list' + +const searchClient = algoliasearch( + 'GJQPAYENIF', + '75c28fc084a80e1129d427d470cf41a3' +) + +const indexPrefix = ENV === 'DEV' ? 'dev-' : '' +const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' + +const sortOptions = [ + { label: 'Newest', value: 'newest' }, + { label: 'Trending', value: 'score' }, + { label: 'Most traded', value: 'most-traded' }, + { label: '24h volume', value: '24-hour-vol' }, + { label: 'Last updated', value: 'last-updated' }, + { label: 'Subsidy', value: 'liquidity' }, + { label: 'Close date', value: 'close-date' }, + { label: 'Resolve date', value: 'resolve-date' }, +] +export const DEFAULT_SORT = 'score' + +type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' + +export function SubmissionSearch(props: { + querySortOptions?: { + defaultSort: Sort + defaultFilter?: filter + shouldLoadFromStorage?: boolean + } + additionalFilter?: { + creatorId?: string + tag?: string + excludeContractIds?: string[] + groupSlug?: string + } + highlightOptions?: ContractHighlightOptions + onContractClick?: (contract: Contract) => void + showPlaceHolder?: boolean + hideOrderSelector?: boolean + overrideGridClassName?: string + cardHideOptions?: { + hideGroupLink?: boolean + hideQuickBet?: boolean + } +}) { + const { + querySortOptions, + additionalFilter, + onContractClick, + overrideGridClassName, + hideOrderSelector, + showPlaceHolder, + cardHideOptions, + highlightOptions, + } = props + + const user = useUser() + 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.contractIds.length > 0), + (group) => group.contractIds.length + ).reverse() + + const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[] + + const pillGroups = + memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups + + const follows = useFollows(user?.id) + + const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {} + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort, + shouldLoadFromStorage, + }) + + const [filter, setFilter] = useState( + querySortOptions?.defaultFilter ?? 'open' + ) + const pillsEnabled = !additionalFilter + + const [pillFilter, setPillFilter] = useState(undefined) + + const selectPill = (pill: string | undefined) => () => { + setPillFilter(pill) + setPage(0) + track('select search category', { category: pill ?? 'all' }) + } + + const additionalFilters = [ + additionalFilter?.creatorId + ? `creatorId:${additionalFilter.creatorId}` + : '', + additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '', + additionalFilter?.groupSlug + ? `groupLinks.slug:${additionalFilter.groupSlug}` + : '', + ] + let facetFilters = query + ? additionalFilters + : [ + ...additionalFilters, + filter === 'open' ? 'isResolved:false' : '', + filter === 'closed' ? 'isResolved:false' : '', + filter === 'resolved' ? 'isResolved:true' : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' + ? `groupLinks.slug:${pillFilter}` + : '', + pillFilter === 'personal' + ? // Show contracts in groups that the user is a member of + memberGroupSlugs + .map((slug) => `groupLinks.slug:${slug}`) + // Show contracts created by users the user follows + .concat(follows?.map((followId) => `creatorId:${followId}`) ?? []) + // Show contracts bet on by users the user follows + .concat( + follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? [] + ) + : '', + // Subtract contracts you bet on from For you. + pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', + pillFilter === 'your-bets' && user + ? // Show contracts bet on by the user + `uniqueBettorIds:${user.id}` + : '', + ].filter((f) => f) + // Hack to make Algolia work. + facetFilters = ['', ...facetFilters] + + const numericFilters = query + ? [] + : [ + filter === 'open' ? `closeTime > ${Date.now()}` : '', + filter === 'closed' ? `closeTime <= ${Date.now()}` : '', + ].filter((f) => f) + + const indexName = `${indexPrefix}contracts-${sort}` + const index = useMemo(() => searchClient.initIndex(indexName), [indexName]) + const searchIndex = useMemo( + () => searchClient.initIndex(searchIndexName), + [searchIndexName] + ) + + const [page, setPage] = useState(0) + const [numPages, setNumPages] = useState(1) + const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>( + {} + ) + + useEffect(() => { + let wasMostRecentQuery = true + const algoliaIndex = query ? searchIndex : index + + algoliaIndex + .search(query, { + facetFilters, + numericFilters, + page, + hitsPerPage: 20, + }) + .then((results) => { + if (!wasMostRecentQuery) return + + if (page === 0) { + setHitsByPage({ + [0]: results.hits as any as Contract[], + }) + } else { + setHitsByPage((hitsByPage) => ({ + ...hitsByPage, + [page]: results.hits, + })) + } + setNumPages(results.nbPages) + }) + return () => { + wasMostRecentQuery = false + } + // Note numeric filters are unique based on current time, so can't compare + // them by value. + }, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter]) + + const loadMore = () => { + if (page >= numPages - 1) return + + const haveLoadedCurrentPage = hitsByPage[page] + if (haveLoadedCurrentPage) setPage(page + 1) + } + + const hits = range(0, page + 1) + .map((p) => hitsByPage[p] ?? []) + .flat() + + const contracts = hits.filter( + (c) => !additionalFilter?.excludeContractIds?.includes(c.id) + ) + + const showTime = + sort === 'close-date' || sort === 'resolve-date' ? sort : undefined + + const updateQuery = (newQuery: string) => { + setQuery(newQuery) + setPage(0) + } + + const selectFilter = (newFilter: filter) => { + if (newFilter === filter) return + setFilter(newFilter) + setPage(0) + trackCallback('select search filter', { filter: newFilter }) + } + + const selectSort = (newSort: Sort) => { + if (newSort === sort) return + + setPage(0) + setSort(newSort) + track('select sort', { sort: newSort }) + } + + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { + return ( + + ) + } + + return ( + + + updateQuery(e.target.value)} + placeholder={showPlaceHolder ? `Search ${filter} markets` : ''} + className="input input-bordered w-full" + /> + {!query && ( + + )} + {!hideOrderSelector && !query && ( + + )} + + + + + {pillsEnabled && ( + + + All + + + {user ? 'For you' : 'Featured'} + + + {user && ( + + Your bets + + )} + + {pillGroups.map(({ name, slug }) => { + return ( + + {name} + + ) + })} + + )} + + + + {filter === 'personal' && + (follows ?? []).length === 0 && + memberGroupSlugs.length === 0 ? ( + <>You're not following anyone, nor in any of your own groups yet. + ) : ( + + )} + + ) +} diff --git a/web/components/submission/submission-card.tsx b/web/components/submission/submission-card.tsx new file mode 100644 index 00000000..3e57712b --- /dev/null +++ b/web/components/submission/submission-card.tsx @@ -0,0 +1,354 @@ +import clsx from 'clsx' +import Link from 'next/link' +import { Row } from '../layout/row' +import { formatLargeNumber, formatPercent } from 'common/util/format' +import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' +import { Col } from '../layout/col' +import { + BinaryContract, + Contract, + FreeResponseContract, + MultipleChoiceContract, + NumericContract, + PseudoNumericContract, +} from 'common/contract' +import { + AnswerLabel, + BinaryContractOutcomeLabel, + CancelLabel, + FreeResponseOutcomeLabel, +} from '../outcome-label' +import { + getOutcomeProbability, + getProbability, + getTopAnswer, +} from 'common/calculate' +import { + AvatarDetails, + MiscDetails, + ShowTime, +} from '../contract/contract-details' +import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' +import { getColor, ProbBar, QuickBet } from '../contract/quick-bet' +import { useContractWithPreload } from 'web/hooks/use-contract' +import { useUser } from 'web/hooks/use-user' +import { track } from '@amplitude/analytics-browser' +import { trackCallback } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' +import { Title } from '../title' + +export function SubmissionCard(props: { + contract: Contract + showHotVolume?: boolean + showTime?: ShowTime + className?: string + onClick?: () => void + hideQuickBet?: boolean + hideGroupLink?: boolean +}) { + const { + showHotVolume, + showTime, + className, + onClick, + hideQuickBet, + hideGroupLink, + } = props + const contract = useContractWithPreload(props.contract) ?? props.contract + const { question, outcomeType } = contract + const { resolution } = contract + + const user = useUser() + + const marketClosed = + (contract.closeTime || Infinity) < Date.now() || !!resolution + + const showQuickBet = + user && + !marketClosed && + (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && + !hideQuickBet + + return ( + + ) +} + +export function BinaryResolutionOrChance(props: { + contract: BinaryContract + large?: boolean + className?: string +}) { + const { contract, large, className } = props + const { resolution } = contract + const textColor = `text-${getColor(contract)}` + + return ( + + {resolution ? ( + <> +
+ Resolved +
+ + + ) : ( + <> +
{getBinaryProbPercent(contract)}
+
+ chance +
+ + )} + + ) +} + +function FreeResponseTopAnswer(props: { + contract: FreeResponseContract | MultipleChoiceContract + truncate: 'short' | 'long' | 'none' + className?: string +}) { + const { contract, truncate } = props + + const topAnswer = getTopAnswer(contract) + + return topAnswer ? ( + + ) : null +} + +export function FreeResponseResolutionOrChance(props: { + contract: FreeResponseContract | MultipleChoiceContract + truncate: 'short' | 'long' | 'none' + className?: string +}) { + const { contract, truncate, className } = props + const { resolution } = contract + + const topAnswer = getTopAnswer(contract) + const textColor = `text-${getColor(contract)}` + + return ( + + {resolution ? ( + <> +
+ Resolved +
+ {(resolution === 'CANCEL' || resolution === 'MKT') && ( + + )} + + ) : ( + topAnswer && ( + + +
+ {formatPercent(getOutcomeProbability(contract, topAnswer.id))} +
+
chance
+ +
+ ) + )} + + ) +} + +export function NumericResolutionOrExpectation(props: { + contract: NumericContract + className?: string +}) { + const { contract, className } = props + const { resolution } = contract + const textColor = `text-${getColor(contract)}` + + const resolutionValue = + contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract) + + return ( + + {resolution ? ( + <> +
Resolved
+ + {resolution === 'CANCEL' ? ( + + ) : ( +
+ {formatLargeNumber(resolutionValue)} +
+ )} + + ) : ( + <> +
+ {formatLargeNumber(getExpectedValue(contract))} +
+
expected
+ + )} + + ) +} + +export function PseudoNumericResolutionOrExpectation(props: { + contract: PseudoNumericContract + className?: string +}) { + const { contract, className } = props + const { resolution, resolutionValue, resolutionProbability } = contract + const textColor = `text-blue-400` + + return ( + + {resolution ? ( + <> +
Resolved
+ + {resolution === 'CANCEL' ? ( + + ) : ( +
+ {resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? 0, + contract + )} +
+ )} + + ) : ( + <> +
+ {formatNumericProbability(getProbability(contract), contract)} +
+
expected
+ + )} + + ) +} diff --git a/web/components/submission/submission-list.tsx b/web/components/submission/submission-list.tsx new file mode 100644 index 00000000..10deff15 --- /dev/null +++ b/web/components/submission/submission-list.tsx @@ -0,0 +1,112 @@ +import { Contract } from 'web/lib/firebase/contracts' +import { User } from 'web/lib/firebase/users' +import { Col } from '../layout/col' +import { SiteLink } from '../site-link' +import { ContractCard } from '../contract/contract-card' +import { ShowTime } from '../contract/contract-details' +import { ContractSearch } from '../contract-search' +import { useIsVisible } from 'web/hooks/use-is-visible' +import { useEffect, useState } from 'react' +import clsx from 'clsx' +import { SubmissionCard } from './submission-card' +import { SubmissionSearch } from '../submission-search' + +export type ContractHighlightOptions = { + contractIds?: string[] + highlightClassName?: string +} + +export function SubmissionsGrid(props: { + contracts: Contract[] + loadMore: () => void + hasMore: boolean + showTime?: ShowTime + onContractClick?: (contract: Contract) => void + overrideGridClassName?: string + cardHideOptions?: { + hideQuickBet?: boolean + hideGroupLink?: boolean + } + highlightOptions?: ContractHighlightOptions +}) { + const { + contracts, + showTime, + hasMore, + loadMore, + onContractClick, + overrideGridClassName, + cardHideOptions, + highlightOptions, + } = props + const { hideQuickBet, hideGroupLink } = cardHideOptions || {} + + const { contractIds, highlightClassName } = highlightOptions || {} + const [elem, setElem] = useState(null) + const isBottomVisible = useIsVisible(elem) + + useEffect(() => { + if (isBottomVisible && hasMore) { + loadMore() + } + }, [isBottomVisible, hasMore, loadMore]) + + if (contracts.length === 0) { + return ( +

+ No markets found. Why not{' '} + + create one? + +

+ ) + } + + return ( + +
    + {contracts.map((contract) => ( + onContractClick(contract) : undefined + } + hideQuickBet={hideQuickBet} + hideGroupLink={hideGroupLink} + className={ + contractIds?.includes(contract.id) + ? highlightClassName + : undefined + } + /> + ))} +
+
+ + ) +} + +export function CreatorContractsList(props: { creator: User }) { + const { creator } = props + + return ( + + ) +} diff --git a/web/pages/contest/[...slugs]/index.tsx b/web/pages/contest/[...slugs]/index.tsx new file mode 100644 index 00000000..9cd9f97d --- /dev/null +++ b/web/pages/contest/[...slugs]/index.tsx @@ -0,0 +1,665 @@ +import { debounce, sortBy, take } from 'lodash' +import PlusSmIcon from '@heroicons/react/solid/PlusSmIcon' + +import { Group, GROUP_CHAT_SLUG } from 'common/group' +import { Page } from 'web/components/page' +import { listAllBets } from 'web/lib/firebase/bets' +import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' +import { + addContractToGroup, + getGroupBySlug, + groupPath, + joinGroup, + updateGroup, +} from 'web/lib/firebase/groups' +import { Row } from 'web/components/layout/row' +import { UserLink } from 'web/components/user-page' +import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' +import { Col } from 'web/components/layout/col' +import { useUser } from 'web/hooks/use-user' +import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' +import { useRouter } from 'next/router' +import { scoreCreators, scoreTraders } from 'common/scoring' +import { Leaderboard } from 'web/components/leaderboard' +import { formatMoney } from 'common/util/format' +import { EditGroupButton } from 'web/components/groups/edit-group-button' +import Custom404 from '../../404' +import { SEO } from 'web/components/SEO' +import { Linkify } from 'web/components/linkify' +import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { Tabs } from 'web/components/layout/tabs' +import { CreateQuestionButton } from 'web/components/create-question-button' +import React, { useState } from 'react' +import { GroupChat } from 'web/components/groups/group-chat' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { Modal } from 'web/components/layout/modal' +import { getSavedSort } from 'web/hooks/use-sort-and-query-params' +import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' +import { toast } from 'react-hot-toast' +import { useCommentsOnGroup } from 'web/hooks/use-comments' +import { REFERRAL_AMOUNT } from 'common/user' +import { ContractSearch } from 'web/components/contract-search' +import clsx from 'clsx' +import { FollowList } from 'web/components/follow-list' +import { SearchIcon } from '@heroicons/react/outline' +import { useTipTxns } from 'web/hooks/use-tip-txns' +import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' +import { searchInAny } from 'common/util/parse' +import { useWindowSize } from 'web/hooks/use-window-size' +import { CopyLinkButton } from 'web/components/copy-link-button' +import { ENV_CONFIG } from 'common/envs/constants' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { Button } from 'web/components/button' +import { SubmissionSearch } from 'web/components/submission-search' + +export const getStaticProps = fromPropz(getStaticPropz) +export async function getStaticPropz(props: { params: { slugs: string[] } }) { + const { slugs } = props.params + + const group = await getGroupBySlug(slugs[0]) + const members = group && (await listMembers(group)) + const creatorPromise = group ? getUser(group.creatorId) : null + + const contracts = + (group && (await listContractsByGroupSlug(group.slug))) ?? [] + + const bets = await Promise.all( + contracts.map((contract: Contract) => listAllBets(contract.id)) + ) + + const creatorScores = scoreCreators(contracts) + const traderScores = scoreTraders(contracts, bets) + const [topCreators, topTraders] = + (members && [ + toTopUsers(creatorScores, members), + toTopUsers(traderScores, members), + ]) ?? + [] + + const creator = await creatorPromise + + return { + props: { + group, + members, + creator, + traderScores, + topTraders, + creatorScores, + topCreators, + }, + + revalidate: 60, // regenerate after a minute + } +} + +function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { + const topUserPairs = take( + sortBy(Object.entries(userScores), ([_, score]) => -1 * score), + 10 + ).filter(([_, score]) => score >= 0.5) + + const topUsers = topUserPairs.map( + ([userId]) => users.filter((user) => user.id === userId)[0] + ) + return topUsers.filter((user) => user) +} + +export async function getStaticPaths() { + return { paths: [], fallback: 'blocking' } +} +const groupSubpages = [ + undefined, + GROUP_CHAT_SLUG, + 'markets', + 'leaderboards', + 'about', +] as const + +export default function GroupPage(props: { + group: Group | null + members: User[] + creator: User + traderScores: { [userId: string]: number } + topTraders: User[] + creatorScores: { [userId: string]: number } + topCreators: User[] +}) { + props = usePropz(props, getStaticPropz) ?? { + group: null, + members: [], + creator: null, + traderScores: {}, + topTraders: [], + creatorScores: {}, + topCreators: [], + } + const { + creator, + members, + traderScores, + topTraders, + creatorScores, + topCreators, + } = props + + const router = useRouter() + const { slugs } = router.query as { slugs: string[] } + const page = slugs?.[1] as typeof groupSubpages[number] + + const group = useGroup(props.group?.id) ?? props.group + const tips = useTipTxns({ groupId: group?.id }) + + const messages = useCommentsOnGroup(group?.id) + + const user = useUser() + + useSaveReferral(user, { + defaultReferrer: creator.username, + groupId: group?.id, + }) + + const { width } = useWindowSize() + const chatDisabled = !group || group.chatDisabled + const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280 + const showChatTab = !chatDisabled && !showChatSidebar + + if (group === null || !groupSubpages.includes(page) || slugs[2]) { + return + } + const { memberIds } = group + const isCreator = user && group && user.id === group.creatorId + const isMember = user && memberIds.includes(user.id) + + const leaderboard = ( + + + + ) + + const aboutTab = ( + + + + ) + + const chatTab = ( + + {messages ? ( + + ) : ( + + )} + + ) + + const questionsTab = ( + + ) + + const tabs = [ + ...(!showChatTab + ? [] + : [ + { + title: 'Chat', + content: chatTab, + href: groupPath(group.slug, GROUP_CHAT_SLUG), + }, + ]), + { + title: 'Markets', + content: questionsTab, + href: groupPath(group.slug, 'markets'), + }, + { + title: 'Leaderboards', + content: leaderboard, + href: groupPath(group.slug, 'leaderboards'), + }, + { + title: 'About', + content: aboutTab, + href: groupPath(group.slug, 'about'), + }, + ] + + const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG) + + return ( + + + + +
+
+ {group.name} +
+
+ +
+
+
+ + 0 ? tabIndex : 0} + tabs={tabs} + /> +
+ ) +} + +function JoinOrAddQuestionsButtons(props: { + group: Group + user: User | null | undefined + isMember: boolean +}) { + const { group, user, isMember } = props + return user && isMember ? ( + + + + ) : group.anyoneCanJoin ? ( + + ) : null +} + +function GroupOverview(props: { + group: Group + creator: User + user: User | null | undefined + isCreator: boolean + members: User[] +}) { + const { group, creator, isCreator, user, members } = props + const anyoneCanJoinChoices: { [key: string]: string } = { + Closed: 'false', + Open: 'true', + } + const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin) + function updateAnyoneCanJoin(newVal: boolean) { + if (group.anyoneCanJoin == newVal || !isCreator) return + setAnyoneCanJoin(newVal) + toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), { + loading: 'Updating group...', + success: 'Updated group!', + error: "Couldn't update group", + }) + } + + const postFix = user ? '?referrer=' + user.username : '' + const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( + group.slug + )}${postFix}` + + return ( + <> + + +
+
Created by
+ +
+ {isCreator ? ( + + ) : ( + user && + group.memberIds.includes(user?.id) && ( + + + + ) + )} +
+
+ +
+ + Membership + {user && user.id === creator.id ? ( + + updateAnyoneCanJoin(choice.toString() === 'true') + } + toggleClassName={'h-10'} + className={'ml-2'} + /> + ) : ( + + {anyoneCanJoin ? 'Open' : 'Closed'} + + )} + + + {anyoneCanJoin && user && ( + +
Invite
+
+ Invite a friend to this group and get M${REFERRAL_AMOUNT} if they + sign up! +
+ + + + )} + + +
Members
+ + + + + ) +} + +function SearchBar(props: { setQuery: (query: string) => void }) { + const { setQuery } = props + const debouncedQuery = debounce(setQuery, 50) + return ( +
+ + debouncedQuery(e.target.value)} + placeholder="Find a member" + className="input input-bordered mb-4 w-full pl-12" + /> +
+ ) +} + +function GroupMemberSearch(props: { members: User[]; group: Group }) { + const [query, setQuery] = useState('') + const { group } = props + let { members } = props + + // Use static members on load, but also listen to member changes: + const listenToMembers = useMembers(group) + if (listenToMembers) { + members = listenToMembers + } + + // TODO use find-active-contracts to sort by? + const matches = sortBy(members, [(member) => member.name]).filter((m) => + searchInAny(query, m.name, m.username) + ) + const matchLimit = 25 + + return ( +
+ + + {matches.length > 0 && ( + m.id)} /> + )} + {matches.length > 25 && ( +
+ And {matches.length - matchLimit} more... +
+ )} + +
+ ) +} + +function SortedLeaderboard(props: { + users: User[] + scoreFunction: (user: User) => number + title: string + header: string + maxToShow?: number +}) { + const { users, scoreFunction, title, header, maxToShow } = props + const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + return ( + formatMoney(scoreFunction(user)) }, + ]} + maxToShow={maxToShow} + /> + ) +} + +function GroupLeaderboards(props: { + traderScores: { [userId: string]: number } + creatorScores: { [userId: string]: number } + topTraders: User[] + topCreators: User[] + members: User[] + user: User | null | undefined +}) { + const { traderScores, creatorScores, members, topTraders, topCreators } = + props + const maxToShow = 50 + // Consider hiding M$0 + // If it's just one member (curator), show all bettors, otherwise just show members + return ( + +
+ {members.length > 1 ? ( + <> + traderScores[user.id] ?? 0} + title="🏅 Top traders" + header="Profit" + maxToShow={maxToShow} + /> + creatorScores[user.id] ?? 0} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxToShow} + /> + + ) : ( + <> + formatMoney(traderScores[user.id] ?? 0), + }, + ]} + maxToShow={maxToShow} + /> + + formatMoney(creatorScores[user.id] ?? 0), + }, + ]} + maxToShow={maxToShow} + /> + + )} +
+ + ) +} + +function AddContractButton(props: { group: Group; user: User }) { + const { group, user } = props + const [open, setOpen] = useState(false) + const [contracts, setContracts] = useState([]) + const [loading, setLoading] = useState(false) + + async function addContractToCurrentGroup(contract: Contract) { + if (contracts.map((c) => c.id).includes(contract.id)) { + setContracts(contracts.filter((c) => c.id !== contract.id)) + } else setContracts([...contracts, contract]) + } + + async function doneAddingContracts() { + Promise.all( + contracts.map(async (contract) => { + setLoading(true) + await addContractToGroup(group, contract, user.id) + }) + ).then(() => { + setLoading(false) + setOpen(false) + setContracts([]) + }) + } + + return ( + <> +
+ +
+ + + + +
+ Add a question to your group +
+ + {contracts.length === 0 ? ( + + + +
+ (or select old questions) +
+ + ) : ( + + {!loading ? ( + + + + + ) : ( + + + + )} + + )} + + +
+ c.id), + highlightClassName: '!bg-indigo-100 border-indigo-100 border-2', + }} + /> +
+ +
+ + ) +} + +function JoinGroupButton(props: { + group: Group + user: User | null | undefined +}) { + const { group, user } = props + function addUserToGroup() { + if (user && !group.memberIds.includes(user.id)) { + toast.promise(joinGroup(group, user.id), { + loading: 'Joining group...', + success: 'Joined group!', + error: "Couldn't join group, try again?", + }) + } + } + return ( +
+ +
+ ) +} diff --git a/web/pages/contests.tsx b/web/pages/contests.tsx new file mode 100644 index 00000000..2f44a538 --- /dev/null +++ b/web/pages/contests.tsx @@ -0,0 +1,202 @@ +import { debounce, sortBy } from 'lodash' +import Link from 'next/link' +import React, { useEffect, useState } from 'react' +import { Group } from 'common/group' +import { CreateGroupButton } from 'web/components/groups/create-group-button' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useGroups, useMemberGroupIds, useMembers } from 'web/hooks/use-group' +import { useUser } from 'web/hooks/use-user' +import { groupPath, listAllGroups } from 'web/lib/firebase/groups' +import { getUser, User } from 'web/lib/firebase/users' +import { Tabs } from 'web/components/layout/tabs' +import { SiteLink } from 'web/components/site-link' +import clsx from 'clsx' +import { Avatar } from 'web/components/avatar' +import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' +import { UserLink } from 'web/components/user-page' +import { searchInAny } from 'common/util/parse' +import { SEO } from 'web/components/SEO' +import { CONTEST_SLUGS, CONTEST_DATA, contestPath } from 'common/contest' + +export async function getStaticProps() { + const groups = await listAllGroups().catch((_) => []) + + const creators = await Promise.all( + groups.map((group) => getUser(group.creatorId)) + ) + const creatorsDict = Object.fromEntries( + creators.map((creator) => [creator.id, creator]) + ) + + return { + props: { + groups: groups, + creatorsDict, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export default function Contests(props: { + groups: Group[] + creatorsDict: { [k: string]: User } +}) { + const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict) + + const contests = (useGroups() ?? props.groups).filter((group) => + CONTEST_SLUGS.includes(group.slug) + ) + //const contests = groups.filter((group) => CONTEST_SLUGS.includes(group.slug)) + console.log(contests) + const user = useUser() + const memberGroupIds = useMemberGroupIds(user) || [] + + // useEffect(() => { + // // Load User object for creator of new Groups. + // const newGroups = groups.filter(({ creatorId }) => !creatorsDict[creatorId]) + // if (newGroups.length > 0) { + // Promise.all(newGroups.map(({ creatorId }) => getUser(creatorId))).then( + // (newUsers) => { + // const newUsersDict = Object.fromEntries( + // newUsers.map((user) => [user.id, user]) + // ) + // setCreatorsDict({ ...creatorsDict, ...newUsersDict }) + // } + // ) + // } + // }, [creatorsDict, groups]) + + const [query, setQuery] = useState('') + + // List groups with the highest question count, then highest member count + // TODO use find-active-contracts to sort by? + const matches = sortBy(contests, [ + (contest) => -1 * contest.contractIds.length, + (contest) => -1 * contest.memberIds.length, + ]).filter((g) => + searchInAny( + query, + g.name, + g.about || '', + creatorsDict[g.creatorId].username + ) + ) + + const matchesOrderedByRecentActivity = sortBy(contests, [ + (contest) => + -1 * + (contest.mostRecentChatActivityTime ?? + contest.mostRecentContractAddedTime ?? + contest.mostRecentActivityTime), + ]).filter((c) => + searchInAny( + query, + c.name, + c.about || '', + creatorsDict[c.creatorId].username + ) + ) + + // Not strictly necessary, but makes the "hold delete" experience less laggy + const debouncedQuery = debounce(setQuery, 50) + + return ( + + + + + + + + </Col> + + <div className="mb-6 text-gray-500"> + Discuss real world contests. Vote on your favorite submissions, get + rewarded in mana if you're right. + </div> + + <Col> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Search contests" + className="input input-bordered mb-4 w-full" + /> + + <div className="flex flex-wrap justify-center gap-4"> + {matches.map((contest) => ( + <ContestCard key={contest.id} contest={contest} /> + ))} + </div> + </Col> + </Col> + </Col> + </Page> + ) +} + +export function ContestCard(props: { contest: Group }) { + const { contest } = props + const slug = contest.slug + return ( + <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:shadow-xl"> + <Link href={contestPath(contest.slug)}> + <a className="absolute left-0 right-0 top-0 bottom-0 z-0" /> + </Link> + <img + className="mb-2 h-24 w-24 self-center" + src={`contests/${contest.slug}.png`} + /> + <Row className="items-center justify-between gap-2"> + <span className="text-xl text-indigo-700">{contest.name}</span> + </Row> + <Row> + <div className="text-sm text-gray-500">{contest.about}</div> + </Row> + </Col> + ) +} + +function GroupMembersList(props: { group: Group }) { + const { group } = props + const maxMembersToShow = 3 + const members = useMembers(group, maxMembersToShow).filter( + (m) => m.id !== group.creatorId + ) + if (group.memberIds.length === 1) return <div /> + return ( + <div className="text-neutral flex flex-wrap gap-1"> + <span className={'text-gray-500'}>Other members</span> + {members.slice(0, maxMembersToShow).map((member, i) => ( + <div key={member.id} className={'flex-shrink'}> + <UserLink name={member.name} username={member.username} /> + {members.length > 1 && i !== members.length - 1 && <span>,</span>} + </div> + ))} + {group.memberIds.length > maxMembersToShow && ( + <span> & {group.memberIds.length - maxMembersToShow} more</span> + )} + </div> + ) +} + +export function GroupLinkItem(props: { group: Group; className?: string }) { + const { group, className } = props + + return ( + <SiteLink + href={groupPath(group.slug)} + className={clsx('z-10 truncate', className)} + > + {group.name} + </SiteLink> + ) +} diff --git a/web/pages/submission-search-firestore.tsx b/web/pages/submission-search-firestore.tsx new file mode 100644 index 00000000..2d45e831 --- /dev/null +++ b/web/pages/submission-search-firestore.tsx @@ -0,0 +1,123 @@ +import { Answer } from 'common/answer' +import { searchInAny } from 'common/util/parse' +import { sortBy } from 'lodash' +import { useState } from 'react' +import { ContractsGrid } from 'web/components/contract/contracts-list' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { useContracts } from 'web/hooks/use-contracts' +import { + Sort, + useInitialQueryAndSort, +} from 'web/hooks/use-sort-and-query-params' + +const MAX_CONTRACTS_RENDERED = 100 + +export default function ContractSearchFirestore(props: { + querySortOptions?: { + defaultSort: Sort + shouldLoadFromStorage?: boolean + } + additionalFilter?: { + creatorId?: string + tag?: string + } +}) { + const contracts = useContracts() + const { querySortOptions, additionalFilter } = props + + const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions) + const [sort, setSort] = useState(initialSort || 'newest') + const [query, setQuery] = useState(initialQuery) + + let matches = (contracts ?? []).filter((c) => + searchInAny( + query, + c.question, + c.creatorName, + c.lowercaseTags.map((tag) => `#${tag}`).join(' '), + ((c as any).answers ?? []).map((answer: Answer) => answer.text).join(' ') + ) + ) + + if (sort === 'newest') { + matches.sort((a, b) => b.createdTime - a.createdTime) + } else if (sort === 'resolve-date') { + 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') { + matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours) + matches = sortBy( + matches, + (contract) => + (sort === 'close-date' ? -1 : 1) * (contract.closeTime ?? Infinity) + ) + } else if (sort === 'most-traded') { + matches.sort((a, b) => b.volume - a.volume) + } else if (sort === 'score') { + matches.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) + } 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) + } + + if (additionalFilter) { + const { creatorId, tag } = additionalFilter + + if (creatorId) { + matches = matches.filter((c) => c.creatorId === creatorId) + } + + if (tag) { + matches = matches.filter((c) => + c.lowercaseTags.includes(tag.toLowerCase()) + ) + } + } + + matches = matches.slice(0, MAX_CONTRACTS_RENDERED) + + const showTime = ['close-date', 'closed'].includes(sort) + ? 'close-date' + : sort === 'resolve-date' + ? 'resolve-date' + : undefined + + return ( + <div> + {/* Show a search input next to a sort dropdown */} + <div className="mt-2 mb-8 flex justify-between gap-2"> + <input + type="text" + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search markets" + className="input input-bordered w-full" + /> + <select + className="select select-bordered" + value={sort} + onChange={(e) => setSort(e.target.value as Sort)} + > + <option value="newest">Newest</option> + <option value="oldest">Oldest</option> + <option value="score">Most popular</option> + <option value="most-traded">Most traded</option> + <option value="24-hour-vol">24h volume</option> + <option value="close-date">Closing soon</option> + </select> + </div> + {contracts === undefined ? ( + <LoadingIndicator /> + ) : ( + <ContractsGrid + contracts={matches} + loadMore={() => {}} + hasMore={false} + showTime={showTime} + /> + )} + </div> + ) +} diff --git a/web/public/contests/ManiTrophy.png b/web/public/contests/ManiTrophy.png new file mode 100644 index 00000000..6b58be69 Binary files /dev/null and b/web/public/contests/ManiTrophy.png differ diff --git a/web/public/contests/cause-exploration-prize-prizes.png b/web/public/contests/cause-exploration-prize-prizes.png new file mode 100644 index 00000000..322efa0e Binary files /dev/null and b/web/public/contests/cause-exploration-prize-prizes.png differ diff --git a/web/public/contests/cause-exploration-prize.png b/web/public/contests/cause-exploration-prize.png new file mode 100644 index 00000000..dfe084d2 Binary files /dev/null and b/web/public/contests/cause-exploration-prize.png differ