🏠 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]]
|
;[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 { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Subtitle } from 'web/components/subtitle'
|
import { Subtitle } from 'web/components/subtitle'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { keyBy } from 'lodash'
|
||||||
import { filterDefined } from 'common/util/array'
|
|
||||||
import { isArray, keyBy } from 'lodash'
|
|
||||||
import { User } from 'common/user'
|
|
||||||
import { Group } from 'common/group'
|
|
||||||
|
|
||||||
export function ArrangeHome(props: {
|
export function ArrangeHome(props: {
|
||||||
user: User | null | undefined
|
sections: { label: string; id: string }[]
|
||||||
homeSections: string[]
|
setSectionIds: (sections: string[]) => void
|
||||||
setHomeSections: (sections: string[]) => void
|
|
||||||
}) {
|
}) {
|
||||||
const { user, homeSections, setHomeSections } = props
|
const { sections, setSectionIds } = props
|
||||||
|
|
||||||
const groups = useMemberGroups(user?.id) ?? []
|
const sectionsById = keyBy(sections, 'id')
|
||||||
const { itemsById, sections } = getHomeItems(groups, homeSections)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
|
@ -27,14 +21,14 @@ export function ArrangeHome(props: {
|
||||||
const { destination, source, draggableId } = e
|
const { destination, source, draggableId } = e
|
||||||
if (!destination) return
|
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)
|
newSectionIds.splice(source.index, 1)
|
||||||
newHomeSections.splice(destination.index, 0, item.id)
|
newSectionIds.splice(destination.index, 0, section.id)
|
||||||
|
|
||||||
setHomeSections(newHomeSections)
|
setSectionIds(newSectionIds)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Row className="relative max-w-md gap-4">
|
<Row className="relative max-w-md gap-4">
|
||||||
|
@ -105,29 +99,3 @@ const SectionItem = (props: {
|
||||||
</div>
|
</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 { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import {
|
import {
|
||||||
storageStore,
|
|
||||||
historyStore,
|
historyStore,
|
||||||
urlParamStore,
|
urlParamStore,
|
||||||
usePersistentState,
|
usePersistentState,
|
||||||
} from 'web/hooks/use-persistent-state'
|
} from 'web/hooks/use-persistent-state'
|
||||||
import { safeLocalStorage } from 'web/lib/util/local'
|
|
||||||
import { track, trackCallback } from 'web/lib/service/analytics'
|
import { track, trackCallback } from 'web/lib/service/analytics'
|
||||||
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
|
@ -68,14 +66,13 @@ type AdditionalFilter = {
|
||||||
tag?: string
|
tag?: string
|
||||||
excludeContractIds?: string[]
|
excludeContractIds?: string[]
|
||||||
groupSlug?: string
|
groupSlug?: string
|
||||||
yourBets?: boolean
|
|
||||||
followed?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContractSearch(props: {
|
export function ContractSearch(props: {
|
||||||
user?: User | null
|
user?: User | null
|
||||||
defaultSort?: Sort
|
defaultSort?: Sort
|
||||||
defaultFilter?: filter
|
defaultFilter?: filter
|
||||||
|
defaultPill?: string
|
||||||
additionalFilter?: AdditionalFilter
|
additionalFilter?: AdditionalFilter
|
||||||
highlightOptions?: ContractHighlightOptions
|
highlightOptions?: ContractHighlightOptions
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
|
@ -95,11 +92,13 @@ export function ContractSearch(props: {
|
||||||
contracts: Contract[] | undefined,
|
contracts: Contract[] | undefined,
|
||||||
loadMore: () => void
|
loadMore: () => void
|
||||||
) => ReactNode
|
) => ReactNode
|
||||||
|
autoFocus?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
defaultSort,
|
defaultSort,
|
||||||
defaultFilter,
|
defaultFilter,
|
||||||
|
defaultPill,
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
hideOrderSelector,
|
hideOrderSelector,
|
||||||
|
@ -112,6 +111,7 @@ export function ContractSearch(props: {
|
||||||
noControls,
|
noControls,
|
||||||
maxResults,
|
maxResults,
|
||||||
renderContracts,
|
renderContracts,
|
||||||
|
autoFocus,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [state, setState] = usePersistentState(
|
const [state, setState] = usePersistentState(
|
||||||
|
@ -207,13 +207,14 @@ export function ContractSearch(props: {
|
||||||
className={headerClassName}
|
className={headerClassName}
|
||||||
defaultSort={defaultSort}
|
defaultSort={defaultSort}
|
||||||
defaultFilter={defaultFilter}
|
defaultFilter={defaultFilter}
|
||||||
|
defaultPill={defaultPill}
|
||||||
additionalFilter={additionalFilter}
|
additionalFilter={additionalFilter}
|
||||||
hideOrderSelector={hideOrderSelector}
|
hideOrderSelector={hideOrderSelector}
|
||||||
persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined}
|
|
||||||
useQueryUrlParam={useQueryUrlParam}
|
useQueryUrlParam={useQueryUrlParam}
|
||||||
user={user}
|
user={user}
|
||||||
onSearchParametersChanged={onSearchParametersChanged}
|
onSearchParametersChanged={onSearchParametersChanged}
|
||||||
noControls={noControls}
|
noControls={noControls}
|
||||||
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{renderContracts ? (
|
{renderContracts ? (
|
||||||
renderContracts(renderedContracts, performQuery)
|
renderContracts(renderedContracts, performQuery)
|
||||||
|
@ -235,25 +236,27 @@ function ContractSearchControls(props: {
|
||||||
className?: string
|
className?: string
|
||||||
defaultSort?: Sort
|
defaultSort?: Sort
|
||||||
defaultFilter?: filter
|
defaultFilter?: filter
|
||||||
|
defaultPill?: string
|
||||||
additionalFilter?: AdditionalFilter
|
additionalFilter?: AdditionalFilter
|
||||||
hideOrderSelector?: boolean
|
hideOrderSelector?: boolean
|
||||||
onSearchParametersChanged: (params: SearchParameters) => void
|
onSearchParametersChanged: (params: SearchParameters) => void
|
||||||
persistPrefix?: string
|
|
||||||
useQueryUrlParam?: boolean
|
useQueryUrlParam?: boolean
|
||||||
user?: User | null
|
user?: User | null
|
||||||
noControls?: boolean
|
noControls?: boolean
|
||||||
|
autoFocus?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
defaultSort,
|
defaultSort,
|
||||||
defaultFilter,
|
defaultFilter,
|
||||||
|
defaultPill,
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
hideOrderSelector,
|
hideOrderSelector,
|
||||||
onSearchParametersChanged,
|
onSearchParametersChanged,
|
||||||
persistPrefix,
|
|
||||||
useQueryUrlParam,
|
useQueryUrlParam,
|
||||||
user,
|
user,
|
||||||
noControls,
|
noControls,
|
||||||
|
autoFocus,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -267,17 +270,31 @@ function ContractSearchControls(props: {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const [state, setState] = usePersistentState(
|
const [sort, setSort] = usePersistentState(
|
||||||
{
|
defaultSort ?? 'score',
|
||||||
sort: defaultSort ?? 'score',
|
!useQueryUrlParam
|
||||||
filter: defaultFilter ?? 'open',
|
|
||||||
pillFilter: null as string | null,
|
|
||||||
},
|
|
||||||
!persistPrefix
|
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
key: `${persistPrefix}-params`,
|
key: 's',
|
||||||
store: storageStore(safeLocalStorage()),
|
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
|
additionalFilter?.groupSlug
|
||||||
? `groupLinks.slug:${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
|
const facetFilters = query
|
||||||
? additionalFilters
|
? additionalFilters
|
||||||
|
@ -331,31 +343,25 @@ function ContractSearchControls(props: {
|
||||||
...additionalFilters,
|
...additionalFilters,
|
||||||
additionalFilter ? '' : 'visibility:public',
|
additionalFilter ? '' : 'visibility:public',
|
||||||
|
|
||||||
state.filter === 'open' ? 'isResolved:false' : '',
|
filter === 'open' ? 'isResolved:false' : '',
|
||||||
state.filter === 'closed' ? 'isResolved:false' : '',
|
filter === 'closed' ? 'isResolved:false' : '',
|
||||||
state.filter === 'resolved' ? 'isResolved:true' : '',
|
filter === 'resolved' ? 'isResolved:true' : '',
|
||||||
|
|
||||||
state.pillFilter &&
|
pill && pill !== 'personal' && pill !== 'your-bets'
|
||||||
state.pillFilter !== 'personal' &&
|
? `groupLinks.slug:${pill}`
|
||||||
state.pillFilter !== 'your-bets'
|
|
||||||
? `groupLinks.slug:${state.pillFilter}`
|
|
||||||
: '',
|
: '',
|
||||||
...(state.pillFilter === 'personal' ? personalFilters : []),
|
...(pill === 'personal' ? personalFilters : []),
|
||||||
state.pillFilter === 'your-bets' && user
|
pill === 'your-bets' && user
|
||||||
? // Show contracts bet on by the user
|
? // Show contracts bet on by the user
|
||||||
`uniqueBettorIds:${user.id}`
|
`uniqueBettorIds:${user.id}`
|
||||||
: '',
|
: '',
|
||||||
].filter((f) => f)
|
].filter((f) => f)
|
||||||
|
|
||||||
const openClosedFilter =
|
const openClosedFilter =
|
||||||
state.filter === 'open'
|
filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined
|
||||||
? 'open'
|
|
||||||
: state.filter === 'closed'
|
|
||||||
? 'closed'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const selectPill = (pill: string | null) => () => {
|
const selectPill = (pill: string | null) => () => {
|
||||||
setState({ ...state, pillFilter: pill })
|
setPill(pill ?? '')
|
||||||
track('select search category', { category: pill ?? 'all' })
|
track('select search category', { category: pill ?? 'all' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -364,25 +370,25 @@ function ContractSearchControls(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectFilter = (newFilter: filter) => {
|
const selectFilter = (newFilter: filter) => {
|
||||||
if (newFilter === state.filter) return
|
if (newFilter === filter) return
|
||||||
setState({ ...state, filter: newFilter })
|
setFilter(newFilter)
|
||||||
track('select search filter', { filter: newFilter })
|
track('select search filter', { filter: newFilter })
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectSort = (newSort: Sort) => {
|
const selectSort = (newSort: Sort) => {
|
||||||
if (newSort === state.sort) return
|
if (newSort === sort) return
|
||||||
setState({ ...state, sort: newSort })
|
setSort(newSort)
|
||||||
track('select search sort', { sort: newSort })
|
track('select search sort', { sort: newSort })
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSearchParametersChanged({
|
onSearchParametersChanged({
|
||||||
query: query,
|
query: query,
|
||||||
sort: state.sort,
|
sort: sort as Sort,
|
||||||
openClosedFilter: openClosedFilter,
|
openClosedFilter: openClosedFilter,
|
||||||
facetFilters: facetFilters,
|
facetFilters: facetFilters,
|
||||||
})
|
})
|
||||||
}, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)])
|
}, [query, sort, openClosedFilter, JSON.stringify(facetFilters)])
|
||||||
|
|
||||||
if (noControls) {
|
if (noControls) {
|
||||||
return <></>
|
return <></>
|
||||||
|
@ -398,11 +404,12 @@ function ContractSearchControls(props: {
|
||||||
onBlur={trackCallback('search', { query: query })}
|
onBlur={trackCallback('search', { query: query })}
|
||||||
placeholder={'Search'}
|
placeholder={'Search'}
|
||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{!query && (
|
{!query && (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered"
|
className="select select-bordered"
|
||||||
value={state.filter}
|
value={filter}
|
||||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||||
>
|
>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
|
@ -414,7 +421,7 @@ function ContractSearchControls(props: {
|
||||||
{!hideOrderSelector && !query && (
|
{!hideOrderSelector && !query && (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered"
|
className="select select-bordered"
|
||||||
value={state.sort}
|
value={sort}
|
||||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||||
>
|
>
|
||||||
{SORTS.map((option) => (
|
{SORTS.map((option) => (
|
||||||
|
@ -428,16 +435,12 @@ function ContractSearchControls(props: {
|
||||||
|
|
||||||
{!additionalFilter && !query && (
|
{!additionalFilter && !query && (
|
||||||
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||||
<PillButton
|
<PillButton key={'all'} selected={!pill} onSelect={selectPill(null)}>
|
||||||
key={'all'}
|
|
||||||
selected={state.pillFilter === undefined}
|
|
||||||
onSelect={selectPill(null)}
|
|
||||||
>
|
|
||||||
All
|
All
|
||||||
</PillButton>
|
</PillButton>
|
||||||
<PillButton
|
<PillButton
|
||||||
key={'personal'}
|
key={'personal'}
|
||||||
selected={state.pillFilter === 'personal'}
|
selected={pill === 'personal'}
|
||||||
onSelect={selectPill('personal')}
|
onSelect={selectPill('personal')}
|
||||||
>
|
>
|
||||||
{user ? 'For you' : 'Featured'}
|
{user ? 'For you' : 'Featured'}
|
||||||
|
@ -446,7 +449,7 @@ function ContractSearchControls(props: {
|
||||||
{user && (
|
{user && (
|
||||||
<PillButton
|
<PillButton
|
||||||
key={'your-bets'}
|
key={'your-bets'}
|
||||||
selected={state.pillFilter === 'your-bets'}
|
selected={pill === 'your-bets'}
|
||||||
onSelect={selectPill('your-bets')}
|
onSelect={selectPill('your-bets')}
|
||||||
>
|
>
|
||||||
Your {PAST_BETS}
|
Your {PAST_BETS}
|
||||||
|
@ -457,7 +460,7 @@ function ContractSearchControls(props: {
|
||||||
return (
|
return (
|
||||||
<PillButton
|
<PillButton
|
||||||
key={slug}
|
key={slug}
|
||||||
selected={state.pillFilter === slug}
|
selected={pill === slug}
|
||||||
onSelect={selectPill(slug)}
|
onSelect={selectPill(slug)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
|
|
|
@ -11,8 +11,9 @@ export function ProbChangeTable(props: {
|
||||||
changes:
|
changes:
|
||||||
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
|
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
|
||||||
| undefined
|
| undefined
|
||||||
|
full?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { changes } = props
|
const { changes, full } = props
|
||||||
|
|
||||||
if (!changes) return <LoadingIndicator />
|
if (!changes) return <LoadingIndicator />
|
||||||
|
|
||||||
|
@ -24,7 +25,10 @@ export function ProbChangeTable(props: {
|
||||||
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
|
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
|
||||||
)
|
)
|
||||||
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
|
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 filteredPositiveChanges = positiveChanges.slice(0, rows)
|
||||||
const filteredNegativeChanges = negativeChanges.slice(0, rows)
|
const filteredNegativeChanges = negativeChanges.slice(0, rows)
|
||||||
|
@ -35,40 +39,33 @@ 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="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">
|
<Col className="flex-1 divide-y">
|
||||||
{filteredPositiveChanges.map((contract) => (
|
{filteredPositiveChanges.map((contract) => (
|
||||||
<Row className="items-center hover:bg-gray-100">
|
<ProbChangeRow key={contract.id} 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)}
|
|
||||||
>
|
|
||||||
<span className="line-clamp-2">{contract.question}</span>
|
|
||||||
</SiteLink>
|
|
||||||
</Row>
|
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
<Col className="flex-1 divide-y">
|
<Col className="flex-1 divide-y">
|
||||||
{filteredNegativeChanges.map((contract) => (
|
{filteredNegativeChanges.map((contract) => (
|
||||||
<Row className="items-center hover:bg-gray-100">
|
<ProbChangeRow key={contract.id} 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)}
|
|
||||||
>
|
|
||||||
<span className="line-clamp-2">{contract.question}</span>
|
|
||||||
</SiteLink>
|
|
||||||
</Row>
|
|
||||||
))}
|
))}
|
||||||
</Col>
|
</Col>
|
||||||
</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} />
|
||||||
|
<SiteLink
|
||||||
|
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||||
|
href={contractPath(contract)}
|
||||||
|
>
|
||||||
|
<span className="line-clamp-2">{contract.question}</span>
|
||||||
|
</SiteLink>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ProbChange(props: {
|
export function ProbChange(props: {
|
||||||
contract: CPMMContract
|
contract: CPMMContract
|
||||||
className?: string
|
className?: string
|
||||||
|
|
|
@ -4,7 +4,7 @@ import clsx from 'clsx'
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
name: string
|
name: string
|
||||||
href: string
|
href?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,11 +38,11 @@ export function MenuButton(props: {
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
target={item.href?.startsWith('http') ? '_blank' : undefined}
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
active ? 'bg-gray-100' : '',
|
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}
|
{item.name}
|
||||||
|
|
|
@ -2,17 +2,19 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
listenForActiveContracts,
|
|
||||||
listenForContracts,
|
listenForContracts,
|
||||||
listenForHotContracts,
|
listenForHotContracts,
|
||||||
listenForInactiveContracts,
|
listenForInactiveContracts,
|
||||||
listenForNewContracts,
|
|
||||||
getUserBetContracts,
|
getUserBetContracts,
|
||||||
getUserBetContractsQuery,
|
getUserBetContractsQuery,
|
||||||
listAllContracts,
|
listAllContracts,
|
||||||
|
trendingContractsQuery,
|
||||||
|
getContractsQuery,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { QueryClient, useQueryClient } from 'react-query'
|
import { QueryClient, useQueryClient } from 'react-query'
|
||||||
import { MINUTE_MS } from 'common/util/time'
|
import { MINUTE_MS } from 'common/util/time'
|
||||||
|
import { query, limit } from 'firebase/firestore'
|
||||||
|
import { Sort } from 'web/components/contract-search'
|
||||||
|
|
||||||
export const useContracts = () => {
|
export const useContracts = () => {
|
||||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
|
@ -30,23 +32,25 @@ export const getCachedContracts = async () =>
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useActiveContracts = () => {
|
export const useTrendingContracts = (maxContracts: number) => {
|
||||||
const [activeContracts, setActiveContracts] = useState<
|
const result = useFirestoreQueryData(
|
||||||
Contract[] | undefined
|
['trending-contracts', maxContracts],
|
||||||
>()
|
query(trendingContractsQuery, limit(maxContracts))
|
||||||
const [newContracts, setNewContracts] = useState<Contract[] | undefined>()
|
)
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
export const useContractsQuery = (
|
||||||
return listenForActiveContracts(setActiveContracts)
|
sort: Sort,
|
||||||
}, [])
|
maxContracts: number,
|
||||||
|
filters: { groupSlug?: string } = {},
|
||||||
useEffect(() => {
|
visibility?: 'public'
|
||||||
return listenForNewContracts(setNewContracts)
|
) => {
|
||||||
}, [])
|
const result = useFirestoreQueryData(
|
||||||
|
['contracts-query', sort, maxContracts, filters],
|
||||||
if (!activeContracts || !newContracts) return undefined
|
getContractsQuery(sort, maxContracts, filters, visibility)
|
||||||
|
)
|
||||||
return [...activeContracts, ...newContracts]
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInactiveContracts = () => {
|
export const useInactiveContracts = () => {
|
||||||
|
|
|
@ -11,13 +11,17 @@ import {
|
||||||
listenForMemberGroupIds,
|
listenForMemberGroupIds,
|
||||||
listenForOpenGroups,
|
listenForOpenGroups,
|
||||||
listGroups,
|
listGroups,
|
||||||
|
topFollowedGroupsQuery,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { getUser } from 'web/lib/firebase/users'
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { uniq } from 'lodash'
|
import { keyBy, uniq, uniqBy } from 'lodash'
|
||||||
import { listenForValues } from 'web/lib/firebase/utils'
|
import { listenForValues } from 'web/lib/firebase/utils'
|
||||||
import { useQuery } from 'react-query'
|
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) => {
|
export const useGroup = (groupId: string | undefined) => {
|
||||||
const [group, setGroup] = useState<Group | null | undefined>()
|
const [group, setGroup] = useState<Group | null | undefined>()
|
||||||
|
@ -49,6 +53,30 @@ export const useOpenGroups = () => {
|
||||||
return groups
|
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) => {
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
const result = useQuery(['member-groups', userId ?? ''], () =>
|
const result = useQuery(['member-groups', userId ?? ''], () =>
|
||||||
getMemberGroups(userId ?? '')
|
getMemberGroups(userId ?? '')
|
||||||
|
@ -56,10 +84,11 @@ export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We cache member group ids in localstorage to speed up the initial load
|
|
||||||
export const useMemberGroupIds = (user: User | null | undefined) => {
|
export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||||
|
const cachedGroups = useMemberGroups(user?.id)
|
||||||
|
|
||||||
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
|
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
|
||||||
undefined
|
cachedGroups?.map((g) => g.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -17,13 +17,14 @@ import { partition, sortBy, sum, uniqBy } from 'lodash'
|
||||||
|
|
||||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
|
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 { formatMoney, formatPercent } from 'common/util/format'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { getBinaryProb } from 'common/contract-details'
|
import { getBinaryProb } from 'common/contract-details'
|
||||||
|
import { Sort } from 'web/components/contract-search'
|
||||||
|
|
||||||
export const contracts = coll<Contract>('contracts')
|
export const contracts = coll<Contract>('contracts')
|
||||||
|
|
||||||
|
@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) {
|
||||||
) as Query<Contract>
|
) 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(
|
const inactiveContractsQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) {
|
||||||
await deleteDoc(followDoc)
|
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(
|
const hotContractsQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
@ -282,16 +259,17 @@ export function listenForHotContracts(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const trendingContractsQuery = query(
|
export const trendingContractsQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
orderBy('popularityScore', 'desc'),
|
orderBy('popularityScore', 'desc')
|
||||||
limit(10)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function getTrendingContracts() {
|
export async function getTrendingContracts(maxContracts = 10) {
|
||||||
return await getValues<Contract>(trendingContractsQuery)
|
return await getValues<Contract>(
|
||||||
|
query(trendingContractsQuery, limit(maxContracts))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsBySlugs(slugs: string[]) {
|
export async function getContractsBySlugs(slugs: string[]) {
|
||||||
|
@ -343,6 +321,51 @@ export const getTopGroupContracts = async (
|
||||||
return await getValues<Contract>(creatorContractsQuery)
|
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 (
|
export const getRecommendedContracts = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
excludeBettorId: string,
|
excludeBettorId: string,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
doc,
|
doc,
|
||||||
getDocs,
|
getDocs,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
|
orderBy,
|
||||||
query,
|
query,
|
||||||
setDoc,
|
setDoc,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
|
@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) {
|
||||||
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||||
return members.map((m) => m.userId)
|
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,26 +171,34 @@ export default function Groups(props: {
|
||||||
|
|
||||||
export function GroupCard(props: {
|
export function GroupCard(props: {
|
||||||
group: Group
|
group: Group
|
||||||
creator: User | undefined
|
creator: User | null | undefined
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
isMember: boolean
|
isMember: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { group, creator, user, isMember } = props
|
const { group, creator, user, isMember, className } = props
|
||||||
const { totalContracts } = group
|
const { totalContracts } = group
|
||||||
return (
|
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)}>
|
<Link href={groupPath(group.slug)}>
|
||||||
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
|
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
{creator !== null && (
|
||||||
<Avatar
|
<div>
|
||||||
className={'absolute top-2 right-2 z-10'}
|
<Avatar
|
||||||
username={creator?.username}
|
className={'absolute top-2 right-2 z-10'}
|
||||||
avatarUrl={creator?.avatarUrl}
|
username={creator?.username}
|
||||||
noLink={false}
|
avatarUrl={creator?.avatarUrl}
|
||||||
size={12}
|
noLink={false}
|
||||||
/>
|
size={12}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Row className="items-center justify-between gap-2">
|
<Row className="items-center justify-between gap-2">
|
||||||
<span className="text-xl">{group.name}</span>
|
<span className="text-xl">{group.name}</span>
|
||||||
</Row>
|
</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 { Page } from 'web/components/page'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { updateUser } from 'web/lib/firebase/users'
|
import { updateUser } from 'web/lib/firebase/users'
|
||||||
|
import { getHomeItems } from '.'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -24,6 +26,9 @@ export default function Home() {
|
||||||
setHomeSections(newHomeSections)
|
setHomeSections(newHomeSections)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groups = useMemberGroups(user?.id) ?? []
|
||||||
|
const { sections } = getHomeItems(groups, homeSections)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
||||||
|
@ -32,11 +37,7 @@ export default function Home() {
|
||||||
<DoneButton />
|
<DoneButton />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<ArrangeHome
|
<ArrangeHome sections={sections} setSectionIds={updateHomeSections} />
|
||||||
user={user}
|
|
||||||
homeSections={homeSections}
|
|
||||||
setHomeSections={updateHomeSections}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
@ -46,7 +47,7 @@ function DoneButton(props: { className?: string }) {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteLink href="/experimental/home">
|
<SiteLink href="/home">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
color="blue"
|
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