Merge branch 'main' into 500-Mana-email
This commit is contained in:
commit
6937c87766
|
@ -5,12 +5,14 @@ import {
|
||||||
CPMMBinaryContract,
|
CPMMBinaryContract,
|
||||||
DPMBinaryContract,
|
DPMBinaryContract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import { noFees } from './fees'
|
import { noFees } from './fees'
|
||||||
import { ENV_CONFIG } from './envs/constants'
|
import { ENV_CONFIG } from './envs/constants'
|
||||||
|
import { Answer } from './answer'
|
||||||
|
|
||||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||||
|
|
||||||
|
@ -111,6 +113,50 @@ export function getFreeAnswerAnte(
|
||||||
return anteBet
|
return anteBet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMultipleChoiceAntes(
|
||||||
|
creator: User,
|
||||||
|
contract: MultipleChoiceContract,
|
||||||
|
answers: string[],
|
||||||
|
betDocIds: string[]
|
||||||
|
) {
|
||||||
|
const { totalBets, totalShares } = contract
|
||||||
|
const amount = totalBets['0']
|
||||||
|
const shares = totalShares['0']
|
||||||
|
const p = 1 / answers.length
|
||||||
|
|
||||||
|
const { createdTime } = contract
|
||||||
|
|
||||||
|
const bets: Bet[] = answers.map((answer, i) => ({
|
||||||
|
id: betDocIds[i],
|
||||||
|
userId: creator.id,
|
||||||
|
contractId: contract.id,
|
||||||
|
amount,
|
||||||
|
shares,
|
||||||
|
outcome: i.toString(),
|
||||||
|
probBefore: p,
|
||||||
|
probAfter: p,
|
||||||
|
createdTime,
|
||||||
|
isAnte: true,
|
||||||
|
fees: noFees,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { username, name, avatarUrl } = creator
|
||||||
|
|
||||||
|
const answerObjects: Answer[] = answers.map((answer, i) => ({
|
||||||
|
id: i.toString(),
|
||||||
|
number: i,
|
||||||
|
contractId: contract.id,
|
||||||
|
createdTime,
|
||||||
|
userId: creator.id,
|
||||||
|
username,
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
text: answer,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { bets, answerObjects }
|
||||||
|
}
|
||||||
|
|
||||||
export function getNumericAnte(
|
export function getNumericAnte(
|
||||||
anteBettorId: string,
|
anteBettorId: string,
|
||||||
contract: NumericContract,
|
contract: NumericContract,
|
||||||
|
|
|
@ -12,7 +12,9 @@ export class APIError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFunctionUrl(name: string) {
|
export function getFunctionUrl(name: string) {
|
||||||
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
if (process.env.NEXT_PUBLIC_FUNCTIONS_URL) {
|
||||||
|
return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}`
|
||||||
|
} else if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
const { projectId, region } = ENV_CONFIG.firebaseConfig
|
const { projectId, region } = ENV_CONFIG.firebaseConfig
|
||||||
return `http://localhost:5001/${projectId}/${region}/${name}`
|
return `http://localhost:5001/${projectId}/${region}/${name}`
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb(
|
||||||
prob: number,
|
prob: number,
|
||||||
outcome: 'YES' | 'NO'
|
outcome: 'YES' | 'NO'
|
||||||
) {
|
) {
|
||||||
|
if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity
|
||||||
if (outcome === 'NO') prob = 1 - prob
|
if (outcome === 'NO') prob = 1 - prob
|
||||||
|
|
||||||
// First, find an upper bound that leads to a more extreme probability than prob.
|
// First, find an upper bound that leads to a more extreme probability than prob.
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {
|
||||||
BinaryContract,
|
BinaryContract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
PseudoNumericContract,
|
PseudoNumericContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { floatingEqual } from './util/math'
|
import { floatingEqual } from './util/math'
|
||||||
|
|
||||||
|
@ -200,7 +201,9 @@ export function getContractBetNullMetrics() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopAnswer(contract: FreeResponseContract) {
|
export function getTopAnswer(
|
||||||
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
) {
|
||||||
const { answers } = contract
|
const { answers } = contract
|
||||||
const top = maxBy(
|
const top = maxBy(
|
||||||
answers?.map((answer) => ({
|
answers?.map((answer) => ({
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { difference } from 'lodash'
|
import { difference } from 'lodash'
|
||||||
|
|
||||||
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
|
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
|
||||||
|
|
||||||
export const CATEGORIES = {
|
export const CATEGORIES = {
|
||||||
politics: 'Politics',
|
politics: 'Politics',
|
||||||
technology: 'Technology',
|
technology: 'Technology',
|
||||||
|
@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [
|
||||||
'manifold',
|
'manifold',
|
||||||
'personal',
|
'personal',
|
||||||
'covid',
|
'covid',
|
||||||
'culture',
|
|
||||||
'gaming',
|
'gaming',
|
||||||
'crypto',
|
'crypto',
|
||||||
'world',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)
|
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],
|
||||||
|
}))
|
||||||
|
|
|
@ -169,7 +169,7 @@ export const charities: Charity[] = [
|
||||||
{
|
{
|
||||||
name: "Founder's Pledge Climate Change Fund",
|
name: "Founder's Pledge Climate Change Fund",
|
||||||
website: 'https://founderspledge.com/funds/climate-change-fund',
|
website: 'https://founderspledge.com/funds/climate-change-fund',
|
||||||
photo: 'https://i.imgur.com/ZAhzHu4.png',
|
photo: 'https://i.imgur.com/9turaJW.png',
|
||||||
preview:
|
preview:
|
||||||
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.',
|
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.',
|
||||||
description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally.
|
description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally.
|
||||||
|
@ -183,7 +183,7 @@ export const charities: Charity[] = [
|
||||||
{
|
{
|
||||||
name: "Founder's Pledge Patient Philanthropy Fund",
|
name: "Founder's Pledge Patient Philanthropy Fund",
|
||||||
website: 'https://founderspledge.com/funds/patient-philanthropy-fund',
|
website: 'https://founderspledge.com/funds/patient-philanthropy-fund',
|
||||||
photo: 'https://i.imgur.com/ZAhzHu4.png',
|
photo: 'https://i.imgur.com/LLR6CI6.png',
|
||||||
preview:
|
preview:
|
||||||
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity',
|
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity',
|
||||||
description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future.
|
description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future.
|
||||||
|
@ -551,6 +551,20 @@ With an emphasis on approval voting, we bring better elections to people across
|
||||||
|
|
||||||
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
|
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Founders Pledge Global Health and Development Fund',
|
||||||
|
website: 'https://founderspledge.com/funds/global-health-and-development',
|
||||||
|
photo: 'https://i.imgur.com/EXbxH7T.png',
|
||||||
|
preview:
|
||||||
|
'Tackling the vast global inequalities in health, wealth and opportunity',
|
||||||
|
description: `Nearly half the world lives on less than $2.50 a day, yet giving by the world’s richest often overlooks the world’s poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally.
|
||||||
|
|
||||||
|
This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to:
|
||||||
|
|
||||||
|
Improve the lives of the world's most vulnerable people.
|
||||||
|
Reduce the number of easily preventable deaths worldwide.
|
||||||
|
Work towards sustainable, systemic change.`,
|
||||||
|
},
|
||||||
].map((charity) => {
|
].map((charity) => {
|
||||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,15 +1,22 @@
|
||||||
import { Answer } from './answer'
|
import { Answer } from './answer'
|
||||||
import { Fees } from './fees'
|
import { Fees } from './fees'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { GroupLink } from 'common/group'
|
||||||
|
|
||||||
export type AnyMechanism = DPM | CPMM
|
export type AnyMechanism = DPM | CPMM
|
||||||
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
export type AnyOutcomeType =
|
||||||
|
| Binary
|
||||||
|
| MultipleChoice
|
||||||
|
| PseudoNumeric
|
||||||
|
| FreeResponse
|
||||||
|
| Numeric
|
||||||
export type AnyContractType =
|
export type AnyContractType =
|
||||||
| (CPMM & Binary)
|
| (CPMM & Binary)
|
||||||
| (CPMM & PseudoNumeric)
|
| (CPMM & PseudoNumeric)
|
||||||
| (DPM & Binary)
|
| (DPM & Binary)
|
||||||
| (DPM & FreeResponse)
|
| (DPM & FreeResponse)
|
||||||
| (DPM & Numeric)
|
| (DPM & Numeric)
|
||||||
|
| (DPM & MultipleChoice)
|
||||||
|
|
||||||
export type Contract<T extends AnyContractType = AnyContractType> = {
|
export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -46,14 +53,17 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
collectedFees: Fees
|
collectedFees: Fees
|
||||||
|
|
||||||
groupSlugs?: string[]
|
groupSlugs?: string[]
|
||||||
|
groupLinks?: GroupLink[]
|
||||||
uniqueBettorIds?: string[]
|
uniqueBettorIds?: string[]
|
||||||
uniqueBettorCount?: number
|
uniqueBettorCount?: number
|
||||||
|
popularityScore?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
export type PseudoNumericContract = Contract & PseudoNumeric
|
export type PseudoNumericContract = Contract & PseudoNumeric
|
||||||
export type NumericContract = Contract & Numeric
|
export type NumericContract = Contract & Numeric
|
||||||
export type FreeResponseContract = Contract & FreeResponse
|
export type FreeResponseContract = Contract & FreeResponse
|
||||||
|
export type MultipleChoiceContract = Contract & MultipleChoice
|
||||||
export type DPMContract = Contract & DPM
|
export type DPMContract = Contract & DPM
|
||||||
export type CPMMContract = Contract & CPMM
|
export type CPMMContract = Contract & CPMM
|
||||||
export type DPMBinaryContract = BinaryContract & DPM
|
export type DPMBinaryContract = BinaryContract & DPM
|
||||||
|
@ -101,6 +111,13 @@ export type FreeResponse = {
|
||||||
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MultipleChoice = {
|
||||||
|
outcomeType: 'MULTIPLE_CHOICE'
|
||||||
|
answers: Answer[]
|
||||||
|
resolution?: string | 'MKT' | 'CANCEL'
|
||||||
|
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
||||||
|
}
|
||||||
|
|
||||||
export type Numeric = {
|
export type Numeric = {
|
||||||
outcomeType: 'NUMERIC'
|
outcomeType: 'NUMERIC'
|
||||||
bucketCount: number
|
bucketCount: number
|
||||||
|
@ -115,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
||||||
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
||||||
export const OUTCOME_TYPES = [
|
export const OUTCOME_TYPES = [
|
||||||
'BINARY',
|
'BINARY',
|
||||||
|
'MULTIPLE_CHOICE',
|
||||||
'FREE_RESPONSE',
|
'FREE_RESPONSE',
|
||||||
'PSEUDO_NUMERIC',
|
'PSEUDO_NUMERIC',
|
||||||
'NUMERIC',
|
'NUMERIC',
|
||||||
|
|
|
@ -22,6 +22,7 @@ export type EnvConfig = {
|
||||||
// Currency controls
|
// Currency controls
|
||||||
fixedAnte?: number
|
fixedAnte?: number
|
||||||
startingBalance?: number
|
startingBalance?: number
|
||||||
|
referralBonus?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig = {
|
type FirebaseConfig = {
|
||||||
|
|
|
@ -19,3 +19,11 @@ export const MAX_ABOUT_LENGTH = 140
|
||||||
export const MAX_ID_LENGTH = 60
|
export const MAX_ID_LENGTH = 60
|
||||||
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
|
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
|
||||||
export const GROUP_CHAT_SLUG = 'chat'
|
export const GROUP_CHAT_SLUG = 'chat'
|
||||||
|
|
||||||
|
export type GroupLink = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
groupId: string
|
||||||
|
createdTime: number
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
|
|
@ -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 { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
|
||||||
import {
|
import {
|
||||||
|
@ -18,6 +18,7 @@ import {
|
||||||
CPMMBinaryContract,
|
CPMMBinaryContract,
|
||||||
DPMBinaryContract,
|
DPMBinaryContract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
PseudoNumericContract,
|
PseudoNumericContract,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
|
@ -142,6 +143,13 @@ export const computeFills = (
|
||||||
limitProb: number | undefined,
|
limitProb: number | undefined,
|
||||||
unfilledBets: LimitBet[]
|
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(
|
const sortedBets = sortBy(
|
||||||
unfilledBets.filter((bet) => bet.outcome !== outcome),
|
unfilledBets.filter((bet) => bet.outcome !== outcome),
|
||||||
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
|
(bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb),
|
||||||
|
@ -239,6 +247,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 = (
|
export const getNewBinaryDpmBetInfo = (
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
amount: number,
|
amount: number,
|
||||||
|
@ -289,7 +323,7 @@ export const getNewBinaryDpmBetInfo = (
|
||||||
export const getNewMultiBetInfo = (
|
export const getNewMultiBetInfo = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: FreeResponseContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
loanAmount: number
|
loanAmount: number
|
||||||
) => {
|
) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
CPMM,
|
CPMM,
|
||||||
DPM,
|
DPM,
|
||||||
FreeResponse,
|
FreeResponse,
|
||||||
|
MultipleChoice,
|
||||||
Numeric,
|
Numeric,
|
||||||
outcomeType,
|
outcomeType,
|
||||||
PseudoNumeric,
|
PseudoNumeric,
|
||||||
|
@ -30,7 +31,10 @@ export function getNewContract(
|
||||||
bucketCount: number,
|
bucketCount: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
isLogScale: boolean
|
isLogScale: boolean,
|
||||||
|
|
||||||
|
// for multiple choice
|
||||||
|
answers: string[]
|
||||||
) {
|
) {
|
||||||
const tags = parseTags(
|
const tags = parseTags(
|
||||||
[
|
[
|
||||||
|
@ -48,6 +52,8 @@ export function getNewContract(
|
||||||
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
|
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
|
||||||
: outcomeType === 'NUMERIC'
|
: outcomeType === 'NUMERIC'
|
||||||
? getNumericProps(ante, bucketCount, min, max)
|
? getNumericProps(ante, bucketCount, min, max)
|
||||||
|
: outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
? getMultipleChoiceProps(ante, answers)
|
||||||
: getFreeAnswerProps(ante)
|
: getFreeAnswerProps(ante)
|
||||||
|
|
||||||
const contract: Contract = removeUndefinedProps({
|
const contract: Contract = removeUndefinedProps({
|
||||||
|
@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
|
||||||
return system
|
return system
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMultipleChoiceProps = (ante: number, answers: string[]) => {
|
||||||
|
const numAnswers = answers.length
|
||||||
|
const betAnte = ante / numAnswers
|
||||||
|
const betShares = Math.sqrt(ante ** 2 / numAnswers)
|
||||||
|
|
||||||
|
const defaultValues = (x: any) =>
|
||||||
|
Object.fromEntries(range(0, numAnswers).map((k) => [k, x]))
|
||||||
|
|
||||||
|
const system: DPM & MultipleChoice = {
|
||||||
|
mechanism: 'dpm-2',
|
||||||
|
outcomeType: 'MULTIPLE_CHOICE',
|
||||||
|
pool: defaultValues(betAnte),
|
||||||
|
totalShares: defaultValues(betShares),
|
||||||
|
totalBets: defaultValues(betAnte),
|
||||||
|
answers: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return system
|
||||||
|
}
|
||||||
|
|
||||||
const getNumericProps = (
|
const getNumericProps = (
|
||||||
ante: number,
|
ante: number,
|
||||||
bucketCount: number,
|
bucketCount: number,
|
||||||
|
|
|
@ -63,3 +63,4 @@ export type notification_reason_types =
|
||||||
| 'on_group_you_are_member_of'
|
| 'on_group_you_are_member_of'
|
||||||
| 'tip_received'
|
| 'tip_received'
|
||||||
| 'bet_fill'
|
| 'bet_fill'
|
||||||
|
| 'user_joined_from_your_group_invite'
|
||||||
|
|
|
@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
|
||||||
|
|
||||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
|
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
"lodash": "4.17.21"
|
"lodash": "4.17.21"
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
|
||||||
|
|
||||||
import { Bet, NumericBet } from './bet'
|
import { Bet, NumericBet } from './bet'
|
||||||
import { deductDpmFees, getDpmProbability } from './calculate-dpm'
|
import { deductDpmFees, getDpmProbability } from './calculate-dpm'
|
||||||
import { DPMContract, FreeResponseContract } from './contract'
|
import {
|
||||||
|
DPMContract,
|
||||||
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
|
} from './contract'
|
||||||
import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
|
import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
|
||||||
import { addObjects } from './util/object'
|
import { addObjects } from './util/object'
|
||||||
|
|
||||||
|
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
|
||||||
|
|
||||||
export const getPayoutsMultiOutcome = (
|
export const getPayoutsMultiOutcome = (
|
||||||
resolutions: { [outcome: string]: number },
|
resolutions: { [outcome: string]: number },
|
||||||
contract: FreeResponseContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
) => {
|
) => {
|
||||||
const poolTotal = sum(Object.values(contract.pool))
|
const poolTotal = sum(Object.values(contract.pool))
|
||||||
|
|
|
@ -117,6 +117,7 @@ export const getDpmPayouts = (
|
||||||
resolutionProbability?: number
|
resolutionProbability?: number
|
||||||
): PayoutInfo => {
|
): PayoutInfo => {
|
||||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||||
|
const { outcomeType } = contract
|
||||||
|
|
||||||
switch (outcome) {
|
switch (outcome) {
|
||||||
case 'YES':
|
case 'YES':
|
||||||
|
@ -124,7 +125,8 @@ export const getDpmPayouts = (
|
||||||
return getDpmStandardPayouts(outcome, contract, openBets)
|
return getDpmStandardPayouts(outcome, contract, openBets)
|
||||||
|
|
||||||
case 'MKT':
|
case 'MKT':
|
||||||
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
return outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
||||||
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
||||||
case 'CANCEL':
|
case 'CANCEL':
|
||||||
|
@ -132,7 +134,7 @@ export const getDpmPayouts = (
|
||||||
return getDpmCancelPayouts(contract, openBets)
|
return getDpmCancelPayouts(contract, openBets)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if (contract.outcomeType === 'NUMERIC')
|
if (outcomeType === 'NUMERIC')
|
||||||
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
|
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
|
||||||
|
|
||||||
// Outcome is a free response answer id.
|
// Outcome is a free response answer id.
|
||||||
|
|
|
@ -16,8 +16,8 @@ export const getMappedValue =
|
||||||
const { min, max, isLogScale } = contract
|
const { min, max, isLogScale } = contract
|
||||||
|
|
||||||
if (isLogScale) {
|
if (isLogScale) {
|
||||||
const logValue = p * Math.log10(max - min)
|
const logValue = p * Math.log10(max - min + 1)
|
||||||
return 10 ** logValue + min
|
return 10 ** logValue + min - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return p * (max - min) + min
|
return p * (max - min) + min
|
||||||
|
@ -37,8 +37,11 @@ export const getPseudoProbability = (
|
||||||
max: number,
|
max: number,
|
||||||
isLogScale = false
|
isLogScale = false
|
||||||
) => {
|
) => {
|
||||||
|
if (value < min) return 0
|
||||||
|
if (value > max) return 1
|
||||||
|
|
||||||
if (isLogScale) {
|
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)
|
return (value - min) / (max - min)
|
||||||
|
|
|
@ -38,13 +38,15 @@ export type User = {
|
||||||
|
|
||||||
referredByUserId?: string
|
referredByUserId?: string
|
||||||
referredByContractId?: string
|
referredByContractId?: string
|
||||||
|
referredByGroupId?: string
|
||||||
lastPingTime?: number
|
lastPingTime?: number
|
||||||
|
shouldShowWelcome?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||||
// for sus users, i.e. multiple sign ups for same person
|
// for sus users, i.e. multiple sign ups for same person
|
||||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
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 = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
|
|
|
@ -33,20 +33,24 @@ export function formatPercent(zeroToOne: number) {
|
||||||
return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
|
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
|
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
|
||||||
export function formatLargeNumber(num: number, sigfigs = 2): string {
|
export function formatLargeNumber(num: number, sigfigs = 2): string {
|
||||||
const absNum = Math.abs(num)
|
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 < 100) return showPrecision(num, 2)
|
||||||
if (absNum < 1000) return num.toPrecision(3)
|
if (absNum < 1000) return showPrecision(num, 3)
|
||||||
if (absNum < 10000) return num.toPrecision(4)
|
if (absNum < 10000) return showPrecision(num, 4)
|
||||||
|
|
||||||
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
||||||
const i = Math.floor(Math.log10(absNum) / 3)
|
const i = Math.floor(Math.log10(absNum) / 3)
|
||||||
|
|
||||||
const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs)
|
const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs)
|
||||||
return `${numStr}${suffix[i]}`
|
return `${numStr}${suffix[i] ?? ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toCamelCase(words: string) {
|
export function toCamelCase(words: string) {
|
||||||
|
|
|
@ -20,6 +20,8 @@ import { Text } from '@tiptap/extension-text'
|
||||||
// other tiptap extensions
|
// other tiptap extensions
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
|
import Iframe from './tiptap-iframe'
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
export function parseTags(text: string) {
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
||||||
|
@ -80,8 +82,9 @@ export const exhibitExts = [
|
||||||
|
|
||||||
Image,
|
Image,
|
||||||
Link,
|
Link,
|
||||||
|
Mention,
|
||||||
|
Iframe,
|
||||||
]
|
]
|
||||||
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
|
||||||
|
|
||||||
export function richTextToString(text?: JSONContent) {
|
export function richTextToString(text?: JSONContent) {
|
||||||
return !text ? '' : generateText(text, exhibitExts)
|
return !text ? '' : generateText(text, exhibitExts)
|
||||||
|
|
92
common/util/tiptap-iframe.ts
Normal file
92
common/util/tiptap-iframe.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
43
dev.sh
Executable file
43
dev.sh
Executable file
|
@ -0,0 +1,43 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
ENV=${1:-dev}
|
||||||
|
case $ENV in
|
||||||
|
dev)
|
||||||
|
FIREBASE_PROJECT=dev
|
||||||
|
NEXT_ENV=DEV ;;
|
||||||
|
prod)
|
||||||
|
FIREBASE_PROJECT=prod
|
||||||
|
NEXT_ENV=PROD ;;
|
||||||
|
localdb)
|
||||||
|
FIREBASE_PROJECT=dev
|
||||||
|
NEXT_ENV=DEV
|
||||||
|
EMULATOR=true ;;
|
||||||
|
*)
|
||||||
|
echo "Invalid environment; must be dev, prod, or localdb."
|
||||||
|
exit 1
|
||||||
|
esac
|
||||||
|
|
||||||
|
firebase use $FIREBASE_PROJECT
|
||||||
|
|
||||||
|
if [ ! -z $EMULATOR ]
|
||||||
|
then
|
||||||
|
npx concurrently \
|
||||||
|
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||||
|
-c green,white,magenta,cyan \
|
||||||
|
"yarn --cwd=functions firestore" \
|
||||||
|
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||||
|
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||||
|
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
|
||||||
|
yarn --cwd=web serve" \
|
||||||
|
"cross-env yarn --cwd=web ts-watch"
|
||||||
|
else
|
||||||
|
npx concurrently \
|
||||||
|
-n FUNCTIONS,NEXT,TS \
|
||||||
|
-c white,magenta,cyan \
|
||||||
|
"yarn --cwd=functions dev" \
|
||||||
|
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
|
||||||
|
yarn --cwd=web serve" \
|
||||||
|
"cross-env yarn --cwd=web ts-watch"
|
||||||
|
fi
|
129
docs/docs/api.md
129
docs/docs/api.md
|
@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
|
|
||||||
|
### GET /v0/me
|
||||||
|
|
||||||
|
Returns the authenticated user.
|
||||||
|
|
||||||
|
### `GET /v0/groups`
|
||||||
|
|
||||||
|
Gets all groups, in no particular order.
|
||||||
|
|
||||||
|
Requires no authorization.
|
||||||
|
|
||||||
|
### `GET /v0/groups/[slug]`
|
||||||
|
|
||||||
|
Gets a group by its slug.
|
||||||
|
|
||||||
|
Requires no authorization.
|
||||||
|
|
||||||
|
### `GET /v0/groups/by-id/[id]`
|
||||||
|
|
||||||
|
Gets a group by its unique ID.
|
||||||
|
|
||||||
|
Requires no authorization.
|
||||||
|
|
||||||
### `GET /v0/markets`
|
### `GET /v0/markets`
|
||||||
|
|
||||||
Lists all markets, ordered by creation date descending.
|
Lists all markets, ordered by creation date descending.
|
||||||
|
@ -481,6 +503,20 @@ Parameters:
|
||||||
answer. For numeric markets, this is a string representing the target bucket,
|
answer. For numeric markets, this is a string representing the target bucket,
|
||||||
and an additional `value` parameter is required which is a number representing
|
and an additional `value` parameter is required which is a number representing
|
||||||
the target value. (Bet on numeric markets at your own peril.)
|
the target value. (Bet on numeric markets at your own peril.)
|
||||||
|
- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing
|
||||||
|
the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the
|
||||||
|
probability percentage).
|
||||||
|
The bet will execute immediately in the direction of `outcome`, but not beyond this
|
||||||
|
specified limit. If not all the bet is filled, the bet will remain as an open offer
|
||||||
|
that can later be matched against an opposite direction bet.
|
||||||
|
- For example, if the current market probability is `50%`:
|
||||||
|
- A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market
|
||||||
|
probability moves down to `40%` and someone bets `M$15` of `NO` to match your
|
||||||
|
bet odds.
|
||||||
|
- A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely
|
||||||
|
depending on current unfilled limit bets and the AMM's liquidity. Any remaining
|
||||||
|
portion of the bet not filled would remain to be matched against in the future.
|
||||||
|
- An unfilled limit order bet can be cancelled using the cancel API.
|
||||||
|
|
||||||
Example request:
|
Example request:
|
||||||
|
|
||||||
|
@ -579,6 +615,26 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||||
]}'
|
]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `POST /v0/market/[marketId]/sell`
|
||||||
|
|
||||||
|
Sells some quantity of shares in a binary market on behalf of the authorized user.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
|
||||||
|
own one kind of shares, you will sell that kind of shares.
|
||||||
|
- `shares`: Optional. The amount of shares to sell of the outcome given
|
||||||
|
above. If not provided, all the shares you own will be sold.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Key {...}' \
|
||||||
|
--data-raw '{"outcome": "YES", "shares": 10}'
|
||||||
|
```
|
||||||
|
|
||||||
### `GET /v0/bets`
|
### `GET /v0/bets`
|
||||||
|
|
||||||
Gets a list of bets, ordered by creation date descending.
|
Gets a list of bets, ordered by creation date descending.
|
||||||
|
@ -597,7 +653,7 @@ Requires no authorization.
|
||||||
|
|
||||||
- Example request
|
- Example request
|
||||||
```
|
```
|
||||||
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa
|
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord
|
||||||
```
|
```
|
||||||
- Response type: A `Bet[]`.
|
- Response type: A `Bet[]`.
|
||||||
|
|
||||||
|
@ -605,31 +661,60 @@ Requires no authorization.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
// Limit bet, partially filled.
|
||||||
{
|
{
|
||||||
"probAfter": 0.44418877319153904,
|
"isFilled": false,
|
||||||
"shares": -645.8346334931828,
|
"amount": 15.596681605353808,
|
||||||
|
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||||
|
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||||
|
"probBefore": 0.5730753474948571,
|
||||||
|
"isCancelled": false,
|
||||||
"outcome": "YES",
|
"outcome": "YES",
|
||||||
"contractId": "tgB1XmvFXZNhjr3xMNLp",
|
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
|
||||||
"sale": {
|
"shares": 31.193363210707616,
|
||||||
"betId": "RcOtarI3d1DUUTjiE0rx",
|
"limitProb": 0.5,
|
||||||
"amount": 474.9999999999998
|
"id": "yXB8lVbs86TKkhWA1FVi",
|
||||||
},
|
"loanAmount": 0,
|
||||||
"createdTime": 1644602886293,
|
"orderAmount": 100,
|
||||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
"probAfter": 0.5730753474948571,
|
||||||
"probBefore": 0.7229189477449224,
|
"createdTime": 1659482775970,
|
||||||
"id": "x9eNmCaqQeXW8AgJ8Zmp",
|
"fills": [
|
||||||
"amount": -499.9999999999998
|
{
|
||||||
|
"timestamp": 1659483249648,
|
||||||
|
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
|
||||||
|
"amount": 15.596681605353808,
|
||||||
|
"shares": 31.193363210707616
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
// Normal bet (no limitProb specified).
|
||||||
{
|
{
|
||||||
"probAfter": 0.9901970375647697,
|
"shares": 17.350459904608414,
|
||||||
"contractId": "zdeaYVAfHlo9jKzWh57J",
|
"probBefore": 0.5304358279113885,
|
||||||
"outcome": "YES",
|
"isFilled": true,
|
||||||
"amount": 1,
|
"probAfter": 0.5730753474948571,
|
||||||
"id": "8PqxKYwXCcLYoXy2m2Nm",
|
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||||
"shares": 1.0049875638533763,
|
"amount": 10,
|
||||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||||
"probBefore": 0.9900000000000001,
|
"id": "1LPJHNz5oAX4K6YtJlP1",
|
||||||
"createdTime": 1644705818872
|
"fees": {
|
||||||
|
"platformFee": 0,
|
||||||
|
"liquidityFee": 0,
|
||||||
|
"creatorFee": 0.4251333951457593
|
||||||
|
},
|
||||||
|
"isCancelled": false,
|
||||||
|
"loanAmount": 0,
|
||||||
|
"orderAmount": 10,
|
||||||
|
"fills": [
|
||||||
|
{
|
||||||
|
"amount": 10,
|
||||||
|
"matchedBetId": null,
|
||||||
|
"shares": 17.350459904608414,
|
||||||
|
"timestamp": 1659482757271
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdTime": 1659482757271,
|
||||||
|
"outcome": "YES"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
|
@ -10,13 +10,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
|
|
||||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||||
|
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
||||||
|
|
||||||
## API / Dev
|
## API / Dev
|
||||||
|
|
||||||
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
|
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
|
||||||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||||
|
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
||||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||||
|
|
||||||
## Bots
|
## Bots
|
||||||
|
|
||||||
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
||||||
|
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
|
||||||
|
|
|
@ -22,11 +22,11 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId', 'lastPingTime']);
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
||||||
// User referral rules
|
// User referral rules
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['referredByUserId'])
|
.hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId'])
|
||||||
// only one referral allowed per user
|
// only one referral allowed per user
|
||||||
&& !("referredByUserId" in resource.data)
|
&& !("referredByUserId" in resource.data)
|
||||||
// user can't refer themselves
|
// user can't refer themselves
|
||||||
|
@ -74,9 +74,9 @@ service cloud.firestore {
|
||||||
match /contracts/{contractId} {
|
match /contracts/{contractId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
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()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['description', 'closeTime'])
|
.hasOnly(['description', 'closeTime', 'question'])
|
||||||
&& resource.data.creatorId == request.auth.uid;
|
&& resource.data.creatorId == request.auth.uid;
|
||||||
allow update: if isAdmin();
|
allow update: if isAdmin();
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
|
|
|
@ -12,6 +12,8 @@
|
||||||
"start": "yarn shell",
|
"start": "yarn shell",
|
||||||
"deploy": "firebase deploy --only functions",
|
"deploy": "firebase deploy --only functions",
|
||||||
"logs": "firebase functions:log",
|
"logs": "firebase functions:log",
|
||||||
|
"dev": "nodemon src/serve.ts",
|
||||||
|
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
|
||||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
||||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||||
|
@ -27,8 +29,11 @@
|
||||||
"@tiptap/core": "2.0.0-beta.181",
|
"@tiptap/core": "2.0.0-beta.181",
|
||||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
"dayjs": "1.11.4",
|
"dayjs": "1.11.4",
|
||||||
|
"cors": "2.8.5",
|
||||||
|
"express": "4.18.1",
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
"firebase-functions": "3.21.2",
|
"firebase-functions": "3.21.2",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { logger } from 'firebase-functions/v2'
|
import { Request, RequestHandler, Response } from 'express'
|
||||||
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https'
|
import { error } from 'firebase-functions/logger'
|
||||||
|
import { HttpsOptions } from 'firebase-functions/v2/https'
|
||||||
import { log } from './utils'
|
import { log } from './utils'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { APIError } from '../../common/api'
|
import { APIError } from '../../common/api'
|
||||||
|
@ -45,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||||
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// This is somewhat suspicious, so get it into the firebase console
|
// This is somewhat suspicious, so get it into the firebase console
|
||||||
logger.error('Error verifying Firebase JWT: ', err)
|
error('Error verifying Firebase JWT: ', err)
|
||||||
throw new APIError(403, 'Error validating token.')
|
throw new APIError(403, 'Error validating token.')
|
||||||
}
|
}
|
||||||
case 'Key':
|
case 'Key':
|
||||||
|
@ -83,6 +84,11 @@ export const zTimestamp = () => {
|
||||||
}, z.date())
|
}, z.date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EndpointDefinition = {
|
||||||
|
opts: EndpointOptions & { method: string }
|
||||||
|
handler: RequestHandler
|
||||||
|
}
|
||||||
|
|
||||||
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||||
const result = schema.safeParse(val)
|
const result = schema.safeParse(val)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
@ -99,12 +105,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EndpointOptions extends HttpsOptions {
|
export interface EndpointOptions extends HttpsOptions {
|
||||||
methods?: string[]
|
method?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTS = {
|
const DEFAULT_OPTS = {
|
||||||
methods: ['POST'],
|
method: 'POST',
|
||||||
minInstances: 1,
|
minInstances: 1,
|
||||||
concurrency: 100,
|
concurrency: 100,
|
||||||
memory: '2GiB',
|
memory: '2GiB',
|
||||||
|
@ -113,28 +119,29 @@ const DEFAULT_OPTS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||||
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
|
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
|
||||||
return onRequest(opts, async (req, res) => {
|
return {
|
||||||
log('Request processing started.')
|
opts,
|
||||||
try {
|
handler: async (req: Request, res: Response) => {
|
||||||
if (!opts.methods.includes(req.method)) {
|
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
|
||||||
const allowed = opts.methods.join(', ')
|
try {
|
||||||
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
if (opts.method !== req.method) {
|
||||||
}
|
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
|
||||||
const authedUser = await lookupUser(await parseCredentials(req))
|
}
|
||||||
log('User credentials processed.')
|
const authedUser = await lookupUser(await parseCredentials(req))
|
||||||
res.status(200).json(await fn(req, authedUser))
|
res.status(200).json(await fn(req, authedUser))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof APIError) {
|
if (e instanceof APIError) {
|
||||||
const output: { [k: string]: unknown } = { message: e.message }
|
const output: { [k: string]: unknown } = { message: e.message }
|
||||||
if (e.details != null) {
|
if (e.details != null) {
|
||||||
output.details = e.details
|
output.details = e.details
|
||||||
|
}
|
||||||
|
res.status(e.code).json(output)
|
||||||
|
} else {
|
||||||
|
error(e)
|
||||||
|
res.status(500).json({ message: 'An unknown error occurred.' })
|
||||||
}
|
}
|
||||||
res.status(e.code).json(output)
|
|
||||||
} else {
|
|
||||||
logger.error(e)
|
|
||||||
res.status(500).json({ message: 'An unknown error occurred.' })
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
} as EndpointDefinition
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ const bodySchema = z.object({
|
||||||
export const cancelbet = newEndpoint({}, async (req, auth) => {
|
export const cancelbet = newEndpoint({}, async (req, auth) => {
|
||||||
const { betId } = validate(bodySchema, req.body)
|
const { betId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
return await firestore.runTransaction(async (trans) => {
|
||||||
const snap = await trans.get(
|
const snap = await trans.get(
|
||||||
firestore.collectionGroup('bets').where('id', '==', betId)
|
firestore.collectionGroup('bets').where('id', '==', betId)
|
||||||
)
|
)
|
||||||
|
@ -28,8 +28,6 @@ export const cancelbet = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
return { ...bet, isCancelled: true }
|
return { ...bet, isCancelled: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -2,33 +2,37 @@ import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CPMMBinaryContract,
|
|
||||||
Contract,
|
Contract,
|
||||||
|
CPMMBinaryContract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
MAX_QUESTION_LENGTH,
|
MAX_QUESTION_LENGTH,
|
||||||
MAX_TAG_LENGTH,
|
MAX_TAG_LENGTH,
|
||||||
|
MultipleChoiceContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
OUTCOME_TYPES,
|
OUTCOME_TYPES,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
|
|
||||||
import { chargeUser } from './utils'
|
import { chargeUser, getContract } from './utils'
|
||||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FIXED_ANTE,
|
FIXED_ANTE,
|
||||||
getCpmmInitialLiquidity,
|
getCpmmInitialLiquidity,
|
||||||
getFreeAnswerAnte,
|
getFreeAnswerAnte,
|
||||||
|
getMultipleChoiceAntes,
|
||||||
getNumericAnte,
|
getNumericAnte,
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
import { getNoneAnswer } from '../../common/answer'
|
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||||
import { getNewContract } from '../../common/new-contract'
|
import { getNewContract } from '../../common/new-contract'
|
||||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
|
||||||
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { uniq, zip } from 'lodash'
|
||||||
|
import { Bet } from '../../common/bet'
|
||||||
|
|
||||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
z.intersection(
|
z.intersection(
|
||||||
|
@ -79,11 +83,15 @@ const numericSchema = z.object({
|
||||||
isLogScale: z.boolean().optional(),
|
isLogScale: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const multipleChoiceSchema = z.object({
|
||||||
|
answers: z.string().trim().min(1).array().min(2),
|
||||||
|
})
|
||||||
|
|
||||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||||
validate(bodySchema, req.body)
|
validate(bodySchema, req.body)
|
||||||
|
|
||||||
let min, max, initialProb, isLogScale
|
let min, max, initialProb, isLogScale, answers
|
||||||
|
|
||||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||||
let initialValue
|
let initialValue
|
||||||
|
@ -97,12 +105,22 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||||
|
|
||||||
if (initialProb < 1 || initialProb > 99)
|
if (initialProb < 1 || initialProb > 99)
|
||||||
throw new APIError(400, 'Invalid initial value.')
|
if (outcomeType === 'PSEUDO_NUMERIC')
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
|
||||||
|
)
|
||||||
|
else throw new APIError(400, 'Invalid initial probability.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outcomeType === 'BINARY') {
|
if (outcomeType === 'BINARY') {
|
||||||
;({ initialProb } = validate(binarySchema, req.body))
|
;({ initialProb } = validate(binarySchema, req.body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||||
|
;({ answers } = validate(multipleChoiceSchema, req.body))
|
||||||
|
}
|
||||||
|
|
||||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||||
if (!userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||||
|
@ -118,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const slug = await getSlug(question)
|
const slug = await getSlug(question)
|
||||||
const contractRef = firestore.collection('contracts').doc()
|
const contractRef = firestore.collection('contracts').doc()
|
||||||
|
|
||||||
let group = null
|
|
||||||
if (groupId) {
|
|
||||||
const groupDocRef = await firestore.collection('groups').doc(groupId)
|
|
||||||
const groupDoc = await groupDocRef.get()
|
|
||||||
if (!groupDoc.exists) {
|
|
||||||
throw new APIError(400, 'No group exists with the given group ID.')
|
|
||||||
}
|
|
||||||
|
|
||||||
group = groupDoc.data() as Group
|
|
||||||
if (!group.memberIds.includes(user.id)) {
|
|
||||||
throw new APIError(
|
|
||||||
400,
|
|
||||||
'User must be a member of the group to add markets to it.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!group.contractIds.includes(contractRef.id))
|
|
||||||
await groupDocRef.update({
|
|
||||||
contractIds: [...group.contractIds, contractRef.id],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
'creating contract for',
|
'creating contract for',
|
||||||
user.username,
|
user.username,
|
||||||
|
@ -162,13 +159,41 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
NUMERIC_BUCKET_COUNT,
|
NUMERIC_BUCKET_COUNT,
|
||||||
min ?? 0,
|
min ?? 0,
|
||||||
max ?? 0,
|
max ?? 0,
|
||||||
isLogScale ?? false
|
isLogScale ?? false,
|
||||||
|
answers ?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ante) await chargeUser(user.id, ante, true)
|
if (ante) await chargeUser(user.id, ante, true)
|
||||||
|
|
||||||
await contractRef.create(contract)
|
await contractRef.create(contract)
|
||||||
|
|
||||||
|
let group = null
|
||||||
|
if (groupId) {
|
||||||
|
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||||
|
const groupDoc = await groupDocRef.get()
|
||||||
|
if (!groupDoc.exists) {
|
||||||
|
throw new APIError(400, 'No group exists with the given group ID.')
|
||||||
|
}
|
||||||
|
|
||||||
|
group = groupDoc.data() as Group
|
||||||
|
if (
|
||||||
|
!group.memberIds.includes(user.id) &&
|
||||||
|
!group.anyoneCanJoin &&
|
||||||
|
group.creatorId !== user.id
|
||||||
|
) {
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
'User must be a member/creator of the group or group must be open to add markets to it.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!group.contractIds.includes(contractRef.id)) {
|
||||||
|
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||||
|
await groupDocRef.update({
|
||||||
|
contractIds: uniq([...group.contractIds, contractRef.id]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const providerId = user.id
|
const providerId = user.id
|
||||||
|
|
||||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
|
@ -184,6 +209,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
await liquidityDoc.set(lp)
|
await liquidityDoc.set(lp)
|
||||||
|
} else if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||||
|
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
|
||||||
|
const betDocs = (answers ?? []).map(() => betCol.doc())
|
||||||
|
|
||||||
|
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
|
||||||
|
const answerDocs = (answers ?? []).map((_, i) =>
|
||||||
|
answerCol.doc(i.toString())
|
||||||
|
)
|
||||||
|
|
||||||
|
const { bets, answerObjects } = getMultipleChoiceAntes(
|
||||||
|
user,
|
||||||
|
contract as MultipleChoiceContract,
|
||||||
|
answers ?? [],
|
||||||
|
betDocs.map((bd) => bd.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
|
||||||
|
)
|
||||||
|
await Promise.all(
|
||||||
|
zip(answerObjects, answerDocs).map(([answer, doc]) =>
|
||||||
|
doc?.create(answer as Answer)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await contractRef.update({ answers: answerObjects })
|
||||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||||
const noneAnswerDoc = firestore
|
const noneAnswerDoc = firestore
|
||||||
.collection(`contracts/${contract.id}/answers`)
|
.collection(`contracts/${contract.id}/answers`)
|
||||||
|
@ -240,3 +290,38 @@ export async function getContractFromSlug(slug: string) {
|
||||||
|
|
||||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createGroupLinks(
|
||||||
|
group: Group,
|
||||||
|
contractIds: string[],
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
for (const contractId of contractIds) {
|
||||||
|
const contract = await getContract(contractId)
|
||||||
|
if (!contract?.groupSlugs?.includes(group.slug)) {
|
||||||
|
await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.update({
|
||||||
|
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
|
||||||
|
await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.update({
|
||||||
|
groupLinks: [
|
||||||
|
{
|
||||||
|
groupId: group.id,
|
||||||
|
name: group.name,
|
||||||
|
slug: group.slug,
|
||||||
|
userId,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as GroupLink,
|
||||||
|
...(contract?.groupLinks ?? []),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -29,12 +29,22 @@ export const createNotification = async (
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
sourceContract?: Contract,
|
miscData?: {
|
||||||
relatedSourceType?: notification_source_types,
|
contract?: Contract
|
||||||
relatedUserId?: string,
|
relatedSourceType?: notification_source_types
|
||||||
sourceSlug?: string,
|
relatedUserId?: string
|
||||||
sourceTitle?: string
|
slug?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
|
const {
|
||||||
|
contract: sourceContract,
|
||||||
|
relatedSourceType,
|
||||||
|
relatedUserId,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
} = miscData ?? {}
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
userToReasonTexts: user_to_reason_texts
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
@ -70,8 +80,8 @@ export const createNotification = async (
|
||||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||||
sourceContractTitle: sourceContract?.question,
|
sourceContractTitle: sourceContract?.question,
|
||||||
sourceContractSlug: sourceContract?.slug,
|
sourceContractSlug: sourceContract?.slug,
|
||||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
sourceSlug: slug ? slug : sourceContract?.slug,
|
||||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
sourceTitle: title ? title : sourceContract?.question,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
})
|
})
|
||||||
|
@ -253,20 +263,6 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyUserReceivedReferralBonus = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
relatedUserId: string
|
|
||||||
) => {
|
|
||||||
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
// If the referrer is the market creator, just tell them they joined to bet on their market
|
|
||||||
reason:
|
|
||||||
sourceContract?.creatorId === relatedUserId
|
|
||||||
? 'user_joined_to_bet_on_your_market'
|
|
||||||
: 'you_referred_user',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
userId: string
|
userId: string
|
||||||
|
@ -284,8 +280,6 @@ export const createNotification = async (
|
||||||
} else if (sourceType === 'group' && relatedUserId) {
|
} else if (sourceType === 'group' && relatedUserId) {
|
||||||
if (sourceUpdateType === 'created')
|
if (sourceUpdateType === 'created')
|
||||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||||
} else if (sourceType === 'user' && relatedUserId) {
|
|
||||||
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The following functions need sourceContract to be defined.
|
// The following functions need sourceContract to be defined.
|
||||||
|
@ -411,6 +405,7 @@ export const createGroupCommentNotification = async (
|
||||||
group: Group,
|
group: Group,
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
) => {
|
) => {
|
||||||
|
if (toUserId === fromUser.id) return
|
||||||
const notificationRef = firestore
|
const notificationRef = firestore
|
||||||
.collection(`/users/${toUserId}/notifications`)
|
.collection(`/users/${toUserId}/notifications`)
|
||||||
.doc(idempotencyKey)
|
.doc(idempotencyKey)
|
||||||
|
@ -434,3 +429,52 @@ export const createGroupCommentNotification = async (
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createReferralNotification = async (
|
||||||
|
toUser: User,
|
||||||
|
referredUser: User,
|
||||||
|
idempotencyKey: string,
|
||||||
|
bonusAmount: string,
|
||||||
|
referredByContract?: Contract,
|
||||||
|
referredByGroup?: Group
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: toUser.id,
|
||||||
|
reason: referredByGroup
|
||||||
|
? 'user_joined_from_your_group_invite'
|
||||||
|
: referredByContract?.creatorId === toUser.id
|
||||||
|
? 'user_joined_to_bet_on_your_market'
|
||||||
|
: 'you_referred_user',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: referredUser.id,
|
||||||
|
sourceType: 'user',
|
||||||
|
sourceUpdateType: 'updated',
|
||||||
|
sourceContractId: referredByContract?.id,
|
||||||
|
sourceUserName: referredUser.name,
|
||||||
|
sourceUserUsername: referredUser.username,
|
||||||
|
sourceUserAvatarUrl: referredUser.avatarUrl,
|
||||||
|
sourceText: bonusAmount,
|
||||||
|
// Only pass the contract referral details if they weren't referred to a group
|
||||||
|
sourceContractCreatorUsername: !referredByGroup
|
||||||
|
? referredByContract?.creatorUsername
|
||||||
|
: undefined,
|
||||||
|
sourceContractTitle: !referredByGroup
|
||||||
|
? referredByContract?.question
|
||||||
|
: undefined,
|
||||||
|
sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined,
|
||||||
|
sourceSlug: referredByGroup
|
||||||
|
? groupPath(referredByGroup.slug)
|
||||||
|
: referredByContract?.slug,
|
||||||
|
sourceTitle: referredByGroup
|
||||||
|
? referredByGroup.name
|
||||||
|
: referredByContract?.question,
|
||||||
|
}
|
||||||
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||||
|
|
|
@ -64,10 +64,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
const deviceUsedBefore =
|
const deviceUsedBefore =
|
||||||
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
|
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
|
||||||
|
|
||||||
const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0
|
const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE
|
||||||
|
|
||||||
const balance =
|
|
||||||
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
|
|
||||||
|
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id: auth.uid,
|
id: auth.uid,
|
||||||
|
@ -81,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||||
followerCountCached: 0,
|
followerCountCached: 0,
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
|
shouldShowWelcome: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('users').doc(auth.uid).create(user)
|
await firestore.collection('users').doc(auth.uid).create(user)
|
||||||
|
@ -114,7 +112,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
|
||||||
return !snap.empty
|
return !snap.empty
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberUsersWithIp = async (ipAddress: string) => {
|
export const numberUsersWithIp = async (ipAddress: string) => {
|
||||||
const snap = await firestore
|
const snap = await firestore
|
||||||
.collection('private-users')
|
.collection('private-users')
|
||||||
.where('initialIpAddress', '==', ipAddress)
|
.where('initialIpAddress', '==', ipAddress)
|
||||||
|
@ -160,7 +158,7 @@ const addUserToDefaultGroups = async (user: User) => {
|
||||||
id: welcomeCommentDoc.id,
|
id: welcomeCommentDoc.id,
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
userId: manifoldAccount,
|
userId: manifoldAccount,
|
||||||
text: `Welcome, ${user.name} (@${user.username})!`,
|
text: `Welcome, @${user.username} aka ${user.name}!`,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
userName: 'Manifold Markets',
|
userName: 'Manifold Markets',
|
||||||
userUsername: MANIFOLD_USERNAME,
|
userUsername: MANIFOLD_USERNAME,
|
||||||
|
|
|
@ -301,7 +301,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>`
|
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||||
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||||
|
|
18
functions/src/get-current-user.ts
Normal file
18
functions/src/get-current-user.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { newEndpoint, APIError } from './api'
|
||||||
|
|
||||||
|
export const getcurrentuser = newEndpoint(
|
||||||
|
{ method: 'GET' },
|
||||||
|
async (_req, auth) => {
|
||||||
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
|
const [userSnap] = await firestore.getAll(userDoc)
|
||||||
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
|
|
||||||
|
const user = userSnap.data() as User
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
|
@ -1,6 +1,6 @@
|
||||||
import { newEndpoint } from './api'
|
import { newEndpoint } from './api'
|
||||||
|
|
||||||
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
|
export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => {
|
||||||
return {
|
return {
|
||||||
message: 'Server is working.',
|
message: 'Server is working.',
|
||||||
uid: auth.uid,
|
uid: auth.uid,
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { onRequest } from 'firebase-functions/v2/https'
|
||||||
|
import { EndpointDefinition } from './api'
|
||||||
|
|
||||||
admin.initializeApp()
|
admin.initializeApp()
|
||||||
|
|
||||||
|
@ -22,6 +24,7 @@ export * from './on-update-user'
|
||||||
export * from './on-create-comment-on-group'
|
export * from './on-create-comment-on-group'
|
||||||
export * from './on-create-txn'
|
export * from './on-create-txn'
|
||||||
export * from './on-delete-group'
|
export * from './on-delete-group'
|
||||||
|
export * from './score-contracts'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -41,4 +44,68 @@ export * from './create-group'
|
||||||
export * from './resolve-market'
|
export * from './resolve-market'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
export * from './mana-bonus-email'
|
export * from './mana-bonus-email'
|
||||||
|
|
||||||
|
import { health } from './health'
|
||||||
|
import { transact } from './transact'
|
||||||
|
import { changeuserinfo } from './change-user-info'
|
||||||
|
import { createuser } from './create-user'
|
||||||
|
import { createanswer } from './create-answer'
|
||||||
|
import { placebet } from './place-bet'
|
||||||
|
import { cancelbet } from './cancel-bet'
|
||||||
|
import { sellbet } from './sell-bet'
|
||||||
|
import { sellshares } from './sell-shares'
|
||||||
|
import { claimmanalink } from './claim-manalink'
|
||||||
|
import { createmarket } from './create-contract'
|
||||||
|
import { addliquidity } from './add-liquidity'
|
||||||
|
import { withdrawliquidity } from './withdraw-liquidity'
|
||||||
|
import { creategroup } from './create-group'
|
||||||
|
import { resolvemarket } from './resolve-market'
|
||||||
|
import { unsubscribe } from './unsubscribe'
|
||||||
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
|
||||||
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
|
return onRequest(opts, handler as any)
|
||||||
|
}
|
||||||
|
const healthFunction = toCloudFunction(health)
|
||||||
|
const transactFunction = toCloudFunction(transact)
|
||||||
|
const changeUserInfoFunction = toCloudFunction(changeuserinfo)
|
||||||
|
const createUserFunction = toCloudFunction(createuser)
|
||||||
|
const createAnswerFunction = toCloudFunction(createanswer)
|
||||||
|
const placeBetFunction = toCloudFunction(placebet)
|
||||||
|
const cancelBetFunction = toCloudFunction(cancelbet)
|
||||||
|
const sellBetFunction = toCloudFunction(sellbet)
|
||||||
|
const sellSharesFunction = toCloudFunction(sellshares)
|
||||||
|
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
||||||
|
const createMarketFunction = toCloudFunction(createmarket)
|
||||||
|
const addLiquidityFunction = toCloudFunction(addliquidity)
|
||||||
|
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
||||||
|
const createGroupFunction = toCloudFunction(creategroup)
|
||||||
|
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||||
|
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||||
|
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||||
|
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
|
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
|
|
||||||
|
export {
|
||||||
|
healthFunction as health,
|
||||||
|
transactFunction as transact,
|
||||||
|
changeUserInfoFunction as changeuserinfo,
|
||||||
|
createUserFunction as createuser,
|
||||||
|
createAnswerFunction as createanswer,
|
||||||
|
placeBetFunction as placebet,
|
||||||
|
cancelBetFunction as cancelbet,
|
||||||
|
sellBetFunction as sellbet,
|
||||||
|
sellSharesFunction as sellshares,
|
||||||
|
claimManalinkFunction as claimmanalink,
|
||||||
|
createMarketFunction as createmarket,
|
||||||
|
addLiquidityFunction as addliquidity,
|
||||||
|
withdrawLiquidityFunction as withdrawliquidity,
|
||||||
|
createGroupFunction as creategroup,
|
||||||
|
resolveMarketFunction as resolvemarket,
|
||||||
|
unsubscribeFunction as unsubscribe,
|
||||||
|
stripeWebhookFunction as stripewebhook,
|
||||||
|
createCheckoutSessionFunction as createcheckoutsession,
|
||||||
|
getCurrentUserFunction as getcurrentuser,
|
||||||
|
}
|
||||||
|
|
|
@ -64,7 +64,7 @@ async function sendMarketCloseEmails() {
|
||||||
user,
|
user,
|
||||||
'closed' + contract.id.slice(6, contract.id.length),
|
'closed' + contract.id.slice(6, contract.id.length),
|
||||||
contract.closeTime?.toString() ?? new Date().toString(),
|
contract.closeTime?.toString() ?? new Date().toString(),
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore
|
||||||
contractId: string
|
contractId: string
|
||||||
}
|
}
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
const contract = await getContract(contractId)
|
|
||||||
if (!contract)
|
|
||||||
throw new Error('Could not find contract corresponding with answer')
|
|
||||||
|
|
||||||
const answer = change.data() as Answer
|
const answer = change.data() as Answer
|
||||||
// Ignore ante answer.
|
// Ignore ante answer.
|
||||||
if (answer.number === 0) return
|
if (answer.number === 0) return
|
||||||
|
|
||||||
|
const contract = await getContract(contractId)
|
||||||
|
if (!contract)
|
||||||
|
throw new Error('Could not find contract corresponding with answer')
|
||||||
|
|
||||||
const answerCreator = await getUser(answer.userId)
|
const answerCreator = await getUser(answer.userId)
|
||||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||||
|
|
||||||
|
@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore
|
||||||
answerCreator,
|
answerCreator,
|
||||||
eventId,
|
eventId,
|
||||||
answer.text,
|
answer.text,
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
|
|
||||||
if (!previousUniqueBettorIds) {
|
if (!previousUniqueBettorIds) {
|
||||||
const contractBets = (
|
const contractBets = (
|
||||||
await firestore
|
await firestore.collection(`contracts/${contractId}/bets`).get()
|
||||||
.collection(`contracts/${contractId}/bets`)
|
|
||||||
.where('userId', '!=', contract.creatorId)
|
|
||||||
.get()
|
|
||||||
).docs.map((doc) => doc.data() as Bet)
|
).docs.map((doc) => doc.data() as Bet)
|
||||||
|
|
||||||
if (contractBets.length === 0) {
|
if (contractBets.length === 0) {
|
||||||
|
@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNewUniqueBettor =
|
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
|
||||||
!previousUniqueBettorIds.includes(bettorId) &&
|
|
||||||
bettorId !== contract.creatorId
|
|
||||||
|
|
||||||
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
|
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
|
||||||
// Update contract unique bettors
|
// Update contract unique bettors
|
||||||
|
@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
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
|
// Create combined txn for all new unique bettors
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
|
@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
fromUser,
|
fromUser,
|
||||||
eventId + '-bonus',
|
eventId + '-bonus',
|
||||||
result.txn.amount + '',
|
result.txn.amount + '',
|
||||||
contract,
|
{
|
||||||
undefined,
|
contract,
|
||||||
// No need to set the user id, we'll use the contract creator id
|
slug: contract.slug,
|
||||||
undefined,
|
title: contract.question,
|
||||||
contract.slug,
|
}
|
||||||
contract.question
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions
|
||||||
? 'answer'
|
? 'answer'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const relatedUser = comment.replyToCommentId
|
const relatedUserId = comment.replyToCommentId
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
|
@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
comment.text,
|
comment.text,
|
||||||
contract,
|
{ contract, relatedSourceType, relatedUserId }
|
||||||
relatedSourceType,
|
|
||||||
relatedUser
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = uniq([
|
||||||
|
|
|
@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore
|
||||||
contractCreator,
|
contractCreator,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(contract.description as JSONContent),
|
richTextToString(contract.description as JSONContent),
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore
|
||||||
groupCreator,
|
groupCreator,
|
||||||
eventId,
|
eventId,
|
||||||
group.about,
|
group.about,
|
||||||
undefined,
|
{
|
||||||
undefined,
|
relatedUserId: memberId,
|
||||||
memberId,
|
slug: group.slug,
|
||||||
group.slug,
|
title: group.name,
|
||||||
group.name
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
.onCreate(async (change, context) => {
|
.onCreate(async (change, context) => {
|
||||||
const liquidity = change.data() as LiquidityProvision
|
const liquidity = change.data() as LiquidityProvision
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
const contract = await getContract(liquidity.contractId)
|
|
||||||
|
|
||||||
if (!contract)
|
|
||||||
throw new Error('Could not find contract corresponding with liquidity')
|
|
||||||
|
|
||||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
||||||
|
|
||||||
|
const contract = await getContract(liquidity.contractId)
|
||||||
|
if (!contract)
|
||||||
|
throw new Error('Could not find contract corresponding with liquidity')
|
||||||
|
|
||||||
const liquidityProvider = await getUser(liquidity.userId)
|
const liquidityProvider = await getUser(liquidity.userId)
|
||||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||||
|
|
||||||
|
@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
liquidityProvider,
|
liquidityProvider,
|
||||||
eventId,
|
eventId,
|
||||||
liquidity.amount.toString(),
|
liquidity.amount.toString(),
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onDeleteGroup = functions.firestore
|
export const onDeleteGroup = functions.firestore
|
||||||
|
@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.where('groupSlugs', 'array-contains', group.slug)
|
.where('groupSlugs', 'array-contains', group.slug)
|
||||||
.get()
|
.get()
|
||||||
|
console.log("contracts with group's slug:", contracts)
|
||||||
|
|
||||||
for (const doc of contracts.docs) {
|
for (const doc of contracts.docs) {
|
||||||
const contract = doc.data() as Contract
|
const contract = doc.data() as Contract
|
||||||
|
const newGroupLinks = contract.groupLinks?.filter(
|
||||||
|
(link) => link.slug !== group.slug
|
||||||
|
)
|
||||||
|
|
||||||
// remove the group from the contract
|
// remove the group from the contract
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
.update({
|
.update({
|
||||||
groupSlugs: (contract.groupSlugs ?? []).filter(
|
groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug),
|
||||||
(groupSlug) => groupSlug !== group.slug
|
groupLinks: newGroupLinks ?? [],
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore
|
||||||
followingUser,
|
followingUser,
|
||||||
eventId,
|
eventId,
|
||||||
'',
|
'',
|
||||||
undefined,
|
{ relatedUserId: follow.userId }
|
||||||
undefined,
|
|
||||||
follow.userId
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
resolutionText,
|
resolutionText,
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
|
@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
sourceText,
|
sourceText,
|
||||||
contract
|
{ contract }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
|
import { getContract } from './utils'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onUpdateGroup = functions.firestore
|
export const onUpdateGroup = functions.firestore
|
||||||
|
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
|
||||||
const prevGroup = change.before.data() as Group
|
const prevGroup = change.before.data() as Group
|
||||||
const group = change.after.data() as Group
|
const group = change.after.data() as Group
|
||||||
|
|
||||||
// ignore the update we just made
|
// Ignore the activity update we just made
|
||||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
|
||||||
.doc(group.id)
|
.doc(group.id)
|
||||||
.update({ mostRecentActivityTime: Date.now() })
|
.update({ mostRecentActivityTime: Date.now() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
||||||
|
for (const contractId of contractIds) {
|
||||||
|
const contract = await getContract(contractId)
|
||||||
|
await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.update({
|
||||||
|
groupSlugs: uniq([
|
||||||
|
...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ??
|
||||||
|
[]),
|
||||||
|
]),
|
||||||
|
groupLinks: [
|
||||||
|
...(contract?.groupLinks?.filter(
|
||||||
|
(link) => link.groupId !== group.id
|
||||||
|
) ?? []),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,11 +2,12 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
||||||
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||||
import { createNotification } from './create-notification'
|
import { createReferralNotification } from './create-notification'
|
||||||
import { ReferralTxn } from '../../common/txn'
|
import { ReferralTxn } from '../../common/txn'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from 'common/bet'
|
||||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
import { QuerySnapshot } from 'firebase-admin/firestore'
|
||||||
|
import { Group } from 'common/group'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onUpdateUser = functions.firestore
|
export const onUpdateUser = functions.firestore
|
||||||
|
@ -54,6 +55,17 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||||
}
|
}
|
||||||
console.log(`referredByContract: ${referredByContract}`)
|
console.log(`referredByContract: ${referredByContract}`)
|
||||||
|
|
||||||
|
let referredByGroup: Group | undefined = undefined
|
||||||
|
if (user.referredByGroupId) {
|
||||||
|
const referredByGroupDoc = firestore.doc(
|
||||||
|
`groups/${user.referredByGroupId}`
|
||||||
|
)
|
||||||
|
referredByGroup = await transaction
|
||||||
|
.get(referredByGroupDoc)
|
||||||
|
.then((snap) => snap.data() as Group)
|
||||||
|
}
|
||||||
|
console.log(`referredByGroup: ${referredByGroup}`)
|
||||||
|
|
||||||
const txns = (
|
const txns = (
|
||||||
await firestore
|
await firestore
|
||||||
.collection('txns')
|
.collection('txns')
|
||||||
|
@ -91,8 +103,8 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||||
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
|
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
|
const txnDoc = firestore.collection(`txns/`).doc(txn.id)
|
||||||
await transaction.set(txnDoc, txn)
|
transaction.set(txnDoc, txn)
|
||||||
console.log('created referral with txn id:', txn.id)
|
console.log('created referral with txn id:', txn.id)
|
||||||
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
|
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
|
||||||
transaction.update(referredByUserDoc, {
|
transaction.update(referredByUserDoc, {
|
||||||
|
@ -100,18 +112,13 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||||
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
|
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
|
||||||
})
|
})
|
||||||
|
|
||||||
await createNotification(
|
await createReferralNotification(
|
||||||
user.id,
|
referredByUser,
|
||||||
'user',
|
|
||||||
'updated',
|
|
||||||
user,
|
user,
|
||||||
eventId,
|
eventId,
|
||||||
txn.amount.toString(),
|
txn.amount.toString(),
|
||||||
referredByContract,
|
referredByContract,
|
||||||
'user',
|
referredByGroup
|
||||||
referredByUser.id,
|
|
||||||
referredByContract?.slug,
|
|
||||||
referredByContract?.question
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
limitProb,
|
limitProb,
|
||||||
unfilledBets
|
unfilledBets
|
||||||
)
|
)
|
||||||
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
|
} else if (
|
||||||
|
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
|
mechanism == 'dpm-2'
|
||||||
|
) {
|
||||||
const { outcome } = validate(freeResponseSchema, req.body)
|
const { outcome } = validate(freeResponseSchema, req.body)
|
||||||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||||
const answerSnap = await trans.get(answerDoc)
|
const answerSnap = await trans.get(answerDoc)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
@ -17,6 +18,7 @@ import {
|
||||||
groupPayoutsByUser,
|
groupPayoutsByUser,
|
||||||
Payout,
|
Payout,
|
||||||
} from '../../common/payouts'
|
} from '../../common/payouts'
|
||||||
|
import { isAdmin } from '../../common/envs/constants'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
@ -68,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
|
||||||
|
|
||||||
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
const { contractId } = validate(bodySchema, req.body)
|
const { contractId } = validate(bodySchema, req.body)
|
||||||
const userId = auth.uid
|
|
||||||
|
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const contractSnap = await contractDoc.get()
|
const contractSnap = await contractDoc.get()
|
||||||
if (!contractSnap.exists)
|
if (!contractSnap.exists)
|
||||||
|
@ -82,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
req.body
|
req.body
|
||||||
)
|
)
|
||||||
|
|
||||||
if (creatorId !== userId)
|
if (creatorId !== auth.uid && !isAdmin(auth.uid))
|
||||||
throw new APIError(403, 'User is not creator of contract')
|
throw new APIError(403, 'User is not creator of contract')
|
||||||
|
|
||||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||||
|
@ -245,7 +245,10 @@ function getResolutionParams(contract: Contract, body: string) {
|
||||||
...validate(pseudoNumericSchema, body),
|
...validate(pseudoNumericSchema, body),
|
||||||
resolutions: undefined,
|
resolutions: undefined,
|
||||||
}
|
}
|
||||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
} else if (
|
||||||
|
outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
) {
|
||||||
const freeResponseParams = validate(freeResponseSchema, body)
|
const freeResponseParams = validate(freeResponseSchema, body)
|
||||||
const { outcome } = freeResponseParams
|
const { outcome } = freeResponseParams
|
||||||
switch (outcome) {
|
switch (outcome) {
|
||||||
|
@ -292,7 +295,10 @@ function getResolutionParams(contract: Contract, body: string) {
|
||||||
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAnswer(contract: FreeResponseContract, answer: number) {
|
function validateAnswer(
|
||||||
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
|
answer: number
|
||||||
|
) {
|
||||||
const validIds = contract.answers.map((a) => a.id)
|
const validIds = contract.answers.map((a) => a.id)
|
||||||
if (!validIds.includes(answer.toString())) {
|
if (!validIds.includes(answer.toString())) {
|
||||||
throw new APIError(400, `${answer} is not a valid answer ID`)
|
throw new APIError(400, `${answer} is not a valid answer ID`)
|
||||||
|
|
54
functions/src/score-contracts.ts
Normal file
54
functions/src/score-contracts.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
55
functions/src/scripts/backfill-comment-ids.ts
Normal file
55
functions/src/scripts/backfill-comment-ids.ts
Normal 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.`)
|
||||||
|
})
|
||||||
|
}
|
25
functions/src/scripts/backfill-group-ids.ts
Normal file
25
functions/src/scripts/backfill-group-ids.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// We have some groups without IDs. Let's fill them in.
|
||||||
|
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { log, writeAsync } from '../utils'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const groupsQuery = firestore.collection('groups')
|
||||||
|
groupsQuery.get().then(async (groupSnaps) => {
|
||||||
|
log(`Loaded ${groupSnaps.size} groups.`)
|
||||||
|
const needsFilling = groupSnaps.docs.filter((ct) => {
|
||||||
|
return !('id' in ct.data())
|
||||||
|
})
|
||||||
|
log(`${needsFilling.length} groups need IDs.`)
|
||||||
|
const updates = needsFilling.map((group) => {
|
||||||
|
return { doc: group.ref, fields: { id: group.id } }
|
||||||
|
})
|
||||||
|
log(`Updating ${updates.length} groups.`)
|
||||||
|
await writeAsync(firestore, updates)
|
||||||
|
log(`Updated all groups.`)
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,14 +1,9 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { initAdmin } from './script-init'
|
import { initAdmin } from './script-init'
|
||||||
initAdmin()
|
|
||||||
|
|
||||||
import { getValues, isProd } from '../utils'
|
import { getValues, isProd } from '../utils'
|
||||||
import {
|
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
|
||||||
CATEGORIES_GROUP_SLUG_POSTFIX,
|
import { Group, GroupLink } from 'common/group'
|
||||||
DEFAULT_CATEGORIES,
|
|
||||||
} from 'common/categories'
|
|
||||||
import { Group } from 'common/group'
|
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
@ -18,28 +13,12 @@ import {
|
||||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
} from 'common/antes'
|
} from 'common/antes'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
const adminFirestore = admin.firestore()
|
const adminFirestore = admin.firestore()
|
||||||
|
|
||||||
async function convertCategoriesToGroups() {
|
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
|
||||||
const groups = await getValues<Group>(adminFirestore.collection('groups'))
|
for (const category of categories) {
|
||||||
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 markets = await getValues<Contract>(
|
const markets = await getValues<Contract>(
|
||||||
adminFirestore
|
adminFirestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
|
@ -77,7 +56,7 @@ async function convertCategoriesToGroups() {
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
anyoneCanJoin: true,
|
anyoneCanJoin: true,
|
||||||
memberIds: [manifoldAccount],
|
memberIds: [manifoldAccount],
|
||||||
about: 'Official group for all things related to ' + category,
|
about: 'Default group for all things related to ' + category,
|
||||||
mostRecentActivityTime: Date.now(),
|
mostRecentActivityTime: Date.now(),
|
||||||
contractIds: markets.map((market) => market.id),
|
contractIds: markets.map((market) => market.id),
|
||||||
chatDisabled: true,
|
chatDisabled: true,
|
||||||
|
@ -93,16 +72,35 @@ async function convertCategoriesToGroups() {
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const market of markets) {
|
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
|
await adminFirestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(market.id)
|
.doc(market.id)
|
||||||
.update({
|
.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) {
|
if (require.main === module) {
|
||||||
convertCategoriesToGroups()
|
convertCategoriesToGroups()
|
||||||
.then(() => process.exit())
|
.then(() => process.exit())
|
||||||
|
|
53
functions/src/scripts/link-contracts-to-groups.ts
Normal file
53
functions/src/scripts/link-contracts-to-groups.ts
Normal 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)
|
||||||
|
}
|
|
@ -66,10 +66,18 @@ export const getServiceAccountCredentials = (env?: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initAdmin = (env?: string) => {
|
export const initAdmin = (env?: string) => {
|
||||||
const serviceAccount = getServiceAccountCredentials(env)
|
try {
|
||||||
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
|
const serviceAccount = getServiceAccountCredentials(env)
|
||||||
return admin.initializeApp({
|
console.log(
|
||||||
projectId: serviceAccount.project_id,
|
`Initializing connection to ${serviceAccount.project_id} Firebase...`
|
||||||
credential: admin.credential.cert(serviceAccount),
|
)
|
||||||
})
|
return admin.initializeApp({
|
||||||
|
projectId: serviceAccount.project_id,
|
||||||
|
credential: admin.credential.cert(serviceAccount),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
console.log(`Initializing connection to default Firebase...`)
|
||||||
|
return admin.initializeApp()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { sumBy, uniq } from 'lodash'
|
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
@ -9,15 +9,15 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||||
import { getValues, log } from './utils'
|
import { getValues, log } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { floatingLesserEqual } from '../../common/util/math'
|
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
shares: z.number(),
|
shares: z.number().optional(), // leave it out to sell all shares
|
||||||
outcome: z.enum(['YES', 'NO']),
|
outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sellshares = newEndpoint({}, async (req, auth) => {
|
export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
|
@ -46,14 +46,37 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
throw new APIError(400, 'Trading is closed.')
|
throw new APIError(400, 'Trading is closed.')
|
||||||
|
|
||||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||||
|
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
||||||
|
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||||
|
sumBy(bets, (b) => b.shares)
|
||||||
|
)
|
||||||
|
|
||||||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
let chosenOutcome: 'YES' | 'NO'
|
||||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
if (outcome != null) {
|
||||||
|
chosenOutcome = outcome
|
||||||
|
} else {
|
||||||
|
const nonzeroShares = Object.entries(sharesByOutcome).filter(
|
||||||
|
([_k, v]) => !floatingEqual(0, v)
|
||||||
|
)
|
||||||
|
if (nonzeroShares.length == 0) {
|
||||||
|
throw new APIError(400, "You don't own any shares in this market.")
|
||||||
|
}
|
||||||
|
if (nonzeroShares.length > 1) {
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
`You own multiple kinds of shares, but did not specify which to sell.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO'
|
||||||
|
}
|
||||||
|
|
||||||
if (!floatingLesserEqual(shares, maxShares))
|
const maxShares = sharesByOutcome[chosenOutcome]
|
||||||
|
const sharesToSell = shares ?? maxShares
|
||||||
|
|
||||||
|
if (!floatingLesserEqual(sharesToSell, maxShares))
|
||||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||||
|
|
||||||
const soldShares = Math.min(shares, maxShares)
|
const soldShares = Math.min(sharesToSell, maxShares)
|
||||||
|
|
||||||
const unfilledBetsSnap = await transaction.get(
|
const unfilledBetsSnap = await transaction.get(
|
||||||
getUnfilledBetsQuery(contractDoc)
|
getUnfilledBetsQuery(contractDoc)
|
||||||
|
@ -62,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||||
soldShares,
|
soldShares,
|
||||||
outcome,
|
chosenOutcome,
|
||||||
contract,
|
contract,
|
||||||
prevLoanAmount,
|
prevLoanAmount,
|
||||||
unfilledBets
|
unfilledBets
|
||||||
|
|
70
functions/src/serve.ts
Normal file
70
functions/src/serve.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import * as cors from 'cors'
|
||||||
|
import * as express from 'express'
|
||||||
|
import { Express, Request, Response, NextFunction } from 'express'
|
||||||
|
import { EndpointDefinition } from './api'
|
||||||
|
|
||||||
|
const PORT = 8088
|
||||||
|
|
||||||
|
import { initAdmin } from './scripts/script-init'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
import { health } from './health'
|
||||||
|
import { transact } from './transact'
|
||||||
|
import { changeuserinfo } from './change-user-info'
|
||||||
|
import { createuser } from './create-user'
|
||||||
|
import { createanswer } from './create-answer'
|
||||||
|
import { placebet } from './place-bet'
|
||||||
|
import { cancelbet } from './cancel-bet'
|
||||||
|
import { sellbet } from './sell-bet'
|
||||||
|
import { sellshares } from './sell-shares'
|
||||||
|
import { claimmanalink } from './claim-manalink'
|
||||||
|
import { createmarket } from './create-contract'
|
||||||
|
import { addliquidity } from './add-liquidity'
|
||||||
|
import { withdrawliquidity } from './withdraw-liquidity'
|
||||||
|
import { creategroup } from './create-group'
|
||||||
|
import { resolvemarket } from './resolve-market'
|
||||||
|
import { unsubscribe } from './unsubscribe'
|
||||||
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
|
||||||
|
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
const addEndpointRoute = (
|
||||||
|
path: string,
|
||||||
|
endpoint: EndpointDefinition,
|
||||||
|
...middlewares: Middleware[]
|
||||||
|
) => {
|
||||||
|
const method = endpoint.opts.method.toLowerCase() as keyof Express
|
||||||
|
const corsMiddleware = cors({ origin: endpoint.opts.cors })
|
||||||
|
const allMiddleware = [...middlewares, corsMiddleware]
|
||||||
|
app.options(path, corsMiddleware) // preflight requests
|
||||||
|
app[method](path, ...allMiddleware, endpoint.handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addJsonEndpointRoute = (name: string, endpoint: EndpointDefinition) => {
|
||||||
|
addEndpointRoute(name, endpoint, express.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
addEndpointRoute('/health', health)
|
||||||
|
addJsonEndpointRoute('/transact', transact)
|
||||||
|
addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
|
||||||
|
addJsonEndpointRoute('/createuser', createuser)
|
||||||
|
addJsonEndpointRoute('/createanswer', createanswer)
|
||||||
|
addJsonEndpointRoute('/placebet', placebet)
|
||||||
|
addJsonEndpointRoute('/cancelbet', cancelbet)
|
||||||
|
addJsonEndpointRoute('/sellbet', sellbet)
|
||||||
|
addJsonEndpointRoute('/sellshares', sellshares)
|
||||||
|
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
||||||
|
addJsonEndpointRoute('/createmarket', createmarket)
|
||||||
|
addJsonEndpointRoute('/addliquidity', addliquidity)
|
||||||
|
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
||||||
|
addJsonEndpointRoute('/creategroup', creategroup)
|
||||||
|
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
|
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||||
|
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||||
|
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||||
|
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||||
|
|
||||||
|
app.listen(PORT)
|
||||||
|
console.log(`Serving functions on port ${PORT}.`)
|
|
@ -1,7 +1,7 @@
|
||||||
import { onRequest } from 'firebase-functions/v2/https'
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
|
import { EndpointDefinition } from './api'
|
||||||
import { getPrivateUser, getUser, isProd, payUser } from './utils'
|
import { getPrivateUser, getUser, isProd, payUser } from './utils'
|
||||||
import { sendThankYouEmail } from './emails'
|
import { sendThankYouEmail } from './emails'
|
||||||
import { track } from './analytics'
|
import { track } from './analytics'
|
||||||
|
@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd()
|
||||||
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
|
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createcheckoutsession = onRequest(
|
export const createcheckoutsession: EndpointDefinition = {
|
||||||
{ minInstances: 1, secrets: ['STRIPE_APIKEY'] },
|
opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] },
|
||||||
async (req, res) => {
|
handler: async (req, res) => {
|
||||||
const userId = req.query.userId?.toString()
|
const userId = req.query.userId?.toString()
|
||||||
|
|
||||||
const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
|
const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
|
||||||
|
@ -86,21 +86,24 @@ export const createcheckoutsession = onRequest(
|
||||||
})
|
})
|
||||||
|
|
||||||
res.redirect(303, session.url || '')
|
res.redirect(303, session.url || '')
|
||||||
}
|
},
|
||||||
)
|
}
|
||||||
|
|
||||||
export const stripewebhook = onRequest(
|
export const stripewebhook: EndpointDefinition = {
|
||||||
{
|
opts: {
|
||||||
|
method: 'POST',
|
||||||
minInstances: 1,
|
minInstances: 1,
|
||||||
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
|
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
handler: async (req, res) => {
|
||||||
const stripe = initStripe()
|
const stripe = initStripe()
|
||||||
let event
|
let event
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Cloud Functions jam the raw body into a special `rawBody` property
|
||||||
|
const rawBody = (req as any).rawBody ?? req.body
|
||||||
event = stripe.webhooks.constructEvent(
|
event = stripe.webhooks.constructEvent(
|
||||||
req.rawBody,
|
rawBody,
|
||||||
req.headers['stripe-signature'] as string,
|
req.headers['stripe-signature'] as string,
|
||||||
process.env.STRIPE_WEBHOOKSECRET as string
|
process.env.STRIPE_WEBHOOKSECRET as string
|
||||||
)
|
)
|
||||||
|
@ -116,8 +119,8 @@ export const stripewebhook = onRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send('success')
|
res.status(200).send('success')
|
||||||
}
|
},
|
||||||
)
|
}
|
||||||
|
|
||||||
const issueMoneys = async (session: StripeSession) => {
|
const issueMoneys = async (session: StripeSession) => {
|
||||||
const { id: sessionId } = session
|
const { id: sessionId } = session
|
||||||
|
|
|
@ -1,66 +1,72 @@
|
||||||
import { onRequest } from 'firebase-functions/v2/https'
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { EndpointDefinition } from './api'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { PrivateUser } from '../../common/user'
|
import { PrivateUser } from '../../common/user'
|
||||||
|
|
||||||
export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
|
export const unsubscribe: EndpointDefinition = {
|
||||||
const id = req.query.id as string
|
opts: { method: 'GET', minInstances: 1 },
|
||||||
let type = req.query.type as string
|
handler: async (req, res) => {
|
||||||
if (!id || !type) {
|
const id = req.query.id as string
|
||||||
res.status(400).send('Empty id or type parameter.')
|
let type = req.query.type as string
|
||||||
return
|
if (!id || !type) {
|
||||||
}
|
res.status(400).send('Empty id or type parameter.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'market-resolved') type = 'market-resolve'
|
if (type === 'market-resolved') type = 'market-resolve'
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!['market-resolve', 'market-comment', 'market-answer', 'generic'].includes(
|
![
|
||||||
type
|
'market-resolve',
|
||||||
)
|
'market-comment',
|
||||||
) {
|
'market-answer',
|
||||||
res.status(400).send('Invalid type parameter.')
|
'generic',
|
||||||
return
|
].includes(type)
|
||||||
}
|
) {
|
||||||
|
res.status(400).send('Invalid type parameter.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const user = await getUser(id)
|
const user = await getUser(id)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.send('This user is not currently subscribed or does not exist.')
|
res.send('This user is not currently subscribed or does not exist.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
|
|
||||||
const update: Partial<PrivateUser> = {
|
const update: Partial<PrivateUser> = {
|
||||||
...(type === 'market-resolve' && {
|
...(type === 'market-resolve' && {
|
||||||
unsubscribedFromResolutionEmails: true,
|
unsubscribedFromResolutionEmails: true,
|
||||||
}),
|
}),
|
||||||
...(type === 'market-comment' && {
|
...(type === 'market-comment' && {
|
||||||
unsubscribedFromCommentEmails: true,
|
unsubscribedFromCommentEmails: true,
|
||||||
}),
|
}),
|
||||||
...(type === 'market-answer' && {
|
...(type === 'market-answer' && {
|
||||||
unsubscribedFromAnswerEmails: true,
|
unsubscribedFromAnswerEmails: true,
|
||||||
}),
|
}),
|
||||||
...(type === 'generic' && {
|
...(type === 'generic' && {
|
||||||
unsubscribedFromGenericEmails: true,
|
unsubscribedFromGenericEmails: true,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(id).update(update)
|
await firestore.collection('private-users').doc(id).update(update)
|
||||||
|
|
||||||
if (type === 'market-resolve')
|
if (type === 'market-resolve')
|
||||||
res.send(
|
res.send(
|
||||||
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
|
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
|
||||||
)
|
)
|
||||||
else if (type === 'market-comment')
|
else if (type === 'market-comment')
|
||||||
res.send(
|
res.send(
|
||||||
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
|
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
|
||||||
)
|
)
|
||||||
else if (type === 'market-answer')
|
else if (type === 'market-answer')
|
||||||
res.send(
|
res.send(
|
||||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||||
)
|
)
|
||||||
else res.send(`${name}, you have been unsubscribed.`)
|
else res.send(`${name}, you have been unsubscribed.`)
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -11,8 +11,6 @@ import { last } from 'lodash'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
const oneDay = 1000 * 60 * 60 * 24
|
|
||||||
|
|
||||||
const computeInvestmentValue = (
|
const computeInvestmentValue = (
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
contractsDict: { [k: string]: Contract }
|
contractsDict: { [k: string]: Contract }
|
||||||
|
@ -59,8 +57,8 @@ export const updateMetricsCore = async () => {
|
||||||
return {
|
return {
|
||||||
doc: firestore.collection('contracts').doc(contract.id),
|
doc: firestore.collection('contracts').doc(contract.id),
|
||||||
fields: {
|
fields: {
|
||||||
volume24Hours: computeVolume(contractBets, now - oneDay),
|
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||||
volume7Days: computeVolume(contractBets, now - oneDay * 7),
|
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -14,10 +14,13 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "5.25.0",
|
"@typescript-eslint/eslint-plugin": "5.25.0",
|
||||||
"@typescript-eslint/parser": "5.25.0",
|
"@typescript-eslint/parser": "5.25.0",
|
||||||
|
"concurrently": "6.5.1",
|
||||||
"eslint": "8.15.0",
|
"eslint": "8.15.0",
|
||||||
"eslint-plugin-lodash": "^7.4.0",
|
"eslint-plugin-lodash": "^7.4.0",
|
||||||
"prettier": "2.5.0",
|
"prettier": "2.5.0",
|
||||||
"typescript": "4.6.4"
|
"typescript": "4.6.4",
|
||||||
|
"ts-node": "10.9.1",
|
||||||
|
"nodemon": "2.0.19"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "17.0.43"
|
"@types/react": "17.0.43"
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
# Ignore Next artifacts
|
# Ignore Next artifacts
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
|
public/**/*.json
|
210
web/components/NotificationSettings.tsx
Normal file
210
web/components/NotificationSettings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,24 +1,26 @@
|
||||||
import { ExclamationIcon } from '@heroicons/react/solid'
|
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Row } from './layout/row'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
|
|
||||||
export function AlertBox(props: { title: string; text: string }) {
|
export function AlertBox(props: { title: string; text: string }) {
|
||||||
const { title, text } = props
|
const { title, text } = props
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-yellow-50 p-4">
|
<Col className="rounded-md bg-yellow-50 p-4">
|
||||||
<div className="flex">
|
<Row className="mb-2 flex-shrink-0">
|
||||||
<div className="flex-shrink-0">
|
<ExclamationIcon
|
||||||
<ExclamationIcon
|
className="h-5 w-5 text-yellow-400"
|
||||||
className="h-5 w-5 text-yellow-400"
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
|
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
|
||||||
<div className="mt-2 text-sm text-yellow-700">
|
|
||||||
<Linkify text={text} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div className="mt-2 whitespace-pre-line text-sm text-yellow-700">
|
||||||
|
<Linkify text={text} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ export function AmountInput(props: {
|
||||||
<span className="bg-gray-200 text-sm">{label}</span>
|
<span className="bg-gray-200 text-sm">{label}</span>
|
||||||
<input
|
<input
|
||||||
className={clsx(
|
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',
|
error && 'input-error',
|
||||||
inputClassName
|
inputClassName
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { XIcon } from '@heroicons/react/solid'
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { FreeResponseContract } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { BuyAmountInput } from '../amount-input'
|
import { BuyAmountInput } from '../amount-input'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||||
|
@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device'
|
||||||
|
|
||||||
export function AnswerBetPanel(props: {
|
export function AnswerBetPanel(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
closePanel: () => void
|
closePanel: () => void
|
||||||
className?: string
|
className?: string
|
||||||
isModal?: boolean
|
isModal?: boolean
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { FreeResponseContract } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
|
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
|
||||||
|
|
||||||
export function AnswerItem(props: {
|
export function AnswerItem(props: {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
showChoice: 'radio' | 'checkbox' | undefined
|
showChoice: 'radio' | 'checkbox' | undefined
|
||||||
chosenProb: number | undefined
|
chosenProb: number | undefined
|
||||||
totalChosenProb?: number
|
totalChosenProb?: number
|
||||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx'
|
||||||
import { sum } from 'lodash'
|
import { sum } from 'lodash'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Contract, FreeResponse } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
|
||||||
export function AnswerResolvePanel(props: {
|
export function AnswerResolvePanel(props: {
|
||||||
contract: Contract & FreeResponse
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||||
setResolveOption: (
|
setResolveOption: (
|
||||||
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||||
|
|
|
@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { FreeResponseContract } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { getOutcomeProbability } from 'common/calculate'
|
import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
const NUM_LINES = 6
|
const NUM_LINES = 6
|
||||||
|
|
||||||
export const AnswersGraph = memo(function AnswersGraph(props: {
|
export const AnswersGraph = memo(function AnswersGraph(props: {
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
height?: number
|
height?: number
|
||||||
}) {
|
}) {
|
||||||
|
@ -178,15 +178,22 @@ function formatTime(
|
||||||
return d.format(format)
|
return d.format(format)
|
||||||
}
|
}
|
||||||
|
|
||||||
const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => {
|
const computeProbsByOutcome = (
|
||||||
const { totalBets } = contract
|
bets: Bet[],
|
||||||
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
) => {
|
||||||
|
const { totalBets, outcomeType } = contract
|
||||||
|
|
||||||
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
|
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
|
||||||
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
|
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
|
||||||
const maxProb = Math.max(
|
const maxProb = Math.max(
|
||||||
...betsByOutcome[outcome].map((bet) => bet.probAfter)
|
...betsByOutcome[outcome].map((bet) => bet.probAfter)
|
||||||
)
|
)
|
||||||
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
|
return (
|
||||||
|
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
|
maxProb > 0.02 &&
|
||||||
|
totalBets[outcome] > 0.000000001
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackedOutcomes = sortBy(
|
const trackedOutcomes = sortBy(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { sortBy, partition, sum, uniq } from 'lodash'
|
import { sortBy, partition, sum, uniq } from 'lodash'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { FreeResponseContract } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||||
|
@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
import { BuyButton } from 'web/components/yes-no-selector'
|
||||||
|
|
||||||
export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
export function AnswersPanel(props: {
|
||||||
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
}) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const { creatorId, resolution, resolutions, totalBets } = contract
|
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||||
|
contract
|
||||||
|
|
||||||
const answers = useAnswers(contract.id) ?? contract.answers
|
const answers = useAnswers(contract.id) ?? contract.answers
|
||||||
const [winningAnswers, losingAnswers] = partition(
|
const [winningAnswers, losingAnswers] = partition(
|
||||||
answers.filter(
|
answers.filter(
|
||||||
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
|
(answer) =>
|
||||||
|
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
|
totalBets[answer.id] > 0.000000001
|
||||||
),
|
),
|
||||||
(answer) =>
|
(answer) =>
|
||||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||||
|
@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
||||||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tradingAllowed(contract) &&
|
{outcomeType === 'FREE_RESPONSE' &&
|
||||||
|
tradingAllowed(contract) &&
|
||||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
(!resolveOption || resolveOption === 'CANCEL') && (
|
||||||
<CreateAnswerPanel contract={contract} />
|
<CreateAnswerPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAnswerItems(
|
function getAnswerItems(
|
||||||
contract: FreeResponseContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
answers: Answer[],
|
answers: Answer[],
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
) {
|
) {
|
||||||
|
@ -178,7 +184,7 @@ function getAnswerItems(
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpenAnswer(props: {
|
function OpenAnswer(props: {
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
answer: Answer
|
answer: Answer
|
||||||
items: ActivityItem[]
|
items: ActivityItem[]
|
||||||
type: string
|
type: string
|
||||||
|
|
65
web/components/answers/multiple-choice-answers.tsx
Normal file
65
web/components/answers/multiple-choice-answers.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Textarea from 'react-expanding-textarea'
|
||||||
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
|
import { Col } from '../layout/col'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
|
||||||
|
export function MultipleChoiceAnswers(props: {
|
||||||
|
setAnswers: (answers: string[]) => void
|
||||||
|
}) {
|
||||||
|
const [answers, setInternalAnswers] = useState(['', '', ''])
|
||||||
|
|
||||||
|
const setAnswer = (i: number, answer: string) => {
|
||||||
|
const newAnswers = setElement(answers, i, answer)
|
||||||
|
setInternalAnswers(newAnswers)
|
||||||
|
props.setAnswers(newAnswers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAnswer = (i: number) => {
|
||||||
|
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
|
||||||
|
setInternalAnswers(newAnswers)
|
||||||
|
props.setAnswers(newAnswers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAnswer = () => setAnswer(answers.length, '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col>
|
||||||
|
{answers.map((answer, i) => (
|
||||||
|
<Row className="mb-2 items-center align-middle">
|
||||||
|
{i + 1}.{' '}
|
||||||
|
<Textarea
|
||||||
|
value={answer}
|
||||||
|
onChange={(e) => setAnswer(i, e.target.value)}
|
||||||
|
className="textarea textarea-bordered ml-2 w-full resize-none"
|
||||||
|
placeholder="Type your answer..."
|
||||||
|
rows={1}
|
||||||
|
maxLength={MAX_ANSWER_LENGTH}
|
||||||
|
/>
|
||||||
|
{answers.length > 2 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-outline ml-2"
|
||||||
|
onClick={() => removeAnswer(i)}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Row className="justify-end">
|
||||||
|
<button className="btn btn-outline btn-xs" onClick={addAnswer}>
|
||||||
|
Add answer
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setElement = <T,>(array: T[], i: number, elem: T) => {
|
||||||
|
const newArray = array.concat()
|
||||||
|
newArray[i] = elem
|
||||||
|
return newArray
|
||||||
|
}
|
77
web/components/auth-context.tsx
Normal file
77
web/components/auth-context.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ export function Avatar(props: {
|
||||||
!noLink && 'cursor-pointer',
|
!noLink && 'cursor-pointer',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{ maxWidth: `${s * 0.25}rem` }}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
alt={username}
|
alt={username}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useEffect, useState } from 'react'
|
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 { useUser } from 'web/hooks/use-user'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
@ -13,34 +13,37 @@ import {
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
} from 'common/util/format'
|
} 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 { User } from 'web/lib/firebase/users'
|
||||||
import { Bet, LimitBet } from 'common/bet'
|
import { Bet, LimitBet } from 'common/bet'
|
||||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||||
import { sellShares } from 'web/lib/firebase/api'
|
import { sellShares } from 'web/lib/firebase/api'
|
||||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
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 { getProbability } from 'common/calculate'
|
||||||
import { useFocus } from 'web/hooks/use-focus'
|
import { useFocus } from 'web/hooks/use-focus'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
import {
|
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||||
getFormattedMappedValue,
|
|
||||||
getPseudoProbability,
|
|
||||||
} from 'common/pseudo-numeric'
|
|
||||||
import { SellRow } from './sell-row'
|
import { SellRow } from './sell-row'
|
||||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { SignUpPrompt } from './sign-up-prompt'
|
import { SignUpPrompt } from './sign-up-prompt'
|
||||||
import { isIOS } from 'web/lib/util/device'
|
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 { track } from 'web/lib/service/analytics'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
|
||||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
import { LimitBets } from './limit-bets'
|
import { LimitBets } from './limit-bets'
|
||||||
import { BucketInput } from './bucket-input'
|
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
import { YesNoSelector } from './yes-no-selector'
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
|
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||||
|
import { AlertBox } from './alert-box'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -50,14 +53,10 @@ export function BetPanel(props: {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const userBets = useUserContractBets(user?.id, contract.id)
|
const userBets = useUserContractBets(user?.id, contract.id)
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
|
|
||||||
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
||||||
|
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
|
||||||
const showLimitOrders =
|
|
||||||
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
<SellRow
|
<SellRow
|
||||||
|
@ -75,17 +74,27 @@ export function BetPanel(props: {
|
||||||
<QuickOrLimitBet
|
<QuickOrLimitBet
|
||||||
isLimitOrder={isLimitOrder}
|
isLimitOrder={isLimitOrder}
|
||||||
setIsLimitOrder={setIsLimitOrder}
|
setIsLimitOrder={setIsLimitOrder}
|
||||||
|
hideToggle={!user}
|
||||||
/>
|
/>
|
||||||
<BuyPanel
|
<BuyPanel
|
||||||
|
hidden={isLimitOrder}
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
/>
|
||||||
|
<LimitOrderPanel
|
||||||
|
hidden={!isLimitOrder}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
isLimitOrder={isLimitOrder}
|
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignUpPrompt />
|
<SignUpPrompt />
|
||||||
|
|
||||||
|
{!user && <PlayMoneyDisclaimer />}
|
||||||
</Col>
|
</Col>
|
||||||
{showLimitOrders && (
|
|
||||||
|
{user && unfilledBets.length > 0 && (
|
||||||
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -105,9 +114,6 @@ export function SimpleBetPanel(props: {
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id)
|
|
||||||
const showLimitOrders =
|
|
||||||
(isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={className}>
|
<Col className={className}>
|
||||||
|
@ -125,20 +131,30 @@ export function SimpleBetPanel(props: {
|
||||||
<QuickOrLimitBet
|
<QuickOrLimitBet
|
||||||
isLimitOrder={isLimitOrder}
|
isLimitOrder={isLimitOrder}
|
||||||
setIsLimitOrder={setIsLimitOrder}
|
setIsLimitOrder={setIsLimitOrder}
|
||||||
|
hideToggle={!user}
|
||||||
/>
|
/>
|
||||||
<BuyPanel
|
<BuyPanel
|
||||||
|
hidden={isLimitOrder}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onBuySuccess={onBetSuccess}
|
onBuySuccess={onBetSuccess}
|
||||||
isLimitOrder={isLimitOrder}
|
/>
|
||||||
|
<LimitOrderPanel
|
||||||
|
hidden={!isLimitOrder}
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
onBuySuccess={onBetSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignUpPrompt />
|
<SignUpPrompt />
|
||||||
|
|
||||||
|
{!user && <PlayMoneyDisclaimer />}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{showLimitOrders && (
|
{unfilledBets.length > 0 && (
|
||||||
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -149,21 +165,17 @@ function BuyPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
unfilledBets: Bet[]
|
unfilledBets: Bet[]
|
||||||
isLimitOrder?: boolean
|
hidden: boolean
|
||||||
selected?: 'YES' | 'NO'
|
selected?: 'YES' | 'NO'
|
||||||
onBuySuccess?: () => void
|
onBuySuccess?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, unfilledBets, isLimitOrder, selected, onBuySuccess } =
|
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props
|
||||||
props
|
|
||||||
|
|
||||||
const initialProb = getProbability(contract)
|
const initialProb = getProbability(contract)
|
||||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
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 [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||||
const [limitProb, setLimitProb] = useState<number | undefined>(
|
|
||||||
Math.round(100 * initialProb)
|
|
||||||
)
|
|
||||||
const [error, setError] = useState<string | undefined>()
|
const [error, setError] = useState<string | undefined>()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
const [wasSubmitted, setWasSubmitted] = useState(false)
|
||||||
|
@ -178,7 +190,7 @@ function BuyPanel(props: {
|
||||||
}, [selected, focusAmountInput])
|
}, [selected, focusAmountInput])
|
||||||
|
|
||||||
function onBetChoice(choice: 'YES' | 'NO') {
|
function onBetChoice(choice: 'YES' | 'NO') {
|
||||||
setBetChoice(choice)
|
setOutcome(choice)
|
||||||
setWasSubmitted(false)
|
setWasSubmitted(false)
|
||||||
focusAmountInput()
|
focusAmountInput()
|
||||||
}
|
}
|
||||||
|
@ -186,29 +198,22 @@ function BuyPanel(props: {
|
||||||
function onBetChange(newAmount: number | undefined) {
|
function onBetChange(newAmount: number | undefined) {
|
||||||
setWasSubmitted(false)
|
setWasSubmitted(false)
|
||||||
setBetAmount(newAmount)
|
setBetAmount(newAmount)
|
||||||
if (!betChoice) {
|
if (!outcome) {
|
||||||
setBetChoice('YES')
|
setOutcome('YES')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBet() {
|
async function submitBet() {
|
||||||
if (!user || !betAmount) return
|
if (!user || !betAmount) return
|
||||||
if (isLimitOrder && limitProb === undefined) return
|
|
||||||
|
|
||||||
const limitProbScaled =
|
|
||||||
isLimitOrder && limitProb !== undefined ? limitProb / 100 : undefined
|
|
||||||
|
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
placeBet(
|
placeBet({
|
||||||
removeUndefinedProps({
|
outcome,
|
||||||
amount: betAmount,
|
amount: betAmount,
|
||||||
outcome: betChoice,
|
contractId: contract.id,
|
||||||
contractId: contract.id,
|
})
|
||||||
limitProb: limitProbScaled,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
console.log('placed bet. Result:', r)
|
console.log('placed bet. Result:', r)
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
@ -232,21 +237,18 @@ function BuyPanel(props: {
|
||||||
slug: contract.slug,
|
slug: contract.slug,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount: betAmount,
|
amount: betAmount,
|
||||||
outcome: betChoice,
|
outcome,
|
||||||
isLimitOrder,
|
isLimitOrder: false,
|
||||||
limitProb: limitProbScaled,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const betDisabled = isSubmitting || !betAmount || error
|
const betDisabled = isSubmitting || !betAmount || error
|
||||||
|
|
||||||
const limitProbFrac = (limitProb ?? 0) / 100
|
|
||||||
|
|
||||||
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
|
const { newPool, newP, newBet } = getBinaryCpmmBetInfo(
|
||||||
betChoice ?? 'YES',
|
outcome ?? 'YES',
|
||||||
betAmount ?? 0,
|
betAmount ?? 0,
|
||||||
contract,
|
contract,
|
||||||
isLimitOrder ? limitProbFrac : undefined,
|
undefined,
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets as LimitBet[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -254,11 +256,7 @@ function BuyPanel(props: {
|
||||||
const probStayedSame =
|
const probStayedSame =
|
||||||
formatPercent(resultProb) === formatPercent(initialProb)
|
formatPercent(resultProb) === formatPercent(initialProb)
|
||||||
|
|
||||||
const remainingMatched = isLimitOrder
|
const currentPayout = newBet.shares
|
||||||
? ((newBet.orderAmount ?? 0) - newBet.amount) /
|
|
||||||
(betChoice === 'YES' ? limitProbFrac : 1 - limitProbFrac)
|
|
||||||
: 0
|
|
||||||
const currentPayout = newBet.shares + remainingMatched
|
|
||||||
|
|
||||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||||
const currentReturnPercent = formatPercent(currentReturn)
|
const currentReturnPercent = formatPercent(currentReturn)
|
||||||
|
@ -267,15 +265,17 @@ function BuyPanel(props: {
|
||||||
|
|
||||||
const format = getFormattedMappedValue(contract)
|
const format = getFormattedMappedValue(contract)
|
||||||
|
|
||||||
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">
|
<div className="my-3 text-left text-sm text-gray-500">
|
||||||
{isPseudoNumeric ? 'Direction' : 'Outcome'}
|
{isPseudoNumeric ? 'Direction' : 'Outcome'}
|
||||||
</div>
|
</div>
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
btnClassName="flex-1"
|
btnClassName="flex-1"
|
||||||
selected={betChoice}
|
selected={outcome}
|
||||||
onSelect={(choice) => onBetChoice(choice)}
|
onSelect={(choice) => onBetChoice(choice)}
|
||||||
isPseudoNumeric={isPseudoNumeric}
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
/>
|
/>
|
||||||
|
@ -290,61 +290,37 @@ function BuyPanel(props: {
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
/>
|
/>
|
||||||
{isLimitOrder && (
|
|
||||||
<>
|
{(betAmount ?? 0) > 10 &&
|
||||||
<Row className="my-3 items-center gap-2 text-left text-sm text-gray-500">
|
bankrollFraction >= 0.5 &&
|
||||||
Limit {isPseudoNumeric ? 'value' : 'probability'}
|
bankrollFraction <= 1 ? (
|
||||||
<InfoTooltip
|
<AlertBox
|
||||||
text={`Bet ${betChoice === 'NO' ? 'down' : 'up'} to this ${
|
title="Whoa, there!"
|
||||||
isPseudoNumeric ? 'value' : 'probability'
|
text={`You might not want to spend ${formatPercent(
|
||||||
} and wait to match other bets.`}
|
bankrollFraction
|
||||||
/>
|
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||||
</Row>
|
user?.balance ?? 0
|
||||||
{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">
|
<Col className="mt-3 w-full gap-3">
|
||||||
{!isLimitOrder && (
|
<Row className="items-center justify-between text-sm">
|
||||||
<Row className="items-center justify-between text-sm">
|
<div className="text-gray-500">
|
||||||
<div className="text-gray-500">
|
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
</div>
|
||||||
|
{probStayedSame ? (
|
||||||
|
<div>{format(initialProb)}</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{format(initialProb)}
|
||||||
|
<span className="mx-2">→</span>
|
||||||
|
{format(resultProb)}
|
||||||
</div>
|
</div>
|
||||||
{probStayedSame ? (
|
)}
|
||||||
<div>{format(initialProb)}</div>
|
</Row>
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{format(initialProb)}
|
|
||||||
<span className="mx-2">→</span>
|
|
||||||
{format(resultProb)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Row className="items-center justify-between gap-2 text-sm">
|
<Row className="items-center justify-between gap-2 text-sm">
|
||||||
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
||||||
|
@ -353,7 +329,7 @@ function BuyPanel(props: {
|
||||||
'Max payout'
|
'Max payout'
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -372,6 +348,348 @@ function BuyPanel(props: {
|
||||||
|
|
||||||
<Spacer h={8} />
|
<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 && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -387,48 +705,47 @@ function BuyPanel(props: {
|
||||||
>
|
>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? 'Submitting...'
|
? 'Submitting...'
|
||||||
: isLimitOrder
|
: `Submit order${hasTwoBets ? 's' : ''}`}
|
||||||
? 'Submit order'
|
|
||||||
: 'Submit bet'}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wasSubmitted && (
|
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
|
||||||
<div className="mt-4">{isLimitOrder ? 'Order' : 'Bet'} submitted!</div>
|
</Col>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickOrLimitBet(props: {
|
function QuickOrLimitBet(props: {
|
||||||
isLimitOrder: boolean
|
isLimitOrder: boolean
|
||||||
setIsLimitOrder: (isLimitOrder: boolean) => void
|
setIsLimitOrder: (isLimitOrder: boolean) => void
|
||||||
|
hideToggle?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { isLimitOrder, setIsLimitOrder } = props
|
const { isLimitOrder, setIsLimitOrder, hideToggle } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="align-center mb-4 justify-between">
|
<Row className="align-center mb-4 justify-between">
|
||||||
<div className="text-4xl">Bet</div>
|
<div className="text-4xl">Bet</div>
|
||||||
<Row className="mt-1 items-center gap-2">
|
{!hideToggle && (
|
||||||
<PillButton
|
<Row className="mt-1 items-center gap-2">
|
||||||
selected={!isLimitOrder}
|
<PillButton
|
||||||
onSelect={() => {
|
selected={!isLimitOrder}
|
||||||
setIsLimitOrder(false)
|
onSelect={() => {
|
||||||
track('select quick order')
|
setIsLimitOrder(false)
|
||||||
}}
|
track('select quick order')
|
||||||
>
|
}}
|
||||||
Quick
|
>
|
||||||
</PillButton>
|
Quick
|
||||||
<PillButton
|
</PillButton>
|
||||||
selected={isLimitOrder}
|
<PillButton
|
||||||
onSelect={() => {
|
selected={isLimitOrder}
|
||||||
setIsLimitOrder(true)
|
onSelect={() => {
|
||||||
track('select limit order')
|
setIsLimitOrder(true)
|
||||||
}}
|
track('select limit order')
|
||||||
>
|
}}
|
||||||
Limit
|
>
|
||||||
</PillButton>
|
Limit
|
||||||
</Row>
|
</PillButton>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -454,7 +771,9 @@ export function SellPanel(props: {
|
||||||
const betDisabled = isSubmitting || !amount || error
|
const betDisabled = isSubmitting || !amount || error
|
||||||
|
|
||||||
// Sell all shares if remaining shares would be < 1
|
// Sell all shares if remaining shares would be < 1
|
||||||
const sellQuantity = amount === Math.floor(shares) ? shares : amount
|
const isSellingAllShares = amount === Math.floor(shares)
|
||||||
|
|
||||||
|
const sellQuantity = isSellingAllShares ? shares : amount
|
||||||
|
|
||||||
async function submitSell() {
|
async function submitSell() {
|
||||||
if (!user || !amount) return
|
if (!user || !amount) return
|
||||||
|
@ -463,7 +782,7 @@ export function SellPanel(props: {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
await sellShares({
|
await sellShares({
|
||||||
shares: sellQuantity,
|
shares: isSellingAllShares ? undefined : amount,
|
||||||
outcome: sharesOutcome,
|
outcome: sharesOutcome,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Bet } from 'web/lib/firebase/bets'
|
import { Bet } from 'web/lib/firebase/bets'
|
||||||
import { User } from 'web/lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
|
@ -50,7 +51,7 @@ import { LimitOrderTable } from './limit-bets'
|
||||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
||||||
|
|
||||||
const CONTRACTS_PER_PAGE = 20
|
const CONTRACTS_PER_PAGE = 50
|
||||||
|
|
||||||
export function BetsList(props: {
|
export function BetsList(props: {
|
||||||
user: User
|
user: User
|
||||||
|
@ -78,10 +79,10 @@ export function BetsList(props: {
|
||||||
|
|
||||||
const getTime = useTimeSinceFirstRender()
|
const getTime = useTimeSinceFirstRender()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bets && contractsById) {
|
if (bets && contractsById && signedInUser) {
|
||||||
trackLatency('portfolio', getTime())
|
trackLatency(signedInUser.id, 'portfolio', getTime())
|
||||||
}
|
}
|
||||||
}, [bets, contractsById, getTime])
|
}, [signedInUser, bets, contractsById, getTime])
|
||||||
|
|
||||||
if (!bets || !contractsById) {
|
if (!bets || !contractsById) {
|
||||||
return <LoadingIndicator />
|
return <LoadingIndicator />
|
||||||
|
@ -156,9 +157,7 @@ export function BetsList(props: {
|
||||||
(c) => contractsMetrics[c.id].netPayout
|
(c) => contractsMetrics[c.id].netPayout
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalPortfolio = currentNetInvestment + user.balance
|
const totalPnl = user.profitCached.allTime
|
||||||
|
|
||||||
const totalPnl = totalPortfolio - user.totalDeposits
|
|
||||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
||||||
const investedProfitPercent =
|
const investedProfitPercent =
|
||||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||||
|
@ -277,13 +276,7 @@ function ContractBets(props: {
|
||||||
bets
|
bets
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<div
|
<div tabIndex={0} className="relative bg-white p-4 pr-6">
|
||||||
tabIndex={0}
|
|
||||||
className={clsx(
|
|
||||||
'collapse collapse-arrow relative bg-white p-4 pr-6',
|
|
||||||
collapsed ? 'collapse-close' : 'collapse-open pb-2'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Row
|
<Row
|
||||||
className="cursor-pointer flex-wrap gap-2"
|
className="cursor-pointer flex-wrap gap-2"
|
||||||
onClick={() => setCollapsed((collapsed) => !collapsed)}
|
onClick={() => setCollapsed((collapsed) => !collapsed)}
|
||||||
|
@ -300,10 +293,11 @@ function ContractBets(props: {
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Show carrot for collapsing. Hack the positioning. */}
|
{/* Show carrot for collapsing. Hack the positioning. */}
|
||||||
<div
|
{collapsed ? (
|
||||||
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
|
<ChevronDownIcon className="absolute top-5 right-4 h-6 w-6" />
|
||||||
style={{ top: -10, right: 0 }}
|
) : (
|
||||||
/>
|
<ChevronUpIcon className="absolute top-5 right-4 h-6 w-6" />
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row className="flex-1 items-center gap-2 text-sm text-gray-500">
|
<Row className="flex-1 items-center gap-2 text-sm text-gray-500">
|
||||||
|
@ -335,55 +329,42 @@ function ContractBets(props: {
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Row className="mr-5 justify-end sm:mr-8">
|
<Col className="mr-5 sm:mr-8">
|
||||||
<Col>
|
<div className="whitespace-nowrap text-right text-lg">
|
||||||
<div className="whitespace-nowrap text-right text-lg">
|
{formatMoney(metric === 'profit' ? profit : payout)}
|
||||||
{formatMoney(metric === 'profit' ? profit : payout)}
|
</div>
|
||||||
</div>
|
<ProfitBadge className="text-right" profitPercent={profitPercent} />
|
||||||
<div className="text-right">
|
</Col>
|
||||||
<ProfitBadge profitPercent={profitPercent} />
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<div
|
{!collapsed && (
|
||||||
className="collapse-content !px-0"
|
<div className="bg-white">
|
||||||
style={{ backgroundColor: 'white' }}
|
<BetsSummary
|
||||||
>
|
className="mt-8 mr-5 flex-1 sm:mr-8"
|
||||||
<Spacer h={8} />
|
contract={contract}
|
||||||
|
bets={bets}
|
||||||
|
isYourBets={isYourBets}
|
||||||
|
/>
|
||||||
|
|
||||||
<BetsSummary
|
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
||||||
className="mr-5 flex-1 sm:mr-8"
|
|
||||||
contract={contract}
|
|
||||||
bets={bets}
|
|
||||||
isYourBets={isYourBets}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Spacer h={4} />
|
|
||||||
|
|
||||||
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<div className="bg-gray-50 px-4 py-2">Limit orders</div>
|
<div className="mt-4 bg-gray-50 px-4 py-2">Limit orders</div>
|
||||||
<LimitOrderTable
|
<LimitOrderTable
|
||||||
contract={contract}
|
contract={contract}
|
||||||
limitBets={limitBets}
|
limitBets={limitBets}
|
||||||
isYou={true}
|
isYou={isYourBets}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<Spacer h={4} />
|
<div className="mt-4 bg-gray-50 px-4 py-2">Bets</div>
|
||||||
|
<ContractBetsTable
|
||||||
<div className="bg-gray-50 px-4 py-2">Bets</div>
|
contract={contract}
|
||||||
<ContractBetsTable
|
bets={bets}
|
||||||
contract={contract}
|
isYourBets={isYourBets}
|
||||||
bets={bets}
|
/>
|
||||||
isYourBets={isYourBets}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -427,107 +408,92 @@ export function BetsSummary(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
|
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
|
||||||
<Row className="flex-wrap gap-4 sm:gap-6">
|
{!isCpmm && (
|
||||||
{!isCpmm && (
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Invested
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{resolution ? (
|
|
||||||
<Col>
|
|
||||||
<div className="text-sm text-gray-500">Payout</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(payout)}{' '}
|
|
||||||
<ProfitBadge profitPercent={profitPercent} />
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isBinary ? (
|
|
||||||
<>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if <YesLabel />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(yesWinnings)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if <NoLabel />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(noWinnings)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
) : isPseudoNumeric ? (
|
|
||||||
<>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(yesWinnings)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(noWinnings)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Current value
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Col>
|
<Col>
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Invested
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{resolution ? (
|
||||||
|
<Col>
|
||||||
|
<div className="text-sm text-gray-500">Payout</div>
|
||||||
<div className="whitespace-nowrap">
|
<div className="whitespace-nowrap">
|
||||||
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
|
||||||
{isYourBets &&
|
|
||||||
isCpmm &&
|
|
||||||
(isBinary || isPseudoNumeric) &&
|
|
||||||
!isClosed &&
|
|
||||||
!resolution &&
|
|
||||||
hasShares &&
|
|
||||||
sharesOutcome &&
|
|
||||||
user && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm ml-2"
|
|
||||||
onClick={() => setShowSellModal(true)}
|
|
||||||
>
|
|
||||||
Sell
|
|
||||||
</button>
|
|
||||||
{showSellModal && (
|
|
||||||
<SellSharesModal
|
|
||||||
contract={contract}
|
|
||||||
user={user}
|
|
||||||
userBets={bets}
|
|
||||||
shares={totalShares[sharesOutcome]}
|
|
||||||
sharesOutcome={sharesOutcome}
|
|
||||||
setOpen={setShowSellModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
) : isBinary ? (
|
||||||
|
<>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if <YesLabel />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if <NoLabel />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
) : isPseudoNumeric ? (
|
||||||
|
<>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Payout if {'<='} {formatLargeNumber(contract.min)}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Current value
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
||||||
|
{isYourBets &&
|
||||||
|
isCpmm &&
|
||||||
|
(isBinary || isPseudoNumeric) &&
|
||||||
|
!isClosed &&
|
||||||
|
!resolution &&
|
||||||
|
hasShares &&
|
||||||
|
sharesOutcome &&
|
||||||
|
user && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm ml-2"
|
||||||
|
onClick={() => setShowSellModal(true)}
|
||||||
|
>
|
||||||
|
Sell
|
||||||
|
</button>
|
||||||
|
{showSellModal && (
|
||||||
|
<SellSharesModal
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
userBets={bets}
|
||||||
|
shares={totalShares[sharesOutcome]}
|
||||||
|
sharesOutcome={sharesOutcome}
|
||||||
|
setOpen={setShowSellModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -689,7 +655,13 @@ function BetRow(props: {
|
||||||
!isClosed &&
|
!isClosed &&
|
||||||
!isSold &&
|
!isSold &&
|
||||||
!isAnte &&
|
!isAnte &&
|
||||||
!isNumeric && <SellButton contract={contract} bet={bet} />}
|
!isNumeric && (
|
||||||
|
<SellButton
|
||||||
|
contract={contract}
|
||||||
|
bet={bet}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
|
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
|
||||||
<td>
|
<td>
|
||||||
|
@ -729,8 +701,12 @@ function BetRow(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SellButton(props: { contract: Contract; bet: Bet }) {
|
function SellButton(props: {
|
||||||
const { contract, bet } = props
|
contract: Contract
|
||||||
|
bet: Bet
|
||||||
|
unfilledBets: LimitBet[]
|
||||||
|
}) {
|
||||||
|
const { contract, bet, unfilledBets } = props
|
||||||
const { outcome, shares, loanAmount } = bet
|
const { outcome, shares, loanAmount } = bet
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
@ -740,8 +716,6 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
||||||
outcome === 'NO' ? 'YES' : outcome
|
outcome === 'NO' ? 'YES' : outcome
|
||||||
)
|
)
|
||||||
|
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
|
||||||
|
|
||||||
const outcomeProb = getProbabilityAfterSale(
|
const outcomeProb = getProbabilityAfterSale(
|
||||||
contract,
|
contract,
|
||||||
outcome,
|
outcome,
|
||||||
|
@ -787,8 +761,8 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfitBadge(props: { profitPercent: number }) {
|
function ProfitBadge(props: { profitPercent: number; className?: string }) {
|
||||||
const { profitPercent } = props
|
const { profitPercent, className } = props
|
||||||
if (!profitPercent) return null
|
if (!profitPercent) return null
|
||||||
const colors =
|
const colors =
|
||||||
profitPercent > 0
|
profitPercent > 0
|
||||||
|
@ -799,7 +773,8 @@ function ProfitBadge(props: { profitPercent: number }) {
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
|
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
|
||||||
colors
|
colors,
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
|
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
|
||||||
|
|
|
@ -9,8 +9,9 @@ export function BucketInput(props: {
|
||||||
contract: NumericContract | PseudoNumericContract
|
contract: NumericContract | PseudoNumericContract
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
onBucketChange: (value?: number, bucket?: string) => void
|
onBucketChange: (value?: number, bucket?: string) => void
|
||||||
|
placeholder?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, isSubmitting, onBucketChange } = props
|
const { contract, isSubmitting, onBucketChange, placeholder } = props
|
||||||
|
|
||||||
const [numberString, setNumberString] = useState('')
|
const [numberString, setNumberString] = useState('')
|
||||||
|
|
||||||
|
@ -39,7 +40,7 @@ export function BucketInput(props: {
|
||||||
error={undefined}
|
error={undefined}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
numberString={numberString}
|
numberString={numberString}
|
||||||
label="Value"
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ export function Button(props: {
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
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'
|
type?: 'button' | 'reset' | 'submit'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
@ -39,7 +39,10 @@ export function Button(props: {
|
||||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||||
color === 'gray' && 'bg-gray-200 text-gray-700 hover:bg-gray-300',
|
color === 'gray' &&
|
||||||
|
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
|
||||||
|
color === 'gray-white' &&
|
||||||
|
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -13,10 +13,10 @@ export function PillButton(props: {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer select-none rounded-full',
|
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
||||||
selected
|
selected
|
||||||
? ['text-white', color ?? 'bg-gray-700']
|
? ['text-white', color ?? 'bg-greyscale-6']
|
||||||
: 'bg-gray-100 hover:bg-gray-200',
|
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
|
|
@ -6,10 +6,9 @@ import { Charity } from 'common/charity'
|
||||||
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
||||||
import { manaToUSD } from '../../../common/util/format'
|
import { manaToUSD } from '../../../common/util/format'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Col } from '../layout/col'
|
|
||||||
|
|
||||||
export function CharityCard(props: { charity: Charity; match?: number }) {
|
export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
const { charity, match } = props
|
const { charity } = props
|
||||||
const { slug, photo, preview, id, tags } = charity
|
const { slug, photo, preview, id, tags } = charity
|
||||||
|
|
||||||
const txns = useCharityTxns(id)
|
const txns = useCharityTxns(id)
|
||||||
|
@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||||
{raised > 0 && (
|
{raised > 0 && (
|
||||||
<>
|
<>
|
||||||
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
|
<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">
|
<span className="text-3xl font-semibold">
|
||||||
{formatUsd(raised)}
|
{formatUsd(raised)}
|
||||||
</span>
|
</span>
|
||||||
<span>raised</span>
|
raised
|
||||||
</Col>
|
</Row>
|
||||||
{match && (
|
{/* {match && (
|
||||||
<Col className="text-gray-500">
|
<Col className="text-gray-500">
|
||||||
<span className="text-xl">+{formatUsd(match)}</span>
|
<span className="text-xl">+{formatUsd(match)}</span>
|
||||||
<span className="">match</span>
|
<span className="">match</span>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)} */}
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -17,58 +17,55 @@ export function UserCommentsList(props: {
|
||||||
contractsById: { [id: string]: Contract }
|
contractsById: { [id: string]: Contract }
|
||||||
}) {
|
}) {
|
||||||
const { comments, contractsById } = props
|
const { comments, contractsById } = props
|
||||||
const commentsByContract = groupBy(comments, 'contractId')
|
|
||||||
|
|
||||||
const contractCommentPairs = Object.entries(commentsByContract)
|
// we don't show comments in groups here atm, just comments on contracts
|
||||||
.map(
|
const contractComments = comments.filter((c) => c.contractId)
|
||||||
([contractId, comments]) => [contractsById[contractId], comments] as const
|
const commentsByContract = groupBy(contractComments, 'contractId')
|
||||||
)
|
|
||||||
.filter(([contract]) => contract)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'bg-white'}>
|
<Col className={'bg-white'}>
|
||||||
{contractCommentPairs.map(([contract, comments]) => (
|
{Object.entries(commentsByContract).map(([contractId, comments]) => {
|
||||||
<div key={contract.id} className={'border-width-1 border-b p-5'}>
|
const contract = contractsById[contractId]
|
||||||
<div className={'mb-2 text-sm text-indigo-700'}>
|
return (
|
||||||
<SiteLink href={contractPath(contract)}>
|
<div key={contractId} className={'border-width-1 border-b p-5'}>
|
||||||
|
<SiteLink
|
||||||
|
className={'mb-2 block text-sm text-indigo-700'}
|
||||||
|
href={contractPath(contract)}
|
||||||
|
>
|
||||||
{contract.question}
|
{contract.question}
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<ProfileComment
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
className="relative flex items-start space-x-3 pb-6"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{comments.map((comment) => (
|
)
|
||||||
<div key={comment.id} className={'relative pb-6'}>
|
})}
|
||||||
<div className="relative flex items-start space-x-3">
|
|
||||||
<ProfileComment comment={comment} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileComment(props: { comment: Comment }) {
|
function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||||
const { comment } = props
|
const { comment, className } = props
|
||||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||||
// TODO: find and attach relevant bets by comment betId at some point
|
// TODO: find and attach relevant bets by comment betId at some point
|
||||||
return (
|
return (
|
||||||
<div>
|
<Row className={className}>
|
||||||
<Row className={'gap-4'}>
|
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
||||||
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1">
|
<p className="mt-0.5 text-sm text-gray-500">
|
||||||
<div>
|
<UserLink
|
||||||
<p className="mt-0.5 text-sm text-gray-500">
|
className="text-gray-500"
|
||||||
<UserLink
|
username={userUsername}
|
||||||
className="text-gray-500"
|
name={userName}
|
||||||
username={userUsername}
|
/>{' '}
|
||||||
name={userName}
|
<RelativeTimestamp time={createdTime} />
|
||||||
/>{' '}
|
</p>
|
||||||
<RelativeTimestamp time={createdTime} />
|
<Linkify text={text} />
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</Row>
|
||||||
<Linkify text={text} />
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,26 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import algoliasearch from 'algoliasearch/lite'
|
import algoliasearch from 'algoliasearch/lite'
|
||||||
import {
|
|
||||||
Configure,
|
|
||||||
InstantSearch,
|
|
||||||
SearchBox,
|
|
||||||
SortBy,
|
|
||||||
useInfiniteHits,
|
|
||||||
useSortBy,
|
|
||||||
} from 'react-instantsearch-hooks-web'
|
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
||||||
import {
|
import {
|
||||||
Sort,
|
ContractHighlightOptions,
|
||||||
useInitialQueryAndSort,
|
ContractsGrid,
|
||||||
useUpdateQueryAndSort,
|
} from './contract/contracts-list'
|
||||||
} from '../hooks/use-sort-and-query-params'
|
|
||||||
import { ContractsGrid } from './contract/contracts-list'
|
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
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 ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
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 { PillButton } from './buttons/pill-button'
|
||||||
import { toPairs } from 'lodash'
|
import { range, sortBy } from 'lodash'
|
||||||
|
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
|
||||||
const searchClient = algoliasearch(
|
const searchClient = algoliasearch(
|
||||||
'GJQPAYENIF',
|
'GJQPAYENIF',
|
||||||
|
@ -35,26 +28,21 @@ const searchClient = algoliasearch(
|
||||||
)
|
)
|
||||||
|
|
||||||
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||||
|
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||||
|
|
||||||
const sortIndexes = [
|
const sortOptions = [
|
||||||
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
|
{ label: 'Newest', value: 'newest' },
|
||||||
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
|
{ label: 'Trending', value: 'score' },
|
||||||
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' },
|
{ label: 'Most traded', value: 'most-traded' },
|
||||||
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
|
{ label: '24h volume', value: '24-hour-vol' },
|
||||||
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
|
{ label: 'Last updated', value: 'last-updated' },
|
||||||
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
|
{ label: 'Subsidy', value: 'liquidity' },
|
||||||
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
|
{ label: 'Close date', value: 'close-date' },
|
||||||
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
|
{ label: 'Resolve date', value: 'resolve-date' },
|
||||||
]
|
]
|
||||||
|
export const DEFAULT_SORT = 'score'
|
||||||
|
|
||||||
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
|
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: {
|
export function ContractSearch(props: {
|
||||||
querySortOptions?: {
|
querySortOptions?: {
|
||||||
|
@ -68,11 +56,15 @@ export function ContractSearch(props: {
|
||||||
excludeContractIds?: string[]
|
excludeContractIds?: string[]
|
||||||
groupSlug?: string
|
groupSlug?: string
|
||||||
}
|
}
|
||||||
|
highlightOptions?: ContractHighlightOptions
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
showPlaceHolder?: boolean
|
showPlaceHolder?: boolean
|
||||||
hideOrderSelector?: boolean
|
hideOrderSelector?: boolean
|
||||||
overrideGridClassName?: string
|
overrideGridClassName?: string
|
||||||
hideQuickBet?: boolean
|
cardHideOptions?: {
|
||||||
|
hideGroupLink?: boolean
|
||||||
|
hideQuickBet?: boolean
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
querySortOptions,
|
querySortOptions,
|
||||||
|
@ -81,68 +73,178 @@ export function ContractSearch(props: {
|
||||||
overrideGridClassName,
|
overrideGridClassName,
|
||||||
hideOrderSelector,
|
hideOrderSelector,
|
||||||
showPlaceHolder,
|
showPlaceHolder,
|
||||||
hideQuickBet,
|
cardHideOptions,
|
||||||
|
highlightOptions,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const memberGroupSlugs = useMemberGroups(user?.id)
|
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||||
?.map((g) => g.slug)
|
(group) => !NEW_USER_GROUP_SLUGS.includes(group.slug)
|
||||||
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s))
|
)
|
||||||
const follows = useFollows(user?.id)
|
const memberGroupSlugs =
|
||||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
memberGroups.length > 0
|
||||||
|
? memberGroups.map((g) => g.slug)
|
||||||
|
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
||||||
|
|
||||||
const sort = sortIndexes
|
const memberPillGroups = sortBy(
|
||||||
.map(({ value }) => value)
|
memberGroups.filter((group) => group.contractIds.length > 0),
|
||||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
(group) => group.contractIds.length
|
||||||
? initialSort
|
).reverse()
|
||||||
: querySortOptions?.defaultSort ?? 'most-popular'
|
|
||||||
|
const defaultPillGroups = DEFAULT_CATEGORY_GROUPS as Group[]
|
||||||
|
|
||||||
|
const pillGroups =
|
||||||
|
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
||||||
|
|
||||||
|
const follows = useFollows(user?.id)
|
||||||
|
|
||||||
|
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
|
||||||
|
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
|
||||||
|
defaultSort,
|
||||||
|
shouldLoadFromStorage,
|
||||||
|
})
|
||||||
|
|
||||||
const [filter, setFilter] = useState<filter>(
|
const [filter, setFilter] = useState<filter>(
|
||||||
querySortOptions?.defaultFilter ?? 'open'
|
querySortOptions?.defaultFilter ?? 'open'
|
||||||
)
|
)
|
||||||
|
const pillsEnabled = !additionalFilter && !query
|
||||||
|
|
||||||
const { filters, numericFilters } = useMemo(() => {
|
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
||||||
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}`
|
|
||||||
: '',
|
|
||||||
].filter((f) => f)
|
|
||||||
// Hack to make Algolia work.
|
|
||||||
filters = ['', ...filters]
|
|
||||||
|
|
||||||
const numericFilters = [
|
const selectPill = (pill: string | undefined) => () => {
|
||||||
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
setPillFilter(pill)
|
||||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
setPage(0)
|
||||||
].filter((f) => f)
|
track('select search category', { category: pill ?? 'all' })
|
||||||
|
}
|
||||||
|
|
||||||
return { filters, numericFilters }
|
const additionalFilters = [
|
||||||
}, [
|
additionalFilter?.creatorId
|
||||||
filter,
|
? `creatorId:${additionalFilter.creatorId}`
|
||||||
Object.values(additionalFilter ?? {}).join(','),
|
: '',
|
||||||
(memberGroupSlugs ?? []).join(','),
|
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
||||||
(follows ?? []).join(','),
|
additionalFilter?.groupSlug
|
||||||
])
|
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
const facetFilters = query
|
||||||
|
? additionalFilters
|
||||||
|
: [
|
||||||
|
...additionalFilters,
|
||||||
|
filter === 'open' ? 'isResolved:false' : '',
|
||||||
|
filter === 'closed' ? 'isResolved:false' : '',
|
||||||
|
filter === 'resolved' ? 'isResolved:true' : '',
|
||||||
|
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)
|
||||||
|
|
||||||
|
const numericFilters = query
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
||||||
|
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||||
|
].filter((f) => f)
|
||||||
|
|
||||||
const indexName = `${indexPrefix}contracts-${sort}`
|
const indexName = `${indexPrefix}contracts-${sort}`
|
||||||
|
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
|
||||||
|
const searchIndex = useMemo(
|
||||||
|
() => searchClient.initIndex(searchIndexName),
|
||||||
|
[searchIndexName]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [numPages, setNumPages] = useState(1)
|
||||||
|
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let wasMostRecentQuery = true
|
||||||
|
const algoliaIndex = query ? searchIndex : index
|
||||||
|
|
||||||
|
algoliaIndex
|
||||||
|
.search(query, {
|
||||||
|
facetFilters,
|
||||||
|
numericFilters,
|
||||||
|
page,
|
||||||
|
hitsPerPage: 20,
|
||||||
|
})
|
||||||
|
.then((results) => {
|
||||||
|
if (!wasMostRecentQuery) return
|
||||||
|
|
||||||
|
if (page === 0) {
|
||||||
|
setHitsByPage({
|
||||||
|
[0]: results.hits as any as Contract[],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setHitsByPage((hitsByPage) => ({
|
||||||
|
...hitsByPage,
|
||||||
|
[page]: results.hits,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
setNumPages(results.nbPages)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
wasMostRecentQuery = false
|
||||||
|
}
|
||||||
|
// Note numeric filters are unique based on current time, so can't compare
|
||||||
|
// them by value.
|
||||||
|
}, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter])
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (page >= numPages - 1) return
|
||||||
|
|
||||||
|
const haveLoadedCurrentPage = hitsByPage[page]
|
||||||
|
if (haveLoadedCurrentPage) setPage(page + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = range(0, page + 1)
|
||||||
|
.map((p) => hitsByPage[p] ?? [])
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
const contracts = hits.filter(
|
||||||
|
(c) => !additionalFilter?.excludeContractIds?.includes(c.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const showTime =
|
||||||
|
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
|
||||||
|
|
||||||
|
const updateQuery = (newQuery: string) => {
|
||||||
|
setQuery(newQuery)
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFilter = (newFilter: filter) => {
|
||||||
|
if (newFilter === filter) return
|
||||||
|
setFilter(newFilter)
|
||||||
|
setPage(0)
|
||||||
|
trackCallback('select search filter', { filter: newFilter })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectSort = (newSort: Sort) => {
|
||||||
|
if (newSort === sort) return
|
||||||
|
|
||||||
|
setPage(0)
|
||||||
|
setSort(newSort)
|
||||||
|
track('select sort', { sort: newSort })
|
||||||
|
}
|
||||||
|
|
||||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return (
|
return (
|
||||||
|
@ -154,143 +256,103 @@ export function ContractSearch(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
<Col>
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<SearchBox
|
<input
|
||||||
className="flex-1"
|
type="text"
|
||||||
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
|
value={query}
|
||||||
classNames={{
|
onChange={(e) => updateQuery(e.target.value)}
|
||||||
form: 'before:top-6',
|
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
||||||
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
|
className="input input-bordered w-full"
|
||||||
resetIcon: 'mt-2 hidden sm:flex',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/*// TODO track WHICH filter users are using*/}
|
{!query && (
|
||||||
{!hideOrderSelector && (
|
<select
|
||||||
<SortBy
|
className="select select-bordered"
|
||||||
items={sortIndexes}
|
value={filter}
|
||||||
classNames={{
|
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||||
select: '!select !select-bordered',
|
>
|
||||||
}}
|
<option value="open">Open</option>
|
||||||
onBlur={trackCallback('select search sort')}
|
<option value="closed">Closed</option>
|
||||||
/>
|
<option value="resolved">Resolved</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{!hideOrderSelector && !query && (
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||||
|
>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
<Configure
|
|
||||||
facetFilters={filters}
|
|
||||||
numericFilters={numericFilters}
|
|
||||||
// Page resets on filters change.
|
|
||||||
page={0}
|
|
||||||
/>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Spacer h={3} />
|
<Spacer h={3} />
|
||||||
|
|
||||||
<Row className="gap-2">
|
{pillsEnabled && (
|
||||||
{toPairs<filter>(filterOptions).map(([label, f]) => {
|
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
|
||||||
return (
|
<PillButton
|
||||||
<PillButton selected={filter === f} onSelect={() => setFilter(f)}>
|
key={'all'}
|
||||||
{label}
|
selected={pillFilter === undefined}
|
||||||
|
onSelect={selectPill(undefined)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</PillButton>
|
||||||
|
<PillButton
|
||||||
|
key={'personal'}
|
||||||
|
selected={pillFilter === 'personal'}
|
||||||
|
onSelect={selectPill('personal')}
|
||||||
|
>
|
||||||
|
{user ? 'For you' : 'Featured'}
|
||||||
|
</PillButton>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<PillButton
|
||||||
|
key={'your-bets'}
|
||||||
|
selected={pillFilter === 'your-bets'}
|
||||||
|
onSelect={selectPill('your-bets')}
|
||||||
|
>
|
||||||
|
Your bets
|
||||||
</PillButton>
|
</PillButton>
|
||||||
)
|
)}
|
||||||
})}
|
|
||||||
</Row>
|
{pillGroups.map(({ name, slug }) => {
|
||||||
|
return (
|
||||||
|
<PillButton
|
||||||
|
key={slug}
|
||||||
|
selected={pillFilter === slug}
|
||||||
|
onSelect={selectPill(slug)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</PillButton>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
<Spacer h={3} />
|
<Spacer h={3} />
|
||||||
|
|
||||||
{filter === 'personal' &&
|
{filter === 'personal' &&
|
||||||
(follows ?? []).length === 0 &&
|
(follows ?? []).length === 0 &&
|
||||||
(memberGroupSlugs ?? []).length === 0 ? (
|
memberGroupSlugs.length === 0 ? (
|
||||||
<>You're not following anyone, nor in any of your own groups yet.</>
|
<>You're not following anyone, nor in any of your own groups yet.</>
|
||||||
) : (
|
) : (
|
||||||
<ContractSearchInner
|
<ContractsGrid
|
||||||
querySortOptions={querySortOptions}
|
contracts={contracts}
|
||||||
|
loadMore={loadMore}
|
||||||
|
hasMore={true}
|
||||||
|
showTime={showTime}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
overrideGridClassName={overrideGridClassName}
|
overrideGridClassName={overrideGridClassName}
|
||||||
hideQuickBet={hideQuickBet}
|
highlightOptions={highlightOptions}
|
||||||
excludeContractIds={additionalFilter?.excludeContractIds}
|
cardHideOptions={cardHideOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InstantSearch>
|
</Col>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractSearchInner(props: {
|
|
||||||
querySortOptions?: {
|
|
||||||
defaultSort: Sort
|
|
||||||
shouldLoadFromStorage?: boolean
|
|
||||||
}
|
|
||||||
onContractClick?: (contract: Contract) => void
|
|
||||||
overrideGridClassName?: string
|
|
||||||
hideQuickBet?: boolean
|
|
||||||
excludeContractIds?: string[]
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
querySortOptions,
|
|
||||||
onContractClick,
|
|
||||||
overrideGridClassName,
|
|
||||||
hideQuickBet,
|
|
||||||
excludeContractIds,
|
|
||||||
} = props
|
|
||||||
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
|
||||||
|
|
||||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
|
||||||
shouldLoadFromStorage: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQuery(initialQuery)
|
|
||||||
}, [initialQuery])
|
|
||||||
|
|
||||||
const { currentRefinement: index } = useSortBy({
|
|
||||||
items: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQuery(query)
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
const isFirstRender = useRef(true)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFirstRender.current) {
|
|
||||||
isFirstRender.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sort = index.split('contracts-')[1] as Sort
|
|
||||||
if (sort) {
|
|
||||||
setSort(sort)
|
|
||||||
}
|
|
||||||
}, [index])
|
|
||||||
|
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setTimeout(() => setIsInitialLoad(false), 1000)
|
|
||||||
return () => clearTimeout(id)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const { showMore, hits, isLastPage } = useInfiniteHits()
|
|
||||||
let contracts = hits as any as Contract[]
|
|
||||||
|
|
||||||
if (isInitialLoad && contracts.length === 0) return <></>
|
|
||||||
|
|
||||||
const showTime = index.endsWith('close-date')
|
|
||||||
? 'close-date'
|
|
||||||
: index.endsWith('resolve-date')
|
|
||||||
? 'resolve-date'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (excludeContractIds)
|
|
||||||
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContractsGrid
|
|
||||||
contracts={contracts}
|
|
||||||
loadMore={showMore}
|
|
||||||
hasMore={!isLastPage}
|
|
||||||
showTime={showTime}
|
|
||||||
onContractClick={onContractClick}
|
|
||||||
overrideGridClassName={overrideGridClassName}
|
|
||||||
hideQuickBet={hideQuickBet}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,10 @@ import { formatLargeNumber, formatPercent } from 'common/util/format'
|
||||||
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import {
|
import {
|
||||||
Contract,
|
|
||||||
BinaryContract,
|
BinaryContract,
|
||||||
|
Contract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
PseudoNumericContract,
|
PseudoNumericContract,
|
||||||
} from 'common/contract'
|
} from 'common/contract'
|
||||||
|
@ -24,12 +25,12 @@ import {
|
||||||
} from 'common/calculate'
|
} from 'common/calculate'
|
||||||
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
||||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
import { getColor, ProbBar, QuickBet } from './quick-bet'
|
||||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { getMappedValue } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
export function ContractCard(props: {
|
export function ContractCard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -38,8 +39,16 @@ export function ContractCard(props: {
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
hideQuickBet?: boolean
|
hideQuickBet?: boolean
|
||||||
|
hideGroupLink?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { showHotVolume, showTime, className, onClick, hideQuickBet } = props
|
const {
|
||||||
|
showHotVolume,
|
||||||
|
showTime,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
hideQuickBet,
|
||||||
|
hideGroupLink,
|
||||||
|
} = props
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
const { question, outcomeType } = contract
|
const { question, outcomeType } = contract
|
||||||
const { resolution } = contract
|
const { resolution } = contract
|
||||||
|
@ -106,7 +115,8 @@ export function ContractCard(props: {
|
||||||
{question}
|
{question}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' &&
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
(resolution ? (
|
(resolution ? (
|
||||||
<FreeResponseOutcomeLabel
|
<FreeResponseOutcomeLabel
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -121,6 +131,7 @@ export function ContractCard(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
showHotVolume={showHotVolume}
|
showHotVolume={showHotVolume}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
|
hideGroupLink={hideGroupLink}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
{showQuickBet ? (
|
{showQuickBet ? (
|
||||||
|
@ -148,7 +159,8 @@ export function ContractCard(props: {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' && (
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||||
<FreeResponseResolutionOrChance
|
<FreeResponseResolutionOrChance
|
||||||
className="self-end text-gray-600"
|
className="self-end text-gray-600"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -200,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FreeResponseTopAnswer(props: {
|
function FreeResponseTopAnswer(props: {
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
truncate: 'short' | 'long' | 'none'
|
truncate: 'short' | 'long' | 'none'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
@ -218,7 +230,7 @@ function FreeResponseTopAnswer(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FreeResponseResolutionOrChance(props: {
|
export function FreeResponseResolutionOrChance(props: {
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
truncate: 'short' | 'long' | 'none'
|
truncate: 'short' | 'long' | 'none'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
@ -305,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
||||||
const { resolution, resolutionValue, resolutionProbability } = contract
|
const { resolution, resolutionValue, resolutionProbability } = contract
|
||||||
const textColor = `text-blue-400`
|
const textColor = `text-blue-400`
|
||||||
|
|
||||||
|
const value = resolution
|
||||||
|
? resolutionValue
|
||||||
|
? resolutionValue
|
||||||
|
: getMappedValue(contract)(resolutionProbability ?? 0)
|
||||||
|
: getMappedValue(contract)(getProbability(contract))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
||||||
{resolution ? (
|
{resolution ? (
|
||||||
|
@ -314,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
||||||
{resolution === 'CANCEL' ? (
|
{resolution === 'CANCEL' ? (
|
||||||
<CancelLabel />
|
<CancelLabel />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-blue-400">
|
<div
|
||||||
{resolutionValue
|
className={clsx('tooltip', textColor)}
|
||||||
? formatLargeNumber(resolutionValue)
|
data-tip={value.toFixed(2)}
|
||||||
: formatNumericProbability(
|
>
|
||||||
resolutionProbability ?? 0,
|
{formatLargeNumber(value)}
|
||||||
contract
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={clsx('text-3xl', textColor)}>
|
<div
|
||||||
{formatNumericProbability(getProbability(contract), contract)}
|
className={clsx('tooltip text-3xl', textColor)}
|
||||||
|
data-tip={value.toFixed(2)}
|
||||||
|
>
|
||||||
|
{formatLargeNumber(value)}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('text-base', textColor)}>expected</div>
|
<div className={clsx('text-base', textColor)}>expected</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -24,13 +24,10 @@ export function ContractDescription(props: {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('mt-2 text-gray-700', className)}>
|
<div className={clsx('mt-2 text-gray-700', className)}>
|
||||||
{isCreator || isAdmin ? (
|
{isCreator || isAdmin ? (
|
||||||
<RichEditContract contract={contract} />
|
<RichEditContract contract={contract} isAdmin={isAdmin && !isCreator} />
|
||||||
) : (
|
) : (
|
||||||
<Content content={contract.description} />
|
<Content content={contract.description} />
|
||||||
)}
|
)}
|
||||||
{isAdmin && !isCreator && (
|
|
||||||
<div className="mt-2 text-red-400">(👆 admin powers)</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -39,8 +36,8 @@ function editTimestamp() {
|
||||||
return `${dayjs().format('MMM D, h:mma')}: `
|
return `${dayjs().format('MMM D, h:mma')}: `
|
||||||
}
|
}
|
||||||
|
|
||||||
function RichEditContract(props: { contract: Contract }) {
|
function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
|
||||||
const { contract } = props
|
const { contract, isAdmin } = props
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editingQ, setEditingQ] = useState(false)
|
const [editingQ, setEditingQ] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
@ -90,9 +87,11 @@ function RichEditContract(props: { contract: Contract }) {
|
||||||
<>
|
<>
|
||||||
<Content content={contract.description} />
|
<Content content={contract.description} />
|
||||||
<Spacer h={2} />
|
<Spacer h={2} />
|
||||||
<Row className="gap-2">
|
<Row className="items-center gap-2">
|
||||||
|
{isAdmin && 'Admin: '}
|
||||||
<Button
|
<Button
|
||||||
color="gray"
|
color="gray"
|
||||||
|
size="xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(true)
|
setEditing(true)
|
||||||
editor
|
editor
|
||||||
|
@ -105,7 +104,7 @@ function RichEditContract(props: { contract: Contract }) {
|
||||||
>
|
>
|
||||||
Edit description
|
Edit description
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="gray" onClick={() => setEditingQ(true)}>
|
<Button color="gray" size="xs" onClick={() => setEditingQ(true)}>
|
||||||
Edit question
|
Edit question
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { UserLink } from '../user-page'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
contractMetrics,
|
contractMetrics,
|
||||||
contractPool,
|
contractPath,
|
||||||
updateContract,
|
updateContract,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
@ -22,17 +22,19 @@ import { useState } from 'react'
|
||||||
import { ContractInfoDialog } from './contract-info-dialog'
|
import { ContractInfoDialog } from './contract-info-dialog'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
|
||||||
import { TagsList } from '../tags-list'
|
|
||||||
import { UserFollowButton } from '../follow-button'
|
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 { DAY_MS } from 'common/util/time'
|
||||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
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'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -40,21 +42,19 @@ export function MiscDetails(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
showHotVolume?: boolean
|
showHotVolume?: boolean
|
||||||
showTime?: ShowTime
|
showTime?: ShowTime
|
||||||
|
hideGroupLink?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, showHotVolume, showTime } = props
|
const { contract, showHotVolume, showTime, hideGroupLink } = props
|
||||||
const {
|
const {
|
||||||
volume,
|
volume,
|
||||||
volume24Hours,
|
volume24Hours,
|
||||||
closeTime,
|
closeTime,
|
||||||
tags,
|
|
||||||
isResolved,
|
isResolved,
|
||||||
createdTime,
|
createdTime,
|
||||||
resolutionTime,
|
resolutionTime,
|
||||||
|
groupLinks,
|
||||||
} = contract
|
} = 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
|
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -76,13 +76,21 @@ export function MiscDetails(props: {
|
||||||
{fromNow(resolutionTime || 0)}
|
{fromNow(resolutionTime || 0)}
|
||||||
</Row>
|
</Row>
|
||||||
) : volume > 0 || !isNew ? (
|
) : volume > 0 || !isNew ? (
|
||||||
<Row>{contractPool(contract)} pool</Row>
|
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
||||||
) : (
|
) : (
|
||||||
<NewContractBadge />
|
<NewContractBadge />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
||||||
<TagsList className="text-gray-400" tags={categories} noLabel />
|
<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>
|
</Row>
|
||||||
)
|
)
|
||||||
|
@ -130,34 +138,15 @@ export function ContractDetails(props: {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, isCreator, disabled } = props
|
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 { 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 =
|
const groupToDisplay =
|
||||||
groupsUserIsCreatorOf.length > 0
|
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
|
||||||
? groupsUserIsCreatorOf[0]
|
const user = useUser()
|
||||||
: groupsUserIsMemberOf.length > 0
|
const [open, setOpen] = useState(false)
|
||||||
? groupsUserIsMemberOf[0]
|
|
||||||
: groups
|
|
||||||
? groups[0]
|
|
||||||
: undefined
|
|
||||||
return (
|
return (
|
||||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
<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">
|
<Row className="items-center gap-2">
|
||||||
|
@ -178,16 +167,34 @@ export function ContractDetails(props: {
|
||||||
)}
|
)}
|
||||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||||
</Row>
|
</Row>
|
||||||
{groupToDisplay ? (
|
<Row>
|
||||||
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
|
<Button
|
||||||
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
|
size={'xs'}
|
||||||
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
|
className={'max-w-[200px]'}
|
||||||
<span>{groupToDisplay.name}</span>
|
color={'gray-white'}
|
||||||
</SiteLink>
|
onClick={() => setOpen(!open)}
|
||||||
</Row>
|
>
|
||||||
) : (
|
<Row>
|
||||||
<div />
|
<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) && (
|
{(!!closeTime || !!resolvedDate) && (
|
||||||
<Row className="items-center gap-1">
|
<Row className="items-center gap-1">
|
||||||
|
@ -222,9 +229,12 @@ export function ContractDetails(props: {
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||||
</Row>
|
</Row>
|
||||||
<ShareIconButton
|
<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%]'}
|
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||||
username={user?.username}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||||
|
@ -321,12 +331,13 @@ function EditableCloseDate(props: {
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
className="btn btn-xs btn-ghost"
|
size={'xs'}
|
||||||
|
color={'gray-white'}
|
||||||
onClick={() => setIsEditingCloseTime(true)}
|
onClick={() => setIsEditingCloseTime(true)}
|
||||||
>
|
>
|
||||||
<PencilIcon className="mr-2 inline h-4 w-4" /> Edit
|
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,11 +16,10 @@ import { ShareEmbedButton } from '../share-embed-button'
|
||||||
import { Title } from '../title'
|
import { Title } from '../title'
|
||||||
import { TweetButton } from '../tweet-button'
|
import { TweetButton } from '../tweet-button'
|
||||||
import { InfoTooltip } from '../info-tooltip'
|
import { InfoTooltip } from '../info-tooltip'
|
||||||
import { TagsInput } from 'web/components/tags-input'
|
|
||||||
import { DuplicateContractButton } from '../copy-contract-button'
|
import { DuplicateContractButton } from '../copy-contract-button'
|
||||||
|
|
||||||
export const contractDetailsButtonClassName =
|
export const contractDetailsButtonClassName =
|
||||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||||
|
|
||||||
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const { contract, bets } = props
|
const { contract, bets } = props
|
||||||
|
@ -42,6 +41,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
? 'YES / NO'
|
? 'YES / NO'
|
||||||
: outcomeType === 'FREE_RESPONSE'
|
: outcomeType === 'FREE_RESPONSE'
|
||||||
? 'Free response'
|
? 'Free response'
|
||||||
|
: outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
? 'Multiple choice'
|
||||||
: 'Numeric'
|
: 'Numeric'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -141,9 +142,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div>Tags</div>
|
|
||||||
<TagsInput contract={contract} />
|
|
||||||
<div />
|
|
||||||
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
||||||
<LiquidityPanel contract={contract} />
|
<LiquidityPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
141
web/components/contract/contract-leaderboard.tsx
Normal file
141
web/components/contract/contract-leaderboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -85,7 +85,8 @@ export const ContractOverview = (props: {
|
||||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
outcomeType === 'FREE_RESPONSE' &&
|
(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
resolution && (
|
resolution && (
|
||||||
<FreeResponseResolutionOrChance
|
<FreeResponseResolutionOrChance
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -110,7 +111,8 @@ export const ContractOverview = (props: {
|
||||||
{(isBinary || isPseudoNumeric) && (
|
{(isBinary || isPseudoNumeric) && (
|
||||||
<ContractProbGraph contract={contract} bets={bets} />
|
<ContractProbGraph contract={contract} bets={bets} />
|
||||||
)}{' '}
|
)}{' '}
|
||||||
{outcomeType === 'FREE_RESPONSE' && (
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||||
<AnswersGraph contract={contract} bets={bets} />
|
<AnswersGraph contract={contract} bets={bets} />
|
||||||
)}
|
)}
|
||||||
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { Bet } from 'common/bet'
|
||||||
import { getInitialProbability } from 'common/calculate'
|
import { getInitialProbability } from 'common/calculate'
|
||||||
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { getMappedValue } from 'common/pseudo-numeric'
|
|
||||||
import { formatLargeNumber } from 'common/util/format'
|
import { formatLargeNumber } from 'common/util/format'
|
||||||
|
|
||||||
export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
|
@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
...bets.map((bet) => bet.createdTime),
|
...bets.map((bet) => bet.createdTime),
|
||||||
].map((time) => new Date(time))
|
].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)
|
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 points: { x: Date; y: number }[] = []
|
||||||
const s = isBinary ? 100 : 1
|
const s = isBinary ? 100 : 1
|
||||||
const c = isLogScale && contract.min === 0 ? 1 : 0
|
|
||||||
|
|
||||||
for (let i = 0; i < times.length - 1; i++) {
|
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(
|
const numPoints: number = Math.floor(
|
||||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
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])
|
x: dayjs(times[i])
|
||||||
.add(thisTimeStep * n, 'ms')
|
.add(thisTimeStep * n, 'ms')
|
||||||
.toDate(),
|
.toDate(),
|
||||||
y: s * probs[i] + c,
|
y: s * probs[i],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
|
|
||||||
const formatter = isBinary
|
const formatter = isBinary
|
||||||
? formatPercent
|
? formatPercent
|
||||||
|
: isLogScale
|
||||||
|
? (x: DatumValue) =>
|
||||||
|
formatLargeNumber(10 ** +x.valueOf() + contract.min - 1)
|
||||||
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
|
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
yScale={
|
yScale={
|
||||||
isBinary
|
isBinary
|
||||||
? { min: 0, max: 100, type: 'linear' }
|
? { min: 0, max: 100, type: 'linear' }
|
||||||
: {
|
: isLogScale
|
||||||
min: contract.min + c,
|
? {
|
||||||
max: contract.max + c,
|
min: 0,
|
||||||
type: contract.isLogScale ? 'log' : 'linear',
|
max: Math.log10(contract.max - contract.min + 1),
|
||||||
|
type: 'linear',
|
||||||
}
|
}
|
||||||
|
: { min: contract.min, max: contract.max, type: 'linear' }
|
||||||
}
|
}
|
||||||
yFormat={formatter}
|
yFormat={formatter}
|
||||||
gridYValues={yTickValues}
|
gridYValues={yTickValues}
|
||||||
|
@ -143,6 +150,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
enableSlices="x"
|
enableSlices="x"
|
||||||
enableGridX={!!width && width >= 800}
|
enableGridX={!!width && width >= 800}
|
||||||
enableArea
|
enableArea
|
||||||
|
areaBaselineValue={isBinary || isLogScale ? 0 : contract.min}
|
||||||
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
|
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
|
||||||
animate={false}
|
animate={false}
|
||||||
sliceTooltip={SliceTooltip}
|
sliceTooltip={SliceTooltip}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { Tabs } from '../layout/tabs'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||||
|
import { useComments } from 'web/hooks/use-comments'
|
||||||
|
|
||||||
export function ContractTabs(props: {
|
export function ContractTabs(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -18,10 +19,17 @@ export function ContractTabs(props: {
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
tips: CommentTipMap
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, bets, comments, tips, liquidityProvisions } = props
|
const { contract, user, bets, tips, liquidityProvisions } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||||
|
const visibleBets = bets.filter(
|
||||||
|
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load comments here, so the badge count will be correct
|
||||||
|
const updatedComments = useComments(contract.id)
|
||||||
|
const comments = updatedComments ?? props.comments
|
||||||
|
|
||||||
const betActivity = (
|
const betActivity = (
|
||||||
<ContractActivity
|
<ContractActivity
|
||||||
|
@ -89,8 +97,12 @@ export function ContractTabs(props: {
|
||||||
<Tabs
|
<Tabs
|
||||||
currentPageForAnalytics={'contract'}
|
currentPageForAnalytics={'contract'}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ title: 'Comments', content: commentActivity },
|
{
|
||||||
{ title: 'Bets', content: betActivity },
|
title: 'Comments',
|
||||||
|
content: commentActivity,
|
||||||
|
badge: `${comments.length}`,
|
||||||
|
},
|
||||||
|
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
|
||||||
...(!user || !userBets?.length
|
...(!user || !userBets?.length
|
||||||
? []
|
? []
|
||||||
: [{ title: 'Your bets', content: yourTrades }]),
|
: [{ title: 'Your bets', content: yourTrades }]),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Contract } from '../../lib/firebase/contracts'
|
import { Contract } from 'web/lib/firebase/contracts'
|
||||||
import { User } from '../../lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { SiteLink } from '../site-link'
|
import { SiteLink } from '../site-link'
|
||||||
import { ContractCard } from './contract-card'
|
import { ContractCard } from './contract-card'
|
||||||
|
@ -9,6 +9,11 @@ import { useIsVisible } from 'web/hooks/use-is-visible'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export type ContractHighlightOptions = {
|
||||||
|
contractIds?: string[]
|
||||||
|
highlightClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function ContractsGrid(props: {
|
export function ContractsGrid(props: {
|
||||||
contracts: Contract[]
|
contracts: Contract[]
|
||||||
loadMore: () => void
|
loadMore: () => void
|
||||||
|
@ -16,7 +21,11 @@ export function ContractsGrid(props: {
|
||||||
showTime?: ShowTime
|
showTime?: ShowTime
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
overrideGridClassName?: string
|
overrideGridClassName?: string
|
||||||
hideQuickBet?: boolean
|
cardHideOptions?: {
|
||||||
|
hideQuickBet?: boolean
|
||||||
|
hideGroupLink?: boolean
|
||||||
|
}
|
||||||
|
highlightOptions?: ContractHighlightOptions
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
contracts,
|
contracts,
|
||||||
|
@ -25,9 +34,12 @@ export function ContractsGrid(props: {
|
||||||
loadMore,
|
loadMore,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
overrideGridClassName,
|
overrideGridClassName,
|
||||||
hideQuickBet,
|
cardHideOptions,
|
||||||
|
highlightOptions,
|
||||||
} = props
|
} = props
|
||||||
|
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
||||||
|
|
||||||
|
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||||
const isBottomVisible = useIsVisible(elem)
|
const isBottomVisible = useIsVisible(elem)
|
||||||
|
|
||||||
|
@ -66,6 +78,12 @@ export function ContractsGrid(props: {
|
||||||
onContractClick ? () => onContractClick(contract) : undefined
|
onContractClick ? () => onContractClick(contract) : undefined
|
||||||
}
|
}
|
||||||
hideQuickBet={hideQuickBet}
|
hideQuickBet={hideQuickBet}
|
||||||
|
hideGroupLink={hideGroupLink}
|
||||||
|
className={
|
||||||
|
contractIds?.includes(contract.id)
|
||||||
|
? highlightClassName
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
|
||||||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
||||||
|
params.groupId = contract.groupLinks[0].groupId
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`/create?` +
|
`/create?` +
|
||||||
Object.entries(params)
|
Object.entries(params)
|
||||||
|
|
|
@ -3,58 +3,63 @@ import { LinkIcon } from '@heroicons/react/outline'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
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 { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { Row } from './layout/row'
|
||||||
function copyContractUrl(contract: Contract) {
|
|
||||||
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyLinkButton(props: {
|
export function CopyLinkButton(props: {
|
||||||
contract: Contract
|
url: string
|
||||||
|
displayUrl?: string
|
||||||
|
tracking?: string
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
toastClassName?: string
|
toastClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, buttonClassName, toastClassName } = props
|
const { url, displayUrl, tracking, buttonClassName, toastClassName } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Row className="w-full">
|
||||||
as="div"
|
<input
|
||||||
className="relative z-10 flex-shrink-0"
|
className="input input-bordered flex-1 rounded-r-none text-gray-500"
|
||||||
onMouseUp={() => {
|
readOnly
|
||||||
copyContractUrl(contract)
|
type="text"
|
||||||
track('copy share link')
|
value={displayUrl ?? url}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<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
|
<Menu
|
||||||
as={Fragment}
|
as="div"
|
||||||
enter="transition ease-out duration-100"
|
className="relative z-10 flex-shrink-0"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
onMouseUp={() => {
|
||||||
enterTo="transform opacity-100 scale-100"
|
copyToClipboard(url)
|
||||||
leave="transition ease-in duration-75"
|
track(tracking ?? 'copy share link')
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
}}
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
>
|
||||||
<Menu.Items>
|
<Menu.Button
|
||||||
<Menu.Item>
|
className={clsx(
|
||||||
<ToastClipboard className={toastClassName} />
|
'btn btn-xs border-2 border-green-600 bg-white normal-case text-green-600 hover:border-green-600 hover:bg-white',
|
||||||
</Menu.Item>
|
buttonClassName
|
||||||
</Menu.Items>
|
)}
|
||||||
</Transition>
|
>
|
||||||
</Menu>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { firebaseLogin, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, User } from 'web/lib/firebase/users'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export const createButtonStyle =
|
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'
|
'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: {
|
export const CreateQuestionButton = (props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
overrideText?: string
|
overrideText?: string
|
||||||
|
@ -15,17 +17,23 @@ export const CreateQuestionButton = (props: {
|
||||||
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
|
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
|
||||||
|
|
||||||
const { user, overrideText, className, query } = props
|
const { user, overrideText, className, query } = props
|
||||||
|
const router = useRouter()
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flex justify-center', className)}>
|
<div className={clsx('flex justify-center', className)}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href={`/create${query ? query : ''}`} passHref>
|
<Link href={`/create${query ? query : ''}`} passHref>
|
||||||
<button className={clsx(gradient, createButtonStyle)}>
|
<button className={clsx(gradient, createButtonStyle)}>
|
||||||
{overrideText ? overrideText : 'Create a question'}
|
{overrideText ? overrideText : 'Create a market'}
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<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)}
|
className={clsx(gradient, createButtonStyle)}
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
|
|
|
@ -11,17 +11,29 @@ import {
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
|
import { Mention } from '@tiptap/extension-mention'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import { uploadImage } from 'web/lib/firebase/storage'
|
import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
import { exhibitExts } from 'common/util/parse'
|
import { exhibitExts } from 'common/util/parse'
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
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(
|
const proseClass = clsx(
|
||||||
'prose prose-p:my-2 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless font-light'
|
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
||||||
|
'font-light prose-a:font-light prose-blockquote:font-light'
|
||||||
)
|
)
|
||||||
|
|
||||||
export function useTextEditor(props: {
|
export function useTextEditor(props: {
|
||||||
|
@ -32,32 +44,41 @@ export function useTextEditor(props: {
|
||||||
}) {
|
}) {
|
||||||
const { placeholder, max, defaultValue = '', disabled } = props
|
const { placeholder, max, defaultValue = '', disabled } = props
|
||||||
|
|
||||||
|
const users = useUsers()
|
||||||
|
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
proseClass,
|
||||||
'box-content min-h-[6em] textarea textarea-bordered'
|
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor(
|
||||||
editorProps: { attributes: { class: editorClass } },
|
{
|
||||||
extensions: [
|
editorProps: { attributes: { class: editorClass } },
|
||||||
StarterKit.configure({
|
extensions: [
|
||||||
heading: { levels: [1, 2, 3] },
|
StarterKit.configure({
|
||||||
}),
|
heading: { levels: [1, 2, 3] },
|
||||||
Placeholder.configure({
|
}),
|
||||||
placeholder,
|
Placeholder.configure({
|
||||||
emptyEditorClass:
|
placeholder,
|
||||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
|
emptyEditorClass:
|
||||||
}),
|
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
|
||||||
CharacterCount.configure({ limit: max }),
|
}),
|
||||||
Image,
|
CharacterCount.configure({ limit: max }),
|
||||||
Link.configure({
|
Image,
|
||||||
HTMLAttributes: {
|
Link.configure({
|
||||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
HTMLAttributes: {
|
||||||
},
|
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||||
}),
|
},
|
||||||
],
|
}),
|
||||||
content: defaultValue,
|
DisplayMention.configure({
|
||||||
})
|
suggestion: mentionSuggestion(users),
|
||||||
|
}),
|
||||||
|
Iframe,
|
||||||
|
],
|
||||||
|
content: defaultValue,
|
||||||
|
},
|
||||||
|
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
|
||||||
|
)
|
||||||
|
|
||||||
const upload = useUploadMutation(editor)
|
const upload = useUploadMutation(editor)
|
||||||
|
|
||||||
|
@ -68,12 +89,19 @@ export function useTextEditor(props: {
|
||||||
(file) => file.type.startsWith('image')
|
(file) => file.type.startsWith('image')
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!imageFiles.length) {
|
if (imageFiles.length) {
|
||||||
return // if no files pasted, use default paste handler
|
event.preventDefault()
|
||||||
|
upload.mutate(imageFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
// If the pasted content is iframe code, directly inject it
|
||||||
upload.mutate(imageFiles)
|
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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -85,20 +113,25 @@ export function useTextEditor(props: {
|
||||||
return { editor, upload }
|
return { editor, upload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidIframe(text: string) {
|
||||||
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
upload: ReturnType<typeof useUploadMutation>
|
||||||
}) {
|
}) {
|
||||||
const { editor, upload } = props
|
const { editor, upload } = props
|
||||||
|
const [iframeOpen, setIframeOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* hide placeholder when focused */}
|
{/* 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 && (
|
{editor && (
|
||||||
<FloatingMenu
|
<FloatingMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
className="-ml-2 mr-2 w-full text-sm text-slate-300"
|
className={clsx(proseClass, '-ml-2 mr-2 w-full text-slate-300 ')}
|
||||||
>
|
>
|
||||||
Type <em>*markdown*</em>. Paste or{' '}
|
Type <em>*markdown*</em>. Paste or{' '}
|
||||||
<FileUploadButton
|
<FileUploadButton
|
||||||
|
@ -110,7 +143,46 @@ export function TextEditor(props: {
|
||||||
images!
|
images!
|
||||||
</FloatingMenu>
|
</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>
|
</div>
|
||||||
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
|
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
|
||||||
{upload.isError && (
|
{upload.isError && (
|
||||||
|
@ -120,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) =>
|
const useUploadMutation = (editor: Editor | null) =>
|
||||||
useMutation(
|
useMutation(
|
||||||
(files: File[]) =>
|
(files: File[]) =>
|
||||||
|
@ -138,11 +269,15 @@ const useUploadMutation = (editor: Editor | null) =>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function RichContent(props: { content: JSONContent }) {
|
function RichContent(props: { content: JSONContent | string }) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editorProps: { attributes: { class: proseClass } },
|
editorProps: { attributes: { class: proseClass } },
|
||||||
extensions: exhibitExts,
|
extensions: [
|
||||||
|
// replace tiptap's Mention with ours, to add style and link
|
||||||
|
...exhibitExts.filter((ex) => ex.name !== Mention.name),
|
||||||
|
DisplayMention,
|
||||||
|
],
|
||||||
content,
|
content,
|
||||||
editable: false,
|
editable: false,
|
||||||
})
|
})
|
||||||
|
@ -155,7 +290,7 @@ function RichContent(props: { content: JSONContent }) {
|
||||||
export function Content(props: { content: JSONContent | string }) {
|
export function Content(props: { content: JSONContent | string }) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
return typeof content === 'string' ? (
|
return typeof content === 'string' ? (
|
||||||
<div className="whitespace-pre-line break-words">
|
<div className="whitespace-pre-line font-light leading-relaxed">
|
||||||
<Linkify text={content} />
|
<Linkify text={content} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
62
web/components/editor/mention-list.tsx
Normal file
62
web/components/editor/mention-list.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { SuggestionProps } from '@tiptap/suggestion'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||||
|
import { Avatar } from '../avatar'
|
||||||
|
|
||||||
|
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||||
|
export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
||||||
|
const { items: users, command } = props
|
||||||
|
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
useEffect(() => setSelectedIndex(0), [users])
|
||||||
|
|
||||||
|
const submitUser = (index: number) => {
|
||||||
|
const user = users[index]
|
||||||
|
if (user) command({ id: user.id, label: user.username } as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () =>
|
||||||
|
setSelectedIndex((i) => (i + users.length - 1) % users.length)
|
||||||
|
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
|
||||||
|
const onEnter = () => submitUser(selectedIndex)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }: any) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
onUp()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
onDown()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
onEnter()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
{!users.length ? (
|
||||||
|
<span className="m-1 whitespace-nowrap">No results...</span>
|
||||||
|
) : (
|
||||||
|
users.map((user, i) => (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
|
||||||
|
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||||
|
)}
|
||||||
|
onClick={() => submitUser(i)}
|
||||||
|
>
|
||||||
|
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||||
|
{user.username}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
72
web/components/editor/mention-suggestion.ts
Normal file
72
web/components/editor/mention-suggestion.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||||
|
import { ReactRenderer } from '@tiptap/react'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { searchInAny } from 'common/util/parse'
|
||||||
|
import { orderBy } from 'lodash'
|
||||||
|
import tippy from 'tippy.js'
|
||||||
|
import { MentionList } from './mention-list'
|
||||||
|
|
||||||
|
type Suggestion = MentionOptions['suggestion']
|
||||||
|
|
||||||
|
const beginsWith = (text: string, query: string) =>
|
||||||
|
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||||
|
|
||||||
|
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||||
|
export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||||
|
items: ({ query }) =>
|
||||||
|
orderBy(
|
||||||
|
users.filter((u) => searchInAny(query, u.username, u.name)),
|
||||||
|
[
|
||||||
|
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
|
||||||
|
'followerCountCached',
|
||||||
|
],
|
||||||
|
['desc', 'desc']
|
||||||
|
).slice(0, 5),
|
||||||
|
render: () => {
|
||||||
|
let component: ReactRenderer
|
||||||
|
let popup: ReturnType<typeof tippy>
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
component = new ReactRenderer(MentionList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
})
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect as any,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onUpdate(props) {
|
||||||
|
component.updateProps(props)
|
||||||
|
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect as any,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onKeyDown(props) {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup[0].hide()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (component.ref as any)?.onKeyDown(props)
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
popup[0].destroy()
|
||||||
|
component.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
29
web/components/editor/mention.tsx
Normal file
29
web/components/editor/mention.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import Mention from '@tiptap/extension-mention'
|
||||||
|
import {
|
||||||
|
mergeAttributes,
|
||||||
|
NodeViewWrapper,
|
||||||
|
ReactNodeViewRenderer,
|
||||||
|
} from '@tiptap/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Linkify } from '../linkify'
|
||||||
|
|
||||||
|
const name = 'mention-component'
|
||||||
|
|
||||||
|
const MentionComponent = (props: any) => {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
|
||||||
|
<Linkify text={'@' + props.node.attrs.label} />
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention extension that renders React. See:
|
||||||
|
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
|
||||||
|
* https://tiptap.dev/guide/node-views/react#render-a-react-component
|
||||||
|
*/
|
||||||
|
export const DisplayMention = Mention.extend({
|
||||||
|
parseHTML: () => [{ tag: name }],
|
||||||
|
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||||
|
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
|
||||||
|
})
|
|
@ -2,7 +2,6 @@ import { Contract } from 'web/lib/firebase/contracts'
|
||||||
import { Comment } from 'web/lib/firebase/comments'
|
import { Comment } from 'web/lib/firebase/comments'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { useBets } from 'web/hooks/use-bets'
|
import { useBets } from 'web/hooks/use-bets'
|
||||||
import { useComments } from 'web/hooks/use-comments'
|
|
||||||
import { getSpecificContractActivityItems } from './activity-items'
|
import { getSpecificContractActivityItems } from './activity-items'
|
||||||
import { FeedItems } from './feed-items'
|
import { FeedItems } from './feed-items'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
@ -26,10 +25,7 @@ export function ContractActivity(props: {
|
||||||
props
|
props
|
||||||
|
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
|
const comments = props.comments
|
||||||
const updatedComments = useComments(contract.id)
|
|
||||||
const comments = updatedComments ?? props.comments
|
|
||||||
|
|
||||||
const updatedBets = useBets(contract.id)
|
const updatedBets = useBets(contract.id)
|
||||||
const bets = (updatedBets ?? props.bets).filter(
|
const bets = (updatedBets ?? props.bets).filter(
|
||||||
(bet) => !bet.isRedemption && bet.amount !== 0
|
(bet) => !bet.isRedemption && bet.amount !== 0
|
||||||
|
@ -50,6 +46,7 @@ export function ContractActivity(props: {
|
||||||
items={items}
|
items={items}
|
||||||
className={className}
|
className={className}
|
||||||
betRowClassName={betRowClassName}
|
betRowClassName={betRowClassName}
|
||||||
|
user={user}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,13 @@
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { formatPercent } from 'common/util/format'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
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 { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
|
||||||
import {
|
import {
|
||||||
CommentInput,
|
CommentInput,
|
||||||
CommentRepliesList,
|
CommentRepliesList,
|
||||||
|
@ -23,7 +18,6 @@ import { useRouter } from 'next/router'
|
||||||
import { groupBy } from 'lodash'
|
import { groupBy } from 'lodash'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEvent } from 'web/hooks/use-event'
|
import { useEvent } from 'web/hooks/use-event'
|
||||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export function FeedAnswerCommentGroup(props: {
|
export function FeedAnswerCommentGroup(props: {
|
||||||
|
@ -38,7 +32,6 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, name, text } = answer
|
||||||
|
|
||||||
const [replyToUsername, setReplyToUsername] = useState('')
|
const [replyToUsername, setReplyToUsername] = useState('')
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [showReply, setShowReply] = useState(false)
|
const [showReply, setShowReply] = useState(false)
|
||||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||||
const [highlighted, setHighlighted] = useState(false)
|
const [highlighted, setHighlighted] = useState(false)
|
||||||
|
@ -50,11 +43,6 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
const commentsList = comments.filter(
|
const commentsList = comments.filter(
|
||||||
(comment) => comment.answerOutcome === answer.number.toString()
|
(comment) => comment.answerOutcome === answer.number.toString()
|
||||||
)
|
)
|
||||||
const thisAnswerProb = getDpmOutcomeProbability(
|
|
||||||
contract.totalShares,
|
|
||||||
answer.id
|
|
||||||
)
|
|
||||||
const probPercent = formatPercent(thisAnswerProb)
|
|
||||||
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
|
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
|
||||||
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
|
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
|
||||||
const isFreeResponseContractPage = !!commentsByCurrentUser
|
const isFreeResponseContractPage = !!commentsByCurrentUser
|
||||||
|
@ -112,27 +100,16 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
}, [answerElementId, router.asPath])
|
}, [answerElementId, router.asPath])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'relative flex-1 gap-2'} key={answer.id + 'comment'}>
|
<Col className={'relative flex-1 gap-3'} 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>
|
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
className={clsx(
|
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` : ''
|
highlighted ? `-m-2 my-3 rounded bg-indigo-500/[0.2] p-2` : ''
|
||||||
)}
|
)}
|
||||||
id={answerElementId}
|
id={answerElementId}
|
||||||
>
|
>
|
||||||
<div className="px-1">
|
<Avatar username={username} avatarUrl={avatarUrl} />
|
||||||
<Avatar username={username} avatarUrl={avatarUrl} />
|
|
||||||
</div>
|
|
||||||
<Col className="min-w-0 flex-1 lg:gap-1">
|
<Col className="min-w-0 flex-1 lg:gap-1">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
<UserLink username={username} name={name} /> answered
|
<UserLink username={username} name={name} /> answered
|
||||||
|
@ -144,43 +121,21 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<span className="whitespace-pre-line text-lg">
|
||||||
<Linkify text={text} />
|
<Linkify text={text} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Row className="items-center justify-center gap-4">
|
{isFreeResponseContractPage && (
|
||||||
{isFreeResponseContractPage && (
|
<div className={'sm:hidden'}>
|
||||||
<div className={'sm:hidden'}>
|
<button
|
||||||
<button
|
className={'text-xs font-bold text-gray-500 hover:underline'}
|
||||||
className={
|
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
|
||||||
'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'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{probPercent}
|
Reply
|
||||||
</span>
|
</button>
|
||||||
<BuyButton
|
|
||||||
className={clsx(
|
|
||||||
'btn-sm flex-initial !px-6 sm:flex',
|
|
||||||
tradingAllowed(contract) ? '' : '!hidden'
|
|
||||||
)}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
{isFreeResponseContractPage && (
|
{isFreeResponseContractPage && (
|
||||||
<div className={'justify-initial hidden sm:block'}>
|
<div className={'justify-initial hidden sm:block'}>
|
||||||
|
@ -207,9 +162,9 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showReply && (
|
{showReply && (
|
||||||
<div className={'ml-6 pt-4'}>
|
<div className={'ml-6'}>
|
||||||
<span
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<CommentInput
|
<CommentInput
|
||||||
|
|
|
@ -93,6 +93,24 @@ export function BetStatusText(props: {
|
||||||
bet.fills?.some((fill) => fill.matchedBetId === null)) ??
|
bet.fills?.some((fill) => fill.matchedBetId === null)) ??
|
||||||
false
|
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 (
|
return (
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{bettor ? (
|
{bettor ? (
|
||||||
|
@ -112,14 +130,9 @@ export function BetStatusText(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
truncate="short"
|
truncate="short"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
{isPseudoNumeric
|
{fromProb === toProb
|
||||||
? ' than ' + formatNumericProbability(bet.probAfter, contract)
|
? `at ${fromProb}`
|
||||||
: ' at ' +
|
: `from ${fromProb} to ${toProb}`}
|
||||||
formatPercent(
|
|
||||||
hadPoolMatch || isFreeResponse
|
|
||||||
? bet.probAfter
|
|
||||||
: bet.limitProb ?? bet.probAfter
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<RelativeTimestamp time={createdTime} />
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user