Merge branch 'manifoldmarkets:main' into main

This commit is contained in:
marsteralex 2022-09-16 17:20:52 -07:00 committed by GitHub
commit 0debf4b21a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 2206 additions and 1274 deletions

View File

@ -142,17 +142,20 @@ function getCpmmInvested(yourBets: Bet[]) {
const { outcome, shares, amount } = bet const { outcome, shares, amount } = bet
if (floatingEqual(shares, 0)) continue if (floatingEqual(shares, 0)) continue
const spent = totalSpent[outcome] ?? 0
const position = totalShares[outcome] ?? 0
if (amount > 0) { if (amount > 0) {
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares totalShares[outcome] = position + shares
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount totalSpent[outcome] = spent + amount
} else if (amount < 0) { } else if (amount < 0) {
const averagePrice = totalSpent[outcome] / totalShares[outcome] const averagePrice = position === 0 ? 0 : spent / position
totalShares[outcome] = totalShares[outcome] + shares totalShares[outcome] = position + shares
totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares totalSpent[outcome] = spent + averagePrice * shares
} }
} }
return sum(Object.values(totalSpent)) return sum([0, ...Object.values(totalSpent)])
} }
function getDpmInvested(yourBets: Bet[]) { function getDpmInvested(yourBets: Bet[]) {

View File

@ -247,6 +247,8 @@ export type BetFillData = {
creatorOutcome: string creatorOutcome: string
probability: number probability: number
fillAmount: number fillAmount: number
limitOrderTotal?: number
limitOrderRemaining?: number
} }
export type ContractResolutionData = { export type ContractResolutionData = {

View File

@ -60,14 +60,6 @@ export const getDefaultNotificationPreferences = (
privateUser?: PrivateUser, privateUser?: PrivateUser,
noEmails?: boolean noEmails?: boolean
) => { ) => {
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => { const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined const email = noEmails ? undefined : emailIf ? 'email' : undefined
@ -75,84 +67,48 @@ export const getDefaultNotificationPreferences = (
} }
return { return {
// Watched Markets // Watched Markets
all_comments_on_watched_markets: constructPref( all_comments_on_watched_markets: constructPref(true, false),
true, all_answers_on_watched_markets: constructPref(true, false),
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
// Comments // Comments
tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails), tips_on_your_comments: constructPref(true, true),
comments_by_followed_users_on_watched_markets: constructPref(true, false), comments_by_followed_users_on_watched_markets: constructPref(true, true),
all_replies_to_my_comments_on_watched_markets: constructPref( all_replies_to_my_comments_on_watched_markets: constructPref(true, true),
true, all_replies_to_my_answers_on_watched_markets: constructPref(true, true),
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
true, true,
!unsubscribedFromCommentEmails false
), ),
// Answers // Answers
answers_by_followed_users_on_watched_markets: constructPref( answers_by_followed_users_on_watched_markets: constructPref(true, true),
true, answers_by_market_creator_on_watched_markets: constructPref(true, true),
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
true, true,
!unsubscribedFromAnswerEmails true
), ),
// On users' markets // On users' markets
your_contract_closed: constructPref( your_contract_closed: constructPref(true, true), // High priority
true, all_comments_on_my_markets: constructPref(true, true),
!unsubscribedFromResolutionEmails all_answers_on_my_markets: constructPref(true, true),
), // High priority
all_comments_on_my_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
subsidized_your_market: constructPref(true, true), subsidized_your_market: constructPref(true, true),
// Market updates // Market updates
resolutions_on_watched_markets: constructPref( resolutions_on_watched_markets: constructPref(true, false),
true,
!unsubscribedFromResolutionEmails
),
market_updates_on_watched_markets: constructPref(true, false), market_updates_on_watched_markets: constructPref(true, false),
market_updates_on_watched_markets_with_shares_in: constructPref( market_updates_on_watched_markets_with_shares_in: constructPref(
true, true,
false false
), ),
resolutions_on_watched_markets_with_shares_in: constructPref( resolutions_on_watched_markets_with_shares_in: constructPref(true, true),
true,
!unsubscribedFromResolutionEmails
),
//Balance Changes //Balance Changes
loan_income: constructPref(true, false), loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false), betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true), referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false), unique_bettors_on_your_contract: constructPref(true, false),
tipped_comments_on_watched_markets: constructPref( tipped_comments_on_watched_markets: constructPref(true, true),
true,
!unsubscribedFromCommentEmails
),
tips_on_your_markets: constructPref(true, true), tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false), limit_order_fills: constructPref(true, false),
@ -160,17 +116,11 @@ export const getDefaultNotificationPreferences = (
tagged_user: constructPref(true, true), tagged_user: constructPref(true, true),
on_new_follow: constructPref(true, true), on_new_follow: constructPref(true, true),
contract_from_followed_user: constructPref(true, true), contract_from_followed_user: constructPref(true, true),
trending_markets: constructPref( trending_markets: constructPref(false, true),
false,
!unsubscribedFromWeeklyTrendingEmails
),
profit_loss_updates: constructPref(false, true), profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(true, false), probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref( thank_you_for_purchases: constructPref(false, false),
false, onboarding_flow: constructPref(false, false),
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
} as notification_preferences } as notification_preferences
} }

View File

@ -71,6 +71,7 @@ export type PrivateUser = {
twitchName: string twitchName: string
controlToken: string controlToken: string
botEnabled?: boolean botEnabled?: boolean
needsRelinking?: boolean
} }
} }

View File

@ -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)
}

View File

@ -10,7 +10,7 @@ import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils' import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { groupBy, uniq } from 'lodash' import { groupBy, sum, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
@ -416,8 +416,9 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
) )
} }
//TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they //TODO: store all possible reasons why the user might be getting the notification
// have enabled so they will unsubscribe from the least important notifications // and choose the most lenient that they have enabled so they will unsubscribe
// from the least important notifications
await notifyRepliedUser() await notifyRepliedUser()
await notifyTaggedUsers() await notifyTaggedUsers()
await notifyContractCreator() await notifyContractCreator()
@ -479,7 +480,7 @@ export const createBetFillNotification = async (
fromUser: User, fromUser: User,
toUser: User, toUser: User,
bet: Bet, bet: Bet,
userBet: LimitBet, limitBet: LimitBet,
contract: Contract, contract: Contract,
idempotencyKey: string idempotencyKey: string
) => { ) => {
@ -491,8 +492,10 @@ export const createBetFillNotification = async (
) )
if (!sendToBrowser) return if (!sendToBrowser) return
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fill = limitBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0 const fillAmount = fill?.amount ?? 0
const remainingAmount =
limitBet.orderAmount - sum(limitBet.fills.map((f) => f.amount))
const notificationRef = firestore const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`) .collection(`/users/${toUser.id}/notifications`)
@ -503,7 +506,7 @@ export const createBetFillNotification = async (
reason: 'bet_fill', reason: 'bet_fill',
createdTime: Date.now(), createdTime: Date.now(),
isSeen: false, isSeen: false,
sourceId: userBet.id, sourceId: limitBet.id,
sourceType: 'bet', sourceType: 'bet',
sourceUpdateType: 'updated', sourceUpdateType: 'updated',
sourceUserName: fromUser.name, sourceUserName: fromUser.name,
@ -516,9 +519,11 @@ export const createBetFillNotification = async (
sourceContractId: contract.id, sourceContractId: contract.id,
data: { data: {
betOutcome: bet.outcome, betOutcome: bet.outcome,
creatorOutcome: userBet.outcome, creatorOutcome: limitBet.outcome,
fillAmount, fillAmount,
probability: userBet.limitProb, probability: limitBet.limitProb,
limitOrderTotal: limitBet.orderAmount,
limitOrderRemaining: remainingAmount,
} as BetFillData, } as BetFillData,
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))

View File

@ -13,6 +13,10 @@ export const onUpdateContract = functions.firestore
if (!contractUpdater) throw new Error('Could not find contract updater') if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if ( if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question previousValue.question !== contract.question

View File

@ -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,
}
}

View File

@ -11,6 +11,7 @@ export type ColorType =
| 'gray' | 'gray'
| 'gradient' | 'gradient'
| 'gray-white' | 'gray-white'
| 'highlight-blue'
export function Button(props: { export function Button(props: {
className?: string className?: string
@ -56,7 +57,9 @@ export function Button(props: {
color === 'gradient' && color === 'gradient' &&
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700', 'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' && color === 'gray-white' &&
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', 'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none',
color === 'highlight-blue' &&
'text-highlight-blue border-none shadow-none',
className className
)} )}
disabled={disabled} disabled={disabled}

View File

@ -6,9 +6,10 @@ export function PillButton(props: {
onSelect: () => void onSelect: () => void
color?: string color?: string
xs?: boolean xs?: boolean
className?: string
children: ReactNode children: ReactNode
}) { }) {
const { children, selected, onSelect, color, xs } = props const { children, selected, onSelect, color, xs, className } = props
return ( return (
<button <button
@ -17,7 +18,8 @@ export function PillButton(props: {
xs ? 'text-xs' : '', xs ? 'text-xs' : '',
selected selected
? ['text-white', color ?? 'bg-greyscale-6'] ? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3' : 'bg-greyscale-2 hover:bg-greyscale-3',
className
)} )}
onClick={onSelect} onClick={onSelect}
> >

View File

@ -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'
@ -29,6 +27,7 @@ import { debounce, isEqual, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col' import { Col } from './layout/col'
import clsx from 'clsx' import clsx from 'clsx'
import { safeLocalStorage } from 'web/lib/util/local'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -41,7 +40,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: `Most ${PAST_BETS}`, value: 'most-traded' }, { label: `Most traded`, value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' }, { label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
@ -68,14 +67,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 +93,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 +112,7 @@ export function ContractSearch(props: {
noControls, noControls,
maxResults, maxResults,
renderContracts, renderContracts,
autoFocus,
} = props } = props
const [state, setState] = usePersistentState( const [state, setState] = usePersistentState(
@ -207,13 +208,15 @@ export function ContractSearch(props: {
className={headerClassName} className={headerClassName}
defaultSort={defaultSort} defaultSort={defaultSort}
defaultFilter={defaultFilter} defaultFilter={defaultFilter}
defaultPill={defaultPill}
additionalFilter={additionalFilter} additionalFilter={additionalFilter}
persistPrefix={persistPrefix}
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 +238,29 @@ function ContractSearchControls(props: {
className?: string className?: string
defaultSort?: Sort defaultSort?: Sort
defaultFilter?: filter defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter additionalFilter?: AdditionalFilter
persistPrefix?: string
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,
persistPrefix,
hideOrderSelector, hideOrderSelector,
onSearchParametersChanged, onSearchParametersChanged,
persistPrefix,
useQueryUrlParam, useQueryUrlParam,
user, user,
noControls, noControls,
autoFocus,
} = props } = props
const router = useRouter() const router = useRouter()
@ -267,19 +274,42 @@ function ContractSearchControls(props: {
} }
) )
const [state, setState] = usePersistentState( const sortKey = `${persistPrefix}-search-sort`
{ const savedSort = safeLocalStorage()?.getItem(sortKey)
sort: defaultSort ?? 'score',
filter: defaultFilter ?? 'open', const [sort, setSort] = usePersistentState(
pillFilter: null as string | null, savedSort ?? defaultSort ?? 'score',
}, !useQueryUrlParam
!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),
}
)
useEffect(() => {
if (persistPrefix && sort) {
safeLocalStorage()?.setItem(sortKey, sort as string)
}
}, [persistPrefix, query, sort, sortKey])
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter( const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
@ -319,11 +349,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 +356,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,34 +383,32 @@ 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 <></>
} }
return ( return (
<Col <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
>
<Row className="gap-1 sm:gap-2"> <Row className="gap-1 sm:gap-2">
<input <input
type="text" type="text"
@ -400,11 +417,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>
@ -416,7 +434,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) => (
@ -430,16 +448,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'}
@ -448,7 +462,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}
@ -459,7 +473,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}

View File

@ -81,7 +81,7 @@ export function SelectMarketsModal(props: {
</div> </div>
)} )}
<div className="overflow-y-auto sm:px-8"> <div className="overflow-y-auto px-2 sm:px-8">
<ContractSearch <ContractSearch
hideOrderSelector hideOrderSelector
onContractClick={addContract} onContractClick={addContract}
@ -96,7 +96,7 @@ export function SelectMarketsModal(props: {
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!bg-indigo-100 outline outline-2 outline-indigo-300',
}} }}
additionalFilter={{}} /* hide pills */ additionalFilter={{}} /* hide pills */
headerClassName="bg-white" headerClassName="bg-white sticky"
{...contractSearchOptions} {...contractSearchOptions}
/> />
</div> </div>

View File

@ -1,9 +1,4 @@
import { import { ClockIcon } from '@heroicons/react/outline'
ClockIcon,
DatabaseIcon,
PencilIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { useState } from 'react' import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog'
import NewContractBadge from '../new-contract-badge' import NewContractBadge from '../new-contract-badge'
import { UserFollowButton } from '../follow-button' import { MiniUserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } from 'common/util/parse'
@ -33,7 +27,11 @@ import { contractMetrics } from 'common/contract-details'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip' import { Tooltip } from 'web/components/tooltip'
import { useWindowSize } from 'web/hooks/use-window-size' import { ExtraContractActionsRow } from './extra-contract-actions-row'
import { PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -116,89 +114,137 @@ export function ContractDetails(props: {
disabled?: boolean disabled?: boolean
}) { }) {
const { contract, disabled } = props const { contract, disabled } = props
const { const isMobile = useIsMobile()
closeTime,
creatorName,
creatorUsername,
creatorId,
creatorAvatarUrl,
resolutionTime,
} = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
const user = useUser()
const isCreator = user?.id === creatorId
const [open, setOpen] = useState(false)
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
const groupToDisplay = getGroupLinkToDisplay(contract)
const groupInfo = groupToDisplay ? (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a
className={clsx(
linkClass,
'flex flex-row items-center truncate pr-0 sm:pr-2',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="items-center truncate">{groupToDisplay.name}</span>
</a>
</Link>
) : (
<Button
size={'xs'}
className={'max-w-[200px] pr-2'}
color={'gray-white'}
onClick={() => !groupToDisplay && setOpen(true)}
>
<Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="truncate">No Group</span>
</Row>
</Button>
)
return ( return (
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2"> <Col>
<Row className="items-center gap-2"> <Row className="justify-between">
<Avatar <MarketSubheader contract={contract} disabled={disabled} />
username={creatorUsername} <div className="mt-0">
avatarUrl={creatorAvatarUrl} <ExtraContractActionsRow contract={contract} />
noLink={disabled} </div>
size={6}
/>
{disabled ? (
creatorName
) : (
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
short={isMobile}
/>
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row> </Row>
<Row> {/* GROUPS */}
{disabled ? ( {isMobile && (
groupInfo <div className="mt-2">
) : !groupToDisplay && !user ? ( <MarketGroups contract={contract} disabled={disabled} />
<div /> </div>
) : ( )}
</Col>
)
}
export function MarketSubheader(props: {
contract: Contract
disabled?: boolean
}) {
const { contract, disabled } = props
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
const { resolvedDate } = contractMetrics(contract)
const user = useUser()
const isCreator = user?.id === creatorId
const isMobile = useIsMobile()
return (
<Row>
<Avatar
username={creatorUsername}
avatarUrl={creatorAvatarUrl}
noLink={disabled}
size={9}
className="mr-1.5"
/>
{!disabled && (
<div className="absolute mt-3 ml-[11px]">
<MiniUserFollowButton userId={creatorId} />
</div>
)}
<Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm">
<Row className="w-full justify-between ">
{disabled ? (
creatorName
) : (
<UserLink
className="my-auto whitespace-nowrap"
name={creatorName}
username={creatorUsername}
short={isMobile}
/>
)}
</Row>
<Row className="text-2xs text-greyscale-4 gap-2 sm:text-xs">
<CloseOrResolveTime
contract={contract}
resolvedDate={resolvedDate}
isCreator={isCreator}
/>
{!isMobile && (
<MarketGroups contract={contract} disabled={disabled} />
)}
</Row>
</Col>
</Row>
)
}
export function CloseOrResolveTime(props: {
contract: Contract
resolvedDate: any
isCreator: boolean
}) {
const { contract, resolvedDate, isCreator } = props
const { resolutionTime, closeTime } = contract
if (!!closeTime || !!resolvedDate) {
return (
<Row className="select-none items-center gap-1">
{resolvedDate && resolutionTime ? (
<>
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
<Row>
<div>resolved&nbsp;</div>
{resolvedDate}
</Row>
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && (
<Row> <Row>
{groupInfo} {dayjs().isBefore(closeTime) && <div>closes&nbsp;</div>}
{user && groupToDisplay && ( {!dayjs().isBefore(closeTime) && <div>closed&nbsp;</div>}
<Button <EditableCloseDate
size={'xs'} closeTime={closeTime}
color={'gray-white'} contract={contract}
onClick={() => setOpen(!open)} isCreator={isCreator ?? false}
> />
<PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
</Button>
)}
</Row> </Row>
)} )}
</Row> </Row>
)
} else return <></>
}
export function MarketGroups(props: {
contract: Contract
disabled?: boolean
}) {
const [open, setOpen] = useState(false)
const user = useUser()
const { contract, disabled } = props
const groupToDisplay = getGroupLinkToDisplay(contract)
return (
<>
<Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} />
{!disabled && user && (
<button
className="text-greyscale-4 hover:text-greyscale-3"
onClick={() => setOpen(true)}
>
<PlusCircleIcon className="h-[18px]" />
</button>
)}
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}> <Modal open={open} setOpen={setOpen} size={'md'}>
<Col <Col
className={ className={
@ -208,45 +254,7 @@ export function ContractDetails(props: {
<ContractGroupsList contract={contract} user={user} /> <ContractGroupsList contract={contract} user={user} />
</Col> </Col>
</Modal> </Modal>
</>
{(!!closeTime || !!resolvedDate) && (
<Row className="hidden items-center gap-1 md:inline-flex">
{resolvedDate && resolutionTime ? (
<>
<ClockIcon className="h-5 w-5" />
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
{resolvedDate}
</DateTimeTooltip>
</>
) : null}
{!resolvedDate && closeTime && user && (
<>
<ClockIcon className="h-5 w-5" />
<EditableCloseDate
closeTime={closeTime}
contract={contract}
isCreator={isCreator ?? false}
/>
</>
)}
</Row>
)}
{user && (
<>
<Row className="hidden items-center gap-1 md:inline-flex">
<DatabaseIcon className="h-5 w-5" />
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
{!disabled && (
<ContractInfoDialog
contract={contract}
className={'hidden md:inline-flex'}
/>
)}
</>
)}
</Row>
) )
} }
@ -287,12 +295,12 @@ export function ExtraMobileContractDetails(props: {
!resolvedDate && !resolvedDate &&
closeTime && ( closeTime && (
<Col className={'items-center text-sm text-gray-500'}> <Col className={'items-center text-sm text-gray-500'}>
<Row className={'text-gray-400'}>Closes&nbsp;</Row>
<EditableCloseDate <EditableCloseDate
closeTime={closeTime} closeTime={closeTime}
contract={contract} contract={contract}
isCreator={creatorId === user?.id} isCreator={creatorId === user?.id}
/> />
<Row className={'text-gray-400'}>Ends</Row>
</Col> </Col>
) )
)} )}
@ -312,6 +320,24 @@ export function ExtraMobileContractDetails(props: {
) )
} }
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) {
const { groupToDisplay } = props
if (groupToDisplay) {
return (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full px-2 text-xs text-white sm:max-w-[250px]">
{groupToDisplay.name}
</a>
</Link>
)
} else
return (
<div className="bg-greyscale-4 truncate rounded-full px-2 text-xs text-white">
No Group
</div>
)
}
function EditableCloseDate(props: { function EditableCloseDate(props: {
closeTime: number closeTime: number
contract: Contract contract: Contract
@ -363,47 +389,59 @@ function EditableCloseDate(props: {
return ( return (
<> <>
{isEditingCloseTime ? ( <Modal
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1"> size="sm"
<input open={isEditingCloseTime}
type="date" setOpen={setIsEditingCloseTime}
className="input input-bordered shrink-0" position="top"
onClick={(e) => e.stopPropagation()} >
onChange={(e) => setCloseDate(e.target.value)} <Col className="rounded bg-white px-8 pb-8">
min={Date.now()} <Subtitle text="Edit Close Date" />
value={closeDate} <Row className="z-10 mr-2 w-full shrink-0 flex-wrap items-center gap-2">
/> <input
<input type="date"
type="time" className="input input-bordered w-full shrink-0 sm:w-fit"
className="input input-bordered shrink-0" onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value)}
onChange={(e) => setCloseHoursMinutes(e.target.value)} min={Date.now()}
min="00:00" value={closeDate}
value={closeHoursMinutes} />
/> <input
<Button size={'xs'} color={'blue'} onClick={onSave}> type="time"
className="input input-bordered w-full shrink-0 sm:w-max"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"
value={closeHoursMinutes}
/>
</Row>
<Button
className="mt-2"
size={'xs'}
color={'indigo'}
onClick={onSave}
>
Done Done
</Button> </Button>
</Row> </Col>
) : ( </Modal>
<DateTimeTooltip <DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'} text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime} time={closeTime}
>
<span
className={isCreator ? 'cursor-pointer' : ''}
onClick={() => isCreator && setIsEditingCloseTime(true)}
> >
<span {isSameDay ? (
className={isCreator ? 'cursor-pointer' : ''} <span className={'capitalize'}> {fromNow(closeTime)}</span>
onClick={() => isCreator && setIsEditingCloseTime(true)} ) : isSameYear ? (
> dayJsCloseTime.format('MMM D')
{isSameDay ? ( ) : (
<span className={'capitalize'}> {fromNow(closeTime)}</span> dayJsCloseTime.format('MMM D, YYYY')
) : isSameYear ? ( )}
dayJsCloseTime.format('MMM D') </span>
) : ( </DateTimeTooltip>
dayJsCloseTime.format('MMM D, YYYY')
)}
</span>
</DateTimeTooltip>
)}
</> </>
) )
} }

View File

@ -2,6 +2,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
@ -19,7 +20,7 @@ import ShortToggle from '../widgets/short-toggle'
import { DuplicateContractButton } from '../copy-contract-button' import { DuplicateContractButton } from '../copy-contract-button'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
import { capitalize } from 'lodash' import { Button } from '../button'
export const contractDetailsButtonClassName = export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
@ -39,10 +40,16 @@ export function ContractInfoDialog(props: {
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } = const {
contract createdTime,
closeTime,
resolutionTime,
uniqueBettorCount,
mechanism,
outcomeType,
id,
} = contract
const bettorsCount = contract.uniqueBettorCount ?? 'Unknown'
const typeDisplay = const typeDisplay =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? 'YES / NO' ? 'YES / NO'
@ -69,19 +76,21 @@ export function ContractInfoDialog(props: {
return ( return (
<> <>
<button <Button
size="sm"
color="gray-white"
className={clsx(contractDetailsButtonClassName, className)} className={clsx(contractDetailsButtonClassName, className)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<DotsHorizontalIcon <DotsHorizontalIcon
className={clsx('h-6 w-6 flex-shrink-0')} className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true" aria-hidden="true"
/> />
</button> </Button>
<Modal open={open} setOpen={setOpen}> <Modal open={open} setOpen={setOpen}>
<Col className="gap-4 rounded bg-white p-6"> <Col className="gap-4 rounded bg-white p-6">
<Title className="!mt-0 !mb-0" text="Market info" /> <Title className="!mt-0 !mb-0" text="This Market" />
<table className="table-compact table-zebra table w-full text-gray-500"> <table className="table-compact table-zebra table w-full text-gray-500">
<tbody> <tbody>
@ -131,14 +140,9 @@ export function ContractInfoDialog(props: {
<td>{formatMoney(contract.volume)}</td> <td>{formatMoney(contract.volume)}</td>
</tr> </tr>
{/* <tr>
<td>Creator earnings</td>
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
</tr> */}
<tr> <tr>
<td>{capitalize(BETTORS)}</td> <td>{capitalize(BETTORS)}</td>
<td>{bettorsCount}</td> <td>{uniqueBettorCount ?? '0'}</td>
</tr> </tr>
<tr> <tr>

View File

@ -25,11 +25,11 @@ import {
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from 'common/contract' } from 'common/contract'
import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph' import { NumericGraph } from './numeric-graph'
const OverviewQuestion = (props: { text: string }) => ( const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} /> <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} />
) )
const BetWidget = (props: { contract: CPMMContract }) => { const BetWidget = (props: { contract: CPMMContract }) => {
@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
const { contract, bets } = props const { contract, bets } = props
return ( return (
<Col className="gap-1 md:gap-2"> <Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4"> <Col className="gap-1 px-2">
<ContractDetails contract={contract} /> <ContractDetails contract={contract} />
<Row className="justify-between gap-4"> <Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} /> <OverviewQuestion text={contract.question} />
@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
</Row> </Row>
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && ( {tradingAllowed(contract) && (
<BetWidget contract={contract as CPMMBinaryContract} /> <BetWidget contract={contract as CPMMBinaryContract} />
)} )}
@ -113,10 +112,6 @@ const ChoiceOverview = (props: {
</Col> </Col>
<Col className={'mb-1 gap-y-2'}> <Col className={'mb-1 gap-y-2'}>
<AnswersGraph contract={contract} bets={[...bets].reverse()} /> <AnswersGraph contract={contract} bets={[...bets].reverse()} />
<ExtraMobileContractDetails
contract={contract}
forceShowVolume={true}
/>
</Col> </Col>
</Col> </Col>
) )
@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: {
</Row> </Row>
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} /> <PseudoNumericResolutionOrExpectation contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && <BetWidget contract={contract} />} {tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row> </Row>
</Col> </Col>

View File

@ -23,6 +23,7 @@ import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes' } from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
@ -33,6 +34,7 @@ export function ContractTabs(props: {
}) { }) {
const { contract, user, bets, tips } = props const { contract, user, bets, tips } = props
const { outcomeType } = contract const { outcomeType } = contract
const isMobile = useIsMobile()
const lps = useLiquidity(contract.id) const lps = useLiquidity(contract.id)
@ -131,7 +133,12 @@ export function ContractTabs(props: {
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: `Your ${PAST_BETS}`, content: yourTrades }]), : [
{
title: isMobile ? `You` : `Your ${PAST_BETS}`,
content: yourTrades,
},
]),
]} ]}
/> />
{!user ? ( {!user ? (

View File

@ -110,6 +110,7 @@ export function CreatorContractsList(props: {
return ( return (
<ContractSearch <ContractSearch
headerClassName="sticky"
user={user} user={user}
defaultSort="newest" defaultSort="newest"
defaultFilter="all" defaultFilter="all"

View File

@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button'
import { LikeMarketButton } from 'web/components/contract/like-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { contract: Contract }) { export function ExtraContractActionsRow(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const { outcomeType, resolution } = contract
const user = useUser() const user = useUser()
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
useState(false)
const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
return ( return (
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> <Row>
<FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<Button <Button
size="lg" size="sm"
color="gray-white" color="gray-white"
className={'flex'} className={'flex'}
onClick={() => { onClick={() => {
setShareOpen(true) setShareOpen(true)
}} }}
> >
<Col className={'items-center sm:flex-row'}> <Row>
<ShareIcon <ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
className={clsx('h-[24px] w-5 sm:mr-2')} </Row>
aria-hidden="true"
/>
<span>Share</span>
</Col>
<ShareModal <ShareModal
isOpen={isShareOpen} isOpen={isShareOpen}
setOpen={setShareOpen} setOpen={setShareOpen}
@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
user={user} user={user}
/> />
</Button> </Button>
<Col className={'justify-center'}>
{showChallenge && (
<Button
size="lg"
color="gray-white"
className="max-w-xs self-center"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<Col className="items-center sm:flex-row">
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
<span>Challenge</span>
</Col>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal}
user={user}
contract={contract}
/>
</Button>
)}
<FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} />
)}
<Col className={'justify-center md:hidden'}>
<ContractInfoDialog contract={contract} /> <ContractInfoDialog contract={contract} />
</Col> </Col>
</Row> </Row>

View File

@ -38,15 +38,16 @@ export function LikeMarketButton(props: {
return ( return (
<Button <Button
size={'lg'} size={'sm'}
className={'max-w-xs self-center'} className={'max-w-xs self-center'}
color={'gray-white'} color={'gray-white'}
onClick={onLike} onClick={onLike}
> >
<Col className={'items-center sm:flex-row'}> <Col className={'relative items-center sm:flex-row'}>
<HeartIcon <HeartIcon
className={clsx( className={clsx(
'h-[24px] w-5 sm:mr-2', 'h-5 w-5 sm:h-6 sm:w-6',
totalTipped > 0 ? 'mr-2' : '',
user && user &&
(userLikedContractIds?.includes(contract.id) || (userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id))) (!likes && contract.likedByUserIds?.includes(user.id)))
@ -54,7 +55,18 @@ export function LikeMarketButton(props: {
: '' : ''
)} )}
/> />
Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''} {totalTipped > 0 && (
<div
className={clsx(
'bg-greyscale-6 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
totalTipped > 99
? 'text-[0.4rem] sm:text-[0.5rem]'
: 'sm:text-2xs text-[0.5rem]'
)}
>
{totalTipped}
</div>
)}
</Col> </Col>
</Button> </Button>
) )

View File

@ -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

View File

@ -1,19 +1,16 @@
import type { MentionOptions } from '@tiptap/extension-mention' import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash' import { orderBy } from 'lodash'
import tippy from 'tippy.js'
import { getCachedContracts } from 'web/hooks/use-contracts' import { getCachedContracts } from 'web/hooks/use-contracts'
import { MentionList } from './contract-mention-list' import { MentionList } from './contract-mention-list'
import { PluginKey } from 'prosemirror-state' import { PluginKey } from 'prosemirror-state'
import { makeMentionRender } from './mention-suggestion'
type Suggestion = MentionOptions['suggestion'] type Suggestion = MentionOptions['suggestion']
const beginsWith = (text: string, query: string) => const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage
// TODO: merge with mention-suggestion.ts?
export const contractMentionSuggestion: Suggestion = { export const contractMentionSuggestion: Suggestion = {
char: '%', char: '%',
allowSpaces: true, allowSpaces: true,
@ -26,51 +23,5 @@ export const contractMentionSuggestion: Suggestion = {
[(c) => [c.question].some((s) => beginsWith(s, query))], [(c) => [c.question].some((s) => beginsWith(s, query))],
['desc', 'desc'] ['desc', 'desc']
).slice(0, 5), ).slice(0, 5),
render: () => { render: makeMentionRender(MentionList),
let component: ReactRenderer
let popup: ReturnType<typeof tippy>
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body,
content: component?.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate(props) {
component?.updateProps(props)
if (!props.clientRect) {
return
}
popup?.[0].setProps({
getReferenceClientRect: props.clientRect as any,
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.[0].hide()
return true
}
return (component?.ref as any)?.onKeyDown(props)
},
onExit() {
popup?.[0].destroy()
component?.destroy()
},
}
},
} }

View File

@ -31,6 +31,7 @@ const ContractMentionComponent = (props: any) => {
* https://tiptap.dev/guide/node-views/react#render-a-react-component * https://tiptap.dev/guide/node-views/react#render-a-react-component
*/ */
export const DisplayContractMention = Mention.extend({ export const DisplayContractMention = Mention.extend({
name: 'contract-mention',
parseHTML: () => [{ tag: name }], parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => addNodeView: () =>

View File

@ -5,6 +5,7 @@ import { orderBy } from 'lodash'
import tippy from 'tippy.js' import tippy from 'tippy.js'
import { getCachedUsers } from 'web/hooks/use-users' import { getCachedUsers } from 'web/hooks/use-users'
import { MentionList } from './mention-list' import { MentionList } from './mention-list'
type Render = Suggestion['render']
type Suggestion = MentionOptions['suggestion'] type Suggestion = MentionOptions['suggestion']
@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = {
], ],
['desc', 'desc'] ['desc', 'desc']
).slice(0, 5), ).slice(0, 5),
render: () => { render: makeMentionRender(MentionList),
}
export function makeMentionRender(mentionList: any): Render {
return () => {
let component: ReactRenderer let component: ReactRenderer
let popup: ReturnType<typeof tippy> let popup: ReturnType<typeof tippy>
return { return {
onStart: (props) => { onStart: (props) => {
component = new ReactRenderer(MentionList, { component = new ReactRenderer(mentionList, {
props, props,
editor: props.editor, editor: props.editor,
}) })
@ -59,10 +64,16 @@ export const mentionSuggestion: Suggestion = {
}) })
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key)
popup?.[0].hide() if (
return true props.event.key === 'Escape' ||
} // Also break out of the mention if the tooltip isn't visible
(props.event.key === 'Enter' && !popup?.[0].state.isShown)
) {
popup?.[0].destroy()
component?.destroy()
return false
}
return (component?.ref as any)?.onKeyDown(props) return (component?.ref as any)?.onKeyDown(props)
}, },
onExit() { onExit() {
@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = {
component?.destroy() component?.destroy()
}, },
} }
}, }
} }

View File

@ -1,4 +1,6 @@
import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid'
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { follow, unfollow } from 'web/lib/firebase/users' import { follow, unfollow } from 'web/lib/firebase/users'
@ -54,18 +56,73 @@ export function FollowButton(props: {
export function UserFollowButton(props: { userId: string; small?: boolean }) { export function UserFollowButton(props: { userId: string; small?: boolean }) {
const { userId, small } = props const { userId, small } = props
const currentUser = useUser() const user = useUser()
const following = useFollows(currentUser?.id) const following = useFollows(user?.id)
const isFollowing = following?.includes(userId) const isFollowing = following?.includes(userId)
if (!currentUser || currentUser.id === userId) return null if (!user || user.id === userId) return null
return ( return (
<FollowButton <FollowButton
isFollowing={isFollowing} isFollowing={isFollowing}
onFollow={() => follow(currentUser.id, userId)} onFollow={() => follow(user.id, userId)}
onUnfollow={() => unfollow(currentUser.id, userId)} onUnfollow={() => unfollow(user.id, userId)}
small={small} small={small}
/> />
) )
} }
export function MiniUserFollowButton(props: { userId: string }) {
const { userId } = props
const user = useUser()
const following = useFollows(user?.id)
const isFollowing = following?.includes(userId)
const isFirstRender = useRef(true)
const [justFollowed, setJustFollowed] = useState(false)
useEffect(() => {
if (isFirstRender.current) {
if (isFollowing != undefined) {
isFirstRender.current = false
}
return
}
if (isFollowing) {
setJustFollowed(true)
setTimeout(() => {
setJustFollowed(false)
}, 1000)
}
}, [isFollowing])
if (justFollowed) {
return (
<CheckCircleIcon
className={clsx(
'text-highlight-blue ml-3 mt-2 h-5 w-5 rounded-full bg-white sm:mr-2'
)}
aria-hidden="true"
/>
)
}
if (
!user ||
user.id === userId ||
isFollowing ||
!user ||
isFollowing === undefined
)
return null
return (
<>
<button onClick={withTracking(() => follow(user.id, userId), 'follow')}>
<PlusCircleIcon
className={clsx(
'text-highlight-blue hover:text-hover-blue mt-2 ml-3 h-5 w-5 rounded-full bg-white sm:mr-2'
)}
aria-hidden="true"
/>
</button>
</>
)
}

View File

@ -25,7 +25,7 @@ export const FollowMarketButton = (props: {
return ( return (
<Button <Button
size={'lg'} size={'sm'}
color={'gray-white'} color={'gray-white'}
onClick={async () => { onClick={async () => {
if (!user) return firebaseLogin() if (!user) return firebaseLogin()
@ -56,13 +56,19 @@ export const FollowMarketButton = (props: {
> >
{followers?.includes(user?.id ?? 'nope') ? ( {followers?.includes(user?.id ?? 'nope') ? (
<Col className={'items-center gap-x-2 sm:flex-row'}> <Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" /> <EyeOffIcon
Unwatch className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Unwatch */}
</Col> </Col>
) : ( ) : (
<Col className={'items-center gap-x-2 sm:flex-row'}> <Col className={'items-center gap-x-2 sm:flex-row'}>
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" /> <EyeIcon
Watch className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
aria-hidden="true"
/>
{/* Watch */}
</Col> </Col>
)} )}
<WatchMarketModal <WatchMarketModal

View File

@ -17,11 +17,14 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { User } from 'common/user'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
function getNavigation() { function getNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
{ {
name: 'Notifications', name: 'Notifications',
href: `/notifications`, href: `/notifications`,
@ -32,9 +35,24 @@ function getNavigation() {
const signedOutNavigation = [ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon }, { name: 'Explore', href: '/search', icon: SearchIcon },
] ]
export const userProfileItem = (user: User) => ({
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=${PAST_BETS}`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
})
// From https://codepen.io/chris__sev/pen/QWGvYbL // From https://codepen.io/chris__sev/pen/QWGvYbL
export function BottomNavBar() { export function BottomNavBar() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
@ -62,20 +80,7 @@ export function BottomNavBar() {
<NavBarItem <NavBarItem
key={'profile'} key={'profile'}
currentPage={currentPage} currentPage={currentPage}
item={{ item={userProfileItem(user)}
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=${PAST_BETS}`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
}}
/> />
)} )}
<div <div
@ -99,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
return ( return (
<Link href={item.href}> <Link href={item.href ?? '#'}>
<a <a
className={clsx( className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',

View File

@ -0,0 +1,94 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar'
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { useUser } from 'web/hooks/use-user'
import NotificationsIcon from '../notifications-icon'
import router from 'next/router'
import { userProfileItem } from './bottom-nav-bar'
const mobileGroupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
]
const mobileGeneralNavigation = [
{
name: 'Notifications',
key: 'notifications',
icon: NotificationsIcon,
href: '/notifications',
},
]
export function GroupNavBar(props: {
currentPage: string
onClick: (key: string) => void
}) {
const { currentPage } = props
const user = useUser()
return (
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{mobileGroupNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={props.onClick}
/>
))}
{mobileGeneralNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={() => {
router.push(item.href)
}}
/>
))}
{user && (
<NavBarItem
key={'profile'}
currentPage={currentPage}
onClick={() => {
router.push(`/${user.username}?tab=trades`)
}}
item={userProfileItem(user)}
/>
)}
</nav>
)
}
function NavBarItem(props: {
item: Item
currentPage: string
onClick: (key: string) => void
}) {
const { item, currentPage } = props
const track = trackCallback(
`group navbar: ${item.trackingEventName ?? item.name}`
)
return (
<button onClick={() => props.onClick(item.key ?? '#')}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.key && 'bg-gray-200 text-indigo-700'
)}
onClick={track}
>
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
{item.name}
</a>
</button>
)
}

View File

@ -0,0 +1,82 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from './manifold-logo'
import { ProfileSummary } from './profile-menu'
import React from 'react'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
import NotificationsIcon from '../notifications-icon'
import { SidebarItem } from './sidebar'
import { buildArray } from 'common/util/array'
import { User } from 'common/user'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
const groupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
]
const generalNavigation = (user?: User | null) =>
buildArray(
user && {
name: 'Notifications',
href: `/notifications`,
key: 'notifications',
icon: NotificationsIcon,
}
)
export function GroupSidebar(props: {
groupName: string
className?: string
onClick: (key: string) => void
joinOrAddQuestionsButton: React.ReactNode
currentKey: string
}) {
const { className, groupName, currentKey } = props
const user = useUser()
return (
<nav
aria-label="Group Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="pt-6" twoLine />
<Row className="pl-2 text-xl text-indigo-700 sm:mt-3">{groupName}</Row>
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
{user ? (
<ProfileSummary user={user} />
) : (
<SignInButton className="mb-4" />
)}
</div>
{/* Desktop navigation */}
{groupNavigation.map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
{generalNavigation(user).map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
<Spacer h={2} />
{props.joinOrAddQuestionsButton}
</nav>
)
}

View File

@ -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}

View File

@ -13,7 +13,7 @@ import Router, { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users' import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo' import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton, MenuItem } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import React from 'react' import React from 'react'
@ -35,6 +35,7 @@ const logout = async () => {
function getNavigation() { function getNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
{ {
name: 'Notifications', name: 'Notifications',
href: `/notifications`, href: `/notifications`,
@ -100,7 +101,7 @@ function getMoreNavigation(user?: User | null) {
const signedOutNavigation = [ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon }, { name: 'Explore', href: '/search', icon: SearchIcon },
{ {
name: 'Help & About', name: 'Help & About',
href: 'https://help.manifold.markets/', href: 'https://help.manifold.markets/',
@ -139,7 +140,7 @@ function getMoreMobileNav() {
} }
if (IS_PRIVATE_MANIFOLD) return [signOut] if (IS_PRIVATE_MANIFOLD) return [signOut]
return buildArray<Item>( return buildArray<MenuItem>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[ [
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
@ -156,39 +157,59 @@ function getMoreMobileNav() {
export type Item = { export type Item = {
name: string name: string
trackingEventName?: string trackingEventName?: string
href: string href?: string
key?: string
icon?: React.ComponentType<{ className?: string }> icon?: React.ComponentType<{ className?: string }>
} }
function SidebarItem(props: { item: Item; currentPage: string }) { export function SidebarItem(props: {
const { item, currentPage } = props item: Item
return ( currentPage: string
<Link href={item.href} key={item.name}> onClick?: (key: string) => void
<a }) {
onClick={trackCallback('sidebar: ' + item.name)} const { item, currentPage, onClick } = props
className={clsx( const isCurrentPage =
item.href == currentPage item.href != null ? item.href === currentPage : item.key === currentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100', const sidebarItem = (
'group flex items-center rounded-md px-3 py-2 text-sm font-medium' <a
)} onClick={trackCallback('sidebar: ' + item.name)}
aria-current={item.href == currentPage ? 'page' : undefined} className={clsx(
> isCurrentPage
{item.icon && ( ? 'bg-gray-200 text-gray-900'
<item.icon : 'text-gray-600 hover:bg-gray-100',
className={clsx( 'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
item.href == currentPage )}
? 'text-gray-500' aria-current={item.href == currentPage ? 'page' : undefined}
: 'text-gray-400 group-hover:text-gray-500', >
'-ml-1 mr-3 h-6 w-6 flex-shrink-0' {item.icon && (
)} <item.icon
aria-hidden="true" className={clsx(
/> isCurrentPage
)} ? 'text-gray-500'
<span className="truncate">{item.name}</span> : 'text-gray-400 group-hover:text-gray-500',
</a> '-ml-1 mr-3 h-6 w-6 flex-shrink-0'
</Link> )}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
) )
if (item.href) {
return (
<Link href={item.href} key={item.name}>
{sidebarItem}
</Link>
)
} else {
return onClick ? (
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
) : (
<> </>
)
}
} }
function SidebarButton(props: { function SidebarButton(props: {

View File

@ -63,7 +63,6 @@ export function NotificationSettings(props: {
'contract_from_followed_user', 'contract_from_followed_user',
'unique_bettors_on_your_contract', 'unique_bettors_on_your_contract',
// TODO: add these // TODO: add these
// one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications
// 'profit_loss_updates', - changes in markets you have shares in // 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets // biggest winner, here are the rest of your markets

View File

@ -0,0 +1,89 @@
import { sortBy } from 'lodash'
import React, { useRef } from 'react'
import { Col } from 'web/components/layout/col'
import { Title } from 'web/components/title'
import { useGroups, useMemberGroupIds } from 'web/hooks/use-group'
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { useUser } from 'web/hooks/use-user'
import { Modal } from 'web/components/layout/modal'
import { PillButton } from 'web/components/buttons/pill-button'
import { Button } from 'web/components/button'
import { Group } from 'common/group'
export default function GroupSelectorDialog(props: {
open: boolean
setOpen: (open: boolean) => void
}) {
const { open, setOpen } = props
const groups = useGroups()
const user = useUser()
const memberGroupIds = useMemberGroupIds(user) || []
const cachedGroups = useRef<Group[]>()
if (groups && !cachedGroups.current) {
cachedGroups.current = groups
}
const excludedGroups = [
'features',
'personal',
'private',
'nomic',
'proofnik',
'free money',
'motivation',
'sf events',
'please resolve',
'short-term',
'washifold',
]
const displayedGroups = sortBy(cachedGroups.current ?? [], [
(group) => -1 * group.totalMembers,
(group) => -1 * group.totalContracts,
])
.filter((group) => group.anyoneCanJoin)
.filter((group) =>
excludedGroups.every((name) => !group.name.toLowerCase().includes(name))
)
.filter(
(group) =>
(group.mostRecentContractAddedTime ?? 0) >
Date.now() - 1000 * 60 * 60 * 24 * 7
)
.slice(0, 30)
return (
<Modal open={open} setOpen={setOpen}>
<Col className="h-[32rem] rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
<Title text="What interests you?" />
<p className="mb-4">
Choose among the categories below to personalize your Manifold
experience.
</p>
<div className="scrollbar-hide items-start gap-2 overflow-x-auto">
{user &&
displayedGroups.map((group) => (
<PillButton
selected={memberGroupIds.includes(group.id)}
onSelect={() =>
memberGroupIds.includes(group.id)
? leaveGroup(group, user.id)
: joinGroup(group, user.id)
}
className="mr-1 mb-2 max-w-[12rem] truncate"
>
{group.name}
</PillButton>
))}
</div>
</Col>
<Col>
<Button onClick={() => setOpen(false)}>Done</Button>
</Col>
</Modal>
)
}

View File

@ -7,6 +7,7 @@ import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Title } from '../title' import { Title } from '../title'
import GroupSelectorDialog from './group-selector-dialog'
export default function Welcome() { export default function Welcome() {
const user = useUser() const user = useUser()
@ -32,17 +33,26 @@ export default function Welcome() {
} }
} }
if (!user || !user.shouldShowWelcome) { const [groupSelectorOpen, setGroupSelectorOpen] = useState(false)
return <></>
} else if (!user || (!user.shouldShowWelcome && !groupSelectorOpen)) return <></>
return (
<Modal const toggleOpen = (isOpen: boolean) => {
open={open} setUserHasSeenWelcome()
setOpen={(newOpen) => { setOpen(isOpen)
setUserHasSeenWelcome()
setOpen(newOpen) if (!isOpen) {
}} setGroupSelectorOpen(true)
> }
}
return (
<>
<GroupSelectorDialog
open={groupSelectorOpen}
setOpen={() => setGroupSelectorOpen(false)}
/>
<Modal open={open} setOpen={toggleOpen}>
<Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg"> <Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
{page === 0 && <Page0 />} {page === 0 && <Page0 />}
{page === 1 && <Page1 />} {page === 1 && <Page1 />}
@ -68,17 +78,15 @@ export default function Welcome() {
</Row> </Row>
<u <u
className="self-center text-xs text-gray-500" className="self-center text-xs text-gray-500"
onClick={() => { onClick={() => toggleOpen(false)}
setOpen(false)
setUserHasSeenWelcome()
}}
> >
I got the gist, exit welcome I got the gist, exit welcome
</u> </u>
</Col> </Col>
</Col> </Col>
</Modal> </Modal>
) </>
)
} }
function PageIndicator(props: { page: number; totalpages: number }) { function PageIndicator(props: { page: number; totalpages: number }) {

View File

@ -1,6 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { BottomNavBar } from './nav/nav-bar' import { BottomNavBar } from './nav/bottom-nav-bar'
import Sidebar from './nav/sidebar' import Sidebar from './nav/sidebar'
import { Toaster } from 'react-hot-toast' import { Toaster } from 'react-hot-toast'

View File

@ -1,133 +0,0 @@
import clsx from 'clsx'
import { MouseEventHandler, ReactNode, useState } from 'react'
import toast from 'react-hot-toast'
import { LinkIcon } from '@heroicons/react/solid'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
import { Button, ColorType } from './../button'
import { Row } from './../layout/row'
import { LoadingIndicator } from './../loading-indicator'
function BouncyButton(props: {
children: ReactNode
onClick?: MouseEventHandler<any>
color?: ColorType
}) {
const { children, onClick, color } = props
return (
<Button
color={color}
size="lg"
onClick={onClick}
className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case"
>
{children}
</Button>
)
}
export function TwitchPanel() {
const user = useUser()
const privateUser = usePrivateUser()
const twitchInfo = privateUser?.twitchInfo
const twitchName = privateUser?.twitchInfo?.twitchName
const twitchToken = privateUser?.twitchInfo?.controlToken
const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
const copyOverlayLink = async () => {
copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`)
toast.success('Overlay link copied!', {
icon: linkIcon,
})
}
const copyDockLink = async () => {
copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`)
toast.success('Dock link copied!', {
icon: linkIcon,
})
}
const updateBotConnected = (connected: boolean) => async () => {
if (user && twitchInfo) {
twitchInfo.botEnabled = connected
await updatePrivateUser(user.id, { twitchInfo })
}
}
const [twitchLoading, setTwitchLoading] = useState(false)
const createLink = async () => {
if (!user || !privateUser) return
setTwitchLoading(true)
const promise = linkTwitchAccountRedirect(user, privateUser)
track('link twitch from profile')
await promise
setTwitchLoading(false)
}
return (
<>
<div>
<label className="label">Twitch</label>
{!twitchName ? (
<Row>
<Button
color="indigo"
onClick={createLink}
disabled={twitchLoading}
>
Link your Twitch account
</Button>
{twitchLoading && <LoadingIndicator className="ml-4" />}
</Row>
) : (
<Row>
<span className="mr-4 text-gray-500">Linked Twitch account</span>{' '}
{twitchName}
</Row>
)}
</div>
{twitchToken && (
<div>
<div className="flex w-full">
<div
className={clsx(
'flex grow gap-4',
twitchToken ? '' : 'tooltip tooltip-top'
)}
data-tip="You must link your Twitch account first"
>
<BouncyButton color="blue" onClick={copyOverlayLink}>
Copy overlay link
</BouncyButton>
<BouncyButton color="indigo" onClick={copyDockLink}>
Copy dock link
</BouncyButton>
{twitchBotConnected ? (
<BouncyButton color="red" onClick={updateBotConnected(false)}>
Remove bot from your channel
</BouncyButton>
) : (
<BouncyButton color="green" onClick={updateBotConnected(true)}>
Add bot to your channel
</BouncyButton>
)}
</div>
</div>
</div>
)}
</>
)
}

View File

@ -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 = () => {

View File

@ -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(() => {

View File

@ -0,0 +1,7 @@
import { useWindowSize } from 'web/hooks/use-window-size'
// matches talwind sm breakpoint
export function useIsMobile() {
const { width } = useWindowSize()
return (width ?? 0) < 640
}

View File

@ -98,7 +98,7 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroup: NotificationGroup = { const notificationGroup: NotificationGroup = {
notifications: notificationsForContractId, notifications: notificationsForContractId,
groupedById: contractId, groupedById: contractId,
isSeen: notificationsForContractId[0].isSeen, isSeen: notificationsForContractId.some((n) => !n.isSeen),
timePeriod: day, timePeriod: day,
type: 'normal', type: 'normal',
} }

View File

@ -1,5 +1,6 @@
import { usePrefetchUserBetContracts } from './use-contracts' import { usePrefetchUserBetContracts } from './use-contracts'
import { usePrefetchPortfolioHistory } from './use-portfolio-history' import { usePrefetchPortfolioHistory } from './use-portfolio-history'
import { usePrefetchProbChanges } from './use-prob-changes'
import { usePrefetchUserBets } from './use-user-bets' import { usePrefetchUserBets } from './use-user-bets'
export function usePrefetch(userId: string | undefined) { export function usePrefetch(userId: string | undefined) {
@ -8,5 +9,6 @@ export function usePrefetch(userId: string | undefined) {
usePrefetchUserBets(maybeUserId), usePrefetchUserBets(maybeUserId),
usePrefetchUserBetContracts(maybeUserId), usePrefetchUserBetContracts(maybeUserId),
usePrefetchPortfolioHistory(maybeUserId, 'weekly'), usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
usePrefetchProbChanges(userId),
]) ])
} }

View File

@ -1,8 +1,11 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { MINUTE_MS } from 'common/util/time'
import { useQueryClient } from 'react-query'
import { import {
getProbChangesNegative, getProbChangesNegative,
getProbChangesPositive, getProbChangesPositive,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { getValues } from 'web/lib/firebase/utils'
export const useProbChanges = (userId: string) => { export const useProbChanges = (userId: string) => {
const { data: positiveChanges } = useFirestoreQueryData( const { data: positiveChanges } = useFirestoreQueryData(
@ -20,3 +23,19 @@ export const useProbChanges = (userId: string) => {
return { positiveChanges, negativeChanges } return { positiveChanges, negativeChanges }
} }
export const usePrefetchProbChanges = (userId: string | undefined) => {
const queryClient = useQueryClient()
if (userId) {
queryClient.prefetchQuery(
['prob-changes-day-positive', userId],
() => getValues(getProbChangesPositive(userId)),
{ staleTime: MINUTE_MS }
)
queryClient.prefetchQuery(
['prob-changes-day-negative', userId],
() => getValues(getProbChangesNegative(userId)),
{ staleTime: MINUTE_MS }
)
}
}

View File

@ -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,

View File

@ -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')
)

View File

@ -0,0 +1,19 @@
export default function CornerDownRightIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className={props.className}
>
<polyline points="15 10 20 15 15 20"></polyline>
<path d="M4 4v7a4 4 0 0 0 4 4h12"></path>
</svg>
)
}

View File

@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
async function postToBot(url: string, body: unknown) {
const result = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await result.json()
if (!result.ok) {
throw new Error(json.message)
} else {
return json
}
}
export async function initLinkTwitchAccount( export async function initLinkTwitchAccount(
manifoldUserID: string, manifoldUserID: string,
manifoldUserAPIKey: string manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
method: 'POST', manifoldID: manifoldUserID,
headers: { apiKey: manifoldUserAPIKey,
'Content-Type': 'application/json', redirectURL: window.location.href,
},
body: JSON.stringify({
manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey,
redirectURL: window.location.href,
}),
}) })
const responseData = await response.json()
if (!response.ok) {
throw new Error(responseData.message)
}
const responseFetch = fetch( const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
) )
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
} }
export async function linkTwitchAccountRedirect( export async function linkTwitchAccountRedirect(
@ -38,4 +42,34 @@ export async function linkTwitchAccountRedirect(
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
window.location.href = twitchAuthURL window.location.href = twitchAuthURL
await new Promise((r) => setTimeout(r, 1e10)) // Wait "forever" for the page to change location
}
export async function updateBotEnabledForUser(
privateUser: PrivateUser,
botEnabled: boolean
) {
if (botEnabled) {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
} else {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
}
}
export function getOverlayURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/overlay?t=${controlToken}`
}
export function getDockURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/dock?t=${controlToken}`
} }

View File

@ -37,7 +37,6 @@ import { User } from 'common/user'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { getOpenGraphProps } from 'common/contract-details' import { getOpenGraphProps } from 'common/contract-details'
import { ContractDescription } from 'web/components/contract/contract-description' import { ContractDescription } from 'web/components/contract/contract-description'
import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
import { import {
ContractLeaderboard, ContractLeaderboard,
ContractTopTrades, ContractTopTrades,
@ -257,7 +256,6 @@ export function ContractPageContent(
)} )}
<ContractOverview contract={contract} bets={nonChallengeBets} /> <ContractOverview contract={contract} bets={nonChallengeBets} />
<ExtraContractActionsRow contract={contract} />
<ContractDescription className="mb-6 px-2" contract={contract} /> <ContractDescription className="mb-6 px-2" contract={contract} />
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (

View 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>
)
}

View File

@ -11,7 +11,7 @@ import {
NumericResolutionOrExpectation, NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation,
} from 'web/components/contract/contract-card' } from 'web/components/contract/contract-card'
import { ContractDetails } from 'web/components/contract/contract-details' import { MarketSubheader } from 'web/components/contract/contract-details'
import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph'
import { NumericGraph } from 'web/components/contract/numeric-graph' import { NumericGraph } from 'web/components/contract/numeric-graph'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
@ -102,50 +102,40 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
return ( return (
<Col className="h-[100vh] w-full bg-white"> <Col className="h-[100vh] w-full bg-white">
<div className="relative flex flex-col pt-2"> <Row className="justify-between gap-4 px-2">
<div className="px-3 text-xl text-indigo-700 md:text-2xl"> <div className="text-xl text-indigo-700 md:text-2xl">
<SiteLink href={href}>{question}</SiteLink> <SiteLink href={href}>{question}</SiteLink>
</div> </div>
{isBinary && (
<BinaryResolutionOrChance contract={contract} probAfter={probAfter} />
)}
<Spacer h={3} /> {isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation contract={contract} />
)}
<Row className="items-center justify-between gap-4 px-2"> {outcomeType === 'FREE_RESPONSE' && (
<ContractDetails contract={contract} disabled /> <FreeResponseResolutionOrChance contract={contract} truncate="long" />
)}
{(isBinary || isPseudoNumeric) && {outcomeType === 'NUMERIC' && (
tradingAllowed(contract) && <NumericResolutionOrExpectation contract={contract} />
!betPanelOpen && ( )}
<Button color="gradient" onClick={() => setBetPanelOpen(true)}> </Row>
Predict <Spacer h={3} />
</Button> <Row className="items-center justify-between gap-4 px-2">
)} <MarketSubheader contract={contract} disabled />
{isBinary && ( {(isBinary || isPseudoNumeric) &&
<BinaryResolutionOrChance tradingAllowed(contract) &&
contract={contract} !betPanelOpen && (
probAfter={probAfter} <Button color="gradient" onClick={() => setBetPanelOpen(true)}>
className="items-center" Predict
/> </Button>
)} )}
</Row>
{isPseudoNumeric && ( <Spacer h={2} />
<PseudoNumericResolutionOrExpectation contract={contract} />
)}
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
contract={contract}
truncate="long"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation contract={contract} />
)}
</Row>
<Spacer h={2} />
</div>
{(isBinary || isPseudoNumeric) && betPanelOpen && ( {(isBinary || isPseudoNumeric) && betPanelOpen && (
<BetInline <BetInline

View File

@ -1,215 +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}
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}
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>
)
}

View 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>
)
}

View File

@ -1,10 +1,9 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { toast } from 'react-hot-toast' import { toast, Toaster } from 'react-hot-toast'
import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import { import {
addContractToGroup, addContractToGroup,
@ -30,7 +29,7 @@ import Custom404 from '../../404'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
@ -49,6 +48,9 @@ import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
import { ArrowLeftIcon } from '@heroicons/react/solid'
import { GroupSidebar } from 'web/components/nav/group-sidebar'
import { SelectMarketsModal } from 'web/components/contract-select-modal' import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
@ -138,6 +140,7 @@ export default function GroupPage(props: {
const user = useUser() const user = useUser()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
const [sidebarIndex, setSidebarIndex] = useState(0)
useSaveReferral(user, { useSaveReferral(user, {
defaultReferrerUsername: creator.username, defaultReferrerUsername: creator.username,
@ -151,7 +154,7 @@ export default function GroupPage(props: {
const isMember = user && memberIds.includes(user.id) const isMember = user && memberIds.includes(user.id)
const maxLeaderboardSize = 50 const maxLeaderboardSize = 50
const leaderboard = ( const leaderboardPage = (
<Col> <Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard <GroupLeaderboard
@ -170,7 +173,7 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const aboutTab = ( const aboutPage = (
<Col> <Col>
{(group.aboutPostId != null || isCreator || isAdmin) && ( {(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost <GroupAboutPost
@ -190,73 +193,118 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const questionsTab = ( const questionsPage = (
<ContractSearch <>
user={user} {/* align the divs to the right */}
defaultSort={'newest'} <div className={' flex justify-end px-2 pb-2 sm:hidden'}>
defaultFilter={suggestedFilter} <div>
additionalFilter={{ groupSlug: group.slug }} <JoinOrAddQuestionsButtons
persistPrefix={`group-${group.slug}`} group={group}
/> user={user}
isMember={!!isMember}
/>
</div>
</div>
<ContractSearch
headerClassName="md:sticky"
user={user}
defaultSort={'newest'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
</>
) )
const tabs = [ const sidebarPages = [
{ {
title: 'Markets', title: 'Markets',
content: questionsTab, content: questionsPage,
href: groupPath(group.slug, 'markets'), href: groupPath(group.slug, 'markets'),
key: 'markets',
}, },
{ {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboard, content: leaderboardPage,
href: groupPath(group.slug, 'leaderboards'), href: groupPath(group.slug, 'leaderboards'),
key: 'leaderboards',
}, },
{ {
title: 'About', title: 'About',
content: aboutTab, content: aboutPage,
href: groupPath(group.slug, 'about'), href: groupPath(group.slug, 'about'),
key: 'about',
}, },
] ]
const tabIndex = tabs const pageContent = sidebarPages[sidebarIndex].content
.map((t) => t.title.toLowerCase()) const onSidebarClick = (key: string) => {
.indexOf(page ?? 'markets') const index = sidebarPages.findIndex((t) => t.key === key)
setSidebarIndex(index)
}
const joinOrAddQuestionsButton = (
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
)
return ( return (
<Page> <>
<SEO <TopGroupNavBar group={group} />
title={group.name} <div>
description={`Created by ${creator.name}. ${group.about}`} <div
url={groupPath(group.slug)} className={
/> 'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
<Col className="relative px-3"> }
<Row className={'items-center justify-between gap-4'}> >
<div className={'sm:mb-1'}> <Toaster />
<div <GroupSidebar
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'} groupName={group.name}
> className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
{group.name} onClick={onSidebarClick}
</div> joinOrAddQuestionsButton={joinOrAddQuestionsButton}
<div className={'hidden sm:block'}> currentKey={sidebarPages[sidebarIndex].key}
<Linkify text={group.about} /> />
</div>
</div> <SEO
<div className="mt-2"> title={group.name}
<JoinOrAddQuestionsButtons description={`Created by ${creator.name}. ${group.about}`}
group={group} url={groupPath(group.slug)}
user={user} />
isMember={!!isMember} <main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
/> {pageContent}
</div> </main>
</Row> </div>
</Col> <GroupNavBar
<Tabs currentPage={sidebarPages[sidebarIndex].key}
currentPageForAnalytics={groupPath(group.slug)} onClick={onSidebarClick}
className={'mx-2 mb-0 sm:mb-2'} />
defaultIndex={tabIndex > 0 ? tabIndex : 0} </div>
tabs={tabs} </>
/> )
</Page> }
export function TopGroupNavBar(props: { group: Group }) {
return (
<header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12">
<div className="flex items-center border-b border-gray-200 bg-white px-4">
<div className="flex-shrink-0">
<Link href="/">
<a className="text-indigo-700 hover:text-gray-500 ">
<ArrowLeftIcon className="h-5 w-5" aria-hidden="true" />
</a>
</Link>
</div>
<div className="ml-3">
<h1 className="text-lg font-medium text-indigo-700">
{props.group.name}
</h1>
</div>
</div>
</header>
) )
} }
@ -264,10 +312,11 @@ function JoinOrAddQuestionsButtons(props: {
group: Group group: Group
user: User | null | undefined user: User | null | undefined
isMember: boolean isMember: boolean
className?: string
}) { }) {
const { group, user, isMember } = props const { group, user, isMember } = props
return user && isMember ? ( return user && isMember ? (
<Row className={'mt-0 justify-end'}> <Row className={'w-full self-start pt-4'}>
<AddContractButton group={group} user={user} /> <AddContractButton group={group} user={user} />
</Row> </Row>
) : group.anyoneCanJoin ? ( ) : group.anyoneCanJoin ? (
@ -411,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) {
return ( return (
<> <>
<div className={'flex justify-center'}> <div className={'flex w-full justify-center'}>
<Button <Button
className="whitespace-nowrap" className="w-full whitespace-nowrap"
size="md" size="md"
color="indigo" color="indigo"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
@ -468,7 +517,9 @@ function JoinGroupButton(props: {
<div> <div>
<button <button
onClick={follow} onClick={follow}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'} className={
'btn-md btn-outline btn w-full whitespace-nowrap normal-case'
}
> >
Follow Follow
</button> </button>

View File

@ -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>

View File

@ -1,46 +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}
/>
</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

View File

@ -7,9 +7,12 @@ 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 { track } from 'web/lib/service/analytics'
import { getHomeItems } from '.'
export default function Home() { export default function Home() {
const user = useUser() const user = useUser()
@ -24,6 +27,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 +38,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,11 +48,12 @@ 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"
className={clsx(className, 'flex whitespace-nowrap')} className={clsx(className, 'flex whitespace-nowrap')}
onClick={() => track('done editing home')}
> >
Done Done
</Button> </Button>

387
web/pages/home/index.tsx Normal file
View File

@ -0,0 +1,387 @@
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'
import { Title } from 'web/components/title'
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 justify-between gap-8'}>
<Title className="!mt-0 !mb-0" text="Home" />
<DailyStats 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 for you', id: 'new-for-you' },
{ label: 'Recently updated', id: 'recently-updated-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}
/>
)
if (id === 'recently-updated-for-you')
return (
<SearchSection
key={id}
label={label}
sort={'last-updated'}
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}
onClick={() => track('home click section header', { section: 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 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?",
})
track('home follow group', { group: g.slug })
}
}}
>
{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>
)
}

View File

@ -904,25 +904,30 @@ function BetFillNotification(props: {
}) { }) {
const { notification, isChildOfGroup, highlighted, justSummary } = props const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification const { sourceText, data } = notification
const { creatorOutcome, probability } = (data as BetFillData) ?? {} const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
(data as BetFillData) ?? {}
const subtitle = 'bet against you' const subtitle = 'bet against you'
const amount = formatMoney(parseInt(sourceText ?? '0')) const amount = formatMoney(parseInt(sourceText ?? '0'))
const description = const description =
creatorOutcome && probability ? ( creatorOutcome && probability ? (
<span> <span>
of your{' '} of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
<span <span
className={ className={clsx(
'mx-1',
creatorOutcome === 'YES' creatorOutcome === 'YES'
? 'text-primary' ? 'text-primary'
: creatorOutcome === 'NO' : creatorOutcome === 'NO'
? 'text-red-500' ? 'text-red-500'
: 'text-blue-500' : 'text-blue-500'
} )}
> >
{creatorOutcome}{' '} {creatorOutcome}
</span> </span>
limit order at {Math.round(probability * 100)}% was filled limit order at {Math.round(probability * 100)}% was filled{' '}
{limitOrderRemaining
? `(${formatMoney(limitOrderRemaining)} remaining)`
: ''}
</span> </span>
) : ( ) : (
<span>of your limit order was filled</span> <span>of your limit order was filled</span>

View File

@ -1,24 +1,28 @@
import React, { useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline' import { RefreshIcon } from '@heroicons/react/outline'
import { PrivateUser, User } from 'common/user'
import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { formatMoney } from 'common/util/format'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { changeUserInfo } from 'web/lib/firebase/api' import { formatMoney } from 'common/util/format'
import { uploadImage } from 'web/lib/firebase/storage' import Link from 'next/link'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { AddFundsButton } from 'web/components/add-funds-button'
import { ConfirmationButton } from 'web/components/confirmation-button'
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 { User, PrivateUser } from 'common/user' import { Page } from 'web/components/page'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { SEO } from 'web/components/SEO'
import { defaultBannerUrl } from 'web/components/user-page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea' import { Title } from 'web/components/title'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { defaultBannerUrl } from 'web/components/user-page'
import { generateNewApiKey } from 'web/lib/api/api-key' import { generateNewApiKey } from 'web/lib/api/api-key'
import { TwitchPanel } from 'web/components/profile/twitch-panel' import { changeUserInfo } from 'web/lib/firebase/api'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { uploadImage } from 'web/lib/firebase/storage'
import {
getUserAndPrivateUser,
updatePrivateUser,
updateUser,
} from 'web/lib/firebase/users'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } } return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -93,10 +97,15 @@ export default function ProfilePage(props: {
} }
} }
const updateApiKey = async (e: React.MouseEvent) => { const updateApiKey = async (e?: React.MouseEvent) => {
const newApiKey = await generateNewApiKey(user.id) const newApiKey = await generateNewApiKey(user.id)
setApiKey(newApiKey ?? '') setApiKey(newApiKey ?? '')
e.preventDefault() e?.preventDefault()
if (!privateUser.twitchInfo) return
await updatePrivateUser(privateUser.id, {
twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true },
})
} }
const fileHandler = async (event: any) => { const fileHandler = async (event: any) => {
@ -229,16 +238,38 @@ export default function ProfilePage(props: {
value={apiKey} value={apiKey}
readOnly readOnly
/> />
<button <ConfirmationButton
className="btn btn-primary btn-square p-2" openModalBtn={{
onClick={updateApiKey} className: 'btn btn-primary btn-square p-2',
label: '',
icon: <RefreshIcon />,
}}
submitBtn={{
label: 'Update key',
className: 'btn-primary',
}}
onSubmitWithSuccess={async () => {
updateApiKey()
return true
}}
> >
<RefreshIcon /> <Col>
</button> <Title text={'Are you sure?'} />
<div>
Updating your API key will break any existing applications
connected to your account, <b>including the Twitch bot</b>.
You will need to go to the{' '}
<Link href="/twitch">
<a className="underline focus:outline-none">
Twitch page
</a>
</Link>{' '}
to relink your account.
</div>
</Col>
</ConfirmationButton>
</div> </div>
</div> </div>
<TwitchPanel />
</Col> </Col>
</Col> </Col>
</Page> </Page>

26
web/pages/search.tsx Normal file
View File

@ -0,0 +1,26 @@
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'
export default function Search() {
const user = useUser()
usePrefetch(user?.id)
useTracking('view search')
return (
<Page>
<Col className="mx-auto w-full p-2">
<ContractSearch
user={user}
persistPrefix="search"
useQueryUrlParam={true}
autoFocus
/>
</Col>
</Page>
)
}

View File

@ -76,6 +76,13 @@ const Salem = {
} }
const tourneys: Tourney[] = [ const tourneys: Tourney[] = [
{
title: 'Clearer Thinking Regrant Project',
blurb: 'Which projects will Clearer Thinking give a grant to?',
award: '$13,000',
endTime: toDate('Sep 30, 2022'),
groupId: 'fhksfIgqyWf7OxsV9nkM',
},
{ {
title: 'Manifold F2P Tournament', title: 'Manifold F2P Tournament',
blurb: blurb:
@ -99,13 +106,6 @@ const tourneys: Tourney[] = [
endTime: toDate('Jan 6, 2023'), endTime: toDate('Jan 6, 2023'),
groupId: 'SxGRqXRpV3RAQKudbcNb', groupId: 'SxGRqXRpV3RAQKudbcNb',
}, },
// {
// title: 'Clearer Thinking Regrant Project',
// blurb: 'Something amazing',
// award: '$10,000',
// endTime: toDate('Sep 22, 2022'),
// groupId: '2VsVVFGhKtIdJnQRAXVb',
// },
// Tournaments without awards get featured belows // Tournaments without awards get featured belows
{ {

View File

@ -1,28 +1,48 @@
import { useState } from 'react' import { LinkIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { PrivateUser, User } from 'common/user'
import Link from 'next/link'
import { MouseEventHandler, ReactNode, useState } from 'react'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { SEO } from 'web/components/SEO'
import { Spacer } from 'web/components/layout/spacer'
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
import { useTracking } from 'web/hooks/use-tracking'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Spacer } from 'web/components/layout/spacer'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { useTracking } from 'web/hooks/use-tracking'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
firebaseLogin,
getUserAndPrivateUser,
updatePrivateUser,
} from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import {
getDockURLForUser,
getOverlayURLForUser,
linkTwitchAccountRedirect,
updateBotEnabledForUser,
} from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
export default function TwitchLandingPage() { function ButtonGetStarted(props: {
useSaveReferral() user?: User | null
useTracking('view twitch landing page') privateUser?: PrivateUser | null
buttonClass?: string
spinnerClass?: string
}) {
const { user, privateUser, buttonClass, spinnerClass } = props
const user = useUser() const [isLoading, setLoading] = useState(false)
const privateUser = usePrivateUser() const needsRelink =
const twitchUser = privateUser?.twitchInfo?.twitchName privateUser?.twitchInfo?.twitchName &&
privateUser?.twitchInfo?.needsRelinking
const callback = const callback =
user && privateUser user && privateUser
@ -34,11 +54,11 @@ export default function TwitchLandingPage() {
const { user, privateUser } = await getUserAndPrivateUser(userId) const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return if (!user || !privateUser) return
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
await linkTwitchAccountRedirect(user, privateUser) await linkTwitchAccountRedirect(user, privateUser)
} }
const [isLoading, setLoading] = useState(false)
const getStarted = async () => { const getStarted = async () => {
try { try {
setLoading(true) setLoading(true)
@ -49,9 +69,335 @@ export default function TwitchLandingPage() {
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.error('Failed to sign up. Please try again later.') toast.error('Failed to sign up. Please try again later.')
} finally {
setLoading(false) setLoading(false)
} }
} }
return isLoading ? (
<LoadingIndicator
spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)}
/>
) : (
<Button
size="xl"
color={needsRelink ? 'red' : 'gradient'}
className={clsx('my-4 self-center !px-16', buttonClass)}
onClick={getStarted}
>
{needsRelink ? 'API key updated: relink Twitch' : 'Start playing'}
</Button>
)
}
function TwitchPlaysManifoldMarkets(props: {
user?: User | null
privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const twitchInfo = privateUser?.twitchInfo
const twitchUser = twitchInfo?.twitchName
return (
<div>
<Row className="mb-4">
<img
src="/twitch-glitch.svg"
className="mb-[0.4rem] mr-4 inline h-10 w-10"
></img>
<Title
text={'Twitch plays Manifold Markets'}
className={'!-my-0 md:block'}
/>
</Row>
<Col className="gap-4">
<div>
Similar to Twitch channel point predictions, Manifold Markets allows
you to create and feature on stream any question you like with users
predicting to earn play money.
</div>
<div>
The key difference is that Manifold's questions function more like a
stock market and viewers can buy and sell shares over the course of
the event and not just at the start. The market will eventually
resolve to yes or no at which point the winning shareholders will
receive their profit.
</div>
Start playing now by logging in with Google and typing commands in chat!
{twitchUser && !twitchInfo.needsRelinking ? (
<Button
size="xl"
color="green"
className="btn-disabled my-4 self-center !border-none"
>
Account connected: {twitchUser}
</Button>
) : (
<ButtonGetStarted user={user} privateUser={privateUser} />
)}
<div>
Instead of Twitch channel points we use our play money, Mana (M$). All
viewers start with M$1000 and more can be earned for free and then{' '}
<Link href="/charity">
<a className="underline">donated to a charity</a>
</Link>{' '}
of their choice at no cost!
</div>
</Col>
</div>
)
}
function Subtitle(props: { text: string }) {
const { text } = props
return <div className="text-2xl">{text}</div>
}
function Command(props: { command: string; desc: string }) {
const { command, desc } = props
return (
<div>
<p className="inline font-bold">{'!' + command}</p>
{' - '}
<p className="inline">{desc}</p>
</div>
)
}
function TwitchChatCommands() {
return (
<div>
<Title text="Twitch Chat Commands" className="md:block" />
<Col className="gap-4">
<Subtitle text="For Chat" />
<Command command="bet yes#" desc="Bets a # of Mana on yes." />
<Command command="bet no#" desc="Bets a # of Mana on no." />
<Command
command="sell"
desc="Sells all shares you own. Using this command causes you to
cash out early before the market resolves. This could be profitable
(if the probability has moved towards the direction you bet) or cause
a loss, although at least you keep some Mana. For maximum profit (but
also risk) it is better to not sell and wait for a favourable
resolution."
/>
<Command command="balance" desc="Shows how much Mana you own." />
<Command command="allin yes" desc="Bets your entire balance on yes." />
<Command command="allin no" desc="Bets your entire balance on no." />
<Subtitle text="For Mods/Streamer" />
<Command
command="create <question>"
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
/>
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
<Command command="resolve no" desc="Resolves the market as 'No'." />
<Command
command="resolve n/a"
desc="Resolves the market as 'N/A' and refunds everyone their Mana."
/>
</Col>
</div>
)
}
function BotSetupStep(props: {
stepNum: number
buttonName?: string
buttonOnClick?: MouseEventHandler
overrideButton?: ReactNode
children: ReactNode
}) {
const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props
return (
<Col className="flex-1">
{(overrideButton || buttonName) && (
<>
{overrideButton ?? (
<Button
size={'md'}
color={'green'}
className="!border-none"
onClick={buttonOnClick}
>
{buttonName}
</Button>
)}
<Spacer h={4} />
</>
)}
<div>
<p className="inline font-bold">Step {stepNum}. </p>
{children}
</div>
</Col>
)
}
function BotConnectButton(props: {
privateUser: PrivateUser | null | undefined
}) {
const { privateUser } = props
const [loading, setLoading] = useState(false)
const updateBotConnected = (connected: boolean) => async () => {
if (!privateUser) return
const twitchInfo = privateUser.twitchInfo
if (!twitchInfo) return
const error = connected
? 'Failed to add bot to your channel'
: 'Failed to remove bot from your channel'
const success = connected
? 'Added bot to your channel'
: 'Removed bot from your channel'
setLoading(true)
toast.promise(
updateBotEnabledForUser(privateUser, connected)
.then(() =>
updatePrivateUser(privateUser.id, {
twitchInfo: { ...twitchInfo, botEnabled: connected },
})
)
.finally(() => setLoading(false)),
{ loading: 'Updating bot settings...', error, success },
{
loading: {
className: '!max-w-sm',
},
success: {
className:
'!bg-primary !transition-all !duration-500 !text-white !max-w-sm',
},
error: {
className:
'!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm',
},
}
)
}
return (
<>
{privateUser?.twitchInfo?.botEnabled ? (
<Button
color="red"
onClick={updateBotConnected(false)}
className={clsx(loading && '!btn-disabled', 'border-none')}
>
{loading ? (
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
) : (
'Remove bot from channel'
)}
</Button>
) : (
<Button
color="green"
onClick={updateBotConnected(true)}
className={clsx(loading && '!btn-disabled', 'border-none')}
>
{loading ? (
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
) : (
'Add bot to your channel'
)}
</Button>
)}
</>
)
}
function SetUpBot(props: {
user?: User | null
privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const twitchLinked =
privateUser?.twitchInfo?.twitchName &&
!privateUser?.twitchInfo?.needsRelinking
? true
: undefined
const toastTheme = {
className: '!bg-primary !text-white',
icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />,
}
const copyOverlayLink = async () => {
if (!privateUser) return
copyToClipboard(getOverlayURLForUser(privateUser))
toast.success('Overlay link copied!', toastTheme)
}
const copyDockLink = async () => {
if (!privateUser) return
copyToClipboard(getDockURLForUser(privateUser))
toast.success('Dock link copied!', toastTheme)
}
return (
<>
<Title
text={'Set up the bot for your own stream'}
className={'!mb-4 md:block'}
/>
<Col className="gap-4">
<img
src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder
className="!-my-2"
></img>
To add the bot to your stream make sure you have logged in then follow
the steps below.
{!twitchLinked && (
<ButtonGetStarted
user={user}
privateUser={privateUser}
buttonClass={'!my-0'}
spinnerClass={'!my-0'}
/>
)}
<div className="flex flex-col gap-6 sm:flex-row">
<BotSetupStep
stepNum={1}
overrideButton={
twitchLinked && <BotConnectButton privateUser={privateUser} />
}
>
Use the button above to add the bot to your channel. Then mod it by
typing in your Twitch chat: <b>/mod ManifoldBot</b>
<br />
If the bot is not modded it will not be able to respond to commands
properly.
</BotSetupStep>
<BotSetupStep
stepNum={2}
buttonName={twitchLinked && 'Overlay link'}
buttonOnClick={copyOverlayLink}
>
Create a new browser source in your streaming software such as OBS.
Paste in the above link and resize it to your liking. We recommend
setting the size to 400x400.
</BotSetupStep>
<BotSetupStep
stepNum={3}
buttonName={twitchLinked && 'Control dock link'}
buttonOnClick={copyDockLink}
>
The bot can be controlled entirely through chat. But we made an easy
to use control panel. Share the link with your mods or embed it into
your OBS as a custom dock.
</BotSetupStep>
</div>
</Col>
</>
)
}
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
const user = useUser()
const privateUser = usePrivateUser()
return ( return (
<Page> <Page>
@ -62,58 +408,11 @@ export default function TwitchLandingPage() {
<div className="px-4 pt-2 md:mt-0 lg:hidden"> <div className="px-4 pt-2 md:mt-0 lg:hidden">
<ManifoldLogo /> <ManifoldLogo />
</div> </div>
<Col className="items-center">
<Col className="max-w-3xl">
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
<Row className="self-center">
<img height={200} width={200} src="/twitch-logo.png" />
<img height={200} width={200} src="/flappy-logo.gif" />
</Row>
<div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2">
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
Bet
</span>{' '}
on your favorite streams
</div>
</h1>
<Spacer h={6} />
<div className="mb-4 px-2 ">
Get more out of Twitch with play-money betting markets.{' '}
{!twitchUser &&
'Click the button below to link your Twitch account.'}
<br />
</div>
</div>
<Spacer h={6} /> <Col className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10">
<TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} />
{twitchUser ? ( <TwitchChatCommands />
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> <SetUpBot user={user} privateUser={privateUser} />
<div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<div className="truncate text-sm font-medium text-gray-500">
Twitch account linked
</div>
<div className="mt-1 text-2xl font-semibold text-gray-900">
{twitchUser}
</div>
</div>
</div>
) : isLoading ? (
<LoadingIndicator spinnerClassName="!w-16 !h-16" />
) : (
<Button
size="2xl"
color="gradient"
className="self-center"
onClick={getStarted}
>
Get started
</Button>
)}
</Col>
</Col>
</Col> </Col>
</Page> </Page>
) )

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#9146FF;}
</style>
<title>Asset 2</title>
<g>
<polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/>
<g>
<g id="Layer_1-2">
<path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600
V1300z"/>
<rect x="1700" y="550" class="st1" width="200" height="600"/>
<rect x="1150" y="550" class="st1" width="200" height="600"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@ -26,6 +26,8 @@ module.exports = {
'greyscale-5': '#9191A7', 'greyscale-5': '#9191A7',
'greyscale-6': '#66667C', 'greyscale-6': '#66667C',
'greyscale-7': '#111140', 'greyscale-7': '#111140',
'highlight-blue': '#5BCEFF',
'hover-blue': '#90DEFF',
}, },
typography: { typography: {
quoteless: { quoteless: {