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
|
||||
if (floatingEqual(shares, 0)) continue
|
||||
|
||||
const spent = totalSpent[outcome] ?? 0
|
||||
const position = totalShares[outcome] ?? 0
|
||||
|
||||
if (amount > 0) {
|
||||
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
|
||||
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount
|
||||
totalShares[outcome] = position + shares
|
||||
totalSpent[outcome] = spent + amount
|
||||
} else if (amount < 0) {
|
||||
const averagePrice = totalSpent[outcome] / totalShares[outcome]
|
||||
totalShares[outcome] = totalShares[outcome] + shares
|
||||
totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares
|
||||
const averagePrice = position === 0 ? 0 : spent / position
|
||||
totalShares[outcome] = position + shares
|
||||
totalSpent[outcome] = spent + averagePrice * shares
|
||||
}
|
||||
}
|
||||
|
||||
return sum(Object.values(totalSpent))
|
||||
return sum([0, ...Object.values(totalSpent)])
|
||||
}
|
||||
|
||||
function getDpmInvested(yourBets: Bet[]) {
|
||||
|
|
|
@ -247,6 +247,8 @@ export type BetFillData = {
|
|||
creatorOutcome: string
|
||||
probability: number
|
||||
fillAmount: number
|
||||
limitOrderTotal?: number
|
||||
limitOrderRemaining?: number
|
||||
}
|
||||
|
||||
export type ContractResolutionData = {
|
||||
|
|
|
@ -60,14 +60,6 @@ export const getDefaultNotificationPreferences = (
|
|||
privateUser?: PrivateUser,
|
||||
noEmails?: boolean
|
||||
) => {
|
||||
const {
|
||||
unsubscribedFromCommentEmails,
|
||||
unsubscribedFromAnswerEmails,
|
||||
unsubscribedFromResolutionEmails,
|
||||
unsubscribedFromWeeklyTrendingEmails,
|
||||
unsubscribedFromGenericEmails,
|
||||
} = privateUser || {}
|
||||
|
||||
const constructPref = (browserIf: boolean, emailIf: boolean) => {
|
||||
const browser = browserIf ? 'browser' : undefined
|
||||
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
||||
|
@ -75,84 +67,48 @@ export const getDefaultNotificationPreferences = (
|
|||
}
|
||||
return {
|
||||
// Watched Markets
|
||||
all_comments_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_answers_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
all_comments_on_watched_markets: constructPref(true, false),
|
||||
all_answers_on_watched_markets: constructPref(true, false),
|
||||
|
||||
// Comments
|
||||
tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails),
|
||||
comments_by_followed_users_on_watched_markets: constructPref(true, false),
|
||||
all_replies_to_my_comments_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_replies_to_my_answers_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
tips_on_your_comments: constructPref(true, true),
|
||||
comments_by_followed_users_on_watched_markets: constructPref(true, true),
|
||||
all_replies_to_my_comments_on_watched_markets: constructPref(true, true),
|
||||
all_replies_to_my_answers_on_watched_markets: constructPref(true, true),
|
||||
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromCommentEmails
|
||||
false
|
||||
),
|
||||
|
||||
// Answers
|
||||
answers_by_followed_users_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
answers_by_market_creator_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
answers_by_followed_users_on_watched_markets: constructPref(true, true),
|
||||
answers_by_market_creator_on_watched_markets: constructPref(true, true),
|
||||
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromAnswerEmails
|
||||
true
|
||||
),
|
||||
|
||||
// On users' markets
|
||||
your_contract_closed: constructPref(
|
||||
true,
|
||||
!unsubscribedFromResolutionEmails
|
||||
), // High priority
|
||||
all_comments_on_my_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
all_answers_on_my_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromAnswerEmails
|
||||
),
|
||||
your_contract_closed: constructPref(true, true), // High priority
|
||||
all_comments_on_my_markets: constructPref(true, true),
|
||||
all_answers_on_my_markets: constructPref(true, true),
|
||||
subsidized_your_market: constructPref(true, true),
|
||||
|
||||
// Market updates
|
||||
resolutions_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromResolutionEmails
|
||||
),
|
||||
resolutions_on_watched_markets: constructPref(true, false),
|
||||
market_updates_on_watched_markets: constructPref(true, false),
|
||||
market_updates_on_watched_markets_with_shares_in: constructPref(
|
||||
true,
|
||||
false
|
||||
),
|
||||
resolutions_on_watched_markets_with_shares_in: constructPref(
|
||||
true,
|
||||
!unsubscribedFromResolutionEmails
|
||||
),
|
||||
resolutions_on_watched_markets_with_shares_in: constructPref(true, true),
|
||||
|
||||
//Balance Changes
|
||||
loan_income: constructPref(true, false),
|
||||
betting_streaks: constructPref(true, false),
|
||||
referral_bonuses: constructPref(true, true),
|
||||
unique_bettors_on_your_contract: constructPref(true, false),
|
||||
tipped_comments_on_watched_markets: constructPref(
|
||||
true,
|
||||
!unsubscribedFromCommentEmails
|
||||
),
|
||||
tipped_comments_on_watched_markets: constructPref(true, true),
|
||||
tips_on_your_markets: constructPref(true, true),
|
||||
limit_order_fills: constructPref(true, false),
|
||||
|
||||
|
@ -160,17 +116,11 @@ export const getDefaultNotificationPreferences = (
|
|||
tagged_user: constructPref(true, true),
|
||||
on_new_follow: constructPref(true, true),
|
||||
contract_from_followed_user: constructPref(true, true),
|
||||
trending_markets: constructPref(
|
||||
false,
|
||||
!unsubscribedFromWeeklyTrendingEmails
|
||||
),
|
||||
trending_markets: constructPref(false, true),
|
||||
profit_loss_updates: constructPref(false, true),
|
||||
probability_updates_on_watched_markets: constructPref(true, false),
|
||||
thank_you_for_purchases: constructPref(
|
||||
false,
|
||||
!unsubscribedFromGenericEmails
|
||||
),
|
||||
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
|
||||
thank_you_for_purchases: constructPref(false, false),
|
||||
onboarding_flow: constructPref(false, false),
|
||||
} as notification_preferences
|
||||
}
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ export type PrivateUser = {
|
|||
twitchName: string
|
||||
controlToken: string
|
||||
botEnabled?: boolean
|
||||
needsRelinking?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => {
|
|||
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseRandomSubset<T>(items: T[], count: number) {
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||
shuffle(items, createRNG(seed))
|
||||
return items.slice(0, count)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { User } from '../../common/user'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { groupBy, uniq } from 'lodash'
|
||||
import { groupBy, sum, uniq } from 'lodash'
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
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
|
||||
// have enabled so they will unsubscribe from the least important notifications
|
||||
//TODO: store all possible reasons why the user might be getting the notification
|
||||
// and choose the most lenient that they have enabled so they will unsubscribe
|
||||
// from the least important notifications
|
||||
await notifyRepliedUser()
|
||||
await notifyTaggedUsers()
|
||||
await notifyContractCreator()
|
||||
|
@ -479,7 +480,7 @@ export const createBetFillNotification = async (
|
|||
fromUser: User,
|
||||
toUser: User,
|
||||
bet: Bet,
|
||||
userBet: LimitBet,
|
||||
limitBet: LimitBet,
|
||||
contract: Contract,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
|
@ -491,8 +492,10 @@ export const createBetFillNotification = async (
|
|||
)
|
||||
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 remainingAmount =
|
||||
limitBet.orderAmount - sum(limitBet.fills.map((f) => f.amount))
|
||||
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${toUser.id}/notifications`)
|
||||
|
@ -503,7 +506,7 @@ export const createBetFillNotification = async (
|
|||
reason: 'bet_fill',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: userBet.id,
|
||||
sourceId: limitBet.id,
|
||||
sourceType: 'bet',
|
||||
sourceUpdateType: 'updated',
|
||||
sourceUserName: fromUser.name,
|
||||
|
@ -516,9 +519,11 @@ export const createBetFillNotification = async (
|
|||
sourceContractId: contract.id,
|
||||
data: {
|
||||
betOutcome: bet.outcome,
|
||||
creatorOutcome: userBet.outcome,
|
||||
creatorOutcome: limitBet.outcome,
|
||||
fillAmount,
|
||||
probability: userBet.limitProb,
|
||||
probability: limitBet.limitProb,
|
||||
limitOrderTotal: limitBet.orderAmount,
|
||||
limitOrderRemaining: remainingAmount,
|
||||
} as BetFillData,
|
||||
}
|
||||
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')
|
||||
|
||||
const previousValue = change.before.data() as Contract
|
||||
|
||||
// Resolution is handled in resolve-market.ts
|
||||
if (!previousValue.isResolved && contract.isResolved) return
|
||||
|
||||
if (
|
||||
previousValue.closeTime !== contract.closeTime ||
|
||||
previousValue.question !== contract.question
|
||||
|
|
|
@ -5,21 +5,15 @@ import { MenuIcon } from '@heroicons/react/solid'
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Subtitle } from 'web/components/subtitle'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { isArray, keyBy } from 'lodash'
|
||||
import { User } from 'common/user'
|
||||
import { Group } from 'common/group'
|
||||
import { keyBy } from 'lodash'
|
||||
|
||||
export function ArrangeHome(props: {
|
||||
user: User | null | undefined
|
||||
homeSections: string[]
|
||||
setHomeSections: (sections: string[]) => void
|
||||
sections: { label: string; id: string }[]
|
||||
setSectionIds: (sections: string[]) => void
|
||||
}) {
|
||||
const { user, homeSections, setHomeSections } = props
|
||||
const { sections, setSectionIds } = props
|
||||
|
||||
const groups = useMemberGroups(user?.id) ?? []
|
||||
const { itemsById, sections } = getHomeItems(groups, homeSections)
|
||||
const sectionsById = keyBy(sections, 'id')
|
||||
|
||||
return (
|
||||
<DragDropContext
|
||||
|
@ -27,14 +21,14 @@ export function ArrangeHome(props: {
|
|||
const { destination, source, draggableId } = e
|
||||
if (!destination) return
|
||||
|
||||
const item = itemsById[draggableId]
|
||||
const section = sectionsById[draggableId]
|
||||
|
||||
const newHomeSections = sections.map((section) => section.id)
|
||||
const newSectionIds = sections.map((section) => section.id)
|
||||
|
||||
newHomeSections.splice(source.index, 1)
|
||||
newHomeSections.splice(destination.index, 0, item.id)
|
||||
newSectionIds.splice(source.index, 1)
|
||||
newSectionIds.splice(destination.index, 0, section.id)
|
||||
|
||||
setHomeSections(newHomeSections)
|
||||
setSectionIds(newSectionIds)
|
||||
}}
|
||||
>
|
||||
<Row className="relative max-w-md gap-4">
|
||||
|
@ -105,29 +99,3 @@ const SectionItem = (props: {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getHomeItems = (groups: Group[], sections: string[]) => {
|
||||
// Accommodate old home sections.
|
||||
if (!isArray(sections)) sections = []
|
||||
|
||||
const items = [
|
||||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'New for you', id: 'newest' },
|
||||
{ label: 'Daily movers', id: 'daily-movers' },
|
||||
...groups.map((g) => ({
|
||||
label: g.name,
|
||||
id: g.id,
|
||||
})),
|
||||
]
|
||||
const itemsById = keyBy(items, 'id')
|
||||
|
||||
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
|
||||
|
||||
// Add unmentioned items to the end.
|
||||
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
|
||||
|
||||
return {
|
||||
sections: sectionItems,
|
||||
itemsById,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export type ColorType =
|
|||
| 'gray'
|
||||
| 'gradient'
|
||||
| 'gray-white'
|
||||
| 'highlight-blue'
|
||||
|
||||
export function Button(props: {
|
||||
className?: string
|
||||
|
@ -56,7 +57,9 @@ export function Button(props: {
|
|||
color === 'gradient' &&
|
||||
'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' &&
|
||||
'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
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -6,9 +6,10 @@ export function PillButton(props: {
|
|||
onSelect: () => void
|
||||
color?: string
|
||||
xs?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { children, selected, onSelect, color, xs } = props
|
||||
const { children, selected, onSelect, color, xs, className } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
|
@ -17,7 +18,8 @@ export function PillButton(props: {
|
|||
xs ? 'text-xs' : '',
|
||||
selected
|
||||
? ['text-white', color ?? 'bg-greyscale-6']
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3'
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||
className
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
|
|
@ -14,12 +14,10 @@ import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
|
|||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import {
|
||||
storageStore,
|
||||
historyStore,
|
||||
urlParamStore,
|
||||
usePersistentState,
|
||||
} from 'web/hooks/use-persistent-state'
|
||||
import { safeLocalStorage } from 'web/lib/util/local'
|
||||
import { track, trackCallback } from 'web/lib/service/analytics'
|
||||
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
|
@ -29,6 +27,7 @@ import { debounce, isEqual, sortBy } from 'lodash'
|
|||
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||
import { Col } from './layout/col'
|
||||
import clsx from 'clsx'
|
||||
import { safeLocalStorage } from 'web/lib/util/local'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -41,7 +40,7 @@ const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
|||
export const SORTS = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
{ 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 change', value: 'prob-change-day' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
|
@ -68,14 +67,13 @@ type AdditionalFilter = {
|
|||
tag?: string
|
||||
excludeContractIds?: string[]
|
||||
groupSlug?: string
|
||||
yourBets?: boolean
|
||||
followed?: boolean
|
||||
}
|
||||
|
||||
export function ContractSearch(props: {
|
||||
user?: User | null
|
||||
defaultSort?: Sort
|
||||
defaultFilter?: filter
|
||||
defaultPill?: string
|
||||
additionalFilter?: AdditionalFilter
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
|
@ -95,11 +93,13 @@ export function ContractSearch(props: {
|
|||
contracts: Contract[] | undefined,
|
||||
loadMore: () => void
|
||||
) => ReactNode
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
defaultSort,
|
||||
defaultFilter,
|
||||
defaultPill,
|
||||
additionalFilter,
|
||||
onContractClick,
|
||||
hideOrderSelector,
|
||||
|
@ -112,6 +112,7 @@ export function ContractSearch(props: {
|
|||
noControls,
|
||||
maxResults,
|
||||
renderContracts,
|
||||
autoFocus,
|
||||
} = props
|
||||
|
||||
const [state, setState] = usePersistentState(
|
||||
|
@ -207,13 +208,15 @@ export function ContractSearch(props: {
|
|||
className={headerClassName}
|
||||
defaultSort={defaultSort}
|
||||
defaultFilter={defaultFilter}
|
||||
defaultPill={defaultPill}
|
||||
additionalFilter={additionalFilter}
|
||||
persistPrefix={persistPrefix}
|
||||
hideOrderSelector={hideOrderSelector}
|
||||
persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined}
|
||||
useQueryUrlParam={useQueryUrlParam}
|
||||
user={user}
|
||||
onSearchParametersChanged={onSearchParametersChanged}
|
||||
noControls={noControls}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{renderContracts ? (
|
||||
renderContracts(renderedContracts, performQuery)
|
||||
|
@ -235,25 +238,29 @@ function ContractSearchControls(props: {
|
|||
className?: string
|
||||
defaultSort?: Sort
|
||||
defaultFilter?: filter
|
||||
defaultPill?: string
|
||||
additionalFilter?: AdditionalFilter
|
||||
persistPrefix?: string
|
||||
hideOrderSelector?: boolean
|
||||
onSearchParametersChanged: (params: SearchParameters) => void
|
||||
persistPrefix?: string
|
||||
useQueryUrlParam?: boolean
|
||||
user?: User | null
|
||||
noControls?: boolean
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const {
|
||||
className,
|
||||
defaultSort,
|
||||
defaultFilter,
|
||||
defaultPill,
|
||||
additionalFilter,
|
||||
persistPrefix,
|
||||
hideOrderSelector,
|
||||
onSearchParametersChanged,
|
||||
persistPrefix,
|
||||
useQueryUrlParam,
|
||||
user,
|
||||
noControls,
|
||||
autoFocus,
|
||||
} = props
|
||||
|
||||
const router = useRouter()
|
||||
|
@ -267,19 +274,42 @@ function ContractSearchControls(props: {
|
|||
}
|
||||
)
|
||||
|
||||
const [state, setState] = usePersistentState(
|
||||
{
|
||||
sort: defaultSort ?? 'score',
|
||||
filter: defaultFilter ?? 'open',
|
||||
pillFilter: null as string | null,
|
||||
},
|
||||
!persistPrefix
|
||||
const sortKey = `${persistPrefix}-search-sort`
|
||||
const savedSort = safeLocalStorage()?.getItem(sortKey)
|
||||
|
||||
const [sort, setSort] = usePersistentState(
|
||||
savedSort ?? defaultSort ?? 'score',
|
||||
!useQueryUrlParam
|
||||
? undefined
|
||||
: {
|
||||
key: `${persistPrefix}-params`,
|
||||
store: storageStore(safeLocalStorage()),
|
||||
key: 's',
|
||||
store: urlParamStore(router),
|
||||
}
|
||||
)
|
||||
const [filter, setFilter] = usePersistentState(
|
||||
defaultFilter ?? 'open',
|
||||
!useQueryUrlParam
|
||||
? undefined
|
||||
: {
|
||||
key: 'f',
|
||||
store: urlParamStore(router),
|
||||
}
|
||||
)
|
||||
const [pill, setPill] = usePersistentState(
|
||||
defaultPill ?? '',
|
||||
!useQueryUrlParam
|
||||
? undefined
|
||||
: {
|
||||
key: 'p',
|
||||
store: urlParamStore(router),
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (persistPrefix && sort) {
|
||||
safeLocalStorage()?.setItem(sortKey, sort as string)
|
||||
}
|
||||
}, [persistPrefix, query, sort, sortKey])
|
||||
|
||||
const follows = useFollows(user?.id)
|
||||
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||
|
@ -319,11 +349,6 @@ function ContractSearchControls(props: {
|
|||
additionalFilter?.groupSlug
|
||||
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||
: '',
|
||||
additionalFilter?.yourBets && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
...(additionalFilter?.followed ? personalFilters : []),
|
||||
]
|
||||
const facetFilters = query
|
||||
? additionalFilters
|
||||
|
@ -331,31 +356,25 @@ function ContractSearchControls(props: {
|
|||
...additionalFilters,
|
||||
additionalFilter ? '' : 'visibility:public',
|
||||
|
||||
state.filter === 'open' ? 'isResolved:false' : '',
|
||||
state.filter === 'closed' ? 'isResolved:false' : '',
|
||||
state.filter === 'resolved' ? 'isResolved:true' : '',
|
||||
filter === 'open' ? 'isResolved:false' : '',
|
||||
filter === 'closed' ? 'isResolved:false' : '',
|
||||
filter === 'resolved' ? 'isResolved:true' : '',
|
||||
|
||||
state.pillFilter &&
|
||||
state.pillFilter !== 'personal' &&
|
||||
state.pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${state.pillFilter}`
|
||||
pill && pill !== 'personal' && pill !== 'your-bets'
|
||||
? `groupLinks.slug:${pill}`
|
||||
: '',
|
||||
...(state.pillFilter === 'personal' ? personalFilters : []),
|
||||
state.pillFilter === 'your-bets' && user
|
||||
...(pill === 'personal' ? personalFilters : []),
|
||||
pill === 'your-bets' && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
].filter((f) => f)
|
||||
|
||||
const openClosedFilter =
|
||||
state.filter === 'open'
|
||||
? 'open'
|
||||
: state.filter === 'closed'
|
||||
? 'closed'
|
||||
: undefined
|
||||
filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined
|
||||
|
||||
const selectPill = (pill: string | null) => () => {
|
||||
setState({ ...state, pillFilter: pill })
|
||||
setPill(pill ?? '')
|
||||
track('select search category', { category: pill ?? 'all' })
|
||||
}
|
||||
|
||||
|
@ -364,34 +383,32 @@ function ContractSearchControls(props: {
|
|||
}
|
||||
|
||||
const selectFilter = (newFilter: filter) => {
|
||||
if (newFilter === state.filter) return
|
||||
setState({ ...state, filter: newFilter })
|
||||
if (newFilter === filter) return
|
||||
setFilter(newFilter)
|
||||
track('select search filter', { filter: newFilter })
|
||||
}
|
||||
|
||||
const selectSort = (newSort: Sort) => {
|
||||
if (newSort === state.sort) return
|
||||
setState({ ...state, sort: newSort })
|
||||
if (newSort === sort) return
|
||||
setSort(newSort)
|
||||
track('select search sort', { sort: newSort })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onSearchParametersChanged({
|
||||
query: query,
|
||||
sort: state.sort,
|
||||
sort: sort as Sort,
|
||||
openClosedFilter: openClosedFilter,
|
||||
facetFilters: facetFilters,
|
||||
})
|
||||
}, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)])
|
||||
}, [query, sort, openClosedFilter, JSON.stringify(facetFilters)])
|
||||
|
||||
if (noControls) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
||||
>
|
||||
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
<input
|
||||
type="text"
|
||||
|
@ -400,11 +417,12 @@ function ContractSearchControls(props: {
|
|||
onBlur={trackCallback('search', { query: query })}
|
||||
placeholder={'Search'}
|
||||
className="input input-bordered w-full"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{!query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={state.filter}
|
||||
value={filter}
|
||||
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
|
@ -416,7 +434,7 @@ function ContractSearchControls(props: {
|
|||
{!hideOrderSelector && !query && (
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={state.sort}
|
||||
value={sort}
|
||||
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||
>
|
||||
{SORTS.map((option) => (
|
||||
|
@ -430,16 +448,12 @@ function ContractSearchControls(props: {
|
|||
|
||||
{!additionalFilter && !query && (
|
||||
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||
<PillButton
|
||||
key={'all'}
|
||||
selected={state.pillFilter === undefined}
|
||||
onSelect={selectPill(null)}
|
||||
>
|
||||
<PillButton key={'all'} selected={!pill} onSelect={selectPill(null)}>
|
||||
All
|
||||
</PillButton>
|
||||
<PillButton
|
||||
key={'personal'}
|
||||
selected={state.pillFilter === 'personal'}
|
||||
selected={pill === 'personal'}
|
||||
onSelect={selectPill('personal')}
|
||||
>
|
||||
{user ? 'For you' : 'Featured'}
|
||||
|
@ -448,7 +462,7 @@ function ContractSearchControls(props: {
|
|||
{user && (
|
||||
<PillButton
|
||||
key={'your-bets'}
|
||||
selected={state.pillFilter === 'your-bets'}
|
||||
selected={pill === 'your-bets'}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your {PAST_BETS}
|
||||
|
@ -459,7 +473,7 @@ function ContractSearchControls(props: {
|
|||
return (
|
||||
<PillButton
|
||||
key={slug}
|
||||
selected={state.pillFilter === slug}
|
||||
selected={pill === slug}
|
||||
onSelect={selectPill(slug)}
|
||||
>
|
||||
{name}
|
||||
|
|
|
@ -81,7 +81,7 @@ export function SelectMarketsModal(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto sm:px-8">
|
||||
<div className="overflow-y-auto px-2 sm:px-8">
|
||||
<ContractSearch
|
||||
hideOrderSelector
|
||||
onContractClick={addContract}
|
||||
|
@ -96,7 +96,7 @@ export function SelectMarketsModal(props: {
|
|||
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
||||
}}
|
||||
additionalFilter={{}} /* hide pills */
|
||||
headerClassName="bg-white"
|
||||
headerClassName="bg-white sticky"
|
||||
{...contractSearchOptions}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
PencilIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import { ClockIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import dayjs from 'dayjs'
|
||||
|
@ -16,9 +11,8 @@ import { DateTimeTooltip } from '../datetime-tooltip'
|
|||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Avatar } from '../avatar'
|
||||
import { useState } from 'react'
|
||||
import { ContractInfoDialog } from './contract-info-dialog'
|
||||
import NewContractBadge from '../new-contract-badge'
|
||||
import { UserFollowButton } from '../follow-button'
|
||||
import { MiniUserFollowButton } from '../follow-button'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
|
@ -33,7 +27,11 @@ import { contractMetrics } from 'common/contract-details'
|
|||
import { UserLink } from 'web/components/user-link'
|
||||
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
||||
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'
|
||||
|
||||
|
@ -116,87 +114,135 @@ export function ContractDetails(props: {
|
|||
disabled?: boolean
|
||||
}) {
|
||||
const { contract, disabled } = props
|
||||
const {
|
||||
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>
|
||||
)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
|
||||
<Row className="items-center gap-2">
|
||||
<Col>
|
||||
<Row className="justify-between">
|
||||
<MarketSubheader contract={contract} disabled={disabled} />
|
||||
<div className="mt-0">
|
||||
<ExtraContractActionsRow contract={contract} />
|
||||
</div>
|
||||
</Row>
|
||||
{/* GROUPS */}
|
||||
{isMobile && (
|
||||
<div className="mt-2">
|
||||
<MarketGroups contract={contract} disabled={disabled} />
|
||||
</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={6}
|
||||
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="whitespace-nowrap"
|
||||
className="my-auto whitespace-nowrap"
|
||||
name={creatorName}
|
||||
username={creatorUsername}
|
||||
short={isMobile}
|
||||
/>
|
||||
)}
|
||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||
</Row>
|
||||
<Row>
|
||||
{disabled ? (
|
||||
groupInfo
|
||||
) : !groupToDisplay && !user ? (
|
||||
<div />
|
||||
) : (
|
||||
<Row>
|
||||
{groupInfo}
|
||||
{user && groupToDisplay && (
|
||||
<Button
|
||||
size={'xs'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
<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>
|
||||
{dayjs().isBefore(closeTime) && <div>closes </div>}
|
||||
{!dayjs().isBefore(closeTime) && <div>closed </div>}
|
||||
<EditableCloseDate
|
||||
closeTime={closeTime}
|
||||
contract={contract}
|
||||
isCreator={isCreator ?? false}
|
||||
/>
|
||||
</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'}>
|
||||
|
@ -208,45 +254,7 @@ export function ContractDetails(props: {
|
|||
<ContractGroupsList contract={contract} user={user} />
|
||||
</Col>
|
||||
</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 &&
|
||||
closeTime && (
|
||||
<Col className={'items-center text-sm text-gray-500'}>
|
||||
<Row className={'text-gray-400'}>Closes </Row>
|
||||
<EditableCloseDate
|
||||
closeTime={closeTime}
|
||||
contract={contract}
|
||||
isCreator={creatorId === user?.id}
|
||||
/>
|
||||
<Row className={'text-gray-400'}>Ends</Row>
|
||||
</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: {
|
||||
closeTime: number
|
||||
contract: Contract
|
||||
|
@ -363,11 +389,18 @@ function EditableCloseDate(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
{isEditingCloseTime ? (
|
||||
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
|
||||
<Modal
|
||||
size="sm"
|
||||
open={isEditingCloseTime}
|
||||
setOpen={setIsEditingCloseTime}
|
||||
position="top"
|
||||
>
|
||||
<Col className="rounded bg-white px-8 pb-8">
|
||||
<Subtitle text="Edit Close Date" />
|
||||
<Row className="z-10 mr-2 w-full shrink-0 flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered shrink-0"
|
||||
className="input input-bordered w-full shrink-0 sm:w-fit"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCloseDate(e.target.value)}
|
||||
min={Date.now()}
|
||||
|
@ -375,17 +408,23 @@ function EditableCloseDate(props: {
|
|||
/>
|
||||
<input
|
||||
type="time"
|
||||
className="input input-bordered shrink-0"
|
||||
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}
|
||||
/>
|
||||
<Button size={'xs'} color={'blue'} onClick={onSave}>
|
||||
</Row>
|
||||
<Button
|
||||
className="mt-2"
|
||||
size={'xs'}
|
||||
color={'indigo'}
|
||||
onClick={onSave}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Row>
|
||||
) : (
|
||||
</Col>
|
||||
</Modal>
|
||||
<DateTimeTooltip
|
||||
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
||||
time={closeTime}
|
||||
|
@ -403,7 +442,6 @@ function EditableCloseDate(props: {
|
|||
)}
|
||||
</span>
|
||||
</DateTimeTooltip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline'
|
|||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import { capitalize } from 'lodash'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
@ -19,7 +20,7 @@ import ShortToggle from '../widgets/short-toggle'
|
|||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
import { Row } from '../layout/row'
|
||||
import { BETTORS } from 'common/user'
|
||||
import { capitalize } from 'lodash'
|
||||
import { Button } from '../button'
|
||||
|
||||
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'
|
||||
|
@ -39,10 +40,16 @@ export function ContractInfoDialog(props: {
|
|||
|
||||
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
|
||||
|
||||
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
|
||||
contract
|
||||
const {
|
||||
createdTime,
|
||||
closeTime,
|
||||
resolutionTime,
|
||||
uniqueBettorCount,
|
||||
mechanism,
|
||||
outcomeType,
|
||||
id,
|
||||
} = contract
|
||||
|
||||
const bettorsCount = contract.uniqueBettorCount ?? 'Unknown'
|
||||
const typeDisplay =
|
||||
outcomeType === 'BINARY'
|
||||
? 'YES / NO'
|
||||
|
@ -69,19 +76,21 @@ export function ContractInfoDialog(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
size="sm"
|
||||
color="gray-white"
|
||||
className={clsx(contractDetailsButtonClassName, className)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<DotsHorizontalIcon
|
||||
className={clsx('h-6 w-6 flex-shrink-0')}
|
||||
className={clsx('h-5 w-5 flex-shrink-0')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<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">
|
||||
<tbody>
|
||||
|
@ -131,14 +140,9 @@ export function ContractInfoDialog(props: {
|
|||
<td>{formatMoney(contract.volume)}</td>
|
||||
</tr>
|
||||
|
||||
{/* <tr>
|
||||
<td>Creator earnings</td>
|
||||
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
|
||||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>{capitalize(BETTORS)}</td>
|
||||
<td>{bettorsCount}</td>
|
||||
<td>{uniqueBettorCount ?? '0'}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
|
|
@ -25,11 +25,11 @@ import {
|
|||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
|
||||
import { ContractDetails } from './contract-details'
|
||||
import { NumericGraph } from './numeric-graph'
|
||||
|
||||
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 }) => {
|
||||
|
@ -73,7 +73,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
|
|||
const { contract, bets } = props
|
||||
return (
|
||||
<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} />
|
||||
<Row className="justify-between gap-4">
|
||||
<OverviewQuestion text={contract.question} />
|
||||
|
@ -85,7 +85,6 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
|
|||
</Row>
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
<ExtraMobileContractDetails contract={contract} />
|
||||
{tradingAllowed(contract) && (
|
||||
<BetWidget contract={contract as CPMMBinaryContract} />
|
||||
)}
|
||||
|
@ -113,10 +112,6 @@ const ChoiceOverview = (props: {
|
|||
</Col>
|
||||
<Col className={'mb-1 gap-y-2'}>
|
||||
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
|
||||
<ExtraMobileContractDetails
|
||||
contract={contract}
|
||||
forceShowVolume={true}
|
||||
/>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
|
@ -140,7 +135,6 @@ const PseudoNumericOverview = (props: {
|
|||
</Row>
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
<ExtraMobileContractDetails contract={contract} />
|
||||
{tradingAllowed(contract) && <BetWidget contract={contract} />}
|
||||
</Row>
|
||||
</Col>
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from 'common/antes'
|
||||
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
||||
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
|
@ -33,6 +34,7 @@ export function ContractTabs(props: {
|
|||
}) {
|
||||
const { contract, user, bets, tips } = props
|
||||
const { outcomeType } = contract
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const lps = useLiquidity(contract.id)
|
||||
|
||||
|
@ -131,7 +133,12 @@ export function ContractTabs(props: {
|
|||
},
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: `Your ${PAST_BETS}`, content: yourTrades }]),
|
||||
: [
|
||||
{
|
||||
title: isMobile ? `You` : `Your ${PAST_BETS}`,
|
||||
content: yourTrades,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
{!user ? (
|
||||
|
|
|
@ -110,6 +110,7 @@ export function CreatorContractsList(props: {
|
|||
|
||||
return (
|
||||
<ContractSearch
|
||||
headerClassName="sticky"
|
||||
user={user}
|
||||
defaultSort="newest"
|
||||
defaultFilter="all"
|
||||
|
|
|
@ -11,38 +11,29 @@ import { FollowMarketButton } from 'web/components/follow-market-button'
|
|||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
|
||||
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 }) {
|
||||
const { contract } = props
|
||||
const { outcomeType, resolution } = contract
|
||||
const user = useUser()
|
||||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
|
||||
useState(false)
|
||||
const showChallenge =
|
||||
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
||||
|
||||
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
|
||||
size="lg"
|
||||
size="sm"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={() => {
|
||||
setShareOpen(true)
|
||||
}}
|
||||
>
|
||||
<Col className={'items-center sm:flex-row'}>
|
||||
<ShareIcon
|
||||
className={clsx('h-[24px] w-5 sm:mr-2')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Share</span>
|
||||
</Col>
|
||||
<Row>
|
||||
<ShareIcon className={clsx('h-5 w-5')} aria-hidden="true" />
|
||||
</Row>
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
|
@ -50,35 +41,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
user={user}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{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'}>
|
||||
<Col className={'justify-center'}>
|
||||
<ContractInfoDialog contract={contract} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -38,15 +38,16 @@ export function LikeMarketButton(props: {
|
|||
|
||||
return (
|
||||
<Button
|
||||
size={'lg'}
|
||||
size={'sm'}
|
||||
className={'max-w-xs self-center'}
|
||||
color={'gray-white'}
|
||||
onClick={onLike}
|
||||
>
|
||||
<Col className={'items-center sm:flex-row'}>
|
||||
<Col className={'relative items-center sm:flex-row'}>
|
||||
<HeartIcon
|
||||
className={clsx(
|
||||
'h-[24px] w-5 sm:mr-2',
|
||||
'h-5 w-5 sm:h-6 sm:w-6',
|
||||
totalTipped > 0 ? 'mr-2' : '',
|
||||
user &&
|
||||
(userLikedContractIds?.includes(contract.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>
|
||||
</Button>
|
||||
)
|
||||
|
|
|
@ -11,8 +11,9 @@ export function ProbChangeTable(props: {
|
|||
changes:
|
||||
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
|
||||
| undefined
|
||||
full?: boolean
|
||||
}) {
|
||||
const { changes } = props
|
||||
const { changes, full } = props
|
||||
|
||||
if (!changes) return <LoadingIndicator />
|
||||
|
||||
|
@ -24,7 +25,10 @@ export function ProbChangeTable(props: {
|
|||
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
|
||||
)
|
||||
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
|
||||
const rows = Math.min(3, Math.min(maxRows, countOverThreshold))
|
||||
const rows = Math.min(
|
||||
full ? Infinity : 3,
|
||||
Math.min(maxRows, countOverThreshold)
|
||||
)
|
||||
|
||||
const filteredPositiveChanges = positiveChanges.slice(0, rows)
|
||||
const filteredNegativeChanges = negativeChanges.slice(0, rows)
|
||||
|
@ -35,27 +39,23 @@ export function ProbChangeTable(props: {
|
|||
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredPositiveChanges.map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
<ProbChangeRow key={contract.id} contract={contract} />
|
||||
))}
|
||||
</Col>
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredNegativeChanges.map((contract) => (
|
||||
<ProbChangeRow key={contract.id} contract={contract} />
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChangeRow(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
return (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
contract={contract}
|
||||
/>
|
||||
<ProbChange className="p-4 text-right text-xl" contract={contract} />
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
|
@ -63,9 +63,6 @@ export function ProbChangeTable(props: {
|
|||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { orderBy } from 'lodash'
|
||||
import tippy from 'tippy.js'
|
||||
import { getCachedContracts } from 'web/hooks/use-contracts'
|
||||
import { MentionList } from './contract-mention-list'
|
||||
import { PluginKey } from 'prosemirror-state'
|
||||
import { makeMentionRender } from './mention-suggestion'
|
||||
|
||||
type Suggestion = MentionOptions['suggestion']
|
||||
|
||||
const beginsWith = (text: string, query: string) =>
|
||||
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
// TODO: merge with mention-suggestion.ts?
|
||||
export const contractMentionSuggestion: Suggestion = {
|
||||
char: '%',
|
||||
allowSpaces: true,
|
||||
|
@ -26,51 +23,5 @@ export const contractMentionSuggestion: Suggestion = {
|
|||
[(c) => [c.question].some((s) => beginsWith(s, query))],
|
||||
['desc', 'desc']
|
||||
).slice(0, 5),
|
||||
render: () => {
|
||||
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()
|
||||
},
|
||||
}
|
||||
},
|
||||
render: makeMentionRender(MentionList),
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ const ContractMentionComponent = (props: any) => {
|
|||
* https://tiptap.dev/guide/node-views/react#render-a-react-component
|
||||
*/
|
||||
export const DisplayContractMention = Mention.extend({
|
||||
name: 'contract-mention',
|
||||
parseHTML: () => [{ tag: name }],
|
||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () =>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { orderBy } from 'lodash'
|
|||
import tippy from 'tippy.js'
|
||||
import { getCachedUsers } from 'web/hooks/use-users'
|
||||
import { MentionList } from './mention-list'
|
||||
type Render = Suggestion['render']
|
||||
|
||||
type Suggestion = MentionOptions['suggestion']
|
||||
|
||||
|
@ -24,12 +25,16 @@ export const mentionSuggestion: Suggestion = {
|
|||
],
|
||||
['desc', 'desc']
|
||||
).slice(0, 5),
|
||||
render: () => {
|
||||
render: makeMentionRender(MentionList),
|
||||
}
|
||||
|
||||
export function makeMentionRender(mentionList: any): Render {
|
||||
return () => {
|
||||
let component: ReactRenderer
|
||||
let popup: ReturnType<typeof tippy>
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
component = new ReactRenderer(mentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
@ -59,9 +64,15 @@ export const mentionSuggestion: Suggestion = {
|
|||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0].hide()
|
||||
return true
|
||||
if (props.event.key)
|
||||
if (
|
||||
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)
|
||||
},
|
||||
|
@ -70,5 +81,5 @@ export const mentionSuggestion: Suggestion = {
|
|||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { CheckCircleIcon, PlusCircleIcon } from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { follow, unfollow } from 'web/lib/firebase/users'
|
||||
|
@ -54,18 +56,73 @@ export function FollowButton(props: {
|
|||
|
||||
export function UserFollowButton(props: { userId: string; small?: boolean }) {
|
||||
const { userId, small } = props
|
||||
const currentUser = useUser()
|
||||
const following = useFollows(currentUser?.id)
|
||||
const user = useUser()
|
||||
const following = useFollows(user?.id)
|
||||
const isFollowing = following?.includes(userId)
|
||||
|
||||
if (!currentUser || currentUser.id === userId) return null
|
||||
if (!user || user.id === userId) return null
|
||||
|
||||
return (
|
||||
<FollowButton
|
||||
isFollowing={isFollowing}
|
||||
onFollow={() => follow(currentUser.id, userId)}
|
||||
onUnfollow={() => unfollow(currentUser.id, userId)}
|
||||
onFollow={() => follow(user.id, userId)}
|
||||
onUnfollow={() => unfollow(user.id, userId)}
|
||||
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 (
|
||||
<Button
|
||||
size={'lg'}
|
||||
size={'sm'}
|
||||
color={'gray-white'}
|
||||
onClick={async () => {
|
||||
if (!user) return firebaseLogin()
|
||||
|
@ -56,13 +56,19 @@ export const FollowMarketButton = (props: {
|
|||
>
|
||||
{followers?.includes(user?.id ?? 'nope') ? (
|
||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
||||
Unwatch
|
||||
<EyeOffIcon
|
||||
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Unwatch */}
|
||||
</Col>
|
||||
) : (
|
||||
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
||||
Watch
|
||||
<EyeIcon
|
||||
className={clsx('h-5 w-5 sm:h-6 sm:w-6')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Watch */}
|
||||
</Col>
|
||||
)}
|
||||
<WatchMarketModal
|
||||
|
|
|
@ -17,11 +17,14 @@ import { useRouter } from 'next/router'
|
|||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { User } from 'common/user'
|
||||
|
||||
import { PAST_BETS } from 'common/user'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{ name: 'Search', href: '/search', icon: SearchIcon },
|
||||
{
|
||||
name: 'Notifications',
|
||||
href: `/notifications`,
|
||||
|
@ -32,9 +35,24 @@ function getNavigation() {
|
|||
|
||||
const signedOutNavigation = [
|
||||
{ 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
|
||||
export function BottomNavBar() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
@ -62,20 +80,7 @@ export function BottomNavBar() {
|
|||
<NavBarItem
|
||||
key={'profile'}
|
||||
currentPage={currentPage}
|
||||
item={{
|
||||
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
|
||||
/>
|
||||
),
|
||||
}}
|
||||
item={userProfileItem(user)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
|
@ -99,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
|||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||
|
||||
return (
|
||||
<Link href={item.href}>
|
||||
<Link href={item.href ?? '#'}>
|
||||
<a
|
||||
className={clsx(
|
||||
'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 = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
|
@ -38,11 +38,11 @@ export function MenuButton(props: {
|
|||
{({ active }) => (
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
||||
target={item.href?.startsWith('http') ? '_blank' : undefined}
|
||||
onClick={item.onClick}
|
||||
className={clsx(
|
||||
active ? 'bg-gray-100' : '',
|
||||
'line-clamp-3 block py-1.5 px-4 text-sm text-gray-700'
|
||||
'line-clamp-3 block cursor-pointer py-1.5 px-4 text-sm text-gray-700'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
|
|
|
@ -13,7 +13,7 @@ import Router, { useRouter } from 'next/router'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||
import { ManifoldLogo } from './manifold-logo'
|
||||
import { MenuButton } from './menu'
|
||||
import { MenuButton, MenuItem } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React from 'react'
|
||||
|
@ -35,6 +35,7 @@ const logout = async () => {
|
|||
function getNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{ name: 'Search', href: '/search', icon: SearchIcon },
|
||||
{
|
||||
name: 'Notifications',
|
||||
href: `/notifications`,
|
||||
|
@ -100,7 +101,7 @@ function getMoreNavigation(user?: User | null) {
|
|||
|
||||
const signedOutNavigation = [
|
||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||
{ name: 'Explore', href: '/search', icon: SearchIcon },
|
||||
{
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
|
@ -139,7 +140,7 @@ function getMoreMobileNav() {
|
|||
}
|
||||
if (IS_PRIVATE_MANIFOLD) return [signOut]
|
||||
|
||||
return buildArray<Item>(
|
||||
return buildArray<MenuItem>(
|
||||
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||
[
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
|
@ -156,18 +157,25 @@ function getMoreMobileNav() {
|
|||
export type Item = {
|
||||
name: string
|
||||
trackingEventName?: string
|
||||
href: string
|
||||
href?: string
|
||||
key?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||
const { item, currentPage } = props
|
||||
return (
|
||||
<Link href={item.href} key={item.name}>
|
||||
export function SidebarItem(props: {
|
||||
item: Item
|
||||
currentPage: string
|
||||
onClick?: (key: string) => void
|
||||
}) {
|
||||
const { item, currentPage, onClick } = props
|
||||
const isCurrentPage =
|
||||
item.href != null ? item.href === currentPage : item.key === currentPage
|
||||
|
||||
const sidebarItem = (
|
||||
<a
|
||||
onClick={trackCallback('sidebar: ' + item.name)}
|
||||
className={clsx(
|
||||
item.href == currentPage
|
||||
isCurrentPage
|
||||
? 'bg-gray-200 text-gray-900'
|
||||
: 'text-gray-600 hover:bg-gray-100',
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
|
||||
|
@ -177,7 +185,7 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
|||
{item.icon && (
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.href == currentPage
|
||||
isCurrentPage
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-400 group-hover:text-gray-500',
|
||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||
|
@ -187,8 +195,21 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
|||
)}
|
||||
<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: {
|
||||
|
|
|
@ -63,7 +63,6 @@ export function NotificationSettings(props: {
|
|||
'contract_from_followed_user',
|
||||
'unique_bettors_on_your_contract',
|
||||
// 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
|
||||
// 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 { Row } from '../layout/row'
|
||||
import { Title } from '../title'
|
||||
import GroupSelectorDialog from './group-selector-dialog'
|
||||
|
||||
export default function Welcome() {
|
||||
const user = useUser()
|
||||
|
@ -32,17 +33,26 @@ export default function Welcome() {
|
|||
}
|
||||
}
|
||||
|
||||
if (!user || !user.shouldShowWelcome) {
|
||||
return <></>
|
||||
} else
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={(newOpen) => {
|
||||
const [groupSelectorOpen, setGroupSelectorOpen] = useState(false)
|
||||
|
||||
if (!user || (!user.shouldShowWelcome && !groupSelectorOpen)) return <></>
|
||||
|
||||
const toggleOpen = (isOpen: boolean) => {
|
||||
setUserHasSeenWelcome()
|
||||
setOpen(newOpen)
|
||||
}}
|
||||
>
|
||||
setOpen(isOpen)
|
||||
|
||||
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">
|
||||
{page === 0 && <Page0 />}
|
||||
{page === 1 && <Page1 />}
|
||||
|
@ -68,16 +78,14 @@ export default function Welcome() {
|
|||
</Row>
|
||||
<u
|
||||
className="self-center text-xs text-gray-500"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setUserHasSeenWelcome()
|
||||
}}
|
||||
onClick={() => toggleOpen(false)}
|
||||
>
|
||||
I got the gist, exit welcome
|
||||
</u>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import { ReactNode } from 'react'
|
||||
import { BottomNavBar } from './nav/nav-bar'
|
||||
import { BottomNavBar } from './nav/bottom-nav-bar'
|
||||
import Sidebar from './nav/sidebar'
|
||||
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 {
|
||||
Contract,
|
||||
listenForActiveContracts,
|
||||
listenForContracts,
|
||||
listenForHotContracts,
|
||||
listenForInactiveContracts,
|
||||
listenForNewContracts,
|
||||
getUserBetContracts,
|
||||
getUserBetContractsQuery,
|
||||
listAllContracts,
|
||||
trendingContractsQuery,
|
||||
getContractsQuery,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { QueryClient, useQueryClient } from 'react-query'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { query, limit } from 'firebase/firestore'
|
||||
import { Sort } from 'web/components/contract-search'
|
||||
|
||||
export const useContracts = () => {
|
||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||
|
@ -30,23 +32,25 @@ export const getCachedContracts = async () =>
|
|||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
export const useActiveContracts = () => {
|
||||
const [activeContracts, setActiveContracts] = useState<
|
||||
Contract[] | undefined
|
||||
>()
|
||||
const [newContracts, setNewContracts] = useState<Contract[] | undefined>()
|
||||
export const useTrendingContracts = (maxContracts: number) => {
|
||||
const result = useFirestoreQueryData(
|
||||
['trending-contracts', maxContracts],
|
||||
query(trendingContractsQuery, limit(maxContracts))
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return listenForActiveContracts(setActiveContracts)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return listenForNewContracts(setNewContracts)
|
||||
}, [])
|
||||
|
||||
if (!activeContracts || !newContracts) return undefined
|
||||
|
||||
return [...activeContracts, ...newContracts]
|
||||
export const useContractsQuery = (
|
||||
sort: Sort,
|
||||
maxContracts: number,
|
||||
filters: { groupSlug?: string } = {},
|
||||
visibility?: 'public'
|
||||
) => {
|
||||
const result = useFirestoreQueryData(
|
||||
['contracts-query', sort, maxContracts, filters],
|
||||
getContractsQuery(sort, maxContracts, filters, visibility)
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
||||
export const useInactiveContracts = () => {
|
||||
|
|
|
@ -11,13 +11,17 @@ import {
|
|||
listenForMemberGroupIds,
|
||||
listenForOpenGroups,
|
||||
listGroups,
|
||||
topFollowedGroupsQuery,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { getUser } from 'web/lib/firebase/users'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { Contract } from 'common/contract'
|
||||
import { uniq } from 'lodash'
|
||||
import { keyBy, uniq, uniqBy } from 'lodash'
|
||||
import { listenForValues } from 'web/lib/firebase/utils'
|
||||
import { useQuery } from 'react-query'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { limit, query } from 'firebase/firestore'
|
||||
import { useTrendingContracts } from './use-contracts'
|
||||
|
||||
export const useGroup = (groupId: string | undefined) => {
|
||||
const [group, setGroup] = useState<Group | null | undefined>()
|
||||
|
@ -49,6 +53,30 @@ export const useOpenGroups = () => {
|
|||
return groups
|
||||
}
|
||||
|
||||
export const useTopFollowedGroups = (count: number) => {
|
||||
const result = useFirestoreQueryData(
|
||||
['top-followed-contracts', count],
|
||||
query(topFollowedGroupsQuery, limit(count))
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
||||
export const useTrendingGroups = () => {
|
||||
const topGroups = useTopFollowedGroups(200)
|
||||
const groupsById = keyBy(topGroups, 'id')
|
||||
|
||||
const trendingContracts = useTrendingContracts(200)
|
||||
|
||||
const groupLinks = uniqBy(
|
||||
(trendingContracts ?? []).map((c) => c.groupLinks ?? []).flat(),
|
||||
(link) => link.groupId
|
||||
)
|
||||
|
||||
return filterDefined(
|
||||
groupLinks.map((link) => groupsById[link.groupId])
|
||||
).filter((group) => group.totalMembers >= 3)
|
||||
}
|
||||
|
||||
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||
const result = useQuery(['member-groups', userId ?? ''], () =>
|
||||
getMemberGroups(userId ?? '')
|
||||
|
@ -56,10 +84,11 @@ export const useMemberGroups = (userId: string | null | undefined) => {
|
|||
return result.data
|
||||
}
|
||||
|
||||
// Note: We cache member group ids in localstorage to speed up the initial load
|
||||
export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||
const cachedGroups = useMemberGroups(user?.id)
|
||||
|
||||
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
|
||||
undefined
|
||||
cachedGroups?.map((g) => g.id)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
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 = {
|
||||
notifications: notificationsForContractId,
|
||||
groupedById: contractId,
|
||||
isSeen: notificationsForContractId[0].isSeen,
|
||||
isSeen: notificationsForContractId.some((n) => !n.isSeen),
|
||||
timePeriod: day,
|
||||
type: 'normal',
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { usePrefetchUserBetContracts } from './use-contracts'
|
||||
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
|
||||
import { usePrefetchProbChanges } from './use-prob-changes'
|
||||
import { usePrefetchUserBets } from './use-user-bets'
|
||||
|
||||
export function usePrefetch(userId: string | undefined) {
|
||||
|
@ -8,5 +9,6 @@ export function usePrefetch(userId: string | undefined) {
|
|||
usePrefetchUserBets(maybeUserId),
|
||||
usePrefetchUserBetContracts(maybeUserId),
|
||||
usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
|
||||
usePrefetchProbChanges(userId),
|
||||
])
|
||||
}
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { useQueryClient } from 'react-query'
|
||||
import {
|
||||
getProbChangesNegative,
|
||||
getProbChangesPositive,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { getValues } from 'web/lib/firebase/utils'
|
||||
|
||||
export const useProbChanges = (userId: string) => {
|
||||
const { data: positiveChanges } = useFirestoreQueryData(
|
||||
|
@ -20,3 +23,19 @@ export const useProbChanges = (userId: string) => {
|
|||
|
||||
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 { BinaryContract, Contract, CPMMContract } from 'common/contract'
|
||||
import { createRNG, shuffle } from 'common/util/random'
|
||||
import { chooseRandomSubset } from 'common/util/random'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { getBinaryProb } from 'common/contract-details'
|
||||
import { Sort } from 'web/components/contract-search'
|
||||
|
||||
export const contracts = coll<Contract>('contracts')
|
||||
|
||||
|
@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) {
|
|||
) as Query<Contract>
|
||||
}
|
||||
|
||||
const activeContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('visibility', '==', 'public'),
|
||||
where('volume7Days', '>', 0)
|
||||
)
|
||||
|
||||
export function getActiveContracts() {
|
||||
return getValues<Contract>(activeContractsQuery)
|
||||
}
|
||||
|
||||
export function listenForActiveContracts(
|
||||
setContracts: (contracts: Contract[]) => void
|
||||
) {
|
||||
return listenForValues<Contract>(activeContractsQuery, setContracts)
|
||||
}
|
||||
|
||||
const inactiveContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
|
@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) {
|
|||
await deleteDoc(followDoc)
|
||||
}
|
||||
|
||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||
shuffle(contracts, createRNG(seed))
|
||||
return contracts.slice(0, count)
|
||||
}
|
||||
|
||||
const hotContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
|
@ -282,16 +259,17 @@ export function listenForHotContracts(
|
|||
})
|
||||
}
|
||||
|
||||
const trendingContractsQuery = query(
|
||||
export const trendingContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('visibility', '==', 'public'),
|
||||
orderBy('popularityScore', 'desc'),
|
||||
limit(10)
|
||||
orderBy('popularityScore', 'desc')
|
||||
)
|
||||
|
||||
export async function getTrendingContracts() {
|
||||
return await getValues<Contract>(trendingContractsQuery)
|
||||
export async function getTrendingContracts(maxContracts = 10) {
|
||||
return await getValues<Contract>(
|
||||
query(trendingContractsQuery, limit(maxContracts))
|
||||
)
|
||||
}
|
||||
|
||||
export async function getContractsBySlugs(slugs: string[]) {
|
||||
|
@ -343,6 +321,51 @@ export const getTopGroupContracts = async (
|
|||
return await getValues<Contract>(creatorContractsQuery)
|
||||
}
|
||||
|
||||
const sortToField = {
|
||||
newest: 'createdTime',
|
||||
score: 'popularityScore',
|
||||
'most-traded': 'volume',
|
||||
'24-hour-vol': 'volume24Hours',
|
||||
'prob-change-day': 'probChanges.day',
|
||||
'last-updated': 'lastUpdated',
|
||||
liquidity: 'totalLiquidity',
|
||||
'close-date': 'closeTime',
|
||||
'resolve-date': 'resolutionTime',
|
||||
'prob-descending': 'prob',
|
||||
'prob-ascending': 'prob',
|
||||
} as const
|
||||
|
||||
const sortToDirection = {
|
||||
newest: 'desc',
|
||||
score: 'desc',
|
||||
'most-traded': 'desc',
|
||||
'24-hour-vol': 'desc',
|
||||
'prob-change-day': 'desc',
|
||||
'last-updated': 'desc',
|
||||
liquidity: 'desc',
|
||||
'close-date': 'asc',
|
||||
'resolve-date': 'desc',
|
||||
'prob-ascending': 'asc',
|
||||
'prob-descending': 'desc',
|
||||
} as const
|
||||
|
||||
export const getContractsQuery = (
|
||||
sort: Sort,
|
||||
maxItems: number,
|
||||
filters: { groupSlug?: string } = {},
|
||||
visibility?: 'public'
|
||||
) => {
|
||||
const { groupSlug } = filters
|
||||
return query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
...(visibility ? [where('visibility', '==', visibility)] : []),
|
||||
...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []),
|
||||
orderBy(sortToField[sort], sortToDirection[sort]),
|
||||
limit(maxItems)
|
||||
)
|
||||
}
|
||||
|
||||
export const getRecommendedContracts = async (
|
||||
contract: Contract,
|
||||
excludeBettorId: string,
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
doc,
|
||||
getDocs,
|
||||
onSnapshot,
|
||||
orderBy,
|
||||
query,
|
||||
setDoc,
|
||||
updateDoc,
|
||||
|
@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) {
|
|||
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||
return members.map((m) => m.userId)
|
||||
}
|
||||
|
||||
export const topFollowedGroupsQuery = query(
|
||||
groups,
|
||||
where('anyoneCanJoin', '==', true),
|
||||
orderBy('totalMembers', 'desc')
|
||||
)
|
||||
|
|
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
|
||||
|
||||
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(
|
||||
manifoldUserID: string,
|
||||
manifoldUserAPIKey: string
|
||||
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
|
||||
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
|
||||
manifoldID: manifoldUserID,
|
||||
apiKey: manifoldUserAPIKey,
|
||||
redirectURL: window.location.href,
|
||||
}),
|
||||
})
|
||||
const responseData = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.message)
|
||||
}
|
||||
const responseFetch = fetch(
|
||||
`${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(
|
||||
|
@ -38,4 +42,34 @@ export async function linkTwitchAccountRedirect(
|
|||
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
|
||||
|
||||
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 { getOpenGraphProps } from 'common/contract-details'
|
||||
import { ContractDescription } from 'web/components/contract/contract-description'
|
||||
import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
|
||||
import {
|
||||
ContractLeaderboard,
|
||||
ContractTopTrades,
|
||||
|
@ -257,7 +256,6 @@ export function ContractPageContent(
|
|||
)}
|
||||
|
||||
<ContractOverview contract={contract} bets={nonChallengeBets} />
|
||||
<ExtraContractActionsRow contract={contract} />
|
||||
<ContractDescription className="mb-6 px-2" contract={contract} />
|
||||
|
||||
{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,
|
||||
PseudoNumericResolutionOrExpectation,
|
||||
} 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 { NumericGraph } from 'web/components/contract/numeric-graph'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
@ -102,15 +102,29 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
return (
|
||||
<Col className="h-[100vh] w-full bg-white">
|
||||
<div className="relative flex flex-col pt-2">
|
||||
<div className="px-3 text-xl text-indigo-700 md:text-2xl">
|
||||
<Row className="justify-between gap-4 px-2">
|
||||
<div className="text-xl text-indigo-700 md:text-2xl">
|
||||
<SiteLink href={href}>{question}</SiteLink>
|
||||
</div>
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance contract={contract} probAfter={probAfter} />
|
||||
)}
|
||||
|
||||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<FreeResponseResolutionOrChance contract={contract} truncate="long" />
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation contract={contract} />
|
||||
)}
|
||||
</Row>
|
||||
<Spacer h={3} />
|
||||
|
||||
<Row className="items-center justify-between gap-4 px-2">
|
||||
<ContractDetails contract={contract} disabled />
|
||||
<MarketSubheader contract={contract} disabled />
|
||||
|
||||
{(isBinary || isPseudoNumeric) &&
|
||||
tradingAllowed(contract) &&
|
||||
|
@ -119,33 +133,9 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
Predict
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
contract={contract}
|
||||
probAfter={probAfter}
|
||||
className="items-center"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<FreeResponseResolutionOrChance
|
||||
contract={contract}
|
||||
truncate="long"
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation contract={contract} />
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Spacer h={2} />
|
||||
</div>
|
||||
|
||||
{(isBinary || isPseudoNumeric) && betPanelOpen && (
|
||||
<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 Link from 'next/link'
|
||||
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 { Page } from 'web/components/page'
|
||||
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
|
||||
import {
|
||||
addContractToGroup,
|
||||
|
@ -30,7 +29,7 @@ import Custom404 from '../../404'
|
|||
import { SEO } from 'web/components/SEO'
|
||||
import { Linkify } from 'web/components/linkify'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { Tabs } from 'web/components/layout/tabs'
|
||||
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { ContractSearch } from 'web/components/contract-search'
|
||||
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 { useAdmin } from 'web/hooks/use-admin'
|
||||
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 { BETTORS } from 'common/user'
|
||||
|
||||
|
@ -138,6 +140,7 @@ export default function GroupPage(props: {
|
|||
const user = useUser()
|
||||
const isAdmin = useAdmin()
|
||||
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
|
||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||
|
||||
useSaveReferral(user, {
|
||||
defaultReferrerUsername: creator.username,
|
||||
|
@ -151,7 +154,7 @@ export default function GroupPage(props: {
|
|||
const isMember = user && memberIds.includes(user.id)
|
||||
const maxLeaderboardSize = 50
|
||||
|
||||
const leaderboard = (
|
||||
const leaderboardPage = (
|
||||
<Col>
|
||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||
<GroupLeaderboard
|
||||
|
@ -170,7 +173,7 @@ export default function GroupPage(props: {
|
|||
</Col>
|
||||
)
|
||||
|
||||
const aboutTab = (
|
||||
const aboutPage = (
|
||||
<Col>
|
||||
{(group.aboutPostId != null || isCreator || isAdmin) && (
|
||||
<GroupAboutPost
|
||||
|
@ -190,73 +193,118 @@ export default function GroupPage(props: {
|
|||
</Col>
|
||||
)
|
||||
|
||||
const questionsTab = (
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={'newest'}
|
||||
defaultFilter={suggestedFilter}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
/>
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: 'Markets',
|
||||
content: questionsTab,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
},
|
||||
{
|
||||
title: 'Leaderboards',
|
||||
content: leaderboard,
|
||||
href: groupPath(group.slug, 'leaderboards'),
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
content: aboutTab,
|
||||
href: groupPath(group.slug, 'about'),
|
||||
},
|
||||
]
|
||||
|
||||
const tabIndex = tabs
|
||||
.map((t) => t.title.toLowerCase())
|
||||
.indexOf(page ?? 'markets')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title={group.name}
|
||||
description={`Created by ${creator.name}. ${group.about}`}
|
||||
url={groupPath(group.slug)}
|
||||
/>
|
||||
<Col className="relative px-3">
|
||||
<Row className={'items-center justify-between gap-4'}>
|
||||
<div className={'sm:mb-1'}>
|
||||
<div
|
||||
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
|
||||
>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className={'hidden sm:block'}>
|
||||
<Linkify text={group.about} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
const questionsPage = (
|
||||
<>
|
||||
{/* align the divs to the right */}
|
||||
<div className={' flex justify-end px-2 pb-2 sm:hidden'}>
|
||||
<div>
|
||||
<JoinOrAddQuestionsButtons
|
||||
group={group}
|
||||
user={user}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</Col>
|
||||
<Tabs
|
||||
currentPageForAnalytics={groupPath(group.slug)}
|
||||
className={'mx-2 mb-0 sm:mb-2'}
|
||||
defaultIndex={tabIndex > 0 ? tabIndex : 0}
|
||||
tabs={tabs}
|
||||
</div>
|
||||
<ContractSearch
|
||||
headerClassName="md:sticky"
|
||||
user={user}
|
||||
defaultSort={'newest'}
|
||||
defaultFilter={suggestedFilter}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
/>
|
||||
</Page>
|
||||
</>
|
||||
)
|
||||
|
||||
const sidebarPages = [
|
||||
{
|
||||
title: 'Markets',
|
||||
content: questionsPage,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
key: 'markets',
|
||||
},
|
||||
{
|
||||
title: 'Leaderboards',
|
||||
content: leaderboardPage,
|
||||
href: groupPath(group.slug, 'leaderboards'),
|
||||
key: 'leaderboards',
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
content: aboutPage,
|
||||
href: groupPath(group.slug, 'about'),
|
||||
key: 'about',
|
||||
},
|
||||
]
|
||||
|
||||
const pageContent = sidebarPages[sidebarIndex].content
|
||||
const onSidebarClick = (key: string) => {
|
||||
const index = sidebarPages.findIndex((t) => t.key === key)
|
||||
setSidebarIndex(index)
|
||||
}
|
||||
|
||||
const joinOrAddQuestionsButton = (
|
||||
<JoinOrAddQuestionsButtons
|
||||
group={group}
|
||||
user={user}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopGroupNavBar group={group} />
|
||||
<div>
|
||||
<div
|
||||
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'
|
||||
}
|
||||
>
|
||||
<Toaster />
|
||||
<GroupSidebar
|
||||
groupName={group.name}
|
||||
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
|
||||
onClick={onSidebarClick}
|
||||
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
|
||||
currentKey={sidebarPages[sidebarIndex].key}
|
||||
/>
|
||||
|
||||
<SEO
|
||||
title={group.name}
|
||||
description={`Created by ${creator.name}. ${group.about}`}
|
||||
url={groupPath(group.slug)}
|
||||
/>
|
||||
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
|
||||
{pageContent}
|
||||
</main>
|
||||
</div>
|
||||
<GroupNavBar
|
||||
currentPage={sidebarPages[sidebarIndex].key}
|
||||
onClick={onSidebarClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
user: User | null | undefined
|
||||
isMember: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { group, user, isMember } = props
|
||||
return user && isMember ? (
|
||||
<Row className={'mt-0 justify-end'}>
|
||||
<Row className={'w-full self-start pt-4'}>
|
||||
<AddContractButton group={group} user={user} />
|
||||
</Row>
|
||||
) : group.anyoneCanJoin ? (
|
||||
|
@ -411,9 +460,9 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex justify-center'}>
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
className="w-full whitespace-nowrap"
|
||||
size="md"
|
||||
color="indigo"
|
||||
onClick={() => setOpen(true)}
|
||||
|
@ -468,7 +517,9 @@ function JoinGroupButton(props: {
|
|||
<div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
|
@ -171,17 +171,24 @@ export default function Groups(props: {
|
|||
|
||||
export function GroupCard(props: {
|
||||
group: Group
|
||||
creator: User | undefined
|
||||
creator: User | null | undefined
|
||||
user: User | undefined | null
|
||||
isMember: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { group, creator, user, isMember } = props
|
||||
const { group, creator, user, isMember, className } = props
|
||||
const { totalContracts } = group
|
||||
return (
|
||||
<Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100">
|
||||
<Col
|
||||
className={clsx(
|
||||
'relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-6 shadow-md hover:bg-gray-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Link href={groupPath(group.slug)}>
|
||||
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
|
||||
</Link>
|
||||
{creator !== null && (
|
||||
<div>
|
||||
<Avatar
|
||||
className={'absolute top-2 right-2 z-10'}
|
||||
|
@ -191,6 +198,7 @@ export function GroupCard(props: {
|
|||
size={12}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Row className="items-center justify-between gap-2">
|
||||
<span className="text-xl">{group.name}</span>
|
||||
</Row>
|
||||
|
|
|
@ -1,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 { SiteLink } from 'web/components/site-link'
|
||||
import { Title } from 'web/components/title'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { updateUser } from 'web/lib/firebase/users'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { getHomeItems } from '.'
|
||||
|
||||
export default function Home() {
|
||||
const user = useUser()
|
||||
|
@ -24,6 +27,9 @@ export default function Home() {
|
|||
setHomeSections(newHomeSections)
|
||||
}
|
||||
|
||||
const groups = useMemberGroups(user?.id) ?? []
|
||||
const { sections } = getHomeItems(groups, homeSections)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
||||
|
@ -32,11 +38,7 @@ export default function Home() {
|
|||
<DoneButton />
|
||||
</Row>
|
||||
|
||||
<ArrangeHome
|
||||
user={user}
|
||||
homeSections={homeSections}
|
||||
setHomeSections={updateHomeSections}
|
||||
/>
|
||||
<ArrangeHome sections={sections} setSectionIds={updateHomeSections} />
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
|
@ -46,11 +48,12 @@ function DoneButton(props: { className?: string }) {
|
|||
const { className } = props
|
||||
|
||||
return (
|
||||
<SiteLink href="/experimental/home">
|
||||
<SiteLink href="/home">
|
||||
<Button
|
||||
size="lg"
|
||||
color="blue"
|
||||
className={clsx(className, 'flex whitespace-nowrap')}
|
||||
onClick={() => track('done editing home')}
|
||||
>
|
||||
Done
|
||||
</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 { sourceText, data } = notification
|
||||
const { creatorOutcome, probability } = (data as BetFillData) ?? {}
|
||||
const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
|
||||
(data as BetFillData) ?? {}
|
||||
const subtitle = 'bet against you'
|
||||
const amount = formatMoney(parseInt(sourceText ?? '0'))
|
||||
const description =
|
||||
creatorOutcome && probability ? (
|
||||
<span>
|
||||
of your{' '}
|
||||
of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
|
||||
<span
|
||||
className={
|
||||
className={clsx(
|
||||
'mx-1',
|
||||
creatorOutcome === 'YES'
|
||||
? 'text-primary'
|
||||
: creatorOutcome === 'NO'
|
||||
? 'text-red-500'
|
||||
: 'text-blue-500'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{creatorOutcome}{' '}
|
||||
{creatorOutcome}
|
||||
</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>of your limit order was filled</span>
|
||||
|
|
|
@ -1,24 +1,28 @@
|
|||
import React, { useState } from 'react'
|
||||
import { RefreshIcon } from '@heroicons/react/outline'
|
||||
|
||||
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 { PrivateUser, User } from 'common/user'
|
||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
||||
import { changeUserInfo } from 'web/lib/firebase/api'
|
||||
import { uploadImage } from 'web/lib/firebase/storage'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
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 { Row } from 'web/components/layout/row'
|
||||
import { User, PrivateUser } from 'common/user'
|
||||
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
import { Page } from 'web/components/page'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { Title } from 'web/components/title'
|
||||
import { defaultBannerUrl } from 'web/components/user-page'
|
||||
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) => {
|
||||
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)
|
||||
setApiKey(newApiKey ?? '')
|
||||
e.preventDefault()
|
||||
e?.preventDefault()
|
||||
|
||||
if (!privateUser.twitchInfo) return
|
||||
await updatePrivateUser(privateUser.id, {
|
||||
twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true },
|
||||
})
|
||||
}
|
||||
|
||||
const fileHandler = async (event: any) => {
|
||||
|
@ -229,16 +238,38 @@ export default function ProfilePage(props: {
|
|||
value={apiKey}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-square p-2"
|
||||
onClick={updateApiKey}
|
||||
<ConfirmationButton
|
||||
openModalBtn={{
|
||||
className: 'btn btn-primary btn-square p-2',
|
||||
label: '',
|
||||
icon: <RefreshIcon />,
|
||||
}}
|
||||
submitBtn={{
|
||||
label: 'Update key',
|
||||
className: 'btn-primary',
|
||||
}}
|
||||
onSubmitWithSuccess={async () => {
|
||||
updateApiKey()
|
||||
return true
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<Col>
|
||||
<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>
|
||||
|
||||
<TwitchPanel />
|
||||
</Col>
|
||||
</Col>
|
||||
</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[] = [
|
||||
{
|
||||
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',
|
||||
blurb:
|
||||
|
@ -99,13 +106,6 @@ const tourneys: Tourney[] = [
|
|||
endTime: toDate('Jan 6, 2023'),
|
||||
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
|
||||
{
|
||||
|
|
|
@ -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 { 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() {
|
||||
useSaveReferral()
|
||||
useTracking('view twitch landing page')
|
||||
function ButtonGetStarted(props: {
|
||||
user?: User | null
|
||||
privateUser?: PrivateUser | null
|
||||
buttonClass?: string
|
||||
spinnerClass?: string
|
||||
}) {
|
||||
const { user, privateUser, buttonClass, spinnerClass } = props
|
||||
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
const twitchUser = privateUser?.twitchInfo?.twitchName
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const needsRelink =
|
||||
privateUser?.twitchInfo?.twitchName &&
|
||||
privateUser?.twitchInfo?.needsRelinking
|
||||
|
||||
const callback =
|
||||
user && privateUser
|
||||
|
@ -34,11 +54,11 @@ export default function TwitchLandingPage() {
|
|||
const { user, privateUser } = await getUserAndPrivateUser(userId)
|
||||
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)
|
||||
}
|
||||
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
|
||||
const getStarted = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
@ -49,9 +69,335 @@ export default function TwitchLandingPage() {
|
|||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('Failed to sign up. Please try again later.')
|
||||
} finally {
|
||||
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 (
|
||||
<Page>
|
||||
|
@ -62,58 +408,11 @@ export default function TwitchLandingPage() {
|
|||
<div className="px-4 pt-2 md:mt-0 lg:hidden">
|
||||
<ManifoldLogo />
|
||||
</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} />
|
||||
|
||||
{twitchUser ? (
|
||||
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 ">
|
||||
<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 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} />
|
||||
<TwitchChatCommands />
|
||||
<SetUpBot user={user} privateUser={privateUser} />
|
||||
</Col>
|
||||
</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-6': '#66667C',
|
||||
'greyscale-7': '#111140',
|
||||
'highlight-blue': '#5BCEFF',
|
||||
'hover-blue': '#90DEFF',
|
||||
},
|
||||
typography: {
|
||||
quoteless: {
|
||||
|
|
Loading…
Reference in New Issue
Block a user