Merge branch 'main' into editor-at-mention

This commit is contained in:
Sinclair Chen 2022-07-23 19:27:56 -07:00
commit ff0c105d9b
120 changed files with 4418 additions and 1735 deletions

View File

@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb(
prob: number,
outcome: 'YES' | 'NO'
) {
if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity
if (outcome === 'NO') prob = 1 - prob
// First, find an upper bound that leads to a more extreme probability than prob.

View File

@ -1,6 +1,7 @@
import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [
'manifold',
'personal',
'covid',
'culture',
'gaming',
'crypto',
'world',
]
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({
slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX,
name: CATEGORIES[c as category],
}))

View File

@ -1,6 +1,7 @@
import { Answer } from './answer'
import { Fees } from './fees'
import { JSONContent } from '@tiptap/core'
import { GroupLink } from 'common/group'
export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
@ -46,8 +47,10 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
collectedFees: Fees
groupSlugs?: string[]
groupLinks?: GroupLink[]
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
} & T
export type BinaryContract = Contract & Binary

View File

@ -22,6 +22,7 @@ export type EnvConfig = {
// Currency controls
fixedAnte?: number
startingBalance?: number
referralBonus?: number
}
type FirebaseConfig = {

View File

@ -19,3 +19,11 @@ export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
export const GROUP_CHAT_SLUG = 'chat'
export type GroupLink = {
slug: string
name: string
groupId: string
createdTime: number
userId?: string
}

View File

@ -1,4 +1,4 @@
import { sortBy, sumBy } from 'lodash'
import { sortBy, sum, sumBy } from 'lodash'
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import {
@ -142,6 +142,13 @@ export const computeFills = (
limitProb: number | undefined,
unfilledBets: LimitBet[]
) => {
if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}')
}
if (isNaN(limitProb ?? 0)) {
throw new Error('Invalid limitProb: ${limitProb}')
}
const sortedBets = sortBy(
unfilledBets.filter((bet) => bet.outcome !== outcome),
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
@ -239,6 +246,32 @@ export const getBinaryCpmmBetInfo = (
}
}
export const getBinaryBetStats = (
outcome: 'YES' | 'NO',
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number,
unfilledBets: LimitBet[]
) => {
const { newBet } = getBinaryCpmmBetInfo(
outcome,
betAmount ?? 0,
contract,
limitProb,
unfilledBets as LimitBet[]
)
const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) /
(outcome === 'YES' ? limitProb : 1 - limitProb)
const currentPayout = newBet.shares + remainingMatched
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const totalFees = sum(Object.values(newBet.fees))
return { currentPayout, currentReturn, totalFees, newBet }
}
export const getNewBinaryDpmBetInfo = (
outcome: 'YES' | 'NO',
amount: number,

View File

@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10

View File

@ -16,8 +16,8 @@ export const getMappedValue =
const { min, max, isLogScale } = contract
if (isLogScale) {
const logValue = p * Math.log10(max - min)
return 10 ** logValue + min
const logValue = p * Math.log10(max - min + 1)
return 10 ** logValue + min - 1
}
return p * (max - min) + min
@ -37,8 +37,11 @@ export const getPseudoProbability = (
max: number,
isLogScale = false
) => {
if (value < min) return 0
if (value > max) return 1
if (isLogScale) {
return Math.log10(value - min) / Math.log10(max - min)
return Math.log10(value - min + 1) / Math.log10(max - min + 1)
}
return (value - min) / (max - min)

View File

@ -45,7 +45,7 @@ export type User = {
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = 500
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User

View File

@ -33,20 +33,24 @@ export function formatPercent(zeroToOne: number) {
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
}
const showPrecision = (x: number, sigfigs: number) =>
// convert back to number for weird formatting reason
`${Number(x.toPrecision(sigfigs))}`
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
export function formatLargeNumber(num: number, sigfigs = 2): string {
const absNum = Math.abs(num)
if (absNum < 1) return num.toPrecision(sigfigs)
if (absNum < 1) return showPrecision(num, sigfigs)
if (absNum < 100) return num.toPrecision(2)
if (absNum < 1000) return num.toPrecision(3)
if (absNum < 10000) return num.toPrecision(4)
if (absNum < 100) return showPrecision(num, 2)
if (absNum < 1000) return showPrecision(num, 3)
if (absNum < 10000) return showPrecision(num, 4)
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const i = Math.floor(Math.log10(absNum) / 3)
const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs)
return `${numStr}${suffix[i]}`
const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs)
return `${numStr}${suffix[i] ?? ''}`
}
export function toCamelCase(words: string) {

View File

@ -21,6 +21,7 @@ import { Text } from '@tiptap/extension-text'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
@ -82,6 +83,7 @@ export const exhibitExts = [
Image,
Link,
Mention,
Iframe,
]
export function richTextToString(text?: JSONContent) {

View File

@ -0,0 +1,92 @@
// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts
import { Node } from '@tiptap/core'
export interface IframeOptions {
allowFullscreen: boolean
HTMLAttributes: {
[key: string]: any
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
setIframe: (options: { src: string }) => ReturnType
}
}
}
// These classes style the outer wrapper and the inner iframe;
// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue
const wrapperClasses = 'relative h-auto w-full overflow-hidden'
const iframeClasses = 'absolute top-0 left-0 h-full w-full'
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper' + ' ' + wrapperClasses,
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
style: 'padding-bottom: 20rem;',
},
}
},
addAttributes() {
return {
src: {
default: null,
},
frameborder: {
default: 0,
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
},
}
},
parseHTML() {
return [{ tag: 'iframe' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
this.options.HTMLAttributes,
[
'iframe',
{
...HTMLAttributes,
class: HTMLAttributes.class + ' ' + iframeClasses,
},
],
]
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr
const node = this.type.create(options)
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node)
}
return true
},
}
},
})

View File

@ -74,7 +74,7 @@ service cloud.firestore {
match /contracts/{contractId} {
allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs']);
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question'])
&& resource.data.creatorId == request.auth.uid;

View File

@ -29,12 +29,22 @@ export const createNotification = async (
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract?: Contract,
relatedSourceType?: notification_source_types,
relatedUserId?: string,
sourceSlug?: string,
sourceTitle?: string
miscData?: {
contract?: Contract
relatedSourceType?: notification_source_types
relatedUserId?: string
slug?: string
title?: string
}
) => {
const {
contract: sourceContract,
relatedSourceType,
relatedUserId,
slug,
title,
} = miscData ?? {}
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
@ -70,8 +80,8 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
sourceSlug: slug ? slug : sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})

View File

@ -159,7 +159,7 @@ const addUserToDefaultGroups = async (user: User) => {
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, ${user.name} (@${user.username})!`,
text: `Welcome, @${user.username} aka ${user.name}!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: MANIFOLD_USERNAME,

View File

@ -302,7 +302,7 @@ export const sendNewCommentEmail = async (
)}`
}
const subject = `Comment from ${commentorName} on ${question}`
const subject = `Comment on ${question}`
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {

View File

@ -22,6 +22,7 @@ export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
export * from './score-contracts'
// v2
export * from './health'

View File

@ -64,7 +64,7 @@ async function sendMarketCloseEmails() {
user,
'closed' + contract.id.slice(6, contract.id.length),
contract.closeTime?.toString() ?? new Date().toString(),
contract
{ contract }
)
}
}

View File

@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore
answerCreator,
eventId,
answer.text,
contract
{ contract }
)
})

View File

@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', contract.creatorId)
.get()
await firestore.collection(`contracts/${contractId}/bets`).get()
).docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
)
}
const isNewUniqueBettor =
!previousUniqueBettorIds.includes(bettorId) &&
bettorId !== contract.creatorId
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
// Update contract unique bettors
@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
uniqueBettorCount: newUniqueBettorIds.length,
})
}
if (!isNewUniqueBettor) return
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) return
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
fromUser,
eventId + '-bonus',
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
{
contract,
slug: contract.slug,
title: contract.question,
}
)
}
}

View File

@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions
? 'answer'
: undefined
const relatedUser = comment.replyToCommentId
const relatedUserId = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions
commentCreator,
eventId,
comment.text,
contract,
relatedSourceType,
relatedUser
{ contract, relatedSourceType, relatedUserId }
)
const recipientUserIds = uniq([

View File

@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore
contractCreator,
eventId,
richTextToString(contract.description as JSONContent),
contract
{ contract }
)
})

View File

@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore
groupCreator,
eventId,
group.about,
undefined,
undefined,
memberId,
group.slug,
group.name
{
relatedUserId: memberId,
slug: group.slug,
title: group.name,
}
)
}
})

View File

@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore
liquidityProvider,
eventId,
liquidity.amount.toString(),
contract
{ contract }
)
})

View File

@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
import { Group } from 'common/group'
import { Contract } from 'common/contract'
const firestore = admin.firestore()
export const onDeleteGroup = functions.firestore
@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore
.collection('contracts')
.where('groupSlugs', 'array-contains', group.slug)
.get()
console.log("contracts with group's slug:", contracts)
for (const doc of contracts.docs) {
const contract = doc.data() as Contract
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
)
// remove the group from the contract
await firestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: (contract.groupSlugs ?? []).filter(
(groupSlug) => groupSlug !== group.slug
),
groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug),
groupLinks: newGroupLinks ?? [],
})
}
})

View File

@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore
followingUser,
eventId,
'',
undefined,
undefined,
follow.userId
{ relatedUserId: follow.userId }
)
})

View File

@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore
contractUpdater,
eventId,
resolutionText,
contract
{ contract }
)
} else if (
previousValue.closeTime !== contract.closeTime ||
@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore
contractUpdater,
eventId,
sourceText,
contract
{ contract }
)
}
})

View File

@ -0,0 +1,54 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { log } from './utils'
export const scoreContracts = functions.pubsub
.schedule('every 1 hours')
.onRun(async () => {
await scoreContractsInternal()
})
const firestore = admin.firestore()
async function scoreContractsInternal() {
const now = Date.now()
const lastHour = now - 60 * 60 * 1000
const last3Days = now - 1000 * 60 * 60 * 24 * 3
const activeContractsSnap = await firestore
.collection('contracts')
.where('lastUpdatedTime', '>', lastHour)
.get()
const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
// We have to downgrade previously active contracts to allow the new ones to bubble up
const previouslyActiveContractsSnap = await firestore
.collection('contracts')
.where('popularityScore', '>', 0)
.get()
const activeContractIds = activeContracts.map((c) => c.id)
const previouslyActiveContracts = previouslyActiveContractsSnap.docs
.map((doc) => doc.data() as Contract)
.filter((c) => !activeContractIds.includes(c.id))
const contracts = activeContracts.concat(previouslyActiveContracts)
log(`Found ${contracts.length} contracts to score`)
for (const contract of contracts) {
const bets = await firestore
.collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days)
.get()
const bettors = bets.docs
.map((doc) => doc.data() as Bet)
.map((bet) => bet.userId)
const score = uniq(bettors).length
if (contract.popularityScore !== score)
await firestore
.collection('contracts')
.doc(contract.id)
.update({ popularityScore: score })
}
}

View File

@ -0,0 +1,55 @@
// We have some old comments without IDs and user IDs. Let's fill them in.
// Luckily, this was back when all comments had associated bets, so it's possible
// to retrieve the user IDs through the bets.
import * as admin from 'firebase-admin'
import { QueryDocumentSnapshot } from 'firebase-admin/firestore'
import { initAdmin } from './script-init'
import { log, writeAsync } from '../utils'
import { Bet } from '../../../common/bet'
initAdmin()
const firestore = admin.firestore()
const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => {
const bets = await firestore.collectionGroup('bets').get()
log(`Loaded ${bets.size} bets.`)
const betsById = Object.fromEntries(
bets.docs.map((b) => [b.id, b.data() as Bet])
)
return Object.fromEntries(
comments.map((c) => [c.id, betsById[c.data().betId].userId])
)
}
if (require.main === module) {
const commentsQuery = firestore.collectionGroup('comments')
commentsQuery.get().then(async (commentSnaps) => {
log(`Loaded ${commentSnaps.size} comments.`)
const needsFilling = commentSnaps.docs.filter((ct) => {
return !('id' in ct.data()) || !('userId' in ct.data())
})
log(`${needsFilling.length} comments need IDs.`)
const userIdNeedsFilling = needsFilling.filter((ct) => {
return !('userId' in ct.data())
})
log(`${userIdNeedsFilling.length} comments need user IDs.`)
const userIdsByCommentId =
userIdNeedsFilling.length > 0
? await getUserIdsByCommentId(userIdNeedsFilling)
: {}
const updates = needsFilling.map((ct) => {
const fields: { [k: string]: unknown } = {}
if (!ct.data().id) {
fields.id = ct.id
}
if (!ct.data().userId && userIdsByCommentId[ct.id]) {
fields.userId = userIdsByCommentId[ct.id]
}
return { doc: ct.ref, fields }
})
log(`Updating ${updates.length} comments.`)
await writeAsync(firestore, updates)
log(`Updated all comments.`)
})
}

View File

@ -1,14 +1,9 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues, isProd } from '../utils'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
DEFAULT_CATEGORIES,
} from 'common/categories'
import { Group } from 'common/group'
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
import { Group, GroupLink } from 'common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
@ -18,28 +13,12 @@ import {
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
initAdmin()
const adminFirestore = admin.firestore()
async function convertCategoriesToGroups() {
const groups = await getValues<Group>(adminFirestore.collection('groups'))
const contracts = await getValues<Contract>(
adminFirestore.collection('contracts')
)
for (const group of groups) {
const groupContracts = contracts.filter((contract) =>
group.contractIds.includes(contract.id)
)
for (const contract of groupContracts) {
await adminFirestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
})
}
}
for (const category of Object.values(DEFAULT_CATEGORIES)) {
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
for (const category of categories) {
const markets = await getValues<Contract>(
adminFirestore
.collection('contracts')
@ -77,7 +56,7 @@ async function convertCategoriesToGroups() {
createdTime: Date.now(),
anyoneCanJoin: true,
memberIds: [manifoldAccount],
about: 'Official group for all things related to ' + category,
about: 'Default group for all things related to ' + category,
mostRecentActivityTime: Date.now(),
contractIds: markets.map((market) => market.id),
chatDisabled: true,
@ -93,16 +72,35 @@ async function convertCategoriesToGroups() {
})
for (const market of markets) {
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: newGroup.id,
createdTime: Date.now(),
slug: newGroup.slug,
name: newGroup.name,
} as GroupLink,
]
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]),
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
groupLinks: newGroupLinks,
})
}
}
}
async function convertCategoriesToGroups() {
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
const moreCategories = ['world', 'culture']
await convertCategoriesToGroupsInternal(moreCategories)
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())

View File

@ -0,0 +1,53 @@
import { getValues } from 'functions/src/utils'
import { Group } from 'common/group'
import { Contract } from 'common/contract'
import { initAdmin } from 'functions/src/scripts/script-init'
import * as admin from 'firebase-admin'
import { filterDefined } from 'common/util/array'
import { uniq } from 'lodash'
initAdmin()
const adminFirestore = admin.firestore()
const addGroupIdToContracts = async () => {
const groups = await getValues<Group>(adminFirestore.collection('groups'))
for (const group of groups) {
const groupContracts = await getValues<Contract>(
adminFirestore
.collection('contracts')
.where('groupSlugs', 'array-contains', group.slug)
)
for (const contract of groupContracts) {
const oldGroupLinks = contract.groupLinks?.filter(
(l) => l.slug != group.slug
)
const newGroupLinks = filterDefined([
...(oldGroupLinks ?? []),
group.id
? {
slug: group.slug,
name: group.name,
groupId: group.id,
createdTime: Date.now(),
}
: undefined,
])
await adminFirestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
}
}
}
if (require.main === module) {
addGroupIdToContracts()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -1,3 +1,4 @@
# Ignore Next artifacts
.next/
out/
out/
public/**/*.json

View File

@ -0,0 +1,210 @@
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import toast from 'react-hot-toast'
import { track } from '@amplitude/analytics-browser'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
export function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
track('In-App Notification Preferences Changed', {
newPreference: newValue,
oldPreference: privateUser.notificationPreferences,
})
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolution of questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
</div>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-sm'}>
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolution of questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
</div>
</div>
)
}

View File

@ -41,7 +41,7 @@ export function AmountInput(props: {
<span className="bg-gray-200 text-sm">{label}</span>
<input
className={clsx(
'input input-bordered max-w-[200px] text-lg',
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
error && 'input-error',
inputClassName
)}

View File

@ -0,0 +1,77 @@
import { createContext, useEffect } from 'react'
import { User } from 'common/user'
import { onIdTokenChanged } from 'firebase/auth'
import {
auth,
listenForUser,
getUser,
setCachedReferralInfoForUser,
} from 'web/lib/firebase/users'
import { deleteAuthCookies, setAuthCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
// Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in (User).
type AuthUser = undefined | null | User
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token')
if (!deviceToken) {
deviceToken = randomString()
localStorage.setItem('device-token', deviceToken)
}
return deviceToken
}
export const AuthContext = createContext<AuthUser>(null)
export function AuthProvider({ children }: any) {
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(undefined)
useEffect(() => {
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
setAuthUser(cachedUser && JSON.parse(cachedUser))
}, [setAuthUser])
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken)
let user = await getUser(fbUser.uid)
if (!user) {
const deviceToken = ensureDeviceToken()
user = (await createUser({ deviceToken })) as User
}
setAuthUser(user)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
} else {
// User logged out; reset to null
deleteAuthCookies()
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
}
})
}, [setAuthUser])
const authUserId = authUser?.id
const authUsername = authUser?.username
useEffect(() => {
if (authUserId && authUsername) {
identifyUser(authUserId)
setUserProperty('username', authUsername)
return listenForUser(authUserId, setAuthUser)
}
}, [authUserId, authUsername, setAuthUser])
return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
)
}

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { partition, sum, sumBy } from 'lodash'
import { clamp, partition, sum, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
@ -13,32 +13,33 @@ import {
formatPercent,
formatWithCommas,
} from 'common/util/format'
import { getBinaryCpmmBetInfo } from 'common/new-bet'
import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet'
import { User } from 'web/lib/firebase/users'
import { Bet, LimitBet } from 'common/bet'
import { APIError, placeBet } from 'web/lib/firebase/api'
import { sellShares } from 'web/lib/firebase/api'
import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label'
import {
BinaryOutcomeLabel,
HigherLabel,
LowerLabel,
NoLabel,
YesLabel,
} from './outcome-label'
import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import {
getFormattedMappedValue,
getPseudoProbability,
} from 'common/pseudo-numeric'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { SignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { ProbabilityInput } from './probability-input'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { removeUndefinedProps } from 'common/util/object'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets'
import { BucketInput } from './bucket-input'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
@ -50,14 +51,10 @@ export function BetPanel(props: {
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false)
const showLimitOrders =
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
return (
<Col className={className}>
<SellRow
@ -77,15 +74,20 @@ export function BetPanel(props: {
setIsLimitOrder={setIsLimitOrder}
/>
<BuyPanel
hidden={isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
isLimitOrder={isLimitOrder}
unfilledBets={unfilledBets}
/>
<SignUpPrompt />
</Col>
{showLimitOrders && (
{unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)}
</Col>
@ -105,9 +107,6 @@ export function SimpleBetPanel(props: {
const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
const showLimitOrders =
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
return (
<Col className={className}>
@ -127,18 +126,24 @@ export function SimpleBetPanel(props: {
setIsLimitOrder={setIsLimitOrder}
/>
<BuyPanel
hidden={isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
selected={selected}
onBuySuccess={onBetSuccess}
isLimitOrder={isLimitOrder}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
onBuySuccess={onBetSuccess}
/>
<SignUpPrompt />
</Col>
{showLimitOrders && (
{unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)}
</Col>
@ -149,21 +154,17 @@ function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
isLimitOrder?: boolean
hidden: boolean
selected?: 'YES' | 'NO'
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } =
props
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [limitProb, setLimitProb] = useState<number | undefined>(
Math.round(100 * initialProb)
)
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
@ -178,7 +179,7 @@ function BuyPanel(props: {
}, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') {
setBetChoice(choice)
setOutcome(choice)
setWasSubmitted(false)
focusAmountInput()
}
@ -186,29 +187,22 @@ function BuyPanel(props: {
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
if (!betChoice) {
setBetChoice('YES')
if (!outcome) {
setOutcome('YES')
}
}
async function submitBet() {
if (!user || !betAmount) return
if (isLimitOrder && limitProb === undefined) return
const limitProbScaled =
isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined
setError(undefined)
setIsSubmitting(true)
placeBet(
removeUndefinedProps({
amount: betAmount,
outcome: betChoice,
contractId: contract.id,
limitProb: limitProbScaled,
})
)
placeBet({
outcome,
amount: betAmount,
contractId: contract.id,
})
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
@ -232,21 +226,18 @@ function BuyPanel(props: {
slug: contract.slug,
contractId: contract.id,
amount: betAmount,
outcome: betChoice,
isLimitOrder,
limitProb: limitProbScaled,
outcome,
isLimitOrder: false,
})
}
const betDisabled = isSubmitting || !betAmount || error
const limitProbFrac = (limitProb ?? 0) / 100
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
betChoice ?? 'YES',
outcome ?? 'YES',
betAmount ?? 0,
contract,
isLimitOrder ? limitProbFrac : undefined,
undefined,
unfilledBets as LimitBet[]
)
@ -254,11 +245,7 @@ function BuyPanel(props: {
const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb)
const remainingMatched = isLimitOrder
? ((newBet.orderAmount ?? 0) - newBet.amount) /
(betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac)
: 0
const currentPayout = newBet.shares + remainingMatched
const currentPayout = newBet.shares
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
@ -268,14 +255,14 @@ function BuyPanel(props: {
const format = getFormattedMappedValue(contract)
return (
<>
<Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
{isPseudoNumeric ? 'Direction' : 'Outcome'}
</div>
<YesNoSelector
className="mb-4"
btnClassName="flex-1"
selected={betChoice}
selected={outcome}
onSelect={(choice) => onBetChoice(choice)}
isPseudoNumeric={isPseudoNumeric}
/>
@ -290,61 +277,21 @@ function BuyPanel(props: {
disabled={isSubmitting}
inputRef={inputRef}
/>
{isLimitOrder && (
<>
<Row className="my-3 items-center gap-2 text-left text-sm text-gray-500">
Limit {isPseudoNumeric ? 'value' : 'probability'}
<InfoTooltip
text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${
isPseudoNumeric ? 'value' : 'probability'
} and wait to match other bets.`}
/>
</Row>
{isPseudoNumeric ? (
<BucketInput
contract={contract}
onBucketChange={(value) =>
setLimitProb(
value === undefined
? undefined
: 100 *
getPseudoProbability(
value,
contract.min,
contract.max,
contract.isLogScale
)
)
}
isSubmitting={isSubmitting}
/>
) : (
<ProbabilityInput
inputClassName="w-full max-w-none"
prob={limitProb}
onChange={setLimitProb}
disabled={isSubmitting}
/>
)}
</>
)}
<Col className="mt-3 w-full gap-3">
{!isLimitOrder && (
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
<Row className="items-center justify-between text-sm">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
</div>
{probStayedSame ? (
<div>{format(initialProb)}</div>
) : (
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
{probStayedSame ? (
<div>{format(initialProb)}</div>
) : (
<div>
{format(initialProb)}
<span className="mx-2"></span>
{format(resultProb)}
</div>
)}
</Row>
)}
)}
</Row>
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
@ -353,7 +300,7 @@ function BuyPanel(props: {
'Max payout'
) : (
<>
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
</>
)}
</div>
@ -372,6 +319,348 @@ function BuyPanel(props: {
<Spacer h={8} />
{user && (
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: outcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitBet}
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
</button>
)}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>}
</Col>
)
}
function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
hidden: boolean
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const rangeError =
lowLimitProb !== undefined &&
highLimitProb !== undefined &&
lowLimitProb >= highLimitProb
const outOfRangeError =
(lowLimitProb !== undefined &&
(lowLimitProb <= 0 || lowLimitProb >= 100)) ||
(highLimitProb !== undefined &&
(highLimitProb <= 0 || highLimitProb >= 100))
const hasYesLimitBet = lowLimitProb !== undefined && !!betAmount
const hasNoLimitBet = highLimitProb !== undefined && !!betAmount
const hasTwoBets = hasYesLimitBet && hasNoLimitBet
const betDisabled =
isSubmitting ||
!betAmount ||
rangeError ||
outOfRangeError ||
error ||
(!hasYesLimitBet && !hasNoLimitBet)
const yesLimitProb =
lowLimitProb === undefined
? undefined
: clamp(lowLimitProb / 100, 0.001, 0.999)
const noLimitProb =
highLimitProb === undefined
? undefined
: clamp(highLimitProb / 100, 0.001, 0.999)
const amount = betAmount ?? 0
const shares =
yesLimitProb !== undefined && noLimitProb !== undefined
? Math.min(amount / yesLimitProb, amount / (1 - noLimitProb))
: yesLimitProb !== undefined
? amount / yesLimitProb
: noLimitProb !== undefined
? amount / (1 - noLimitProb)
: 0
const yesAmount = shares * (yesLimitProb ?? 1)
const noAmount = shares * (1 - (noLimitProb ?? 0))
const profitIfBothFilled = shares - (yesAmount + noAmount)
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
}
async function submitBet() {
if (!user || betDisabled) return
setError(undefined)
setIsSubmitting(true)
const betsPromise = hasTwoBets
? Promise.all([
placeBet({
outcome: 'YES',
amount: yesAmount,
limitProb: yesLimitProb,
contractId: contract.id,
}),
placeBet({
outcome: 'NO',
amount: noAmount,
limitProb: noLimitProb,
contractId: contract.id,
}),
])
: placeBet({
outcome: hasYesLimitBet ? 'YES' : 'NO',
amount: betAmount,
contractId: contract.id,
limitProb: hasYesLimitBet ? yesLimitProb : noLimitProb,
})
betsPromise
.catch((e) => {
if (e instanceof APIError) {
setError(e.toString())
} else {
console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
if (onBuySuccess) onBuySuccess()
})
if (hasYesLimitBet) {
track('bet', {
location: 'bet panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: yesAmount,
outcome: 'YES',
limitProb: yesLimitProb,
isLimitOrder: true,
isRangeOrder: hasTwoBets,
})
}
if (hasNoLimitBet) {
track('bet', {
location: 'bet panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: noAmount,
outcome: 'NO',
limitProb: noLimitProb,
isLimitOrder: true,
isRangeOrder: hasTwoBets,
})
}
}
const {
currentPayout: yesPayout,
currentReturn: yesReturn,
totalFees: yesFees,
newBet: yesBet,
} = getBinaryBetStats(
'YES',
yesAmount,
contract,
yesLimitProb ?? initialProb,
unfilledBets as LimitBet[]
)
const yesReturnPercent = formatPercent(yesReturn)
const {
currentPayout: noPayout,
currentReturn: noReturn,
totalFees: noFees,
newBet: noBet,
} = getBinaryBetStats(
'NO',
noAmount,
contract,
noLimitProb ?? initialProb,
unfilledBets as LimitBet[]
)
const noReturnPercent = formatPercent(noReturn)
return (
<Col className={hidden ? 'hidden' : ''}>
<Row className="mt-1 items-center gap-4">
<Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at
</div>
<ProbabilityOrNumericInput
contract={contract}
prob={lowLimitProb}
setProb={setLowLimitProb}
isSubmitting={isSubmitting}
/>
</Col>
<Col className="gap-2">
<div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at
</div>
<ProbabilityOrNumericInput
contract={contract}
prob={highLimitProb}
setProb={setHighLimitProb}
isSubmitting={isSubmitting}
/>
</Col>
</Row>
{outOfRangeError && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
Limit is out of range
</div>
)}
{rangeError && !outOfRangeError && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{isPseudoNumeric ? 'HIGHER' : 'YES'} limit must be less than{' '}
{isPseudoNumeric ? 'LOWER' : 'NO'} limit
</div>
)}
<div className="mt-1 mb-3 text-left text-sm text-gray-500">
Max amount<span className="ml-1 text-red-500">*</span>
</div>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
onChange={onBetChange}
error={error}
setError={setError}
disabled={isSubmitting}
/>
<Col className="mt-3 w-full gap-3">
{(hasTwoBets || (hasYesLimitBet && yesBet.amount !== 0)) && (
<Row className="items-center justify-between gap-2 text-sm">
<div className="whitespace-nowrap text-gray-500">
{isPseudoNumeric ? (
<HigherLabel />
) : (
<BinaryOutcomeLabel outcome={'YES'} />
)}{' '}
filled now
</div>
<div className="mr-2 whitespace-nowrap">
{formatMoney(yesBet.amount)} of{' '}
{formatMoney(yesBet.orderAmount ?? 0)}
</div>
</Row>
)}
{(hasTwoBets || (hasNoLimitBet && noBet.amount !== 0)) && (
<Row className="items-center justify-between gap-2 text-sm">
<div className="whitespace-nowrap text-gray-500">
{isPseudoNumeric ? (
<LowerLabel />
) : (
<BinaryOutcomeLabel outcome={'NO'} />
)}{' '}
filled now
</div>
<div className="mr-2 whitespace-nowrap">
{formatMoney(noBet.amount)} of{' '}
{formatMoney(noBet.orderAmount ?? 0)}
</div>
</Row>
)}
{hasTwoBets && (
<Row className="items-center justify-between gap-2 text-sm">
<div className="whitespace-nowrap text-gray-500">
Profit if both orders filled
</div>
<div className="mr-2 whitespace-nowrap">
{formatMoney(profitIfBothFilled)}
</div>
</Row>
)}
{hasYesLimitBet && !hasTwoBets && (
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>
Max <BinaryOutcomeLabel outcome={'YES'} /> payout
</>
)}
</div>
<InfoTooltip
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
/>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(yesPayout)}
</span>
(+{yesReturnPercent})
</div>
</Row>
)}
{hasNoLimitBet && !hasTwoBets && (
<Row className="items-center justify-between gap-2 text-sm">
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
<div>
{isPseudoNumeric ? (
'Max payout'
) : (
<>
Max <BinaryOutcomeLabel outcome={'NO'} /> payout
</>
)}
</div>
<InfoTooltip
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
/>
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
{formatMoney(noPayout)}
</span>
(+{noReturnPercent})
</div>
</Row>
)}
</Col>
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
{user && (
<button
className={clsx(
@ -387,16 +676,12 @@ function BuyPanel(props: {
>
{isSubmitting
? 'Submitting...'
: isLimitOrder
? 'Submit order'
: 'Submit bet'}
: `Submit order${hasTwoBets ? 's' : ''}`}
</button>
)}
{wasSubmitted && (
<div className="mt-4">{isLimitOrder ? 'Order' : 'Bet'} submitted!</div>
)}
</>
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
</Col>
)
}

View File

@ -50,7 +50,7 @@ import { LimitOrderTable } from './limit-bets'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
const CONTRACTS_PER_PAGE = 20
const CONTRACTS_PER_PAGE = 50
export function BetsList(props: {
user: User
@ -78,10 +78,10 @@ export function BetsList(props: {
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contractsById) {
trackLatency('portfolio', getTime())
if (bets && contractsById && signedInUser) {
trackLatency(signedInUser.id, 'portfolio', getTime())
}
}, [bets, contractsById, getTime])
}, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) {
return <LoadingIndicator />

View File

@ -9,8 +9,9 @@ export function BucketInput(props: {
contract: NumericContract | PseudoNumericContract
isSubmitting?: boolean
onBucketChange: (value?: number, bucket?: string) => void
placeholder?: string
}) {
const { contract, isSubmitting, onBucketChange } = props
const { contract, isSubmitting, onBucketChange, placeholder } = props
const [numberString, setNumberString] = useState('')
@ -39,7 +40,7 @@ export function BucketInput(props: {
error={undefined}
disabled={isSubmitting}
numberString={numberString}
label="Value"
placeholder={placeholder}
/>
)
}

View File

@ -6,7 +6,7 @@ export function Button(props: {
onClick?: () => void
children?: ReactNode
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray'
color?: 'green' | 'red' | 'blue' | 'indigo' | 'yellow' | 'gray' | 'gray-white'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
}) {
@ -40,6 +40,7 @@ export function Button(props: {
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200',
className
)}
disabled={disabled}

View File

@ -13,7 +13,7 @@ export function PillButton(props: {
return (
<button
className={clsx(
'cursor-pointer select-none rounded-full',
'cursor-pointer select-none whitespace-nowrap rounded-full',
selected
? ['text-white', color ?? 'bg-gray-700']
: 'bg-gray-100 hover:bg-gray-200',

View File

@ -6,10 +6,9 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity, match } = props
const { charity } = props
const { slug, photo, preview, id, tags } = charity
const txns = useCharityTxns(id)
@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
{raised > 0 && (
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Col>
<Row className="items-baseline gap-1">
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
<span>raised</span>
</Col>
{match && (
raised
</Row>
{/* {match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)}
)} */}
</Row>
</>
)}

View File

@ -22,12 +22,13 @@ import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { trackCallback } from 'web/lib/service/analytics'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group'
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button'
import { toPairs } from 'lodash'
import { sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -38,23 +39,18 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const sortIndexes = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' },
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-score' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
]
export const DEFAULT_SORT = 'score'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
const filterOptions: { [label: string]: filter } = {
All: 'all',
Open: 'open',
Closed: 'closed',
Resolved: 'resolved',
'For you': 'personal',
}
export function ContractSearch(props: {
querySortOptions?: {
@ -85,9 +81,24 @@ export function ContractSearch(props: {
} = props
const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id)
?.map((g) => g.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s))
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
)
const memberGroupSlugs =
memberGroups.length > 0
? memberGroups.map((g) => g.slug)
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
).reverse()
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
const pillGroups =
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions)
@ -95,35 +106,51 @@ export function ContractSearch(props: {
.map(({ value }) => value)
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
? initialSort
: querySortOptions?.defaultSort ?? 'most-popular'
: querySortOptions?.defaultSort ?? DEFAULT_SORT
const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open'
)
const pillsEnabled = !additionalFilter
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const selectFilter = (pill: string | undefined) => () => {
setPillFilter(pill)
track('select search category', { category: pill ?? 'all' })
}
const { filters, numericFilters } = useMemo(() => {
let filters = [
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
filter === 'personal'
? // Show contracts in groups that the user is a member of
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? [])
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
// Show contracts bet on by the user
)
.concat(user ? `uniqueBettorIds:${user.id}` : [])
: '',
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
additionalFilter?.groupSlug
? `groupSlugs:${additionalFilter.groupSlug}`
? `groupLinks.slug:${additionalFilter.groupSlug}`
: '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupLinks.slug:${pillFilter}`
: '',
pillFilter === 'personal'
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
)
: '',
// Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
pillFilter === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f)
// Hack to make Algolia work.
@ -138,8 +165,9 @@ export function ContractSearch(props: {
}, [
filter,
Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','),
memberGroupSlugs.join(','),
(follows ?? []).join(','),
pillFilter,
])
const indexName = `${indexPrefix}contracts-${sort}`
@ -166,13 +194,24 @@ export function ContractSearch(props: {
}}
/>
{/*// TODO track WHICH filter users are using*/}
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter', { filter })}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort')}
onBlur={trackCallback('select search sort', { sort })}
/>
)}
<Configure
@ -185,25 +224,52 @@ export function ContractSearch(props: {
<Spacer h={3} />
<Row className="gap-2">
{toPairs<filter>(filterOptions).map(([label, f]) => {
return (
{pillsEnabled && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectFilter(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectFilter('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
{user && (
<PillButton
key={f}
selected={filter === f}
onSelect={() => setFilter(f)}
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectFilter('your-bets')}
>
{label}
Your bets
</PillButton>
)
})}
</Row>
)}
{pillGroups.map(({ name, slug }) => {
return (
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectFilter(slug)}
>
{name}
</PillButton>
)
})}
</Row>
)}
<Spacer h={3} />
{filter === 'personal' &&
(follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? (
memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</>
) : (
<ContractSearchInner

View File

@ -11,7 +11,7 @@ import { UserLink } from '../user-page'
import {
Contract,
contractMetrics,
contractPool,
contractPath,
updateContract,
} from 'web/lib/firebase/contracts'
import dayjs from 'dayjs'
@ -22,17 +22,19 @@ import { useState } from 'react'
import { ContractInfoDialog } from './contract-info-dialog'
import { Bet } from 'common/bet'
import NewContractBadge from '../new-contract-badge'
import { CATEGORY_LIST } from 'common/categories'
import { TagsList } from '../tags-list'
import { UserFollowButton } from '../follow-button'
import { groupPath } from 'web/lib/firebase/groups'
import { SiteLink } from 'web/components/site-link'
import { DAY_MS } from 'common/util/time'
import { useGroupsWithContract } from 'web/hooks/use-group'
import { ShareIconButton } from 'web/components/share-icon-button'
import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse'
import { ENV_CONFIG } from 'common/envs/constants'
import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups'
export type ShowTime = 'resolve-date' | 'close-date'
@ -46,15 +48,12 @@ export function MiscDetails(props: {
volume,
volume24Hours,
closeTime,
tags,
isResolved,
createdTime,
resolutionTime,
groupLinks,
} = contract
// Show at most one category that this contract is tagged by
const categories = CATEGORY_LIST.filter((category) =>
tags.map((t) => t.toLowerCase()).includes(category)
).slice(0, 1)
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
return (
@ -76,13 +75,21 @@ export function MiscDetails(props: {
{fromNow(resolutionTime || 0)}
</Row>
) : volume > 0 || !isNew ? (
<Row>{contractPool(contract)} pool</Row>
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
) : (
<NewContractBadge />
)}
{categories.length > 0 && (
<TagsList className="text-gray-400" tags={categories} noLabel />
{groupLinks && groupLinks.length > 0 && (
<SiteLink
href={groupPath(groupLinks[0].slug)}
className="text-sm text-gray-400"
>
<Row className={'line-clamp-1 flex-wrap items-center '}>
<UserGroupIcon className="mx-1 mb-0.5 inline h-4 w-4 shrink-0" />
{groupLinks[0].name}
</Row>
</SiteLink>
)}
</Row>
)
@ -130,34 +137,15 @@ export function ContractDetails(props: {
disabled?: boolean
}) {
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } =
contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => {
return g2.createdTime - g1.createdTime
})
const user = useUser()
const groupsUserIsMemberOf = groups
? groups.filter((g) => g.memberIds.includes(contract.creatorId))
: []
const groupsUserIsCreatorOf = groups
? groups.filter((g) => g.creatorId === contract.creatorId)
: []
// Priorities for which group the contract belongs to:
// In order of created most recently
// Group that the contract owner created
// Group the contract owner is a member of
// Any group the contract is in
const groupToDisplay =
groupsUserIsCreatorOf.length > 0
? groupsUserIsCreatorOf[0]
: groupsUserIsMemberOf.length > 0
? groupsUserIsMemberOf[0]
: groups
? groups[0]
: undefined
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
const user = useUser()
const [open, setOpen] = useState(false)
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2">
@ -178,16 +166,34 @@ export function ContractDetails(props: {
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
{groupToDisplay ? (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groupToDisplay.name}</span>
</SiteLink>
</Row>
) : (
<div />
)}
<Row>
<Button
size={'xs'}
className={'max-w-[200px]'}
color={'gray-white'}
onClick={() => setOpen(!open)}
>
<Row>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className={'line-clamp-1'}>
{groupToDisplay ? groupToDisplay.name : 'No group'}
</span>
</Row>
</Button>
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}>
<Col
className={
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
}
>
<ContractGroupsList
groupLinks={groupLinks ?? []}
contract={contract}
user={user}
/>
</Col>
</Modal>
{(!!closeTime || !!resolvedDate) && (
<Row className="items-center gap-1">
@ -222,9 +228,12 @@ export function ContractDetails(props: {
<div className="whitespace-nowrap">{volumeLabel}</div>
</Row>
<ShareIconButton
contract={contract}
copyPayload={`https://${ENV_CONFIG.domain}${contractPath(contract)}${
user?.username && contract.creatorUsername !== user?.username
? '?referrer=' + user?.username
: ''
}`}
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
username={user?.username}
/>
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
@ -321,12 +330,13 @@ function EditableCloseDate(props: {
Done
</button>
) : (
<button
className="btn btn-xs btn-ghost"
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setIsEditingCloseTime(true)}
>
<PencilIcon className="mr-2 inline h-4 w-4" /> Edit
</button>
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
</Button>
))}
</>
)

View File

@ -16,11 +16,10 @@ import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title'
import { TweetButton } from '../tweet-button'
import { InfoTooltip } from '../info-tooltip'
import { TagsInput } from 'web/components/tags-input'
import { DuplicateContractButton } from '../copy-contract-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'
'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'
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
@ -141,9 +140,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
</tbody>
</table>
<div>Tags</div>
<TagsInput contract={contract} />
<div />
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
<LiquidityPanel contract={contract} />
)}

View File

@ -0,0 +1,141 @@
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { resolvedPayout } from 'common/calculate'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useUserById } from 'web/hooks/use-user'
import { listUsers, User } from 'web/lib/firebase/users'
import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments'
import { Spacer } from '../layout/spacer'
import { Leaderboard } from '../leaderboard'
import { Title } from '../title'
export function ContractLeaderboard(props: {
contract: Contract
bets: Bet[]
}) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top traders"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
export function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}

View File

@ -7,7 +7,6 @@ import { Bet } from 'common/bet'
import { getInitialProbability } from 'common/calculate'
import { BinaryContract, PseudoNumericContract } from 'common/contract'
import { useWindowSize } from 'web/hooks/use-window-size'
import { getMappedValue } from 'common/pseudo-numeric'
import { formatLargeNumber } from 'common/util/format'
export const ContractProbGraph = memo(function ContractProbGraph(props: {
@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time))
const f = getMappedValue(contract)
const f: (p: number) => number = isBinary
? (p) => p
: isLogScale
? (p) => p * Math.log10(contract.max - contract.min + 1)
: (p) => p * (contract.max - contract.min) + contract.min
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
@ -69,10 +72,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const points: { x: Date; y: number }[] = []
const s = isBinary ? 100 : 1
const c = isLogScale && contract.min === 0 ? 1 : 0
for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: s * probs[i] + c }
points[points.length] = { x: times[i], y: s * probs[i] }
const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
)
@ -84,7 +86,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
x: dayjs(times[i])
.add(thisTimeStep * n, 'ms')
.toDate(),
y: s * probs[i] + c,
y: s * probs[i],
}
}
}
@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const formatter = isBinary
? formatPercent
: isLogScale
? (x: DatumValue) =>
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
return (
@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
yScale={
isBinary
? { min: 0, max: 100, type: 'linear' }
: {
min: contract.min + c,
max: contract.max + c,
type: contract.isLogScale ? 'log' : 'linear',
: isLogScale
? {
min: 0,
max: Math.log10(contract.max - contract.min + 1),
type: 'linear',
}
: { min: contract.min, max: contract.max, type: 'linear' }
}
yFormat={formatter}
gridYValues={yTickValues}
@ -143,6 +150,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
enableSlices="x"
enableGridX={!!width && width >= 800}
enableArea
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
animate={false}
sliceTooltip={SliceTooltip}

View File

@ -3,58 +3,63 @@ import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
}
import { Row } from './layout/row'
export function CopyLinkButton(props: {
contract: Contract
url: string
displayUrl?: string
tracking?: string
buttonClassName?: string
toastClassName?: string
}) {
const { contract, buttonClassName, toastClassName } = props
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
return (
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyContractUrl(contract)
track('copy share link')
}}
>
<Menu.Button
className={clsx(
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
buttonClassName
)}
>
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Copy link
</Menu.Button>
<Row className="w-full">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={displayUrl ?? url}
/>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyToClipboard(url)
track(tracking ?? 'copy share link')
}}
>
<Menu.Items>
<Menu.Item>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
<Menu.Button
className={clsx(
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
buttonClassName
)}
>
<LinkIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
Copy link
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items>
<Menu.Item>
<ToastClipboard className={toastClassName} />
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</Row>
)
}

View File

@ -1,10 +1,12 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { firebaseLogin, User } from 'web/lib/firebase/users'
import React from 'react'
export const createButtonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
export const CreateQuestionButton = (props: {
user: User | null | undefined
overrideText?: string
@ -15,17 +17,23 @@ export const CreateQuestionButton = (props: {
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
const { user, overrideText, className, query } = props
const router = useRouter()
return (
<div className={clsx('flex justify-center', className)}>
{user ? (
<Link href={`/create${query ? query : ''}`} passHref>
<button className={clsx(gradient, createButtonStyle)}>
{overrideText ? overrideText : 'Create a question'}
{overrideText ? overrideText : 'Create a market'}
</button>
</Link>
) : (
<button
onClick={firebaseLogin}
onClick={async () => {
// login, and then reload the page, to hit any SSR redirect (e.g.
// redirecting from / to /home for logged in users)
await firebaseLogin()
router.replace(router.asPath)
}}
className={clsx(gradient, createButtonStyle)}
>
Sign in

View File

@ -13,7 +13,7 @@ import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import clsx from 'clsx'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Linkify } from './linkify'
import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query'
@ -23,6 +23,13 @@ import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe'
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Button } from './button'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
const proseClass = clsx(
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
@ -41,7 +48,7 @@ export function useTextEditor(props: {
const editorClass = clsx(
proseClass,
'box-content min-h-[6em] textarea textarea-bordered text-base'
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
)
const editor = useEditor(
@ -66,6 +73,7 @@ export function useTextEditor(props: {
DisplayMention.configure({
suggestion: mentionSuggestion(users),
}),
Iframe,
],
content: defaultValue,
},
@ -81,12 +89,19 @@ export function useTextEditor(props: {
(file) => file.type.startsWith('image')
)
if (!imageFiles.length) {
return // if no files pasted, use default paste handler
if (imageFiles.length) {
event.preventDefault()
upload.mutate(imageFiles)
}
event.preventDefault()
upload.mutate(imageFiles)
// If the pasted content is iframe code, directly inject it
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
if (isValidIframe(text)) {
editor.chain().insertContent(text).run()
return true // Prevent the code from getting pasted as text
}
return // Otherwise, use default paste handler
},
},
})
@ -98,16 +113,21 @@ export function useTextEditor(props: {
return { editor, upload }
}
function isValidIframe(text: string) {
return /^<iframe.*<\/iframe>$/.test(text)
}
export function TextEditor(props: {
editor: Editor | null
upload: ReturnType<typeof useUploadMutation>
}) {
const { editor, upload } = props
const [iframeOpen, setIframeOpen] = useState(false)
return (
<>
{/* hide placeholder when focused */}
<div className="w-full [&:focus-within_p.is-empty]:before:content-none">
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
{editor && (
<FloatingMenu
editor={editor}
@ -123,7 +143,46 @@ export function TextEditor(props: {
images!
</FloatingMenu>
)}
<EditorContent editor={editor} />
<div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<EditorContent editor={editor} />
{/* Spacer element to match the height of the toolbar */}
<div className="py-2" aria-hidden="true">
{/* Matches height of button in toolbar (1px border + 36px content height) */}
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
{/* Toolbar, with buttons for image and embeds */}
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
<div className="flex items-center space-x-5">
<div className="flex items-center">
<FileUploadButton
onFiles={upload.mutate}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
>
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Upload an image</span>
</FileUploadButton>
</div>
<div className="flex items-center">
<button
type="button"
onClick={() => setIframeOpen(true)}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
>
<IframeModal
editor={editor}
open={iframeOpen}
setOpen={setIframeOpen}
/>
<CodeIcon className="h-5 w-5" aria-hidden="true" />
<span className="sr-only">Embed an iframe</span>
</button>
</div>
</div>
</div>
</div>
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
{upload.isError && (
@ -133,6 +192,65 @@ export function TextEditor(props: {
)
}
function IframeModal(props: {
editor: Editor | null
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const [embedCode, setEmbedCode] = useState('')
const valid = isValidIframe(embedCode)
return (
<Modal open={open} setOpen={setOpen}>
<Col className="gap-2 rounded bg-white p-6">
<label
htmlFor="embed"
className="block text-sm font-medium text-gray-700"
>
Embed a market, Youtube video, etc.
</label>
<input
type="text"
name="embed"
id="embed"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder='e.g. <iframe src="..."></iframe>'
value={embedCode}
onChange={(e) => setEmbedCode(e.target.value)}
/>
{/* Preview the embed if it's valid */}
{valid ? <RichContent content={embedCode} /> : <Spacer h={2} />}
<Row className="gap-2">
<Button
disabled={!valid}
onClick={() => {
if (editor && valid) {
editor.chain().insertContent(embedCode).run()
setEmbedCode('')
setOpen(false)
}
}}
>
Embed
</Button>
<Button
color="gray"
onClick={() => {
setEmbedCode('')
setOpen(false)
}}
>
Cancel
</Button>
</Row>
</Col>
</Modal>
)
}
const useUploadMutation = (editor: Editor | null) =>
useMutation(
(files: File[]) =>
@ -151,7 +269,7 @@ const useUploadMutation = (editor: Editor | null) =>
}
)
function RichContent(props: { content: JSONContent }) {
function RichContent(props: { content: JSONContent | string }) {
const { content } = props
const editor = useEditor({
editorProps: { attributes: { class: proseClass } },

View File

@ -1,18 +1,13 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { formatPercent } from 'common/util/format'
import React, { useEffect, useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Modal } from 'web/components/layout/modal'
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector'
import {
CommentInput,
CommentRepliesList,
@ -23,7 +18,6 @@ import { useRouter } from 'next/router'
import { groupBy } from 'lodash'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
export function FeedAnswerCommentGroup(props: {
@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: {
const { username, avatarUrl, name, text } = answer
const [replyToUsername, setReplyToUsername] = useState('')
const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [highlighted, setHighlighted] = useState(false)
@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: {
const commentsList = comments.filter(
(comment) => comment.answerOutcome === answer.number.toString()
)
const thisAnswerProb = getDpmOutcomeProbability(
contract.totalShares,
answer.id
)
const probPercent = formatPercent(thisAnswerProb)
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser
@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath])
return (
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}>
<Modal open={open} setOpen={setOpen}>
<AnswerBetPanel
answer={answer}
contract={contract}
closePanel={() => setOpen(false)}
className="sm:max-w-84 !rounded-md bg-white !px-8 !py-6"
isModal={true}
/>
</Modal>
<Col className={'relative flex-1 gap-3'} key={answer.id + 'comment'}>
<Row
className={clsx(
'my-4 flex gap-3 space-x-3 transition-all duration-1000',
'flex gap-3 space-x-3 pt-4 transition-all duration-1000',
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
)}
id={answerElementId}
>
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Avatar username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: {
/>
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<Col className="align-items justify-between gap-2 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={
'text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
</div>
)}
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
Reply
</button>
</div>
</Row>
)}
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: {
/>
{showReply && (
<div className={'ml-6 pt-4'}>
<div className={'ml-6'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
className="absolute -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<CommentInput

View File

@ -93,6 +93,24 @@ export function BetStatusText(props: {
bet.fills?.some((fill) => fill.matchedBetId === null)) ??
false
const fromProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probBefore, contract)
: formatPercent(bet.probBefore)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probBefore, contract)
: formatPercent(bet.limitProb ?? bet.probBefore)
const toProb =
hadPoolMatch || isFreeResponse
? isPseudoNumeric
? formatNumericProbability(bet.probAfter, contract)
: formatPercent(bet.probAfter)
: isPseudoNumeric
? formatNumericProbability(bet.limitProb ?? bet.probAfter, contract)
: formatPercent(bet.limitProb ?? bet.probAfter)
return (
<div className="text-sm text-gray-500">
{bettor ? (
@ -112,14 +130,9 @@ export function BetStatusText(props: {
contract={contract}
truncate="short"
/>{' '}
{isPseudoNumeric
? ' than ' + formatNumericProbability(bet.probAfter, contract)
: ' at ' +
formatPercent(
hadPoolMatch || isFreeResponse
? bet.probAfter
: bet.limitProb ?? bet.probAfter
)}
{fromProb === toProb
? `at ${fromProb}`
: `from ${fromProb} to ${toProb}`}
</>
)}
<RelativeTimestamp time={createdTime} />

View File

@ -70,7 +70,7 @@ export function FeedCommentThread(props: {
if (showReply && inputRef) inputRef.focus()
}, [inputRef, showReply])
return (
<div className={'w-full flex-col pr-1'}>
<Col className={'w-full gap-3 pr-1'}>
<span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
aria-hidden="true"
@ -86,7 +86,7 @@ export function FeedCommentThread(props: {
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
/>
{showReply && (
<div className={'-pb-2 ml-6 flex flex-col pt-5'}>
<Col className={'-pb-2 ml-6'}>
<span
className="absolute -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
@ -106,9 +106,9 @@ export function FeedCommentThread(props: {
setReplyToUsername('')
}}
/>
</div>
</Col>
)}
</div>
</Col>
)
}
@ -142,7 +142,7 @@ export function CommentRepliesList(props: {
id={comment.id}
className={clsx(
'relative',
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'mt-3 ml-6'
!treatFirstIndexEqually && commentIdx === 0 ? '' : 'ml-6'
)}
>
{/*draw a gray line from the comment to the left:*/}

View File

@ -23,6 +23,7 @@ import BetRow from '../bet-row'
import { Avatar } from '../avatar'
import { ActivityItem } from './activity-items'
import { useSaveSeenContract } from 'web/hooks/use-seen-contracts'
import { useUser } from 'web/hooks/use-user'
import { trackClick } from 'web/lib/firebase/tracking'
import { DAY_MS } from 'common/util/time'
import NewContractBadge from '../new-contract-badge'
@ -118,6 +119,7 @@ export function FeedQuestion(props: {
const { volumeLabel } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY'
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const user = useUser()
return (
<div className={'flex gap-2'}>
@ -149,7 +151,7 @@ export function FeedQuestion(props: {
href={
props.contractPath ? props.contractPath : contractPath(contract)
}
onClick={() => trackClick(contract.id)}
onClick={() => user && trackClick(user.id, contract.id)}
className="text-lg text-indigo-700 sm:text-xl"
>
{question}

View File

@ -0,0 +1,73 @@
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import clsx from 'clsx'
import { GroupLinkItem } from 'web/pages/groups'
import { XIcon } from '@heroicons/react/outline'
import { Button } from 'web/components/button'
import { GroupSelector } from 'web/components/groups/group-selector'
import {
addContractToGroup,
removeContractFromGroup,
} from 'web/lib/firebase/groups'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import { SiteLink } from 'web/components/site-link'
import { GroupLink } from 'common/group'
import { useGroupsWithContract } from 'web/hooks/use-group'
export function ContractGroupsList(props: {
groupLinks: GroupLink[]
contract: Contract
user: User | null | undefined
}) {
const { groupLinks, user, contract } = props
const groups = useGroupsWithContract(contract)
return (
<Col className={'gap-2'}>
<span className={'text-xl text-indigo-700'}>
<SiteLink href={'/groups/'}>Groups</SiteLink>
</span>
{user && (
<Col className={'ml-2 items-center justify-between sm:flex-row'}>
<span>Add to: </span>
<GroupSelector
options={{
showSelector: true,
showLabel: false,
ignoreGroupIds: groupLinks.map((g) => g.groupId),
}}
setSelectedGroup={(group) =>
group && addContractToGroup(group, contract, user.id)
}
selectedGroup={undefined}
creator={user}
/>
</Col>
)}
{groups.length === 0 && (
<Col className="ml-2 h-full justify-center text-gray-500">
No groups yet...
</Col>
)}
{groups.map((group) => (
<Row
key={group.id}
className={clsx('items-center justify-between gap-2 p-2')}
>
<Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} />
</Row>
{user && group.memberIds.includes(user.id) && (
<Button
color={'gray-white'}
size={'xs'}
onClick={() => removeContractFromGroup(group, contract)}
>
<XIcon className="h-4 w-4 text-gray-500" />
</Button>
)}
</Row>
))}
</Col>
)
}

View File

@ -14,16 +14,22 @@ import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
export function GroupSelector(props: {
selectedGroup?: Group
selectedGroup: Group | undefined
setSelectedGroup: (group: Group) => void
creator: User | null | undefined
showSelector?: boolean
options: {
showSelector: boolean
showLabel: boolean
ignoreGroupIds?: string[]
}
}) {
const { selectedGroup, setSelectedGroup, creator, showSelector } = props
const { selectedGroup, setSelectedGroup, creator, options } = props
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('')
const memberGroups = useMemberGroups(creator?.id) ?? []
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter(
(group) => !ignoreGroupIds?.includes(group.id)
)
const filteredGroups = memberGroups.filter((group) =>
searchInAny(query, group.name)
)
@ -53,19 +59,20 @@ export function GroupSelector(props: {
nullable={true}
className={'text-sm'}
>
{({ open }) => (
{() => (
<>
{!open && setQuery('')}
<Combobox.Label className="label justify-start gap-2 text-base">
Add to Group
<InfoTooltip text="Question will be displayed alongside the other questions in the group." />
</Combobox.Label>
{showLabel && (
<Combobox.Label className="label justify-start gap-2 text-base">
Add to Group
<InfoTooltip text="Question will be displayed alongside the other questions in the group." />
</Combobox.Label>
)}
<div className="relative mt-2">
<Combobox.Input
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 "
className="w-60 rounded-md border border-gray-300 bg-white p-3 pl-4 pr-20 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 "
onChange={(event) => setQuery(event.target.value)}
displayValue={(group: Group) => group && group.name}
placeholder={'None'}
placeholder={'E.g. Science, Politics'}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<SelectorIcon

View File

@ -11,7 +11,7 @@ import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { firebaseLogin } from 'web/lib/firebase/users'
import { GroupLink } from 'web/pages/groups'
import { GroupLinkItem } from 'web/pages/groups'
import toast from 'react-hot-toast'
export function GroupsButton(props: { user: User }) {
@ -77,7 +77,7 @@ function GroupItem(props: { group: Group; className?: string }) {
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="line-clamp-1 items-center gap-2">
<GroupLink group={group} />
<GroupLinkItem group={group} />
</Row>
<JoinOrLeaveGroupButton group={group} />
</Row>

View File

@ -0,0 +1,30 @@
import clsx from 'clsx'
import { InformationCircleIcon } from '@heroicons/react/solid'
import { Linkify } from './linkify'
export function InfoBox(props: {
title: string
text: string
className?: string
}) {
const { title, text, className } = props
return (
<div className={clsx('rounded-md bg-gray-50 p-4', className)}>
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-black">{title}</h3>
<div className="mt-2 text-sm text-black">
<Linkify text={text} />
</div>
</div>
</div>
</div>
)
}

View File

@ -76,11 +76,13 @@ export function LimitOrderTable(props: {
return (
<table className="table-compact table w-full rounded text-gray-500">
<thead>
{!isYou && <th></th>}
<th>Outcome</th>
<th>Amount</th>
<th>{isPseudoNumeric ? 'Value' : 'Prob'}</th>
{isYou && <th></th>}
<tr>
{!isYou && <th></th>}
<th>Outcome</th>
<th>{isPseudoNumeric ? 'Value' : 'Prob'}</th>
<th>Amount</th>
{isYou && <th></th>}
</tr>
</thead>
<tbody>
{limitBets.map((bet) => (
@ -129,12 +131,12 @@ function LimitBet(props: {
)}
</div>
</td>
<td>{formatMoney(orderAmount - amount)}</td>
<td>
{isPseudoNumeric
? getFormattedMappedValue(contract)(limitProb)
: formatPercent(limitProb)}
</td>
<td>{formatMoney(orderAmount - amount)}</td>
{isYou && (
<td>
{isCancelling ? (

View File

@ -3,9 +3,13 @@ import { formatMoney } from 'common/util/format'
import { fromNow } from 'web/lib/util/time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { User } from 'web/lib/firebase/users'
import { Button } from './button'
import { Claim, Manalink } from 'common/manalink'
import { useState } from 'react'
import { ShareIconButton } from './share-icon-button'
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user'
import getManalinkUrl from 'web/get-manalink-url'
export type ManalinkInfo = {
expiresTime: number | null
maxUses: number | null
@ -15,94 +19,202 @@ export type ManalinkInfo = {
}
export function ManalinkCard(props: {
user: User | null | undefined
className?: string
info: ManalinkInfo
isClaiming: boolean
onClaim?: () => void
className?: string
preview?: boolean
}) {
const { user, className, isClaiming, info, onClaim } = props
const { className, info, preview = false } = props
const { expiresTime, maxUses, uses, amount, message } = info
return (
<div
className={clsx(
className,
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<Col>
<Col
className={clsx(
className,
'min-h-20 group rounded-lg bg-gradient-to-br drop-shadow-sm transition-all',
getManalinkGradient(info.amount)
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className="mb-6 block self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
width={200}
height={200}
/>
<Row className="justify-end rounded-b-xl bg-white p-4">
<Col>
<div className="mb-1 text-xl text-indigo-500">
<img
className={clsx(
'block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12',
preview ? 'my-2' : 'w-1/2 md:mb-6 md:h-1/2'
)}
src="/logo-white.svg"
/>
<Row className="rounded-b-lg bg-white p-4">
<div
className={clsx(
'mb-1 text-xl text-indigo-500',
getManalinkAmountColor(amount)
)}
>
{formatMoney(amount)}
</div>
<div>{message}</div>
</Col>
<div className="ml-auto">
<Button onClick={onClaim} disabled={isClaiming}>
{user ? 'Claim' : 'Login'}
</Button>
</div>
</Row>
</div>
</Row>
</Col>
<div className="text-md mt-2 mb-4 text-gray-500">{message}</div>
</Col>
)
}
export function ManalinkCardPreview(props: {
export function ManalinkCardFromView(props: {
className?: string
info: ManalinkInfo
link: Manalink
highlightedSlug: string
}) {
const { className, info } = props
const { expiresTime, maxUses, uses, amount, message } = info
const { className, link, highlightedSlug } = props
const { message, amount, expiresTime, maxUses, claims } = link
const [showDetails, setShowDetails] = useState(false)
return (
<div
className={clsx(
className,
' group flex flex-col rounded-lg bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
<Col>
<Col
className={clsx(
'group z-10 rounded-lg drop-shadow-sm transition-all hover:drop-shadow-lg',
className,
link.slug === highlightedSlug ? 'shadow-md shadow-indigo-400' : ''
)}
>
<Col
className={clsx(
'relative rounded-t-lg bg-gradient-to-br transition-all',
getManalinkGradient(link.amount)
)}
onClick={() => setShowDetails(!showDetails)}
>
{showDetails && (
<ClaimsList
className="absolute h-full w-full bg-white opacity-90"
link={link}
/>
)}
<Col className="mx-4 mt-2 -mb-4 text-right text-xs text-gray-100">
<div>
{maxUses != null
? `${maxUses - claims.length}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className={clsx('my-auto block w-1/3 select-none self-center py-3')}
src="/logo-white.svg"
/>
</Col>
<Row className="relative w-full gap-1 rounded-b-lg bg-white px-4 py-2 text-lg">
<div
className={clsx(
'my-auto mb-1 w-full',
getManalinkAmountColor(amount)
)}
>
{formatMoney(amount)}
</div>
<ShareIconButton
toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'}
onCopyButtonClassName={
'bg-gray-200 text-gray-600 transition-none hover:bg-gray-200 hover:text-gray-600'
}
copyPayload={getManalinkUrl(link.slug)}
/>
<button
onClick={() => setShowDetails(!showDetails)}
className={clsx(
contractDetailsButtonClassName,
showDetails
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
: ''
)}
>
<DotsHorizontalIcon className="h-[24px] w-5" />
</button>
</Row>
</Col>
<div className="mt-2 mb-4 text-xs text-gray-500 md:text-sm">
{message || ''}
</div>
</Col>
)
}
function ClaimsList(props: { link: Manalink; className: string }) {
const { link, className } = props
return (
<>
<Col className={clsx('px-4 py-2', className)}>
<div className="text-md mb-1 mt-2 w-full font-semibold">
Claimed by...
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
<div className="overflow-auto">
{link.claims.length > 0 ? (
<>
{link.claims.map((claim) => (
<Row key={claim.txnId}>
<Claim claim={claim} />
</Row>
))}
</>
) : (
<div className="h-full">
No one has claimed this manalink yet! Share your manalink to start
spreading the wealth.
</div>
)}
</div>
</Col>
<img
className="my-2 block h-1/3 w-1/3 self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
/>
<Row className="rounded-b-lg bg-white p-2">
<Col className="text-md">
<div className="mb-1 text-indigo-500">{formatMoney(amount)}</div>
<div className="text-xs">{message}</div>
</Col>
</Row>
</div>
</>
)
}
function Claim(props: { claim: Claim }) {
const { claim } = props
const who = useUserById(claim.toId)
return (
<Row className="my-1 gap-2 text-xs">
<div>{who?.name || 'Loading...'}</div>
<div className="text-gray-500">{fromNow(claim.claimedTime)}</div>
</Row>
)
}
function getManalinkGradient(amount: number) {
if (amount < 20) {
return 'from-indigo-200 via-indigo-500 to-indigo-800'
} else if (amount >= 20 && amount < 50) {
return 'from-fuchsia-200 via-fuchsia-500 to-fuchsia-800'
} else if (amount >= 50 && amount < 100) {
return 'from-rose-100 via-rose-400 to-rose-700'
} else if (amount >= 100) {
return 'from-amber-200 via-amber-500 to-amber-700'
}
}
function getManalinkAmountColor(amount: number) {
if (amount < 20) {
return 'text-indigo-500'
} else if (amount >= 20 && amount < 50) {
return 'text-fuchsia-600'
} else if (amount >= 50 && amount < 100) {
return 'text-rose-600'
} else if (amount >= 100) {
return 'text-amber-600'
}
}

View File

@ -4,7 +4,7 @@ import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Title } from '../title'
import { User } from 'common/user'
import { ManalinkCardPreview, ManalinkInfo } from 'web/components/manalink-card'
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
import { createManalink } from 'web/lib/firebase/manalinks'
import { Modal } from 'web/components/layout/modal'
import Textarea from 'react-expanding-textarea'
@ -164,6 +164,7 @@ function CreateManalinkForm(props: {
<label className="label">Message</label>
<Textarea
placeholder={defaultMessage}
maxLength={200}
className="input input-bordered resize-none"
autoFocus
value={newManalink.message}
@ -191,7 +192,7 @@ function CreateManalinkForm(props: {
{finishedCreating && (
<>
<Title className="!my-0" text="Manalink Created!" />
<ManalinkCardPreview className="my-4" info={newManalink} />
<ManalinkCard className="my-4" info={newManalink} preview />
<Row
className={clsx(
'rounded border bg-gray-50 py-2 px-3 text-sm text-gray-500 transition-colors duration-700',

View File

@ -11,7 +11,7 @@ import {
} from '@heroicons/react/outline'
import clsx from 'clsx'
import Link from 'next/link'
import { useRouter } from 'next/router'
import Router, { useRouter } from 'next/router'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
@ -31,6 +31,13 @@ import { setNotificationsAsSeen } from 'web/pages/notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out
// of whatever logged-in-only area of the site they might be in
await withTracking(firebaseLogout, 'sign out')()
await Router.replace(Router.asPath)
}
function getNavigation() {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
@ -40,6 +47,8 @@ function getNavigation() {
icon: NotificationsIcon,
},
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -53,7 +62,6 @@ function getMoreNavigation(user?: User | null) {
if (!user) {
return [
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -62,15 +70,15 @@ function getMoreNavigation(user?: User | null) {
}
return [
{ name: 'Send M$', href: '/links' },
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
href: '#',
onClick: withTracking(firebaseLogout, 'sign out'),
onClick: logout,
},
]
}
@ -78,7 +86,6 @@ function getMoreNavigation(user?: User | null) {
const signedOutNavigation = [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon },
{ name: 'Charity', href: '/charity', icon: HeartIcon },
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
@ -98,6 +105,7 @@ const signedOutMobileNavigation = [
]
const signedInMobileNavigation = [
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -113,15 +121,15 @@ function getMoreMobileNav() {
...(IS_PRIVATE_MANIFOLD
? []
: [
{ name: 'Send M$', href: '/links' },
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
]),
{ name: 'Leaderboards', href: '/leaderboards' },
{
name: 'Sign out',
href: '#',
onClick: withTracking(firebaseLogout, 'sign out'),
onClick: logout,
},
]
}
@ -235,7 +243,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />}
/>
)}
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
{privateUser && (
<GroupsList
currentPage={router.asPath}
@ -256,11 +267,7 @@ export default function Sidebar(props: { className?: string }) {
/>
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<div className="py-3">
<div className="h-[1px] bg-gray-300" />
</div>
)}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
{privateUser && (
<GroupsList
currentPage={router.asPath}

View File

@ -9,8 +9,8 @@ export function NumberInput(props: {
numberString: string
onChange: (newNumberString: string) => void
error: string | undefined
label: string
disabled?: boolean
placeholder?: string
className?: string
inputClassName?: string
// Needed to focus the amount input
@ -21,8 +21,8 @@ export function NumberInput(props: {
numberString,
onChange,
error,
label,
disabled,
placeholder,
className,
inputClassName,
inputRef,
@ -32,16 +32,17 @@ export function NumberInput(props: {
return (
<Col className={className}>
<label className="input-group">
<span className="bg-gray-200 text-sm">{label}</span>
<input
className={clsx(
'input input-bordered max-w-[200px] text-lg',
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
error && 'input-error',
inputClassName
)}
ref={inputRef}
type="number"
placeholder="0"
pattern="[0-9]*"
inputMode="numeric"
placeholder={placeholder ?? '0'}
maxLength={9}
value={numberString}
disabled={disabled}

View File

@ -62,4 +62,6 @@ const visuallyHiddenStyle = {
position: 'absolute',
width: 1,
whiteSpace: 'nowrap',
userSelect: 'none',
visibility: 'hidden',
} as const

View File

@ -1,17 +1,34 @@
import clsx from 'clsx'
export function Pagination(props: {
page: number
itemsPerPage: number
totalItems: number
setPage: (page: number) => void
scrollToTop?: boolean
className?: string
nextTitle?: string
prevTitle?: string
}) {
const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props
const {
page,
itemsPerPage,
totalItems,
setPage,
scrollToTop,
nextTitle,
prevTitle,
className,
} = props
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
return (
<nav
className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"
className={clsx(
'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6',
className
)}
aria-label="Pagination"
>
<div className="hidden sm:block">
@ -25,19 +42,21 @@ export function Pagination(props: {
</p>
</div>
<div className="flex flex-1 justify-between sm:justify-end">
<a
href={scrollToTop ? '#' : undefined}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page > 0 && setPage(page - 1)}
>
Previous
</a>
{page > 0 && (
<a
href={scrollToTop ? '#' : undefined}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page > 0 && setPage(page - 1)}
>
{prevTitle ?? 'Previous'}
</a>
)}
<a
href={scrollToTop ? '#' : undefined}
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page < maxPage && setPage(page + 1)}
>
Next
{nextTitle ?? 'Next'}
</a>
</div>
</nav>

View File

@ -15,17 +15,17 @@ export const PortfolioValueSection = memo(
const lastPortfolioMetrics = last(portfolioHistory)
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
// PATCH: If portfolio history started on June 1st, then we label it as "Since June"
// instead of "All time"
const allTimeLabel =
portfolioHistory[0].timestamp < Date.parse('2022-06-20T00:00:00.000Z')
? 'Since June'
: 'All time'
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
return <></>
}
// PATCH: If portfolio history started on June 1st, then we label it as "Since June"
// instead of "All time"
const allTimeLabel =
lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z')
? 'Since June'
: 'All time'
return (
<div>
<Row className="gap-8">

View File

@ -1,4 +1,7 @@
import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getPseudoProbability } from 'common/pseudo-numeric'
import { BucketInput } from './bucket-input'
import { Col } from './layout/col'
import { Spacer } from './layout/spacer'
@ -6,10 +9,12 @@ export function ProbabilityInput(props: {
prob: number | undefined
onChange: (newProb: number | undefined) => void
disabled?: boolean
placeholder?: string
className?: string
inputClassName?: string
}) {
const { prob, onChange, disabled, className, inputClassName } = props
const { prob, onChange, disabled, placeholder, className, inputClassName } =
props
const onProbChange = (str: string) => {
let prob = parseInt(str.replace(/\D/g, ''))
@ -27,7 +32,7 @@ export function ProbabilityInput(props: {
<label className="input-group">
<input
className={clsx(
'input input-bordered max-w-[200px] text-lg',
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
inputClassName
)}
type="number"
@ -35,7 +40,7 @@ export function ProbabilityInput(props: {
min={1}
pattern="[0-9]*"
inputMode="numeric"
placeholder="0"
placeholder={placeholder ?? '0'}
maxLength={2}
value={prob ?? ''}
disabled={disabled}
@ -47,3 +52,43 @@ export function ProbabilityInput(props: {
</Col>
)
}
export function ProbabilityOrNumericInput(props: {
contract: CPMMBinaryContract | PseudoNumericContract
prob: number | undefined
setProb: (prob: number | undefined) => void
isSubmitting: boolean
placeholder?: string
}) {
const { contract, prob, setProb, isSubmitting, placeholder } = props
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
return isPseudoNumeric ? (
<BucketInput
contract={contract}
onBucketChange={(value) =>
setProb(
value === undefined
? undefined
: 100 *
getPseudoProbability(
value,
contract.min,
contract.max,
contract.isLogScale
)
)
}
isSubmitting={isSubmitting}
placeholder={placeholder}
/>
) : (
<ProbabilityInput
inputClassName="w-full max-w-none"
prob={prob}
onChange={setProb}
disabled={isSubmitting}
placeholder={placeholder}
/>
)
}

View File

@ -2,65 +2,48 @@ import React, { useState } from 'react'
import { ShareIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
import { Group } from 'common/group'
import { groupPath } from 'web/lib/firebase/groups'
function copyContractWithReferral(contract: Contract, username?: string) {
const postFix =
username && contract.creatorUsername !== username
? '?referrer=' + username
: ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
)
}
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
function copyGroupWithReferral(group: Group, username?: string) {
const postFix = username ? '?referrer=' + username : ''
copyToClipboard(
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
)
}
export function ShareIconButton(props: {
contract?: Contract
group?: Group
buttonClassName?: string
onCopyButtonClassName?: string
toastClassName?: string
username?: string
children?: React.ReactNode
iconClassName?: string
copyPayload: string
}) {
const {
contract,
buttonClassName,
onCopyButtonClassName,
toastClassName,
username,
group,
children,
iconClassName,
copyPayload,
} = props
const [showToast, setShowToast] = useState(false)
return (
<div className="relative z-10 flex-shrink-0">
<button
className={clsx(contractDetailsButtonClassName, buttonClassName)}
className={clsx(
contractDetailsButtonClassName,
buttonClassName,
showToast ? onCopyButtonClassName : ''
)}
onClick={() => {
if (contract) copyContractWithReferral(contract, username)
if (group) copyGroupWithReferral(group, username)
copyToClipboard(copyPayload)
track('copy share link')
setShowToast(true)
setTimeout(() => setShowToast(false), 2000)
}}
>
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
<ShareIcon
className={clsx(iconClassName ? iconClassName : 'h-[24px] w-5')}
aria-hidden="true"
/>
{children}
</button>

View File

@ -1,5 +1,8 @@
import clsx from 'clsx'
import { Contract, contractUrl } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { Contract, contractPath, contractUrl } from 'web/lib/firebase/contracts'
import { CopyLinkButton } from './copy-link-button'
import { Col } from './layout/col'
import { Row } from './layout/row'
@ -7,18 +10,15 @@ import { Row } from './layout/row'
export function ShareMarket(props: { contract: Contract; className?: string }) {
const { contract, className } = props
const url = `https://${ENV_CONFIG.domain}${contractPath(contract)}`
return (
<Col className={clsx(className, 'gap-3')}>
<div>Share your market</div>
<Row className="mb-6 items-center">
<input
className="input input-bordered flex-1 rounded-r-none text-gray-500"
readOnly
type="text"
value={contractUrl(contract)}
/>
<CopyLinkButton
contract={contract}
url={url}
displayUrl={contractUrl(contract)}
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>

View File

@ -39,6 +39,7 @@ import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { filterDefined } from 'common/util/array'
import { useUserBets } from 'web/hooks/use-user-bets'
import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format'
export function UserLink(props: {
name: string
@ -123,6 +124,7 @@ export function UserPage(props: {
const yourFollows = useFollows(currentUser?.id)
const isFollowing = yourFollows?.includes(user.id)
const profit = user.profitCached.allTime
const onFollow = () => {
if (!currentUser) return
@ -187,6 +189,17 @@ export function UserPage(props: {
<Col className="mx-4 -mt-6">
<span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span>
<span className="text-gray-500">
<span
className={clsx(
'text-md',
profit >= 0 ? 'text-green-600' : 'text-red-400'
)}
>
{formatMoney(profit)}
</span>{' '}
profit
</span>
<Spacer h={4} />

View File

@ -25,7 +25,7 @@ export const useAlgoFeed = (
getDefaultFeed().then((feed) => setAllFeed(feed))
} else setAllFeed(feed)
trackLatency('feed', getTime())
trackLatency(user.id, 'feed', getTime())
console.log('"all" feed load time', getTime())
})

View File

@ -2,13 +2,15 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getGroupsWithContractId,
listenForGroup,
listenForGroups,
listenForMemberGroups,
listGroups,
} from 'web/lib/firebase/groups'
import { getUser, getUsers } from 'web/lib/firebase/users'
import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract'
import { uniq } from 'lodash'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
@ -103,12 +105,15 @@ export async function listMembers(group: Group, max?: number) {
return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser))
}
export const useGroupsWithContract = (contractId: string | undefined) => {
const [groups, setGroups] = useState<Group[] | null | undefined>()
export const useGroupsWithContract = (contract: Contract) => {
const [groups, setGroups] = useState<Group[]>([])
useEffect(() => {
if (contractId) getGroupsWithContractId(contractId, setGroups)
}, [contractId])
if (contract.groupSlugs)
listGroups(uniq(contract.groupSlugs)).then((groups) =>
setGroups(filterDefined(groups))
)
}, [contract.groupSlugs])
return groups
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { Notification } from 'common/notification'
import {
@ -6,7 +6,7 @@ import {
listenForNotifications,
} from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash'
import { useFirestoreQuery } from '@react-query-firebase/firestore'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
export type NotificationGroup = {
@ -19,36 +19,33 @@ export type NotificationGroup = {
// For some reason react-query subscriptions don't actually listen for notifications
// Use useUnseenPreferredNotificationGroups to listen for new notifications
export function usePreferredGroupedNotifications(privateUser: PrivateUser) {
const [notificationGroups, setNotificationGroups] = useState<
NotificationGroup[] | undefined
>(undefined)
const [notifications, setNotifications] = useState<Notification[]>([])
const key = `notifications-${privateUser.id}-all`
export function usePreferredGroupedNotifications(
privateUser: PrivateUser,
cachedNotifications?: Notification[]
) {
const result = useFirestoreQueryData(
['notifications-all', privateUser.id],
getNotificationsQuery(privateUser.id)
)
const notifications = useMemo(() => {
if (result.isLoading) return cachedNotifications ?? []
if (!result.data) return cachedNotifications ?? []
const notifications = result.data as Notification[]
const result = useFirestoreQuery([key], getNotificationsQuery(privateUser.id))
useEffect(() => {
if (result.isLoading) return
if (!result.data) return setNotifications([])
const notifications = result.data.docs.map(
(doc) => doc.data() as Notification
)
const notificationsToShow = getAppropriateNotifications(
return getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref)
setNotifications(notificationsToShow)
}, [privateUser.notificationPreferences, result.data, result.isLoading])
}, [
cachedNotifications,
privateUser.notificationPreferences,
result.data,
result.isLoading,
])
useEffect(() => {
if (!notifications) return
const groupedNotifications = groupNotifications(notifications)
setNotificationGroups(groupedNotifications)
return useMemo(() => {
if (notifications) return groupNotifications(notifications)
}, [notifications])
return notificationGroups
}
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) {

View File

@ -0,0 +1,27 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { User, writeReferralInfo } from 'web/lib/firebase/users'
export const useSaveReferral = (
user?: User | null,
options?: {
defaultReferrer?: string
contractId?: string
groupId?: string
}
) => {
const router = useRouter()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
const actualReferrer = referrer || options?.defaultReferrer
if (!user && router.isReady && actualReferrer) {
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId)
}
}, [user, router, options])
}

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Contract } from 'common/contract'
import { trackView } from 'web/lib/firebase/tracking'
import { useIsVisible } from './use-is-visible'
import { useUser } from './use-user'
export const useSeenContracts = () => {
const [seenContracts, setSeenContracts] = useState<{
@ -21,18 +22,19 @@ export const useSaveSeenContract = (
contract: Contract
) => {
const isVisible = useIsVisible(elem)
const user = useUser()
useEffect(() => {
if (isVisible) {
if (isVisible && user) {
const newSeenContracts = {
...getSeenContracts(),
[contract.id]: Date.now(),
}
localStorage.setItem(key, JSON.stringify(newSeenContracts))
trackView(contract.id)
trackView(user.id, contract.id)
}
}, [isVisible, contract])
}, [isVisible, user, contract])
}
const key = 'feed-seen-contracts'

View File

@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { useSearchBox } from 'react-instantsearch-hooks-web'
import { track } from 'web/lib/service/analytics'
import { DEFAULT_SORT } from 'web/components/contract-search'
const MARKETS_SORT = 'markets_sort'
@ -10,11 +11,11 @@ export type Sort =
| 'newest'
| 'oldest'
| 'most-traded'
| 'most-popular'
| '24-hour-vol'
| 'close-date'
| 'resolve-date'
| 'last-updated'
| 'score'
export function getSavedSort() {
// TODO: this obviously doesn't work with SSR, common sense would suggest
@ -31,7 +32,7 @@ export function useInitialQueryAndSort(options?: {
shouldLoadFromStorage?: boolean
}) {
const { defaultSort, shouldLoadFromStorage } = defaults(options, {
defaultSort: 'most-popular',
defaultSort: DEFAULT_SORT,
shouldLoadFromStorage: true,
})
const router = useRouter()
@ -53,9 +54,12 @@ export function useInitialQueryAndSort(options?: {
console.log('ready loading from storage ', sort ?? defaultSort)
const localSort = getSavedSort()
if (localSort) {
router.query.s = localSort
// Use replace to not break navigating back.
router.replace(router, undefined, { shallow: true })
router.replace(
{ query: { ...router.query, s: localSort } },
undefined,
{ shallow: true }
)
}
setInitialSort(localSort ?? defaultSort)
} else {
@ -79,7 +83,9 @@ export function useUpdateQueryAndSort(props: {
const setSort = (sort: Sort | undefined) => {
if (sort !== router.query.s) {
router.query.s = sort
router.push(router, undefined, { shallow: true })
router.replace({ query: { ...router.query, s: sort } }, undefined, {
shallow: true,
})
if (shouldLoadFromStorage) {
localStorage.setItem(MARKETS_SORT, sort || '')
}
@ -97,7 +103,9 @@ export function useUpdateQueryAndSort(props: {
} else {
delete router.query.q
}
router.push(router, undefined, { shallow: true })
router.replace({ query: router.query }, undefined, {
shallow: true,
})
track('search', { query })
}, 500),
[router]

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useContext, useEffect, useState } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query'
@ -6,32 +6,14 @@ import { doc, DocumentData } from 'firebase/firestore'
import { PrivateUser } from 'common/user'
import {
getUser,
listenForLogin,
listenForPrivateUser,
listenForUser,
User,
users,
} from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { AuthContext } from 'web/components/auth-context'
export const useUser = () => {
const [user, setUser] = useStateCheckEquality<User | null | undefined>(
undefined
)
useEffect(() => listenForLogin(setUser), [setUser])
useEffect(() => {
if (user) {
identifyUser(user.id)
setUserProperty('username', user.username)
return listenForUser(user.id, setUser)
}
}, [user, setUser])
return user
return useContext(AuthContext)
}
export const usePrivateUser = (userId?: string) => {

View File

@ -1,32 +1,28 @@
import { useState, useEffect } from 'react'
import { PrivateUser, User } from 'common/user'
import {
listenForAllUsers,
listenForPrivateUsers,
} from 'web/lib/firebase/users'
import { groupBy, sortBy, difference } from 'lodash'
import { getContractsOfUserBets } from 'web/lib/firebase/bets'
import { useFollows } from './use-follows'
import { useUser } from './use-user'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DocumentData } from 'firebase/firestore'
import { users, privateUsers } from 'web/lib/firebase/users'
export const useUsers = () => {
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
listenForAllUsers(setUsers)
}, [])
return users
const result = useFirestoreQueryData<DocumentData, User[]>(['users'], users, {
subscribe: true,
includeMetadataChanges: true,
})
return result.data ?? []
}
export const usePrivateUsers = () => {
const [users, setUsers] = useState<PrivateUser[]>([])
useEffect(() => {
listenForPrivateUsers(setUsers)
}, [])
return users
const result = useFirestoreQueryData<DocumentData, PrivateUser[]>(
['private users'],
privateUsers,
{ subscribe: true, includeMetadataChanges: true }
)
return result.data || []
}
export const useDiscoverUsers = (userId: string | null | undefined) => {

54
web/lib/firebase/auth.ts Normal file
View File

@ -0,0 +1,54 @@
import { PROJECT_ID } from 'common/envs/constants'
import { setCookie, getCookies } from '../util/cookie'
import { IncomingMessage, ServerResponse } from 'http'
const TOKEN_KINDS = ['refresh', 'id'] as const
type TokenKind = typeof TOKEN_KINDS[number]
const getAuthCookieName = (kind: TokenKind) => {
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
return `FIREBASE_TOKEN_${suffix}`
}
const ID_COOKIE_NAME = getAuthCookieName('id')
const REFRESH_COOKIE_NAME = getAuthCookieName('refresh')
export const getAuthCookies = (request?: IncomingMessage) => {
const data = request != null ? request.headers.cookie ?? '' : document.cookie
const cookies = getCookies(data)
return {
idToken: cookies[ID_COOKIE_NAME] as string | undefined,
refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined,
}
}
export const setAuthCookies = (
idToken?: string,
refreshToken?: string,
response?: ServerResponse
) => {
// these tokens last an hour
const idMaxAge = idToken != null ? 60 * 60 : 0
const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [
['path', '/'],
['max-age', idMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
// these tokens don't expire
const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0
const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [
['path', '/'],
['max-age', refreshMaxAge.toString()],
['samesite', 'lax'],
['secure'],
])
if (response != null) {
response.setHeader('Set-Cookie', [idCookie, refreshCookie])
} else {
document.cookie = idCookie
document.cookie = refreshCookie
}
}
export const deleteAuthCookies = () => setAuthCookies()

View File

@ -1,17 +1,17 @@
import dayjs from 'dayjs'
import {
doc,
setDoc,
deleteDoc,
where,
collection,
query,
getDocs,
orderBy,
deleteDoc,
doc,
getDoc,
updateDoc,
getDocs,
limit,
orderBy,
query,
setDoc,
startAfter,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy, sum } from 'lodash'
@ -129,6 +129,7 @@ export async function listContractsByGroupSlug(
): Promise<Contract[]> {
const q = query(contracts, where('groupSlugs', 'array-contains', slug))
const snapshot = await getDocs(q)
console.log(snapshot.docs.map((doc) => doc.data()))
return snapshot.docs.map((doc) => doc.data())
}

View File

@ -7,7 +7,7 @@ import {
where,
} from 'firebase/firestore'
import { sortBy, uniq } from 'lodash'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import { updateContract } from './contracts'
import {
coll,
@ -22,7 +22,12 @@ export const groups = coll<Group>('groups')
export function groupPath(
groupSlug: string,
subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings'
subpath?:
| 'edit'
| 'markets'
| 'about'
| typeof GROUP_CHAT_SLUG
| 'leaderboards'
) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}
@ -39,6 +44,10 @@ export async function listAllGroups() {
return getValues<Group>(groups)
}
export async function listGroups(groupSlugs: string[]) {
return Promise.all(groupSlugs.map(getGroupBySlug))
}
export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groups, setGroups)
}
@ -81,12 +90,12 @@ export function listenForMemberGroups(
})
}
export async function getGroupsWithContractId(
export async function listenForGroupsWithContractId(
contractId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(groups, where('contractIds', 'array-contains', contractId))
setGroups(await getValues<Group>(q))
return listenForValues<Group>(q, setGroups)
}
export async function addUserToGroupViaId(groupId: string, userId: string) {
@ -115,9 +124,27 @@ export async function leaveGroup(group: Group, userId: string): Promise<void> {
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
}
export async function addContractToGroup(group: Group, contract: Contract) {
export async function addContractToGroup(
group: Group,
contract: Contract,
userId: string
) {
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // already in that group
const newGroupLinks = [
...(contract.groupLinks ?? []),
{
groupId: group.id,
createdTime: Date.now(),
slug: group.slug,
userId,
name: group.name,
} as GroupLink,
]
await updateContract(contract.id, {
groupSlugs: [...(contract.groupSlugs ?? []), group.slug],
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contract.id]),
@ -129,8 +156,48 @@ export async function addContractToGroup(group: Group, contract: Contract) {
})
}
export async function setContractGroupSlugs(group: Group, contractId: string) {
await updateContract(contractId, { groupSlugs: [group.slug] })
export async function removeContractFromGroup(
group: Group,
contract: Contract
) {
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // not in that group
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
)
await updateContract(contract.id, {
groupSlugs:
contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [],
groupLinks: newGroupLinks ?? [],
})
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
return await updateGroup(group, {
contractIds: uniq(newContractIds),
})
.then(() => group)
.catch((err) => {
console.error('error removing contract from group', err)
return err
})
}
export async function setContractGroupLinks(
group: Group,
contractId: string,
userId: string
) {
await updateContract(contractId, {
groupSlugs: [group.slug],
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
createdTime: Date.now(),
} as GroupLink,
],
})
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contractId]),
})

View File

@ -0,0 +1,93 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { IncomingMessage, ServerResponse } from 'http'
import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants'
import { getAuthCookies, setAuthCookies } from './auth'
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
const ensureApp = async () => {
// Note: firebase-admin can only be imported from a server context,
// because it relies on Node standard library dependencies.
if (admin.apps.length === 0) {
// never initialize twice
return admin.initializeApp({ projectId: PROJECT_ID })
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return admin.apps[0]!
}
const requestFirebaseIdToken = async (refreshToken: string) => {
// See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token
const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token')
refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey)
const result = await fetch(refreshUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
})
if (!result.ok) {
throw new Error(`Could not refresh ID token: ${await result.text()}`)
}
return (await result.json()) as any
}
type RequestContext = {
req: IncomingMessage
res: ServerResponse
}
export const getServerAuthenticatedUid = async (ctx: RequestContext) => {
const app = await ensureApp()
const auth = app.auth()
const { idToken, refreshToken } = getAuthCookies(ctx.req)
// If we have a valid ID token, verify the user immediately with no network trips.
// If the ID token doesn't verify, we'll have to refresh it to see who they are.
// If they don't have any tokens, then we have no idea who they are.
if (idToken != null) {
try {
return (await auth.verifyIdToken(idToken))?.uid
} catch {
// plausibly expired; try the refresh token, if it's present
}
}
if (refreshToken != null) {
try {
const resp = await requestFirebaseIdToken(refreshToken)
setAuthCookies(resp.id_token, resp.refresh_token, ctx.res)
return (await auth.verifyIdToken(resp.id_token))?.uid
} catch (e) {
// this is a big unexpected problem -- either their cookies are corrupt
// or the refresh token API is down. functionally, they are not logged in
console.error(e)
}
}
return undefined
}
export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => {
return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx)
if (uid == null) {
return fn != null ? await fn(ctx) : { props: {} }
} else {
return { redirect: { destination: dest, permanent: false } }
}
}
}
export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => {
return async (ctx: GetServerSidePropsContext) => {
const uid = await getServerAuthenticatedUid(ctx)
if (uid == null) {
return { redirect: { destination: dest, permanent: false } }
} else {
return fn != null ? await fn(ctx) : { props: {} }
}
}
}

View File

@ -2,16 +2,9 @@ import { doc, collection, setDoc } from 'firebase/firestore'
import { db } from './init'
import { ClickEvent, LatencyEvent, View } from 'common/tracking'
import { listenForLogin, User } from './users'
let user: User | null = null
if (typeof window !== 'undefined') {
listenForLogin((u) => (user = u))
}
export async function trackView(contractId: string) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'views'))
export async function trackView(userId: string, contractId: string) {
const ref = doc(collection(db, 'private-users', userId, 'views'))
const view: View = {
contractId,
@ -21,9 +14,8 @@ export async function trackView(contractId: string) {
return await setDoc(ref, view)
}
export async function trackClick(contractId: string) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'events'))
export async function trackClick(userId: string, contractId: string) {
const ref = doc(collection(db, 'private-users', userId, 'events'))
const clickEvent: ClickEvent = {
type: 'click',
@ -35,11 +27,11 @@ export async function trackClick(contractId: string) {
}
export async function trackLatency(
userId: string,
type: 'feed' | 'portfolio',
latency: number
) {
if (!user) return
const ref = doc(collection(db, 'private-users', user.id, 'latency'))
const ref = doc(collection(db, 'private-users', userId, 'latency'))
const latencyEvent: LatencyEvent = {
type,

View File

@ -15,15 +15,10 @@ import {
} from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
import {
onAuthStateChanged,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth'
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
import { zip } from 'lodash'
import { app, db } from './init'
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import { createUser } from './api'
import {
coll,
getValue,
@ -37,7 +32,6 @@ import { safeLocalStorage } from '../util/local'
import { filterDefined } from 'common/util/array'
import { addUserToGroupViaId } from 'web/lib/firebase/groups'
import { removeUndefinedProps } from 'common/util/object'
import { randomString } from 'common/util/random'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
@ -96,7 +90,6 @@ export function listenForPrivateUser(
return listenForValue<PrivateUser>(userRef, setPrivateUser)
}
const CACHED_USER_KEY = 'CACHED_USER_KEY'
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
@ -129,7 +122,7 @@ export function writeReferralInfo(
local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId)
}
async function setCachedReferralInfoForUser(user: User | null) {
export async function setCachedReferralInfoForUser(user: User | null) {
if (!user || user.referredByUserId) return
// if the user wasn't created in the last minute, don't bother
const now = dayjs().utc()
@ -180,52 +173,13 @@ async function setCachedReferralInfoForUser(user: User | null) {
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
}
// used to avoid weird race condition
let createUserPromise: Promise<User> | undefined = undefined
export function listenForLogin(onUser: (user: User | null) => void) {
const local = safeLocalStorage()
const cachedUser = local?.getItem(CACHED_USER_KEY)
onUser(cachedUser && JSON.parse(cachedUser))
return onAuthStateChanged(auth, async (fbUser) => {
if (fbUser) {
let user: User | null = await getUser(fbUser.uid)
if (!user) {
if (createUserPromise == null) {
const local = safeLocalStorage()
let deviceToken = local?.getItem('device-token')
if (!deviceToken) {
deviceToken = randomString()
local?.setItem('device-token', deviceToken)
}
createUserPromise = createUser({ deviceToken }).then((r) => r as User)
}
user = await createUserPromise
}
onUser(user)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
setCachedReferralInfoForUser(user)
} else {
// User logged out; reset to null
onUser(null)
local?.removeItem(CACHED_USER_KEY)
}
})
}
export async function firebaseLogin() {
const provider = new GoogleAuthProvider()
return signInWithPopup(auth, provider)
}
export async function firebaseLogout() {
auth.signOut()
await auth.signOut()
}
const storage = getStorage(app)
@ -256,16 +210,6 @@ export async function listAllUsers() {
return docs.map((doc) => doc.data())
}
export function listenForAllUsers(setUsers: (users: User[]) => void) {
listenForValues(users, setUsers)
}
export function listenForPrivateUsers(
setUsers: (users: PrivateUser[]) => void
) {
listenForValues(privateUsers, setUsers)
}
export function getTopTraders(period: Period) {
const topTraders = query(
users,
@ -273,7 +217,7 @@ export function getTopTraders(period: Period) {
limit(20)
)
return getValues(topTraders)
return getValues<User>(topTraders)
}
export function getTopCreators(period: Period) {
@ -282,7 +226,7 @@ export function getTopCreators(period: Period) {
orderBy('creatorVolumeCached.' + period, 'desc'),
limit(20)
)
return getValues(topCreators)
return getValues<User>(topCreators)
}
export async function getTopFollowed() {

33
web/lib/util/cookie.ts Normal file
View File

@ -0,0 +1,33 @@
type CookieOptions = string[][]
const encodeCookie = (name: string, val: string) => {
return `${name}=${encodeURIComponent(val)}`
}
const decodeCookie = (cookie: string) => {
const parts = cookie.trim().split('=')
if (parts.length < 2) {
throw new Error(`Invalid cookie contents: ${cookie}`)
}
const rest = parts.slice(1).join('') // there may be more = in the value
return [parts[0], decodeURIComponent(rest)] as const
}
export const setCookie = (name: string, val: string, opts?: CookieOptions) => {
const parts = [encodeCookie(name, val)]
if (opts != null) {
parts.push(...opts.map((opt) => opt.join('=')))
}
return parts.join('; ')
}
// Note that this intentionally ignores the case where multiple cookies have
// the same name but different paths. Hopefully we never need to think about it.
export const getCookies = (cookies: string) => {
const data = cookies.trim()
if (!data) {
return {}
} else {
return Object.fromEntries(data.split(';').map(decodeCookie))
}
}

View File

@ -4,6 +4,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
module.exports = {
staticPageGenerationTimeout: 600, // e.g. stats page
reactStrictMode: true,
optimizeFonts: false,
experimental: {
externalDir: true,
optimizeCss: true,

View File

@ -41,7 +41,7 @@
"gridjs-react": "5.0.2",
"lodash": "4.17.21",
"nanoid": "^3.3.4",
"next": "12.1.2",
"next": "12.2.2",
"node-fetch": "3.2.4",
"react": "17.0.2",
"react-confetti": "6.0.1",

View File

@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
import { keyBy, sortBy, groupBy, sumBy, mapValues } from 'lodash'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { ContractOverview } from 'web/components/contract/contract-overview'
@ -8,9 +7,7 @@ import { BetPanel } from 'web/components/bet-panel'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { ResolutionPanel } from 'web/components/resolution-panel'
import { Title } from 'web/components/title'
import { Spacer } from 'web/components/layout/spacer'
import { listUsers, User, writeReferralInfo } from 'web/lib/firebase/users'
import {
Contract,
getContractFromSlug,
@ -24,28 +21,26 @@ import { Comment, listAllComments } from 'web/lib/firebase/comments'
import Custom404 from '../404'
import { AnswersPanel } from 'web/components/answers/answers-panel'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Leaderboard } from 'web/components/leaderboard'
import { resolvedPayout } from 'common/calculate'
import { formatMoney } from 'common/util/format'
import { useUserById } from 'web/hooks/use-user'
import { ContractTabs } from 'web/components/contract/contract-tabs'
import { contractTextDetails } from 'web/components/contract/contract-details'
import { useWindowSize } from 'web/hooks/use-window-size'
import Confetti from 'react-confetti'
import { NumericBetPanel } from '../../components/numeric-bet-panel'
import { NumericResolutionPanel } from '../../components/numeric-resolution-panel'
import { FeedComment } from 'web/components/feed/feed-comments'
import { FeedBet } from 'web/components/feed/feed-bets'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useRouter } from 'next/router'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { richTextToString } from 'common/util/parse'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import {
ContractLeaderboard,
ContractTopTrades,
} from 'web/components/contract/contract-leaderboard'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -157,15 +152,10 @@ export function ContractPageContent(
const ogCardProps = getOpenGraphProps(contract)
const router = useRouter()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(contract.creatorUsername, contract.id, referrer)
}, [user, contract, router])
useSaveReferral(user, {
defaultReferrer: contract.creatorUsername,
contractId: contract.id,
})
const rightSidebar = hasSidePanel ? (
<Col className="gap-4">
@ -267,129 +257,6 @@ export function ContractPageContent(
)
}
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [users, setUsers] = useState<User[]>()
const { userProfits, top5Ids } = useMemo(() => {
// Create a map of userIds to total profits (including sales)
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
const betsByUser = groupBy(openBets, 'userId')
const userProfits = mapValues(betsByUser, (bets) =>
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
)
// Find the 5 users with the most profits
const top5Ids = Object.entries(userProfits)
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
.filter(([, p]) => p > 0)
.slice(0, 5)
.map(([id]) => id)
return { userProfits, top5Ids }
}, [contract, bets])
useEffect(() => {
if (top5Ids.length > 0) {
listUsers(top5Ids).then((users) => {
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
setUsers(sortedUsers)
})
}
}, [userProfits, top5Ids])
return users && users.length > 0 ? (
<Leaderboard
title="🏅 Top bettors"
users={users || []}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
},
]}
className="mt-12 max-w-sm"
/>
) : null
}
function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
// Otherwise, we record the profit at resolution time
const profitById: Record<string, number> = {}
for (const bet of bets) {
if (bet.sale) {
const originalBet = betsById[bet.sale.betId]
const profit = bet.sale.amount - originalBet.amount
profitById[bet.id] = profit
profitById[originalBet.id] = profit
} else {
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
}
}
// Now find the betId with the highest profit
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">
{topCommentId && profitById[topCommentId] > 0 && (
<>
<Title text="💬 Proven correct" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
truncate={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet
contract={contract}
bet={betsById[topBetId]}
hideOutcome={false}
smallAvatar={false}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>
)}
</div>
)
}
const getOpenGraphProps = (contract: Contract) => {
const {
resolution,

View File

@ -5,6 +5,7 @@ import Head from 'next/head'
import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context'
function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '')
@ -78,9 +79,11 @@ function MyApp({ Component, pageProps }: AppProps) {
/>
</Head>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</AuthProvider>
</>
)
}

View File

@ -6,16 +6,15 @@ export default function Document() {
<Html data-theme="mantic" className="min-h-screen">
<Head>
<link rel="icon" href={ENV_CONFIG.faviconPath} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="true"
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@300;400;600;700&display=swap"
rel="stylesheet"
crossOrigin="anonymous"
/>
<link
rel="stylesheet"
@ -24,7 +23,6 @@ export default function Document() {
crossOrigin="anonymous"
/>
</Head>
<body className="font-readex-pro bg-base-200 min-h-screen">
<Main />
<NextScript />

View File

@ -8,6 +8,9 @@ import { checkoutURL } from 'web/lib/service/stripe'
import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { trackCallback } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
export default function AddFundsPage() {
const user = useUser()

View File

@ -9,6 +9,9 @@ import { useContracts } from 'web/hooks/use-contracts'
import { mapKeys } from 'lodash'
import { useAdmin } from 'web/hooks/use-admin'
import { contractPath } from 'web/lib/firebase/contracts'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/')
function avatarHtml(avatarUrl: string) {
return `<img

View File

@ -1,6 +1,9 @@
import { sortBy, sumBy, uniqBy } from 'lodash'
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Confetti from 'react-confetti'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
@ -16,11 +19,10 @@ import { useRouter } from 'next/router'
import Custom404 from '../404'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { useWindowSize } from 'web/hooks/use-window-size'
import Confetti from 'react-confetti'
import { Donation } from 'web/components/charity/feed-items'
import Image from 'next/image'
import { manaToUSD } from '../../../common/util/format'
import { manaToUSD } from 'common/util/format'
import { track } from 'web/lib/service/analytics'
import { SEO } from 'web/components/SEO'
export default function CharityPageWrapper() {
const router = useRouter()
@ -63,6 +65,7 @@ function CharityPage(props: { charity: Charity }) {
/>
}
>
<SEO title={name} description={description} url="/groups" />
{showConfetti && (
<Confetti
width={width ? width : 500}

View File

@ -13,7 +13,6 @@ import { CharityCard } from 'web/components/charity/charity-card'
import { Col } from 'web/components/layout/col'
import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
import { getAllCharityTxns } from 'web/lib/firebase/txns'
import { manaToUSD } from 'common/util/format'
@ -21,6 +20,10 @@ import { quadraticMatches } from 'common/quadratic-funding'
import { Txn } from 'common/txn'
import { useTracking } from 'web/hooks/use-tracking'
import { searchInAny } from 'common/util/parse'
import { getUser } from 'web/lib/firebase/users'
import { SiteLink } from 'web/components/site-link'
import { User } from 'common/user'
import { SEO } from 'web/components/SEO'
export async function getStaticProps() {
const txns = await getAllCharityTxns()
@ -34,6 +37,7 @@ export async function getStaticProps() {
])
const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
const mostRecentDonor = await getUser(txns[txns.length - 1].fromId)
return {
props: {
@ -42,6 +46,7 @@ export async function getStaticProps() {
matches,
txns,
numDonors,
mostRecentDonor,
},
revalidate: 60,
}
@ -50,22 +55,28 @@ export async function getStaticProps() {
type Stat = {
name: string
stat: string
url?: string
}
function DonatedStats(props: { stats: Stat[] }) {
const { stats } = props
return (
<dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3">
{stats.map((item) => (
{stats.map((stat) => (
<div
key={item.name}
key={stat.name}
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"
>
<dt className="truncate text-sm font-medium text-gray-500">
{item.name}
{stat.name}
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{item.stat}
{stat.url ? (
<SiteLink href={stat.url}>{stat.stat}</SiteLink>
) : (
<span>{stat.stat}</span>
)}
</dd>
</div>
))}
@ -79,8 +90,9 @@ export default function Charity(props: {
matches: { [charityId: string]: number }
txns: Txn[]
numDonors: number
mostRecentDonor: User
}) {
const { totalRaised, charities, matches, numDonors } = props
const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props
const [query, setQuery] = useState('')
const debouncedQuery = debounce(setQuery, 50)
@ -103,10 +115,15 @@ export default function Charity(props: {
return (
<Page>
<SEO
title="Manifold for Charity"
description="Donate your prediction market earnings to charity on Manifold."
url="/charity"
/>
<Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]">
<Col className="">
<Title className="!mt-0" text="Manifold for Charity" />
<span className="text-gray-600">
{/* <span className="text-gray-600">
Through July 15, up to $25k of donations will be matched via{' '}
<SiteLink href="https://wtfisqf.com/" className="font-bold">
quadratic funding
@ -116,6 +133,9 @@ export default function Charity(props: {
the FTX Future Fund
</SiteLink>
!
</span> */}
<span className="text-gray-600">
Convert your M$ earnings into real charitable donations.
</span>
<DonatedStats
stats={[
@ -128,8 +148,9 @@ export default function Charity(props: {
stat: `${numDonors}`,
},
{
name: 'Matched via quadratic funding',
stat: manaToUSD(sum(Object.values(matches))),
name: 'Most recent donor',
stat: mostRecentDonor.name ?? 'Nobody',
url: `/${mostRecentDonor.username}`,
},
]}
/>

View File

@ -54,10 +54,8 @@ export default function ContractSearchFirestore(props: {
)
} else if (sort === 'most-traded') {
matches.sort((a, b) => b.volume - a.volume)
} else if (sort === 'most-popular') {
matches.sort(
(a, b) => (b.uniqueBettorCount ?? 0) - (a.uniqueBettorCount ?? 0)
)
} else if (sort === 'score') {
matches.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
} else if (sort === '24-hour-vol') {
// Use lodash for stable sort, so previous sort breaks all ties.
matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days)
@ -104,7 +102,7 @@ export default function ContractSearchFirestore(props: {
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="most-popular">Most popular</option>
<option value="score">Most popular</option>
<option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option>

View File

@ -19,7 +19,7 @@ import {
import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { setContractGroupSlugs, getGroup } from 'web/lib/firebase/groups'
import { getGroup, setContractGroupLinks } from 'web/lib/firebase/groups'
import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -28,6 +28,11 @@ import { GroupSelector } from 'web/components/groups/group-selector'
import { User } from 'common/user'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { Checkbox } from 'web/components/checkbox'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO'
export const getServerSideProps = redirectIfLoggedOut('/')
type NewQuestionParams = {
groupId?: string
@ -55,16 +60,19 @@ export default function Create() {
}, [params.q])
const creator = useUser()
useEffect(() => {
if (creator === null) router.push('/')
}, [creator, router])
if (!router.isReady || !creator) return <div />
return (
<Page>
<SEO
title="Create a market"
description="Create a play-money prediction market on any question."
url="/create"
/>
<div className="mx-auto w-full max-w-2xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a market" />
<form>
<div className="form-control w-full">
<label className="label">
@ -93,7 +101,7 @@ export default function Create() {
// Allow user to create a new contract
export function NewContract(props: {
creator: User
creator?: User | null
question: string
params?: NewQuestionParams
}) {
@ -207,7 +215,7 @@ export function NewContract(props: {
min,
max,
initialValue,
isLogScale: (min ?? 0) < 0 ? false : isLogScale,
isLogScale,
groupId: selectedGroup?.id,
})
)
@ -218,7 +226,7 @@ export function NewContract(props: {
isFree: false,
})
if (result && selectedGroup) {
await setContractGroupSlugs(selectedGroup, result.id)
await setContractGroupLinks(selectedGroup, result.id, creator.id)
}
await router.push(contractPath(result as Contract))
@ -294,15 +302,13 @@ export function NewContract(props: {
/>
</Row>
{!(min !== undefined && min < 0) && (
<Checkbox
className="my-2 text-sm"
label="Log scale"
checked={isLogScale}
toggle={() => setIsLogScale(!isLogScale)}
disabled={isSubmitting}
/>
)}
<Checkbox
className="my-2 text-sm"
label="Log scale"
checked={isLogScale}
toggle={() => setIsLogScale(!isLogScale)}
disabled={isSubmitting}
/>
{min !== undefined && max !== undefined && min >= max && (
<div className="mt-2 mb-2 text-sm text-red-500">
@ -347,7 +353,7 @@ export function NewContract(props: {
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
creator={creator}
showSelector={showGroupSelector}
options={{ showSelector: showGroupSelector, showLabel: true }}
/>
</div>

View File

@ -1,24 +1,20 @@
import { take, sortBy, debounce } from 'lodash'
import { debounce, sortBy, take } from 'lodash'
import PlusSmIcon from '@heroicons/react/solid/PlusSmIcon'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page'
import { listAllBets } from 'web/lib/firebase/bets'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import {
groupPath,
getGroupBySlug,
updateGroup,
joinGroup,
addContractToGroup,
getGroupBySlug,
groupPath,
joinGroup,
updateGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import {
firebaseLogin,
getUser,
User,
writeReferralInfo,
} from 'web/lib/firebase/users'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
@ -32,11 +28,8 @@ 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 {
createButtonStyle,
CreateQuestionButton,
} from 'web/components/create-question-button'
import React, { useEffect, useState } from 'react'
import { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useState } from 'react'
import { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
@ -44,7 +37,6 @@ import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import { ShareIconButton } from 'web/components/share-icon-button'
import { REFERRAL_AMOUNT } from 'common/user'
import { ContractSearch } from 'web/components/contract-search'
import clsx from 'clsx'
@ -54,6 +46,9 @@ import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -114,8 +109,8 @@ export async function getStaticPaths() {
const groupSubpages = [
undefined,
GROUP_CHAT_SLUG,
'questions',
'rankings',
'markets',
'leaderboards',
'about',
] as const
@ -156,13 +151,11 @@ export default function GroupPage(props: {
const messages = useCommentsOnGroup(group?.id)
const user = useUser()
useEffect(() => {
const { referrer } = router.query as {
referrer?: string
}
if (!user && router.isReady)
writeReferralInfo(creator.username, undefined, referrer, group?.id)
}, [user, creator, group, router])
useSaveReferral(user, {
defaultReferrer: creator.username,
groupId: group?.id,
})
const { width } = useWindowSize()
const chatDisabled = !group || group.chatDisabled
@ -233,14 +226,14 @@ export default function GroupPage(props: {
},
]),
{
title: 'Questions',
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'questions'),
href: groupPath(group.slug, 'markets'),
},
{
title: 'Rankings',
title: 'Leaderboards',
content: leaderboard,
href: groupPath(group.slug, 'rankings'),
href: groupPath(group.slug, 'leaderboards'),
},
{
title: 'About',
@ -254,7 +247,7 @@ export default function GroupPage(props: {
<Page
rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-none !pb-0' : ''}
className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
>
<SEO
title={group.name}
@ -265,9 +258,7 @@ export default function GroupPage(props: {
<Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}>
<div
className={
'line-clamp-1 my-1 text-lg text-indigo-700 sm:my-3 sm:text-2xl'
}
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
>
{group.name}
</div>
@ -275,7 +266,7 @@ export default function GroupPage(props: {
<Linkify text={group.about} />
</div>
</div>
<div className="hidden sm:block xl:hidden">
<div className="mt-2">
<JoinOrAddQuestionsButtons
group={group}
user={user}
@ -283,13 +274,6 @@ export default function GroupPage(props: {
/>
</div>
</Row>
<div className="block sm:hidden">
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</Col>
<Tabs
currentPageForAnalytics={groupPath(group.slug)}
@ -308,21 +292,7 @@ function JoinOrAddQuestionsButtons(props: {
}) {
const { group, user, isMember } = props
return user && isMember ? (
<Row
className={'-mt-2 justify-between sm:mt-0 sm:flex-col sm:justify-center'}
>
<CreateQuestionButton
user={user}
overrideText={'Add a new question'}
className={'hidden w-48 flex-shrink-0 sm:block'}
query={`?groupId=${group.id}`}
/>
<CreateQuestionButton
user={user}
overrideText={'New question'}
className={'block w-40 flex-shrink-0 sm:hidden'}
query={`?groupId=${group.id}`}
/>
<Row className={'mt-0 justify-end'}>
<AddContractButton group={group} user={user} />
</Row>
) : group.anyoneCanJoin ? (
@ -353,6 +323,11 @@ function GroupOverview(props: {
})
}
const postFix = user ? '?referrer=' + user.username : ''
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
return (
<>
<Col className="gap-2 rounded-b bg-white p-2">
@ -397,21 +372,26 @@ function GroupOverview(props: {
</span>
)}
</Row>
{anyoneCanJoin && user && (
<Row className={'flex-wrap items-center gap-1'}>
<span className={'text-gray-500'}>Share</span>
<ShareIconButton
group={group}
username={user.username}
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'}
>
<span className={'mx-2'}>
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up!
</span>
</ShareIconButton>
</Row>
<Col className="my-4 px-2">
<div className="text-lg">Invite</div>
<div className={'mb-2 text-gray-500'}>
Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
sign up!
</div>
<CopyLinkButton
url={shareUrl}
tracking="copy group share link"
buttonClassName="btn-md rounded-l-none"
toastClassName={'-left-28 mt-1'}
/>
</Col>
)}
<Col className={'mt-2'}>
<div className="mb-2 text-lg">Members</div>
<GroupMemberSearch members={members} group={group} />
</Col>
</Col>
@ -512,14 +492,14 @@ function GroupLeaderboards(props: {
<SortedLeaderboard
users={members}
scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Bettor rankings"
title="🏅 Top traders"
header="Profit"
maxToShow={maxToShow}
/>
<SortedLeaderboard
users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Creator rankings"
title="🏅 Top creators"
header="Market volume"
maxToShow={maxToShow}
/>
@ -528,7 +508,7 @@ function GroupLeaderboards(props: {
<>
<Leaderboard
className="max-w-xl"
title="🏅 Top bettors"
title="🏅 Top traders"
users={topTraders}
columns={[
{
@ -559,26 +539,49 @@ function GroupLeaderboards(props: {
}
function AddContractButton(props: { group: Group; user: User }) {
const { group } = props
const { group, user } = props
const [open, setOpen] = useState(false)
async function addContractToCurrentGroup(contract: Contract) {
await addContractToGroup(group, contract)
await addContractToGroup(group, contract, user.id)
setOpen(false)
}
return (
<>
<div className={'flex justify-center'}>
<button
className={clsx('btn btn-sm btn-outline')}
onClick={() => setOpen(true)}
>
<PlusSmIcon className="h-6 w-6" aria-hidden="true" /> question
</button>
</div>
<Modal open={open} setOpen={setOpen} className={'sm:p-0'}>
<Col
className={
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white p-8'
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white'
}
>
<div className={'text-lg text-indigo-700'}>
Add a question to your group
</div>
<div className={'overflow-y-scroll p-1'}>
<Col className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}>
Add a question to your group
</div>
<Col className="items-center">
<CreateQuestionButton
user={user}
overrideText={'New question'}
className={'w-48 flex-shrink-0 '}
query={`?groupId=${group.id}`}
/>
<div className={'mt-2 text-lg text-indigo-700'}>or</div>
</Col>
</Col>
<div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
@ -590,26 +593,6 @@ function AddContractButton(props: { group: Group; user: User }) {
</div>
</Col>
</Modal>
<div className={'flex justify-center'}>
<button
className={clsx(
createButtonStyle,
'hidden w-48 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:block'
)}
onClick={() => setOpen(true)}
>
Add an old question
</button>
<button
className={clsx(
createButtonStyle,
'block w-40 whitespace-nowrap border border-black text-black hover:bg-black hover:text-white sm:hidden'
)}
onClick={() => setOpen(true)}
>
Old question
</button>
</div>
</>
)
}

View File

@ -1,4 +1,4 @@
import { sortBy, debounce } from 'lodash'
import { debounce, sortBy } from 'lodash'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { Group } from 'common/group'
@ -18,6 +18,7 @@ import { Avatar } from 'web/components/avatar'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { UserLink } from 'web/components/user-page'
import { searchInAny } from 'common/util/parse'
import { SEO } from 'web/components/SEO'
export async function getStaticProps() {
const groups = await listAllGroups().catch((_) => [])
@ -100,6 +101,11 @@ export default function Groups(props: {
return (
<Page>
<SEO
title="Groups"
description="Manifold Groups are communities centered around a collection of prediction markets. Discuss and compete on questions with your friends."
url="/groups"
/>
<Col className="items-center">
<Col className="w-full max-w-2xl px-4 sm:px-2">
<Row className="items-center justify-between">
@ -232,7 +238,7 @@ function GroupMembersList(props: { group: Group }) {
)
}
export function GroupLink(props: { group: Group; className?: string }) {
export function GroupLinkItem(props: { group: Group; className?: string }) {
const { group, className } = props
return (

View File

@ -1,29 +1,28 @@
import React, { useEffect, useState } from 'react'
import Router, { useRouter } from 'next/router'
import { useRouter } from 'next/router'
import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ContractSearch } from 'web/components/contract-search'
import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search'
import { Contract } from 'common/contract'
import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getServerSideProps = redirectIfLoggedOut('/')
const Home = () => {
const user = useUser()
const [contract, setContract] = useContractPage()
const router = useRouter()
useTracking('view home')
if (user === null) {
Router.replace('/')
return <></>
}
useSaveReferral()
return (
<>
@ -32,7 +31,7 @@ const Home = () => {
<ContractSearch
querySortOptions={{
shouldLoadFromStorage: true,
defaultSort: getSavedSort() ?? 'most-popular',
defaultSort: getSavedSort() ?? DEFAULT_SORT,
}}
onContractClick={(c) => {
// Show contract without navigating to contract page.

View File

@ -1,14 +1,13 @@
import React from 'react'
import Router from 'next/router'
import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts'
import { Page } from 'web/components/page'
import { LandingPagePanel } from 'web/components/landing-page-panel'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { SEO } from 'web/components/SEO'
export async function getStaticProps() {
export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => {
// These hardcoded markets will be shown in the frontpage for signed-out users:
const hotContracts = await getContractsBySlugs([
'will-max-go-to-prom-with-a-girl',
@ -22,25 +21,21 @@ export async function getStaticProps() {
'will-congress-hold-any-hearings-abo-e21f987033b3',
'will-at-least-10-world-cities-have',
])
return { props: { hotContracts } }
})
return {
props: { hotContracts },
revalidate: 60, // regenerate after a minute
}
}
const Home = (props: { hotContracts: Contract[] }) => {
export default function Home(props: { hotContracts: Contract[] }) {
const { hotContracts } = props
const user = useUser()
if (user) {
Router.replace('/home')
return <></>
}
useSaveReferral()
return (
<Page>
<SEO
title="Manifold Markets"
description="Create a play-money prediction market on any topic you care about
and bet with your friends on what will happen!"
/>
<div className="px-4 pt-2 md:mt-0 lg:hidden">
<ManifoldLogo />
</div>
@ -58,5 +53,3 @@ const Home = (props: { hotContracts: Contract[] }) => {
</Page>
)
}
export default Home

View File

@ -9,97 +9,97 @@ import {
User,
} from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useEffect, useState } from 'react'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Title } from 'web/components/title'
import { Tabs } from 'web/components/layout/tabs'
import { useTracking } from 'web/hooks/use-tracking'
import { SEO } from 'web/components/SEO'
export async function getStaticProps() {
const props = await fetchProps()
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() {
return queryLeaderboardUsers('allTime')
}
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators, topFollowed] = await Promise.all([
getTopTraders(period).catch(() => {}),
getTopCreators(period).catch(() => {}),
getTopFollowed().catch(() => {}),
])
return {
props: {
topTraders,
topCreators,
topFollowed,
},
props,
revalidate: 60, // regenerate after a minute
}
}
export default function Leaderboards(props: {
const fetchProps = async () => {
const [allTime, monthly, weekly, daily] = await Promise.all([
queryLeaderboardUsers('allTime'),
queryLeaderboardUsers('monthly'),
queryLeaderboardUsers('weekly'),
queryLeaderboardUsers('daily'),
])
const topFollowed = await getTopFollowed()
return {
allTime,
monthly,
weekly,
daily,
topFollowed,
}
}
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators] = await Promise.all([
getTopTraders(period),
getTopCreators(period),
])
return {
topTraders,
topCreators,
}
}
type leaderboard = {
topTraders: User[]
topCreators: User[]
}
export default function Leaderboards(_props: {
allTime: leaderboard
monthly: leaderboard
weekly: leaderboard
daily: leaderboard
topFollowed: User[]
}) {
props = usePropz(props, getStaticPropz) ?? {
topTraders: [],
topCreators: [],
topFollowed: [],
}
const { topFollowed } = props
const [topTradersState, setTopTraders] = useState(props.topTraders)
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
const [isLoading, setLoading] = useState(false)
const [period, setPeriod] = useState<Period>('allTime')
const [props, setProps] = useState<Parameters<typeof Leaderboards>[0]>(_props)
useEffect(() => {
setLoading(true)
queryLeaderboardUsers(period).then((res) => {
setTopTraders(res.props.topTraders as User[])
setTopCreators(res.props.topCreators as User[])
setLoading(false)
})
}, [period])
fetchProps().then((props) => setProps(props))
}, [])
const { topFollowed } = props
const LeaderboardWithPeriod = (period: Period) => {
const { topTraders, topCreators } = props[period]
return (
<>
<Col className="mx-4 items-center gap-10 lg:flex-row">
{!isLoading ? (
<>
{period === 'allTime' ||
period == 'weekly' ||
period === 'daily' ? ( //TODO: show other periods once they're available
<Leaderboard
title="🏅 Top bettors"
users={topTradersState}
columns={[
{
header: 'Total profit',
renderCell: (user) =>
formatMoney(user.profitCached[period]),
},
]}
/>
) : (
<></>
)}
<Leaderboard
title="🏅 Top traders"
users={topTraders}
columns={[
{
header: 'Total profit',
renderCell: (user) => formatMoney(user.profitCached[period]),
},
]}
/>
<Leaderboard
title="🏅 Top creators"
users={topCreatorsState}
columns={[
{
header: 'Total bet',
renderCell: (user) =>
formatMoney(user.creatorVolumeCached[period]),
},
]}
/>
</>
) : (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
<Leaderboard
title="🏅 Top creators"
users={topCreators}
columns={[
{
header: 'Total bet',
renderCell: (user) =>
formatMoney(user.creatorVolumeCached[period]),
},
]}
/>
</Col>
{period === 'allTime' ? (
<Col className="mx-4 my-10 items-center gap-10 lg:mx-0 lg:w-1/2 lg:flex-row">
@ -124,23 +124,25 @@ export default function Leaderboards(props: {
return (
<Page>
<SEO
title="Leaderboards"
description="Manifold's leaderboards show the top traders and market creators."
url="/leaderboards"
/>
<Title text={'Leaderboards'} className={'hidden md:block'} />
<Tabs
currentPageForAnalytics={'leaderboards'}
defaultIndex={0}
onClick={(title, index) => {
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
setPeriod(period as Period)
}}
defaultIndex={1}
tabs={[
{
title: 'All Time',
content: LeaderboardWithPeriod('allTime'),
},
{
title: 'Monthly',
content: LeaderboardWithPeriod('monthly'),
},
// TODO: Enable this near the end of July!
// {
// title: 'Monthly',
// content: LeaderboardWithPeriod('monthly'),
// },
{
title: 'Weekly',
content: LeaderboardWithPeriod('weekly'),

Some files were not shown because too many files have changed in this diff Show More