🏠 New home (#889)
* Factor out section header * Remove daily balance change * Remove dead code * Layout, add streak * Fix visibility observer to work on server * Tweak * Search perserved by url * Add pill query param * Add search page * Extract component for ProbChangeRow * Explore groups page * Add search row * Add trending groups section * Add unfollow option for group * Experimental home: accommodate old saved sections. * Tweaks to search layout * Rearrange layout * Daily movers page * Add streak grayed out indicator * Use firebase query instead of algolia search for groups * Replace trending group card with pills * Hide streak if you turned off that notification * Listen for group updates * Better UI for adding / removing groups * Toast feedback for join/leave group. Customize button moved to bottom. * Remove Home title * Refactor arrange home * Add new for you section * Add prefetch * Move home out of experimental! * Remove unused import * Show non-public markets from group
This commit is contained in:
parent
f7164ddd7d
commit
25ee793208
|
@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => {
|
|||
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseRandomSubset<T>(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)
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<DragDropContext
|
||||
|
@ -27,14 +21,14 @@ export function ArrangeHome(props: {
|
|||
const { destination, source, draggableId } = e
|
||||
if (!destination) return
|
||||
|
||||
const item = itemsById[draggableId]
|
||||
const section = sectionsById[draggableId]
|
||||
|
||||
const newHomeSections = sections.map((section) => 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)
|
||||
}}
|
||||
>
|
||||
<Row className="relative max-w-md gap-4">
|
||||
|
@ -105,29 +99,3 @@ const SectionItem = (props: {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={state.filter}
|
||||
value={filter}
|
||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
|
@ -414,7 +421,7 @@ function ContractSearchControls(props: {
|
|||
{!hideOrderSelector && !query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={state.sort}
|
||||
value={sort}
|
||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||
>
|
||||
{SORTS.map((option) => (
|
||||
|
@ -428,16 +435,12 @@ function ContractSearchControls(props: {
|
|||
|
||||
{!additionalFilter && !query && (
|
||||
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||
<PillButton
|
||||
key={'all'}
|
||||
selected={state.pillFilter === undefined}
|
||||
onSelect={selectPill(null)}
|
||||
>
|
||||
<PillButton key={'all'} selected={!pill} onSelect={selectPill(null)}>
|
||||
All
|
||||
</PillButton>
|
||||
<PillButton
|
||||
key={'personal'}
|
||||
selected={state.pillFilter === 'personal'}
|
||||
selected={pill === 'personal'}
|
||||
onSelect={selectPill('personal')}
|
||||
>
|
||||
{user ? 'For you' : 'Featured'}
|
||||
|
@ -446,7 +449,7 @@ function ContractSearchControls(props: {
|
|||
{user && (
|
||||
<PillButton
|
||||
key={'your-bets'}
|
||||
selected={state.pillFilter === 'your-bets'}
|
||||
selected={pill === 'your-bets'}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your {PAST_BETS}
|
||||
|
@ -457,7 +460,7 @@ function ContractSearchControls(props: {
|
|||
return (
|
||||
<PillButton
|
||||
key={slug}
|
||||
selected={state.pillFilter === slug}
|
||||
selected={pill === slug}
|
||||
onSelect={selectPill(slug)}
|
||||
>
|
||||
{name}
|
||||
|
|
|
@ -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 <LoadingIndicator />
|
||||
|
||||
|
@ -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,27 +39,23 @@ export function ProbChangeTable(props: {
|
|||
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredPositiveChanges.map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
<ProbChangeRow key={contract.id} contract={contract} />
|
||||
))}
|
||||
</Col>
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredNegativeChanges.map((contract) => (
|
||||
<ProbChangeRow key={contract.id} contract={contract} />
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChangeRow(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
return (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<ProbChange className="p-4 text-right text-xl" contract={contract} />
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
|
@ -63,9 +63,6 @@ export function ProbChangeTable(props: {
|
|||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }) => (
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
||||
target={item.href?.startsWith('http') ? '_blank' : undefined}
|
||||
onClick={item.onClick}
|
||||
className={clsx(
|
||||
active ? 'bg-gray-100' : '',
|
||||
'line-clamp-3 block py-1.5 px-4 text-sm text-gray-700'
|
||||
'line-clamp-3 block cursor-pointer py-1.5 px-4 text-sm text-gray-700'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
|
|
|
@ -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<Contract[] | undefined>()
|
||||
|
@ -30,23 +32,25 @@ export const getCachedContracts = async () =>
|
|||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
export const useActiveContracts = () => {
|
||||
const [activeContracts, setActiveContracts] = useState<
|
||||
Contract[] | undefined
|
||||
>()
|
||||
const [newContracts, setNewContracts] = useState<Contract[] | undefined>()
|
||||
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 = () => {
|
||||
|
|
|
@ -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<Group | null | undefined>()
|
||||
|
@ -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<string[] | undefined>(
|
||||
undefined
|
||||
cachedGroups?.map((g) => g.id)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -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<Contract>('contracts')
|
||||
|
||||
|
@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) {
|
|||
) as Query<Contract>
|
||||
}
|
||||
|
||||
const activeContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('visibility', '==', 'public'),
|
||||
where('volume7Days', '>', 0)
|
||||
)
|
||||
|
||||
export function getActiveContracts() {
|
||||
return getValues<Contract>(activeContractsQuery)
|
||||
}
|
||||
|
||||
export function listenForActiveContracts(
|
||||
setContracts: (contracts: Contract[]) => void
|
||||
) {
|
||||
return listenForValues<Contract>(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<Contract>(trendingContractsQuery)
|
||||
export async function getTrendingContracts(maxContracts = 10) {
|
||||
return await getValues<Contract>(
|
||||
query(trendingContractsQuery, limit(maxContracts))
|
||||
)
|
||||
}
|
||||
|
||||
export async function getContractsBySlugs(slugs: string[]) {
|
||||
|
@ -343,6 +321,51 @@ export const getTopGroupContracts = async (
|
|||
return await getValues<Contract>(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,
|
||||
|
|
|
@ -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<GroupMemberDoc>(groupMembers(group.id))
|
||||
return members.map((m) => m.userId)
|
||||
}
|
||||
|
||||
export const topFollowedGroupsQuery = query(
|
||||
groups,
|
||||
where('anyoneCanJoin', '==', true),
|
||||
orderBy('totalMembers', 'desc')
|
||||
)
|
||||
|
|
21
web/pages/daily-movers.tsx
Normal file
21
web/pages/daily-movers.tsx
Normal file
|
@ -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 (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4">
|
||||
<Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" />
|
||||
<ProbChangeTable changes={changes} full />
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
41
web/pages/explore-groups.tsx
Normal file
41
web/pages/explore-groups.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -171,17 +171,24 @@ 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>
|
||||
{creator !== null && (
|
||||
<div>
|
||||
<Avatar
|
||||
className={'absolute top-2 right-2 z-10'}
|
||||
|
@ -191,6 +198,7 @@ export function GroupCard(props: {
|
|||
size={12}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Row className="items-center justify-between gap-2">
|
||||
<span className="text-xl">{group.name}</span>
|
||||
</Row>
|
||||
|
|
|
@ -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
|
|
@ -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"
|
378
web/pages/home/index.tsx
Normal file
378
web/pages/home/index.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
30
web/pages/search.tsx
Normal file
30
web/pages/search.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user