diff --git a/common/util/random.ts b/common/util/random.ts index c26b361b..93a574ab 100644 --- a/common/util/random.ts +++ b/common/util/random.ts @@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => { ;[array[i], array[swapIndex]] = [array[swapIndex], array[i]] } } + +export function chooseRandomSubset(items: T[], count: number) { + const fiveMinutes = 5 * 60 * 1000 + const seed = Math.round(Date.now() / fiveMinutes).toString() + shuffle(items, createRNG(seed)) + return items.slice(0, count) +} diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 6be187f8..96fe0e6f 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -5,21 +5,15 @@ import { MenuIcon } from '@heroicons/react/solid' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' -import { useMemberGroups } from 'web/hooks/use-group' -import { filterDefined } from 'common/util/array' -import { isArray, keyBy } from 'lodash' -import { User } from 'common/user' -import { Group } from 'common/group' +import { keyBy } from 'lodash' export function ArrangeHome(props: { - user: User | null | undefined - homeSections: string[] - setHomeSections: (sections: string[]) => void + sections: { label: string; id: string }[] + setSectionIds: (sections: string[]) => void }) { - const { user, homeSections, setHomeSections } = props + const { sections, setSectionIds } = props - const groups = useMemberGroups(user?.id) ?? [] - const { itemsById, sections } = getHomeItems(groups, homeSections) + const sectionsById = keyBy(sections, 'id') return ( section.id) + const newSectionIds = sections.map((section) => section.id) - newHomeSections.splice(source.index, 1) - newHomeSections.splice(destination.index, 0, item.id) + newSectionIds.splice(source.index, 1) + newSectionIds.splice(destination.index, 0, section.id) - setHomeSections(newHomeSections) + setSectionIds(newSectionIds) }} > @@ -105,29 +99,3 @@ const SectionItem = (props: { ) } - -export const getHomeItems = (groups: Group[], sections: string[]) => { - // Accommodate old home sections. - if (!isArray(sections)) sections = [] - - const items = [ - { label: 'Trending', id: 'score' }, - { label: 'New for you', id: 'newest' }, - { label: 'Daily movers', id: 'daily-movers' }, - ...groups.map((g) => ({ - label: g.name, - id: g.id, - })), - ] - const itemsById = keyBy(items, 'id') - - const sectionItems = filterDefined(sections.map((id) => itemsById[id])) - - // Add unmentioned items to the end. - sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) - - return { - sections: sectionItems, - itemsById, - } -} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index a0126b2e..1781d2a0 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -14,12 +14,10 @@ import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { useFollows } from 'web/hooks/use-follows' import { - storageStore, historyStore, urlParamStore, usePersistentState, } from 'web/hooks/use-persistent-state' -import { safeLocalStorage } from 'web/lib/util/local' import { track, trackCallback } from 'web/lib/service/analytics' import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' @@ -68,14 +66,13 @@ type AdditionalFilter = { tag?: string excludeContractIds?: string[] groupSlug?: string - yourBets?: boolean - followed?: boolean } export function ContractSearch(props: { user?: User | null defaultSort?: Sort defaultFilter?: filter + defaultPill?: string additionalFilter?: AdditionalFilter highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void @@ -95,11 +92,13 @@ export function ContractSearch(props: { contracts: Contract[] | undefined, loadMore: () => void ) => ReactNode + autoFocus?: boolean }) { const { user, defaultSort, defaultFilter, + defaultPill, additionalFilter, onContractClick, hideOrderSelector, @@ -112,6 +111,7 @@ export function ContractSearch(props: { noControls, maxResults, renderContracts, + autoFocus, } = props const [state, setState] = usePersistentState( @@ -207,13 +207,14 @@ export function ContractSearch(props: { className={headerClassName} defaultSort={defaultSort} defaultFilter={defaultFilter} + defaultPill={defaultPill} additionalFilter={additionalFilter} hideOrderSelector={hideOrderSelector} - persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined} useQueryUrlParam={useQueryUrlParam} user={user} onSearchParametersChanged={onSearchParametersChanged} noControls={noControls} + autoFocus={autoFocus} /> {renderContracts ? ( renderContracts(renderedContracts, performQuery) @@ -235,25 +236,27 @@ function ContractSearchControls(props: { className?: string defaultSort?: Sort defaultFilter?: filter + defaultPill?: string additionalFilter?: AdditionalFilter hideOrderSelector?: boolean onSearchParametersChanged: (params: SearchParameters) => void - persistPrefix?: string useQueryUrlParam?: boolean user?: User | null noControls?: boolean + autoFocus?: boolean }) { const { className, defaultSort, defaultFilter, + defaultPill, additionalFilter, hideOrderSelector, onSearchParametersChanged, - persistPrefix, useQueryUrlParam, user, noControls, + autoFocus, } = props const router = useRouter() @@ -267,17 +270,31 @@ function ContractSearchControls(props: { } ) - const [state, setState] = usePersistentState( - { - sort: defaultSort ?? 'score', - filter: defaultFilter ?? 'open', - pillFilter: null as string | null, - }, - !persistPrefix + const [sort, setSort] = usePersistentState( + defaultSort ?? 'score', + !useQueryUrlParam ? undefined : { - key: `${persistPrefix}-params`, - store: storageStore(safeLocalStorage()), + 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), } ) @@ -319,11 +336,6 @@ function ContractSearchControls(props: { additionalFilter?.groupSlug ? `groupLinks.slug:${additionalFilter.groupSlug}` : '', - additionalFilter?.yourBets && user - ? // Show contracts bet on by the user - `uniqueBettorIds:${user.id}` - : '', - ...(additionalFilter?.followed ? personalFilters : []), ] const facetFilters = query ? additionalFilters @@ -331,31 +343,25 @@ function ContractSearchControls(props: { ...additionalFilters, additionalFilter ? '' : 'visibility:public', - state.filter === 'open' ? 'isResolved:false' : '', - state.filter === 'closed' ? 'isResolved:false' : '', - state.filter === 'resolved' ? 'isResolved:true' : '', + filter === 'open' ? 'isResolved:false' : '', + filter === 'closed' ? 'isResolved:false' : '', + filter === 'resolved' ? 'isResolved:true' : '', - state.pillFilter && - state.pillFilter !== 'personal' && - state.pillFilter !== 'your-bets' - ? `groupLinks.slug:${state.pillFilter}` + pill && pill !== 'personal' && pill !== 'your-bets' + ? `groupLinks.slug:${pill}` : '', - ...(state.pillFilter === 'personal' ? personalFilters : []), - state.pillFilter === 'your-bets' && user + ...(pill === 'personal' ? personalFilters : []), + pill === 'your-bets' && user ? // Show contracts bet on by the user `uniqueBettorIds:${user.id}` : '', ].filter((f) => f) const openClosedFilter = - state.filter === 'open' - ? 'open' - : state.filter === 'closed' - ? 'closed' - : undefined + filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined const selectPill = (pill: string | null) => () => { - setState({ ...state, pillFilter: pill }) + setPill(pill ?? '') track('select search category', { category: pill ?? 'all' }) } @@ -364,25 +370,25 @@ function ContractSearchControls(props: { } const selectFilter = (newFilter: filter) => { - if (newFilter === state.filter) return - setState({ ...state, filter: newFilter }) + if (newFilter === filter) return + setFilter(newFilter) track('select search filter', { filter: newFilter }) } const selectSort = (newSort: Sort) => { - if (newSort === state.sort) return - setState({ ...state, sort: newSort }) + if (newSort === sort) return + setSort(newSort) track('select search sort', { sort: newSort }) } useEffect(() => { onSearchParametersChanged({ query: query, - sort: state.sort, + sort: sort as Sort, openClosedFilter: openClosedFilter, facetFilters: facetFilters, }) - }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)]) + }, [query, sort, openClosedFilter, JSON.stringify(facetFilters)]) if (noControls) { return <> @@ -398,11 +404,12 @@ function ContractSearchControls(props: { onBlur={trackCallback('search', { query: query })} placeholder={'Search'} className="input input-bordered w-full" + autoFocus={autoFocus} /> {!query && ( selectSort(e.target.value as Sort)} > {SORTS.map((option) => ( @@ -428,16 +435,12 @@ function ContractSearchControls(props: { {!additionalFilter && !query && ( - + All {user ? 'For you' : 'Featured'} @@ -446,7 +449,7 @@ function ContractSearchControls(props: { {user && ( Your {PAST_BETS} @@ -457,7 +460,7 @@ function ContractSearchControls(props: { return ( {name} diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index 49216b88..16de0d44 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -11,8 +11,9 @@ export function ProbChangeTable(props: { changes: | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] } | undefined + full?: boolean }) { - const { changes } = props + const { changes, full } = props if (!changes) return @@ -24,7 +25,10 @@ export function ProbChangeTable(props: { negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1 ) const maxRows = Math.min(positiveChanges.length, negativeChanges.length) - const rows = Math.min(3, Math.min(maxRows, countOverThreshold)) + const rows = Math.min( + full ? Infinity : 3, + Math.min(maxRows, countOverThreshold) + ) const filteredPositiveChanges = positiveChanges.slice(0, rows) const filteredNegativeChanges = negativeChanges.slice(0, rows) @@ -35,40 +39,33 @@ export function ProbChangeTable(props: { {filteredPositiveChanges.map((contract) => ( - - - - {contract.question} - - + ))} {filteredNegativeChanges.map((contract) => ( - - - - {contract.question} - - + ))} ) } +function ProbChangeRow(props: { contract: CPMMContract }) { + const { contract } = props + return ( + + + + {contract.question} + + + ) +} + export function ProbChange(props: { contract: CPMMContract className?: string diff --git a/web/components/nav/menu.tsx b/web/components/nav/menu.tsx index f61ebad9..492488d8 100644 --- a/web/components/nav/menu.tsx +++ b/web/components/nav/menu.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx' export type MenuItem = { name: string - href: string + href?: string onClick?: () => void } @@ -38,11 +38,11 @@ export function MenuButton(props: { {({ active }) => ( {item.name} diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 87eefa38..f2403a15 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -2,17 +2,19 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useEffect, useState } from 'react' import { Contract, - listenForActiveContracts, listenForContracts, listenForHotContracts, listenForInactiveContracts, - listenForNewContracts, getUserBetContracts, getUserBetContractsQuery, listAllContracts, + trendingContractsQuery, + getContractsQuery, } from 'web/lib/firebase/contracts' import { QueryClient, useQueryClient } from 'react-query' import { MINUTE_MS } from 'common/util/time' +import { query, limit } from 'firebase/firestore' +import { Sort } from 'web/components/contract-search' export const useContracts = () => { const [contracts, setContracts] = useState() @@ -30,23 +32,25 @@ export const getCachedContracts = async () => staleTime: Infinity, }) -export const useActiveContracts = () => { - const [activeContracts, setActiveContracts] = useState< - Contract[] | undefined - >() - const [newContracts, setNewContracts] = useState() +export const useTrendingContracts = (maxContracts: number) => { + const result = useFirestoreQueryData( + ['trending-contracts', maxContracts], + query(trendingContractsQuery, limit(maxContracts)) + ) + return result.data +} - useEffect(() => { - return listenForActiveContracts(setActiveContracts) - }, []) - - useEffect(() => { - return listenForNewContracts(setNewContracts) - }, []) - - if (!activeContracts || !newContracts) return undefined - - return [...activeContracts, ...newContracts] +export const useContractsQuery = ( + sort: Sort, + maxContracts: number, + filters: { groupSlug?: string } = {}, + visibility?: 'public' +) => { + const result = useFirestoreQueryData( + ['contracts-query', sort, maxContracts, filters], + getContractsQuery(sort, maxContracts, filters, visibility) + ) + return result.data } export const useInactiveContracts = () => { diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 781da9cb..d3d8dd9f 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -11,13 +11,17 @@ import { listenForMemberGroupIds, listenForOpenGroups, listGroups, + topFollowedGroupsQuery, } from 'web/lib/firebase/groups' import { getUser } from 'web/lib/firebase/users' import { filterDefined } from 'common/util/array' import { Contract } from 'common/contract' -import { uniq } from 'lodash' +import { keyBy, uniq, uniqBy } from 'lodash' import { listenForValues } from 'web/lib/firebase/utils' import { useQuery } from 'react-query' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' +import { limit, query } from 'firebase/firestore' +import { useTrendingContracts } from './use-contracts' export const useGroup = (groupId: string | undefined) => { const [group, setGroup] = useState() @@ -49,6 +53,30 @@ export const useOpenGroups = () => { return groups } +export const useTopFollowedGroups = (count: number) => { + const result = useFirestoreQueryData( + ['top-followed-contracts', count], + query(topFollowedGroupsQuery, limit(count)) + ) + return result.data +} + +export const useTrendingGroups = () => { + const topGroups = useTopFollowedGroups(200) + const groupsById = keyBy(topGroups, 'id') + + const trendingContracts = useTrendingContracts(200) + + const groupLinks = uniqBy( + (trendingContracts ?? []).map((c) => c.groupLinks ?? []).flat(), + (link) => link.groupId + ) + + return filterDefined( + groupLinks.map((link) => groupsById[link.groupId]) + ).filter((group) => group.totalMembers >= 3) +} + export const useMemberGroups = (userId: string | null | undefined) => { const result = useQuery(['member-groups', userId ?? ''], () => getMemberGroups(userId ?? '') @@ -56,10 +84,11 @@ export const useMemberGroups = (userId: string | null | undefined) => { return result.data } -// Note: We cache member group ids in localstorage to speed up the initial load export const useMemberGroupIds = (user: User | null | undefined) => { + const cachedGroups = useMemberGroups(user?.id) + const [memberGroupIds, setMemberGroupIds] = useState( - undefined + cachedGroups?.map((g) => g.id) ) useEffect(() => { diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 51ec3108..33f6533b 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -17,13 +17,14 @@ import { partition, sortBy, sum, uniqBy } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract, CPMMContract } from 'common/contract' -import { createRNG, shuffle } from 'common/util/random' +import { chooseRandomSubset } from 'common/util/random' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' import { getBinaryProb } from 'common/contract-details' +import { Sort } from 'web/components/contract-search' export const contracts = coll('contracts') @@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) { ) as Query } -const activeContractsQuery = query( - contracts, - where('isResolved', '==', false), - where('visibility', '==', 'public'), - where('volume7Days', '>', 0) -) - -export function getActiveContracts() { - return getValues(activeContractsQuery) -} - -export function listenForActiveContracts( - setContracts: (contracts: Contract[]) => void -) { - return listenForValues(activeContractsQuery, setContracts) -} - const inactiveContractsQuery = query( contracts, where('isResolved', '==', false), @@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) { await deleteDoc(followDoc) } -function chooseRandomSubset(contracts: Contract[], count: number) { - const fiveMinutes = 5 * 60 * 1000 - const seed = Math.round(Date.now() / fiveMinutes).toString() - shuffle(contracts, createRNG(seed)) - return contracts.slice(0, count) -} - const hotContractsQuery = query( contracts, where('isResolved', '==', false), @@ -282,16 +259,17 @@ export function listenForHotContracts( }) } -const trendingContractsQuery = query( +export const trendingContractsQuery = query( contracts, where('isResolved', '==', false), where('visibility', '==', 'public'), - orderBy('popularityScore', 'desc'), - limit(10) + orderBy('popularityScore', 'desc') ) -export async function getTrendingContracts() { - return await getValues(trendingContractsQuery) +export async function getTrendingContracts(maxContracts = 10) { + return await getValues( + query(trendingContractsQuery, limit(maxContracts)) + ) } export async function getContractsBySlugs(slugs: string[]) { @@ -343,6 +321,51 @@ export const getTopGroupContracts = async ( return await getValues(creatorContractsQuery) } +const sortToField = { + newest: 'createdTime', + score: 'popularityScore', + 'most-traded': 'volume', + '24-hour-vol': 'volume24Hours', + 'prob-change-day': 'probChanges.day', + 'last-updated': 'lastUpdated', + liquidity: 'totalLiquidity', + 'close-date': 'closeTime', + 'resolve-date': 'resolutionTime', + 'prob-descending': 'prob', + 'prob-ascending': 'prob', +} as const + +const sortToDirection = { + newest: 'desc', + score: 'desc', + 'most-traded': 'desc', + '24-hour-vol': 'desc', + 'prob-change-day': 'desc', + 'last-updated': 'desc', + liquidity: 'desc', + 'close-date': 'asc', + 'resolve-date': 'desc', + 'prob-ascending': 'asc', + 'prob-descending': 'desc', +} as const + +export const getContractsQuery = ( + sort: Sort, + maxItems: number, + filters: { groupSlug?: string } = {}, + visibility?: 'public' +) => { + const { groupSlug } = filters + return query( + contracts, + where('isResolved', '==', false), + ...(visibility ? [where('visibility', '==', visibility)] : []), + ...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []), + orderBy(sortToField[sort], sortToDirection[sort]), + limit(maxItems) + ) +} + export const getRecommendedContracts = async ( contract: Contract, excludeBettorId: string, diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index f27460d9..61424b8f 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -6,6 +6,7 @@ import { doc, getDocs, onSnapshot, + orderBy, query, setDoc, updateDoc, @@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) { const members = await getValues(groupMembers(group.id)) return members.map((m) => m.userId) } + +export const topFollowedGroupsQuery = query( + groups, + where('anyoneCanJoin', '==', true), + orderBy('totalMembers', 'desc') +) diff --git a/web/pages/daily-movers.tsx b/web/pages/daily-movers.tsx new file mode 100644 index 00000000..1e5b4c48 --- /dev/null +++ b/web/pages/daily-movers.tsx @@ -0,0 +1,21 @@ +import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { Col } from 'web/components/layout/col' +import { Page } from 'web/components/page' +import { Title } from 'web/components/title' +import { useProbChanges } from 'web/hooks/use-prob-changes' +import { useUser } from 'web/hooks/use-user' + +export default function DailyMovers() { + const user = useUser() + + const changes = useProbChanges(user?.id ?? '') + + return ( + + + + <ProbChangeTable changes={changes} full /> + </Col> + </Page> + ) +} diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx deleted file mode 100644 index 2d3270aa..00000000 --- a/web/pages/experimental/home/index.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import React from 'react' -import Router from 'next/router' -import { - AdjustmentsIcon, - PlusSmIcon, - ArrowSmRightIcon, -} from '@heroicons/react/solid' -import clsx from 'clsx' - -import { Page } from 'web/components/page' -import { Col } from 'web/components/layout/col' -import { ContractSearch, SORTS } from 'web/components/contract-search' -import { User } from 'common/user' -import { useTracking } from 'web/hooks/use-tracking' -import { track } from 'web/lib/service/analytics' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { Sort } from 'web/components/contract-search' -import { Group } from 'common/group' -import { SiteLink } from 'web/components/site-link' -import { useUser } from 'web/hooks/use-user' -import { useMemberGroups } from 'web/hooks/use-group' -import { Button } from 'web/components/button' -import { getHomeItems } from '../../../components/arrange-home' -import { Title } from 'web/components/title' -import { Row } from 'web/components/layout/row' -import { ProbChangeTable } from 'web/components/contract/prob-change-table' -import { groupPath } from 'web/lib/firebase/groups' -import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' -import { formatMoney } from 'common/util/format' -import { useProbChanges } from 'web/hooks/use-prob-changes' -import { ProfitBadge } from 'web/components/bets-list' -import { calculatePortfolioProfit } from 'common/calculate-metrics' - -export default function Home() { - const user = useUser() - - useTracking('view home') - - useSaveReferral() - - const groups = useMemberGroups(user?.id) ?? [] - - const { sections } = getHomeItems(groups, user?.homeSections ?? []) - - return ( - <Page> - <Col className="pm:mx-10 gap-4 px-4 pb-12"> - <Row className={'mt-4 w-full items-start justify-between'}> - <Row className="items-end gap-4"> - <Title className="!mb-1 !mt-0" text="Home" /> - <EditButton /> - </Row> - <DailyProfitAndBalance className="" user={user} /> - </Row> - - {sections.map((item) => { - const { id } = item - if (id === 'daily-movers') { - return <DailyMoversSection key={id} userId={user?.id} /> - } - const sort = SORTS.find((sort) => sort.value === id) - if (sort) - return ( - <SearchSection - key={id} - label={sort.value === 'newest' ? 'New for you' : sort.label} - sort={sort.value} - followed={sort.value === 'newest'} - user={user} - /> - ) - - const group = groups.find((g) => g.id === id) - if (group) return <GroupSection key={id} group={group} user={user} /> - - return null - })} - </Col> - <button - type="button" - className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" - onClick={() => { - Router.push('/create') - track('mobile create button') - }} - > - <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> - </button> - </Page> - ) -} - -function SearchSection(props: { - label: string - user: User | null | undefined | undefined - sort: Sort - yourBets?: boolean - followed?: boolean -}) { - const { label, user, sort, yourBets, followed } = props - - return ( - <Col> - <SectionHeader label={label} href={`/home?s=${sort}`} /> - <ContractSearch - user={user} - defaultSort={sort} - additionalFilter={ - yourBets - ? { yourBets: true } - : followed - ? { followed: true } - : undefined - } - noControls - maxResults={6} - headerClassName="sticky" - persistPrefix={`experimental-home-${sort}`} - /> - </Col> - ) -} - -function GroupSection(props: { - group: Group - user: User | null | undefined | undefined -}) { - const { group, user } = props - - return ( - <Col> - <SectionHeader label={group.name} href={groupPath(group.slug)} /> - <ContractSearch - user={user} - defaultSort={'score'} - additionalFilter={{ groupSlug: group.slug }} - noControls - maxResults={6} - headerClassName="sticky" - persistPrefix={`experimental-home-${group.slug}`} - /> - </Col> - ) -} - -function DailyMoversSection(props: { userId: string | null | undefined }) { - const { userId } = props - const changes = useProbChanges(userId ?? '') - - return ( - <Col className="gap-2"> - <SectionHeader label="Daily movers" href="daily-movers" /> - <ProbChangeTable changes={changes} /> - </Col> - ) -} - -function SectionHeader(props: { label: string; href: string }) { - const { label, href } = props - - return ( - <Row className="mb-3 items-center justify-between"> - <SiteLink className="text-xl" href={href}> - {label}{' '} - <ArrowSmRightIcon - className="mb-0.5 inline h-6 w-6 text-gray-500" - aria-hidden="true" - /> - </SiteLink> - </Row> - ) -} - -function EditButton(props: { className?: string }) { - const { className } = props - - return ( - <SiteLink href="/experimental/home/edit"> - <Button size="sm" color="gray-white" className={clsx(className, 'flex')}> - <AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" /> - </Button> - </SiteLink> - ) -} - -function DailyProfitAndBalance(props: { - user: User | null | undefined - className?: string -}) { - const { user, className } = props - const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] - const [first, last] = [metrics[0], metrics[metrics.length - 1]] - - if (first === undefined || last === undefined) return null - - const profit = - calculatePortfolioProfit(last) - calculatePortfolioProfit(first) - const profitPercent = profit / first.investmentValue - - return ( - <Row className={'gap-4'}> - <Col> - <div className="text-gray-500">Daily profit</div> - <Row className={clsx(className, 'items-center text-lg')}> - <span>{formatMoney(profit)}</span>{' '} - <ProfitBadge profitPercent={profitPercent * 100} /> - </Row> - </Col> - <Col> - <div className="text-gray-500">Streak</div> - <Row className={clsx(className, 'items-center text-lg')}> - <span>🔥 {user?.currentBettingStreak ?? 0}</span> - </Row> - </Col> - </Row> - ) -} diff --git a/web/pages/explore-groups.tsx b/web/pages/explore-groups.tsx new file mode 100644 index 00000000..222aba13 --- /dev/null +++ b/web/pages/explore-groups.tsx @@ -0,0 +1,41 @@ +import Masonry from 'react-masonry-css' +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 { useMemberGroupIds, useTrendingGroups } from 'web/hooks/use-group' +import { useUser } from 'web/hooks/use-user' +import { GroupCard } from './groups' + +export default function Explore() { + const user = useUser() + const groups = useTrendingGroups() + const memberGroupIds = useMemberGroupIds(user) || [] + + return ( + <Page> + <Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[115%]"> + <Row className={'w-full items-center justify-between'}> + <Title className="!mb-0" text="Trending groups" /> + </Row> + + <Masonry + breakpointCols={{ default: 3, 1200: 2, 570: 1 }} + className="-ml-4 flex w-auto self-center" + columnClassName="pl-4 bg-clip-padding" + > + {groups.map((g) => ( + <GroupCard + key={g.id} + className="mb-4 !min-w-[250px]" + group={g} + creator={null} + user={user} + isMember={memberGroupIds.includes(g.id)} + /> + ))} + </Masonry> + </Col> + </Page> + ) +} diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index f39a7647..1854da34 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -171,26 +171,34 @@ export default function Groups(props: { export function GroupCard(props: { group: Group - creator: User | undefined + creator: User | null | undefined user: User | undefined | null isMember: boolean + className?: string }) { - const { group, creator, user, isMember } = props + const { group, creator, user, isMember, className } = props const { totalContracts } = group return ( - <Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"> + <Col + className={clsx( + 'relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-6 shadow-md hover:bg-gray-100', + className + )} + > <Link href={groupPath(group.slug)}> <a className="absolute left-0 right-0 top-0 bottom-0 z-0" /> </Link> - <div> - <Avatar - className={'absolute top-2 right-2 z-10'} - username={creator?.username} - avatarUrl={creator?.avatarUrl} - noLink={false} - size={12} - /> - </div> + {creator !== null && ( + <div> + <Avatar + className={'absolute top-2 right-2 z-10'} + username={creator?.username} + avatarUrl={creator?.avatarUrl} + noLink={false} + size={12} + /> + </div> + )} <Row className="items-center justify-between gap-2"> <span className="text-xl">{group.name}</span> </Row> diff --git a/web/pages/home.tsx b/web/pages/home.tsx deleted file mode 100644 index 50e2c35f..00000000 --- a/web/pages/home.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useRouter } from 'next/router' -import { PencilAltIcon } from '@heroicons/react/solid' - -import { Page } from 'web/components/page' -import { Col } from 'web/components/layout/col' -import { ContractSearch } from 'web/components/contract-search' -import { useTracking } from 'web/hooks/use-tracking' -import { useUser } from 'web/hooks/use-user' -import { track } from 'web/lib/service/analytics' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { usePrefetch } from 'web/hooks/use-prefetch' - -const Home = () => { - const user = useUser() - const router = useRouter() - useTracking('view home') - - useSaveReferral() - usePrefetch(user?.id) - - return ( - <> - <Page> - <Col className="mx-auto w-full p-2"> - <ContractSearch - user={user} - persistPrefix="home-search" - useQueryUrlParam={true} - headerClassName="sticky" - /> - </Col> - <button - type="button" - className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" - onClick={() => { - router.push('/create') - track('mobile create button') - }} - > - <PencilAltIcon className="h-7 w-7" aria-hidden="true" /> - </button> - </Page> - </> - ) -} - -export default Home diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/home/edit.tsx similarity index 82% rename from web/pages/experimental/home/edit.tsx rename to web/pages/home/edit.tsx index 8c242a34..db10b7e4 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/home/edit.tsx @@ -7,9 +7,11 @@ import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' +import { useMemberGroups } from 'web/hooks/use-group' import { useTracking } from 'web/hooks/use-tracking' import { useUser } from 'web/hooks/use-user' import { updateUser } from 'web/lib/firebase/users' +import { getHomeItems } from '.' export default function Home() { const user = useUser() @@ -24,6 +26,9 @@ export default function Home() { setHomeSections(newHomeSections) } + const groups = useMemberGroups(user?.id) ?? [] + const { sections } = getHomeItems(groups, homeSections) + return ( <Page> <Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2"> @@ -32,11 +37,7 @@ export default function Home() { <DoneButton /> </Row> - <ArrangeHome - user={user} - homeSections={homeSections} - setHomeSections={updateHomeSections} - /> + <ArrangeHome sections={sections} setSectionIds={updateHomeSections} /> </Col> </Page> ) @@ -46,7 +47,7 @@ function DoneButton(props: { className?: string }) { const { className } = props return ( - <SiteLink href="/experimental/home"> + <SiteLink href="/home"> <Button size="lg" color="blue" diff --git a/web/pages/home/index.tsx b/web/pages/home/index.tsx new file mode 100644 index 00000000..ae23d18a --- /dev/null +++ b/web/pages/home/index.tsx @@ -0,0 +1,378 @@ +import React, { ReactNode, useEffect, useState } from 'react' +import Router from 'next/router' +import { + AdjustmentsIcon, + PencilAltIcon, + ArrowSmRightIcon, +} from '@heroicons/react/solid' +import { XCircleIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { toast, Toaster } from 'react-hot-toast' + +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ContractSearch, SORTS } from 'web/components/contract-search' +import { User } from 'common/user' +import { useTracking } from 'web/hooks/use-tracking' +import { track } from 'web/lib/service/analytics' +import { useSaveReferral } from 'web/hooks/use-save-referral' +import { Sort } from 'web/components/contract-search' +import { Group } from 'common/group' +import { SiteLink } from 'web/components/site-link' +import { usePrivateUser, useUser } from 'web/hooks/use-user' +import { + useMemberGroupIds, + useMemberGroups, + useTrendingGroups, +} from 'web/hooks/use-group' +import { Button } from 'web/components/button' +import { Row } from 'web/components/layout/row' +import { ProbChangeTable } from 'web/components/contract/prob-change-table' +import { + getGroup, + groupPath, + joinGroup, + leaveGroup, +} from 'web/lib/firebase/groups' +import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' +import { formatMoney } from 'common/util/format' +import { useProbChanges } from 'web/hooks/use-prob-changes' +import { ProfitBadge } from 'web/components/bets-list' +import { calculatePortfolioProfit } from 'common/calculate-metrics' +import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' +import { useContractsQuery } from 'web/hooks/use-contracts' +import { ContractsGrid } from 'web/components/contract/contracts-grid' +import { PillButton } from 'web/components/buttons/pill-button' +import { filterDefined } from 'common/util/array' +import { updateUser } from 'web/lib/firebase/users' +import { isArray, keyBy } from 'lodash' +import { usePrefetch } from 'web/hooks/use-prefetch' + +export default function Home() { + const user = useUser() + + useTracking('view home') + + useSaveReferral() + usePrefetch(user?.id) + + const cachedGroups = useMemberGroups(user?.id) ?? [] + const groupIds = useMemberGroupIds(user) + const [groups, setGroups] = useState(cachedGroups) + + useEffect(() => { + if (groupIds) { + Promise.all(groupIds.map((id) => getGroup(id))).then((groups) => + setGroups(filterDefined(groups)) + ) + } + }, [groupIds]) + + const { sections } = getHomeItems(groups, user?.homeSections ?? []) + + return ( + <Page> + <Toaster /> + + <Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0"> + <Row className={'mb-2 w-full items-center gap-8'}> + <SearchRow /> + <DailyStats className="" user={user} /> + </Row> + + {sections.map((section) => renderSection(section, user, groups))} + + <TrendingGroupsSection user={user} /> + </Col> + <button + type="button" + className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" + onClick={() => { + Router.push('/create') + track('mobile create button') + }} + > + <PencilAltIcon className="h-7 w-7" aria-hidden="true" /> + </button> + </Page> + ) +} + +const HOME_SECTIONS = [ + { label: 'Daily movers', id: 'daily-movers' }, + { label: 'Trending', id: 'score' }, + { label: 'New', id: 'newest' }, + { label: 'New for you', id: 'new-for-you' }, +] + +export const getHomeItems = (groups: Group[], sections: string[]) => { + // Accommodate old home sections. + if (!isArray(sections)) sections = [] + + const items = [ + ...HOME_SECTIONS, + ...groups.map((g) => ({ + label: g.name, + id: g.id, + })), + ] + const itemsById = keyBy(items, 'id') + + const sectionItems = filterDefined(sections.map((id) => itemsById[id])) + + // Add unmentioned items to the end. + sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) + + return { + sections: sectionItems, + itemsById, + } +} + +function renderSection( + section: { id: string; label: string }, + user: User | null | undefined, + groups: Group[] +) { + const { id, label } = section + if (id === 'daily-movers') { + return <DailyMoversSection key={id} userId={user?.id} /> + } + if (id === 'new-for-you') + return ( + <SearchSection + key={id} + label={label} + sort={'newest'} + pill="personal" + user={user} + /> + ) + const sort = SORTS.find((sort) => sort.value === id) + if (sort) + return ( + <SearchSection key={id} label={label} sort={sort.value} user={user} /> + ) + + const group = groups.find((g) => g.id === id) + if (group) return <GroupSection key={id} group={group} user={user} /> + + return null +} + +function SectionHeader(props: { + label: string + href: string + children?: ReactNode +}) { + const { label, href, children } = props + + return ( + <Row className="mb-3 items-center justify-between"> + <SiteLink className="text-xl" href={href}> + {label}{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </SiteLink> + {children} + </Row> + ) +} + +function SearchSection(props: { + label: string + user: User | null | undefined | undefined + sort: Sort + pill?: string +}) { + const { label, user, sort, pill } = props + + return ( + <Col> + <SectionHeader + label={label} + href={`/search?s=${sort}${pill ? `&p=${pill}` : ''}`} + /> + <ContractSearch + user={user} + defaultSort={sort} + defaultPill={pill} + noControls + maxResults={6} + headerClassName="sticky" + persistPrefix={`home-${sort}`} + /> + </Col> + ) +} + +function GroupSection(props: { + group: Group + user: User | null | undefined | undefined +}) { + const { group, user } = props + + const contracts = useContractsQuery('score', 4, { groupSlug: group.slug }) + + return ( + <Col> + <SectionHeader label={group.name} href={groupPath(group.slug)}> + <Button + className="" + color="gray-white" + onClick={() => { + if (user) { + const homeSections = (user.homeSections ?? []).filter( + (id) => id !== group.id + ) + updateUser(user.id, { homeSections }) + + toast.promise(leaveGroup(group, user.id), { + loading: 'Unfollowing group...', + success: `Unfollowed ${group.name}`, + error: "Couldn't unfollow group, try again?", + }) + } + }} + > + <XCircleIcon + className={clsx('h-5 w-5 flex-shrink-0')} + aria-hidden="true" + /> + </Button> + </SectionHeader> + <ContractsGrid contracts={contracts} /> + </Col> + ) +} + +function DailyMoversSection(props: { userId: string | null | undefined }) { + const { userId } = props + const changes = useProbChanges(userId ?? '') + + return ( + <Col className="gap-2"> + <SectionHeader label="Daily movers" href="/daily-movers" /> + <ProbChangeTable changes={changes} /> + </Col> + ) +} + +function SearchRow() { + return ( + <SiteLink href="/search" className="flex-1 hover:no-underline"> + <input className="input input-bordered w-full" placeholder="Search" /> + </SiteLink> + ) +} + +function DailyStats(props: { + user: User | null | undefined + className?: string +}) { + const { user, className } = props + + const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? [] + const [first, last] = [metrics[0], metrics[metrics.length - 1]] + + const privateUser = usePrivateUser() + const streaksHidden = + privateUser?.notificationPreferences.betting_streaks.length === 0 + + let profit = 0 + let profitPercent = 0 + if (first && last) { + profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first) + profitPercent = profit / first.investmentValue + } + + return ( + <Row className={'gap-4'}> + <Col> + <div className="text-gray-500">Daily profit</div> + <Row className={clsx(className, 'items-center text-lg')}> + <span>{formatMoney(profit)}</span>{' '} + <ProfitBadge profitPercent={profitPercent * 100} /> + </Row> + </Col> + {!streaksHidden && ( + <Col> + <div className="text-gray-500">Streak</div> + <Row + className={clsx( + className, + 'items-center text-lg', + user && !hasCompletedStreakToday(user) && 'grayscale' + )} + > + <span>🔥 {user?.currentBettingStreak ?? 0}</span> + </Row> + </Col> + )} + </Row> + ) +} + +function TrendingGroupsSection(props: { user: User | null | undefined }) { + const { user } = props + const memberGroupIds = useMemberGroupIds(user) || [] + + const groups = useTrendingGroups().filter( + (g) => !memberGroupIds.includes(g.id) + ) + const count = 25 + const chosenGroups = groups.slice(0, count) + + return ( + <Col> + <SectionHeader label="Trending groups" href="/explore-groups"> + <CustomizeButton /> + </SectionHeader> + <Row className="flex-wrap gap-2"> + {chosenGroups.map((g) => ( + <PillButton + key={g.id} + selected={memberGroupIds.includes(g.id)} + onSelect={() => { + if (!user) return + if (memberGroupIds.includes(g.id)) leaveGroup(g, user?.id) + else { + const homeSections = (user.homeSections ?? []) + .filter((id) => id !== g.id) + .concat(g.id) + updateUser(user.id, { homeSections }) + + toast.promise(joinGroup(g, user.id), { + loading: 'Following group...', + success: `Followed ${g.name}`, + error: "Couldn't follow group, try again?", + }) + } + }} + > + {g.name} + </PillButton> + ))} + </Row> + </Col> + ) +} + +function CustomizeButton() { + return ( + <SiteLink + className="mb-2 flex flex-row items-center text-xl hover:no-underline" + href="/home/edit" + > + <Button size="lg" color="gray" className={clsx('flex gap-2')}> + <AdjustmentsIcon + className={clsx('h-[24px] w-5 text-gray-500')} + aria-hidden="true" + /> + Customize + </Button> + </SiteLink> + ) +} diff --git a/web/pages/search.tsx b/web/pages/search.tsx new file mode 100644 index 00000000..03ef5c52 --- /dev/null +++ b/web/pages/search.tsx @@ -0,0 +1,30 @@ +import { Page } from 'web/components/page' +import { Col } from 'web/components/layout/col' +import { ContractSearch } from 'web/components/contract-search' +import { useTracking } from 'web/hooks/use-tracking' +import { useUser } from 'web/hooks/use-user' +import { usePrefetch } from 'web/hooks/use-prefetch' +import { useRouter } from 'next/router' + +export default function Search() { + const user = useUser() + usePrefetch(user?.id) + + useTracking('view search') + + const { query } = useRouter() + const autoFocus = !(query['q'] || query['s'] || query['p']) + + return ( + <Page> + <Col className="mx-auto w-full p-2"> + <ContractSearch + user={user} + persistPrefix="search" + useQueryUrlParam={true} + autoFocus={autoFocus} + /> + </Col> + </Page> + ) +}