Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
0debf4b21a
|
@ -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[]) {
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,7 @@ export type PrivateUser = {
|
||||||
twitchName: string
|
twitchName: string
|
||||||
controlToken: string
|
controlToken: string
|
||||||
botEnabled?: boolean
|
botEnabled?: boolean
|
||||||
|
needsRelinking?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 </div>
|
||||||
|
{resolvedDate}
|
||||||
|
</Row>
|
||||||
|
</DateTimeTooltip>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!resolvedDate && closeTime && (
|
||||||
<Row>
|
<Row>
|
||||||
{groupInfo}
|
{dayjs().isBefore(closeTime) && <div>closes </div>}
|
||||||
{user && groupToDisplay && (
|
{!dayjs().isBefore(closeTime) && <div>closed </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 </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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 ? (
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: () =>
|
||||||
|
|
|
@ -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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
94
web/components/nav/group-nav-bar.tsx
Normal file
94
web/components/nav/group-nav-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
82
web/components/nav/group-sidebar.tsx
Normal file
82
web/components/nav/group-sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
89
web/components/onboarding/group-selector-dialog.tsx
Normal file
89
web/components/onboarding/group-selector-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 }) {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -2,17 +2,19 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
listenForActiveContracts,
|
|
||||||
listenForContracts,
|
listenForContracts,
|
||||||
listenForHotContracts,
|
listenForHotContracts,
|
||||||
listenForInactiveContracts,
|
listenForInactiveContracts,
|
||||||
listenForNewContracts,
|
|
||||||
getUserBetContracts,
|
getUserBetContracts,
|
||||||
getUserBetContractsQuery,
|
getUserBetContractsQuery,
|
||||||
listAllContracts,
|
listAllContracts,
|
||||||
|
trendingContractsQuery,
|
||||||
|
getContractsQuery,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { QueryClient, useQueryClient } from 'react-query'
|
import { QueryClient, useQueryClient } from 'react-query'
|
||||||
import { MINUTE_MS } from 'common/util/time'
|
import { MINUTE_MS } from 'common/util/time'
|
||||||
|
import { query, limit } from 'firebase/firestore'
|
||||||
|
import { Sort } from 'web/components/contract-search'
|
||||||
|
|
||||||
export const useContracts = () => {
|
export const useContracts = () => {
|
||||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
|
@ -30,23 +32,25 @@ export const getCachedContracts = async () =>
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useActiveContracts = () => {
|
export const useTrendingContracts = (maxContracts: number) => {
|
||||||
const [activeContracts, setActiveContracts] = useState<
|
const result = useFirestoreQueryData(
|
||||||
Contract[] | undefined
|
['trending-contracts', maxContracts],
|
||||||
>()
|
query(trendingContractsQuery, limit(maxContracts))
|
||||||
const [newContracts, setNewContracts] = useState<Contract[] | undefined>()
|
)
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
export const useContractsQuery = (
|
||||||
return listenForActiveContracts(setActiveContracts)
|
sort: Sort,
|
||||||
}, [])
|
maxContracts: number,
|
||||||
|
filters: { groupSlug?: string } = {},
|
||||||
useEffect(() => {
|
visibility?: 'public'
|
||||||
return listenForNewContracts(setNewContracts)
|
) => {
|
||||||
}, [])
|
const result = useFirestoreQueryData(
|
||||||
|
['contracts-query', sort, maxContracts, filters],
|
||||||
if (!activeContracts || !newContracts) return undefined
|
getContractsQuery(sort, maxContracts, filters, visibility)
|
||||||
|
)
|
||||||
return [...activeContracts, ...newContracts]
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInactiveContracts = () => {
|
export const useInactiveContracts = () => {
|
||||||
|
|
|
@ -11,13 +11,17 @@ import {
|
||||||
listenForMemberGroupIds,
|
listenForMemberGroupIds,
|
||||||
listenForOpenGroups,
|
listenForOpenGroups,
|
||||||
listGroups,
|
listGroups,
|
||||||
|
topFollowedGroupsQuery,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { getUser } from 'web/lib/firebase/users'
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { uniq } from 'lodash'
|
import { keyBy, uniq, uniqBy } from 'lodash'
|
||||||
import { listenForValues } from 'web/lib/firebase/utils'
|
import { listenForValues } from 'web/lib/firebase/utils'
|
||||||
import { useQuery } from 'react-query'
|
import { useQuery } from 'react-query'
|
||||||
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
|
import { limit, query } from 'firebase/firestore'
|
||||||
|
import { useTrendingContracts } from './use-contracts'
|
||||||
|
|
||||||
export const useGroup = (groupId: string | undefined) => {
|
export const useGroup = (groupId: string | undefined) => {
|
||||||
const [group, setGroup] = useState<Group | null | undefined>()
|
const [group, setGroup] = useState<Group | null | undefined>()
|
||||||
|
@ -49,6 +53,30 @@ export const useOpenGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useTopFollowedGroups = (count: number) => {
|
||||||
|
const result = useFirestoreQueryData(
|
||||||
|
['top-followed-contracts', count],
|
||||||
|
query(topFollowedGroupsQuery, limit(count))
|
||||||
|
)
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrendingGroups = () => {
|
||||||
|
const topGroups = useTopFollowedGroups(200)
|
||||||
|
const groupsById = keyBy(topGroups, 'id')
|
||||||
|
|
||||||
|
const trendingContracts = useTrendingContracts(200)
|
||||||
|
|
||||||
|
const groupLinks = uniqBy(
|
||||||
|
(trendingContracts ?? []).map((c) => c.groupLinks ?? []).flat(),
|
||||||
|
(link) => link.groupId
|
||||||
|
)
|
||||||
|
|
||||||
|
return filterDefined(
|
||||||
|
groupLinks.map((link) => groupsById[link.groupId])
|
||||||
|
).filter((group) => group.totalMembers >= 3)
|
||||||
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (userId: string | null | undefined) => {
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
const result = useQuery(['member-groups', userId ?? ''], () =>
|
const result = useQuery(['member-groups', userId ?? ''], () =>
|
||||||
getMemberGroups(userId ?? '')
|
getMemberGroups(userId ?? '')
|
||||||
|
@ -56,10 +84,11 @@ export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We cache member group ids in localstorage to speed up the initial load
|
|
||||||
export const useMemberGroupIds = (user: User | null | undefined) => {
|
export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||||
|
const cachedGroups = useMemberGroups(user?.id)
|
||||||
|
|
||||||
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
|
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
|
||||||
undefined
|
cachedGroups?.map((g) => g.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
7
web/hooks/use-is-mobile.ts
Normal file
7
web/hooks/use-is-mobile.ts
Normal 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
|
||||||
|
}
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -17,13 +17,14 @@ import { partition, sortBy, sum, uniqBy } from 'lodash'
|
||||||
|
|
||||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
|
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
|
||||||
import { createRNG, shuffle } from 'common/util/random'
|
import { chooseRandomSubset } from 'common/util/random'
|
||||||
import { formatMoney, formatPercent } from 'common/util/format'
|
import { formatMoney, formatPercent } from 'common/util/format'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { getBinaryProb } from 'common/contract-details'
|
import { getBinaryProb } from 'common/contract-details'
|
||||||
|
import { Sort } from 'web/components/contract-search'
|
||||||
|
|
||||||
export const contracts = coll<Contract>('contracts')
|
export const contracts = coll<Contract>('contracts')
|
||||||
|
|
||||||
|
@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) {
|
||||||
) as Query<Contract>
|
) as Query<Contract>
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeContractsQuery = query(
|
|
||||||
contracts,
|
|
||||||
where('isResolved', '==', false),
|
|
||||||
where('visibility', '==', 'public'),
|
|
||||||
where('volume7Days', '>', 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
export function getActiveContracts() {
|
|
||||||
return getValues<Contract>(activeContractsQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listenForActiveContracts(
|
|
||||||
setContracts: (contracts: Contract[]) => void
|
|
||||||
) {
|
|
||||||
return listenForValues<Contract>(activeContractsQuery, setContracts)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inactiveContractsQuery = query(
|
const inactiveContractsQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) {
|
||||||
await deleteDoc(followDoc)
|
await deleteDoc(followDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
|
||||||
const fiveMinutes = 5 * 60 * 1000
|
|
||||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
|
||||||
shuffle(contracts, createRNG(seed))
|
|
||||||
return contracts.slice(0, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hotContractsQuery = query(
|
const hotContractsQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
|
@ -282,16 +259,17 @@ export function listenForHotContracts(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const trendingContractsQuery = query(
|
export const trendingContractsQuery = query(
|
||||||
contracts,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
orderBy('popularityScore', 'desc'),
|
orderBy('popularityScore', 'desc')
|
||||||
limit(10)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function getTrendingContracts() {
|
export async function getTrendingContracts(maxContracts = 10) {
|
||||||
return await getValues<Contract>(trendingContractsQuery)
|
return await getValues<Contract>(
|
||||||
|
query(trendingContractsQuery, limit(maxContracts))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsBySlugs(slugs: string[]) {
|
export async function getContractsBySlugs(slugs: string[]) {
|
||||||
|
@ -343,6 +321,51 @@ export const getTopGroupContracts = async (
|
||||||
return await getValues<Contract>(creatorContractsQuery)
|
return await getValues<Contract>(creatorContractsQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortToField = {
|
||||||
|
newest: 'createdTime',
|
||||||
|
score: 'popularityScore',
|
||||||
|
'most-traded': 'volume',
|
||||||
|
'24-hour-vol': 'volume24Hours',
|
||||||
|
'prob-change-day': 'probChanges.day',
|
||||||
|
'last-updated': 'lastUpdated',
|
||||||
|
liquidity: 'totalLiquidity',
|
||||||
|
'close-date': 'closeTime',
|
||||||
|
'resolve-date': 'resolutionTime',
|
||||||
|
'prob-descending': 'prob',
|
||||||
|
'prob-ascending': 'prob',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const sortToDirection = {
|
||||||
|
newest: 'desc',
|
||||||
|
score: 'desc',
|
||||||
|
'most-traded': 'desc',
|
||||||
|
'24-hour-vol': 'desc',
|
||||||
|
'prob-change-day': 'desc',
|
||||||
|
'last-updated': 'desc',
|
||||||
|
liquidity: 'desc',
|
||||||
|
'close-date': 'asc',
|
||||||
|
'resolve-date': 'desc',
|
||||||
|
'prob-ascending': 'asc',
|
||||||
|
'prob-descending': 'desc',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const getContractsQuery = (
|
||||||
|
sort: Sort,
|
||||||
|
maxItems: number,
|
||||||
|
filters: { groupSlug?: string } = {},
|
||||||
|
visibility?: 'public'
|
||||||
|
) => {
|
||||||
|
const { groupSlug } = filters
|
||||||
|
return query(
|
||||||
|
contracts,
|
||||||
|
where('isResolved', '==', false),
|
||||||
|
...(visibility ? [where('visibility', '==', visibility)] : []),
|
||||||
|
...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []),
|
||||||
|
orderBy(sortToField[sort], sortToDirection[sort]),
|
||||||
|
limit(maxItems)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const getRecommendedContracts = async (
|
export const getRecommendedContracts = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
excludeBettorId: string,
|
excludeBettorId: string,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
doc,
|
doc,
|
||||||
getDocs,
|
getDocs,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
|
orderBy,
|
||||||
query,
|
query,
|
||||||
setDoc,
|
setDoc,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
|
@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) {
|
||||||
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||||
return members.map((m) => m.userId)
|
return members.map((m) => m.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const topFollowedGroupsQuery = query(
|
||||||
|
groups,
|
||||||
|
where('anyoneCanJoin', '==', true),
|
||||||
|
orderBy('totalMembers', 'desc')
|
||||||
|
)
|
||||||
|
|
19
web/lib/icons/corner-down-right-icon.tsx
Normal file
19
web/lib/icons/corner-down-right-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' && (
|
||||||
|
|
21
web/pages/daily-movers.tsx
Normal file
21
web/pages/daily-movers.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { Page } from 'web/components/page'
|
||||||
|
import { Title } from 'web/components/title'
|
||||||
|
import { useProbChanges } from 'web/hooks/use-prob-changes'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
|
||||||
|
export default function DailyMovers() {
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
const changes = useProbChanges(user?.id ?? '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4">
|
||||||
|
<Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" />
|
||||||
|
<ProbChangeTable changes={changes} full />
|
||||||
|
</Col>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
41
web/pages/explore-groups.tsx
Normal file
41
web/pages/explore-groups.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import Masonry from 'react-masonry-css'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Page } from 'web/components/page'
|
||||||
|
import { Title } from 'web/components/title'
|
||||||
|
import { useMemberGroupIds, useTrendingGroups } from 'web/hooks/use-group'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { GroupCard } from './groups'
|
||||||
|
|
||||||
|
export default function Explore() {
|
||||||
|
const user = useUser()
|
||||||
|
const groups = useTrendingGroups()
|
||||||
|
const memberGroupIds = useMemberGroupIds(user) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[115%]">
|
||||||
|
<Row className={'w-full items-center justify-between'}>
|
||||||
|
<Title className="!mb-0" text="Trending groups" />
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Masonry
|
||||||
|
breakpointCols={{ default: 3, 1200: 2, 570: 1 }}
|
||||||
|
className="-ml-4 flex w-auto self-center"
|
||||||
|
columnClassName="pl-4 bg-clip-padding"
|
||||||
|
>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<GroupCard
|
||||||
|
key={g.id}
|
||||||
|
className="mb-4 !min-w-[250px]"
|
||||||
|
group={g}
|
||||||
|
creator={null}
|
||||||
|
user={user}
|
||||||
|
isMember={memberGroupIds.includes(g.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Masonry>
|
||||||
|
</Col>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -171,26 +171,34 @@ export default function Groups(props: {
|
||||||
|
|
||||||
export function GroupCard(props: {
|
export function GroupCard(props: {
|
||||||
group: Group
|
group: Group
|
||||||
creator: User | undefined
|
creator: User | null | undefined
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
isMember: boolean
|
isMember: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { group, creator, user, isMember } = props
|
const { group, creator, user, isMember, className } = props
|
||||||
const { totalContracts } = group
|
const { totalContracts } = group
|
||||||
return (
|
return (
|
||||||
<Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100">
|
<Col
|
||||||
|
className={clsx(
|
||||||
|
'relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-6 shadow-md hover:bg-gray-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Link href={groupPath(group.slug)}>
|
<Link href={groupPath(group.slug)}>
|
||||||
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
|
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
{creator !== null && (
|
||||||
<Avatar
|
<div>
|
||||||
className={'absolute top-2 right-2 z-10'}
|
<Avatar
|
||||||
username={creator?.username}
|
className={'absolute top-2 right-2 z-10'}
|
||||||
avatarUrl={creator?.avatarUrl}
|
username={creator?.username}
|
||||||
noLink={false}
|
avatarUrl={creator?.avatarUrl}
|
||||||
size={12}
|
noLink={false}
|
||||||
/>
|
size={12}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Row className="items-center justify-between gap-2">
|
<Row className="items-center justify-between gap-2">
|
||||||
<span className="text-xl">{group.name}</span>
|
<span className="text-xl">{group.name}</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -1,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
|
|
|
@ -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
387
web/pages/home/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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
26
web/pages/search.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
21
web/public/twitch-glitch.svg
Normal file
21
web/public/twitch-glitch.svg
Normal 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 |
|
@ -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: {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user