Merge branch 'main' into fast-fold-following
This commit is contained in:
commit
e469cc6037
31
common/answer.ts
Normal file
31
common/answer.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { User } from './user'
|
||||
|
||||
export type Answer = {
|
||||
id: string
|
||||
number: number
|
||||
contractId: string
|
||||
createdTime: number
|
||||
|
||||
userId: string
|
||||
username: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
|
||||
text: string
|
||||
}
|
||||
|
||||
export const getNoneAnswer = (contractId: string, creator: User) => {
|
||||
const { username, name, avatarUrl } = creator
|
||||
|
||||
return {
|
||||
id: '0',
|
||||
number: 0,
|
||||
contractId,
|
||||
createdTime: Date.now(),
|
||||
userId: creator.id,
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
text: 'None',
|
||||
}
|
||||
}
|
|
@ -61,3 +61,30 @@ export function getAnteBets(
|
|||
|
||||
return { yesBet, noBet }
|
||||
}
|
||||
|
||||
export function getFreeAnswerAnte(
|
||||
creator: User,
|
||||
contract: Contract,
|
||||
anteBetId: string
|
||||
) {
|
||||
const { totalBets, totalShares } = contract
|
||||
const amount = totalBets['0']
|
||||
const shares = totalShares['0']
|
||||
|
||||
const { createdTime } = contract
|
||||
|
||||
const anteBet: Bet = {
|
||||
id: anteBetId,
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
outcome: '0',
|
||||
probBefore: 0,
|
||||
probAfter: 1,
|
||||
createdTime,
|
||||
isAnte: true,
|
||||
}
|
||||
|
||||
return anteBet
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ export type Bet = {
|
|||
contractId: string
|
||||
|
||||
amount: number // bet size; negative if SELL bet
|
||||
outcome: 'YES' | 'NO'
|
||||
outcome: string
|
||||
shares: number // dynamic parimutuel pool weight; negative if SELL bet
|
||||
|
||||
probBefore: number
|
||||
|
|
|
@ -1,69 +1,86 @@
|
|||
import * as _ from 'lodash'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
import { FEES } from './fees'
|
||||
|
||||
export function getProbability(totalShares: { YES: number; NO: number }) {
|
||||
const { YES: y, NO: n } = totalShares
|
||||
return y ** 2 / (y ** 2 + n ** 2)
|
||||
export function getProbability(totalShares: { [outcome: string]: number }) {
|
||||
// For binary contracts only.
|
||||
return getOutcomeProbability(totalShares, 'YES')
|
||||
}
|
||||
|
||||
export function getOutcomeProbability(
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
outcome: string
|
||||
) {
|
||||
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
|
||||
const shares = totalShares[outcome] ?? 0
|
||||
return shares ** 2 / squareSum
|
||||
}
|
||||
|
||||
export function getProbabilityAfterBet(
|
||||
totalShares: { YES: number; NO: number },
|
||||
outcome: 'YES' | 'NO',
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
outcome: string,
|
||||
bet: number
|
||||
) {
|
||||
const shares = calculateShares(totalShares, bet, outcome)
|
||||
|
||||
const [YES, NO] =
|
||||
outcome === 'YES'
|
||||
? [totalShares.YES + shares, totalShares.NO]
|
||||
: [totalShares.YES, totalShares.NO + shares]
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const newTotalShares = { ...totalShares, [outcome]: prevShares + shares }
|
||||
|
||||
return getProbability({ YES, NO })
|
||||
return getOutcomeProbability(newTotalShares, outcome)
|
||||
}
|
||||
|
||||
export function getProbabilityAfterSale(
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
outcome: string,
|
||||
shares: number
|
||||
) {
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const newTotalShares = { ...totalShares, [outcome]: prevShares - shares }
|
||||
|
||||
const predictionOutcome = outcome === 'NO' ? 'YES' : outcome
|
||||
return getOutcomeProbability(newTotalShares, predictionOutcome)
|
||||
}
|
||||
|
||||
export function calculateShares(
|
||||
totalShares: { YES: number; NO: number },
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
bet: number,
|
||||
betChoice: 'YES' | 'NO'
|
||||
betChoice: string
|
||||
) {
|
||||
const [yesShares, noShares] = [totalShares.YES, totalShares.NO]
|
||||
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
|
||||
const shares = totalShares[betChoice] ?? 0
|
||||
|
||||
const c = 2 * bet * Math.sqrt(yesShares ** 2 + noShares ** 2)
|
||||
const c = 2 * bet * Math.sqrt(squareSum)
|
||||
|
||||
return betChoice === 'YES'
|
||||
? Math.sqrt(bet ** 2 + yesShares ** 2 + c) - yesShares
|
||||
: Math.sqrt(bet ** 2 + noShares ** 2 + c) - noShares
|
||||
}
|
||||
|
||||
export function calculateEstimatedWinnings(
|
||||
totalShares: { YES: number; NO: number },
|
||||
shares: number,
|
||||
betChoice: 'YES' | 'NO'
|
||||
) {
|
||||
const ind = betChoice === 'YES' ? 1 : 0
|
||||
|
||||
const yesShares = totalShares.YES + ind * shares
|
||||
const noShares = totalShares.NO + (1 - ind) * shares
|
||||
|
||||
const estPool = Math.sqrt(yesShares ** 2 + noShares ** 2)
|
||||
const total = ind * yesShares + (1 - ind) * noShares
|
||||
|
||||
return ((1 - FEES) * (shares * estPool)) / total
|
||||
return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares
|
||||
}
|
||||
|
||||
export function calculateRawShareValue(
|
||||
totalShares: { YES: number; NO: number },
|
||||
totalShares: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
shares: number,
|
||||
betChoice: 'YES' | 'NO'
|
||||
betChoice: string
|
||||
) {
|
||||
const [yesShares, noShares] = [totalShares.YES, totalShares.NO]
|
||||
const currentValue = Math.sqrt(yesShares ** 2 + noShares ** 2)
|
||||
const currentValue = Math.sqrt(
|
||||
_.sumBy(Object.values(totalShares), (shares) => shares ** 2)
|
||||
)
|
||||
|
||||
const postSaleValue =
|
||||
betChoice === 'YES'
|
||||
? Math.sqrt(Math.max(0, yesShares - shares) ** 2 + noShares ** 2)
|
||||
: Math.sqrt(yesShares ** 2 + Math.max(0, noShares - shares) ** 2)
|
||||
const postSaleValue = Math.sqrt(
|
||||
_.sumBy(Object.keys(totalShares), (outcome) =>
|
||||
outcome === betChoice
|
||||
? Math.max(0, totalShares[outcome] - shares) ** 2
|
||||
: totalShares[outcome] ** 2
|
||||
)
|
||||
)
|
||||
|
||||
return currentValue - postSaleValue
|
||||
}
|
||||
|
@ -73,17 +90,22 @@ export function calculateMoneyRatio(
|
|||
bet: Bet,
|
||||
shareValue: number
|
||||
) {
|
||||
const { totalShares, pool } = contract
|
||||
const { totalShares, totalBets, pool } = contract
|
||||
const { outcome, amount } = bet
|
||||
|
||||
const p = getProbability(totalShares)
|
||||
const p = getOutcomeProbability(totalShares, outcome)
|
||||
|
||||
const actual = pool.YES + pool.NO - shareValue
|
||||
const actual = _.sum(Object.values(pool)) - shareValue
|
||||
|
||||
const betAmount =
|
||||
bet.outcome === 'YES' ? p * bet.amount : (1 - p) * bet.amount
|
||||
const betAmount = p * amount
|
||||
|
||||
const expected =
|
||||
p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO - betAmount
|
||||
_.sumBy(
|
||||
Object.keys(totalBets),
|
||||
(outcome) =>
|
||||
getOutcomeProbability(totalShares, outcome) *
|
||||
(totalBets as { [outcome: string]: number })[outcome]
|
||||
) - betAmount
|
||||
|
||||
if (actual <= 0 || expected <= 0) return 0
|
||||
|
||||
|
@ -91,14 +113,13 @@ export function calculateMoneyRatio(
|
|||
}
|
||||
|
||||
export function calculateShareValue(contract: Contract, bet: Bet) {
|
||||
const shareValue = calculateRawShareValue(
|
||||
contract.totalShares,
|
||||
bet.shares,
|
||||
bet.outcome
|
||||
)
|
||||
const { pool, totalShares } = contract
|
||||
const { shares, outcome } = bet
|
||||
|
||||
const shareValue = calculateRawShareValue(totalShares, shares, outcome)
|
||||
const f = calculateMoneyRatio(contract, bet, shareValue)
|
||||
|
||||
const myPool = contract.pool[bet.outcome]
|
||||
const myPool = pool[outcome]
|
||||
const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool)
|
||||
return adjShareValue
|
||||
}
|
||||
|
@ -109,11 +130,7 @@ export function calculateSaleAmount(contract: Contract, bet: Bet) {
|
|||
return deductFees(amount, winnings)
|
||||
}
|
||||
|
||||
export function calculatePayout(
|
||||
contract: Contract,
|
||||
bet: Bet,
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||
) {
|
||||
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
|
||||
if (outcome === 'CANCEL') return calculateCancelPayout(contract, bet)
|
||||
if (outcome === 'MKT') return calculateMktPayout(contract, bet)
|
||||
|
||||
|
@ -121,67 +138,100 @@ export function calculatePayout(
|
|||
}
|
||||
|
||||
export function calculateCancelPayout(contract: Contract, bet: Bet) {
|
||||
const totalBets = contract.totalBets.YES + contract.totalBets.NO
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
const { totalBets, pool } = contract
|
||||
const betTotal = _.sum(Object.values(totalBets))
|
||||
const poolTotal = _.sum(Object.values(pool))
|
||||
|
||||
return (bet.amount / totalBets) * pool
|
||||
return (bet.amount / betTotal) * poolTotal
|
||||
}
|
||||
|
||||
export function calculateStandardPayout(
|
||||
contract: Contract,
|
||||
bet: Bet,
|
||||
outcome: 'YES' | 'NO'
|
||||
outcome: string
|
||||
) {
|
||||
const { amount, outcome: betOutcome, shares } = bet
|
||||
if (betOutcome !== outcome) return 0
|
||||
|
||||
const { totalShares, phantomShares } = contract
|
||||
if (totalShares[outcome] === 0) return 0
|
||||
const { totalShares, phantomShares, pool } = contract
|
||||
if (!totalShares[outcome]) return 0
|
||||
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
const total = totalShares[outcome] - phantomShares[outcome]
|
||||
const poolTotal = _.sum(Object.values(pool))
|
||||
|
||||
const winnings = (shares / total) * pool
|
||||
const total =
|
||||
totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0)
|
||||
|
||||
const winnings = (shares / total) * poolTotal
|
||||
// profit can be negative if using phantom shares
|
||||
return amount + (1 - FEES) * Math.max(0, winnings - amount)
|
||||
}
|
||||
|
||||
export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
|
||||
const { totalShares, pool, totalBets } = contract
|
||||
const { shares, amount, outcome } = bet
|
||||
|
||||
const ind = bet.outcome === 'YES' ? 1 : 0
|
||||
const { shares, amount } = bet
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const prevPool = pool[outcome] ?? 0
|
||||
const prevTotalBet = totalBets[outcome] ?? 0
|
||||
|
||||
const newContract = {
|
||||
...contract,
|
||||
totalShares: {
|
||||
YES: totalShares.YES + ind * shares,
|
||||
NO: totalShares.NO + (1 - ind) * shares,
|
||||
...totalShares,
|
||||
[outcome]: prevShares + shares,
|
||||
},
|
||||
pool: {
|
||||
YES: pool.YES + ind * amount,
|
||||
NO: pool.NO + (1 - ind) * amount,
|
||||
...pool,
|
||||
[outcome]: prevPool + amount,
|
||||
},
|
||||
totalBets: {
|
||||
YES: totalBets.YES + ind * amount,
|
||||
NO: totalBets.NO + (1 - ind) * amount,
|
||||
...totalBets,
|
||||
[outcome]: prevTotalBet + amount,
|
||||
},
|
||||
}
|
||||
|
||||
return calculateStandardPayout(newContract, bet, bet.outcome)
|
||||
return calculateStandardPayout(newContract, bet, outcome)
|
||||
}
|
||||
|
||||
function calculateMktPayout(contract: Contract, bet: Bet) {
|
||||
if (contract.outcomeType === 'BINARY')
|
||||
return calculateBinaryMktPayout(contract, bet)
|
||||
|
||||
const { totalShares, pool } = contract
|
||||
|
||||
const totalPool = _.sum(Object.values(pool))
|
||||
const sharesSquareSum = _.sumBy(
|
||||
Object.values(totalShares),
|
||||
(shares) => shares ** 2
|
||||
)
|
||||
|
||||
const weightedShareTotal = _.sumBy(Object.keys(totalShares), (outcome) => {
|
||||
// Avoid O(n^2) by reusing sharesSquareSum for prob.
|
||||
const shares = totalShares[outcome]
|
||||
const prob = shares ** 2 / sharesSquareSum
|
||||
return prob * shares
|
||||
})
|
||||
|
||||
const { outcome, amount, shares } = bet
|
||||
|
||||
const betP = getOutcomeProbability(totalShares, outcome)
|
||||
const winnings = ((betP * shares) / weightedShareTotal) * totalPool
|
||||
|
||||
return deductFees(amount, winnings)
|
||||
}
|
||||
|
||||
function calculateBinaryMktPayout(contract: Contract, bet: Bet) {
|
||||
const { resolutionProbability, totalShares, phantomShares } = contract
|
||||
const p =
|
||||
contract.resolutionProbability !== undefined
|
||||
? contract.resolutionProbability
|
||||
: getProbability(contract.totalShares)
|
||||
resolutionProbability !== undefined
|
||||
? resolutionProbability
|
||||
: getProbability(totalShares)
|
||||
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
|
||||
const weightedShareTotal =
|
||||
p * (contract.totalShares.YES - contract.phantomShares.YES) +
|
||||
(1 - p) * (contract.totalShares.NO - contract.phantomShares.NO)
|
||||
p * (totalShares.YES - (phantomShares?.YES ?? 0)) +
|
||||
(1 - p) * (totalShares.NO - (phantomShares?.NO ?? 0))
|
||||
|
||||
const { outcome, amount, shares } = bet
|
||||
|
||||
|
@ -197,15 +247,6 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
|
|||
throw new Error('Contract was not resolved')
|
||||
}
|
||||
|
||||
// deprecated use MKT payout
|
||||
export function currentValue(contract: Contract, bet: Bet) {
|
||||
const prob = getProbability(contract.pool)
|
||||
const yesPayout = calculatePayout(contract, bet, 'YES')
|
||||
const noPayout = calculatePayout(contract, bet, 'NO')
|
||||
|
||||
return prob * yesPayout + (1 - prob) * noPayout
|
||||
}
|
||||
|
||||
export const deductFees = (betAmount: number, winnings: number) => {
|
||||
return winnings > betAmount
|
||||
? betAmount + (1 - FEES) * (winnings - betAmount)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { Answer } from './answer'
|
||||
|
||||
export type Contract = {
|
||||
id: string
|
||||
slug: string // auto-generated; must be unique
|
||||
|
@ -11,14 +13,17 @@ export type Contract = {
|
|||
description: string // More info about what the contract is about
|
||||
tags: string[]
|
||||
lowercaseTags: string[]
|
||||
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
|
||||
visibility: 'public' | 'unlisted'
|
||||
|
||||
outcomeType: 'BINARY' | 'MULTI' | 'FREE_RESPONSE'
|
||||
multiOutcomes?: string[] // Used for outcomeType 'MULTI'.
|
||||
answers?: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
|
||||
|
||||
mechanism: 'dpm-2'
|
||||
phantomShares: { YES: number; NO: number }
|
||||
pool: { YES: number; NO: number }
|
||||
totalShares: { YES: number; NO: number }
|
||||
totalBets: { YES: number; NO: number }
|
||||
phantomShares?: { [outcome: string]: number }
|
||||
pool: { [outcome: string]: number }
|
||||
totalShares: { [outcome: string]: number }
|
||||
totalBets: { [outcome: string]: number }
|
||||
|
||||
createdTime: number // Milliseconds since epoch
|
||||
lastUpdatedTime: number // If the question or description was changed
|
||||
|
@ -26,11 +31,12 @@ export type Contract = {
|
|||
|
||||
isResolved: boolean
|
||||
resolutionTime?: number // When the contract creator resolved the market
|
||||
resolution?: outcome // Chosen by creator; must be one of outcomes
|
||||
resolution?: string
|
||||
resolutionProbability?: number
|
||||
closeEmailsSent?: number
|
||||
|
||||
volume24Hours: number
|
||||
volume7Days: number
|
||||
}
|
||||
|
||||
export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE'
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import { Bet } from './bet'
|
||||
import { calculateShares, getProbability } from './calculate'
|
||||
import {
|
||||
calculateShares,
|
||||
getProbability,
|
||||
getOutcomeProbability,
|
||||
} from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { User } from './user'
|
||||
|
||||
export const getNewBetInfo = (
|
||||
export const getNewBinaryBetInfo = (
|
||||
user: User,
|
||||
outcome: 'YES' | 'NO',
|
||||
amount: number,
|
||||
|
@ -52,3 +56,43 @@ export const getNewBetInfo = (
|
|||
|
||||
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
|
||||
}
|
||||
|
||||
export const getNewMultiBetInfo = (
|
||||
user: User,
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: Contract,
|
||||
newBetId: string
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
const prevOutcomePool = pool[outcome] ?? 0
|
||||
const newPool = { ...pool, [outcome]: prevOutcomePool + amount }
|
||||
|
||||
const shares = calculateShares(contract.totalShares, amount, outcome)
|
||||
|
||||
const prevShares = totalShares[outcome] ?? 0
|
||||
const newTotalShares = { ...totalShares, [outcome]: prevShares + shares }
|
||||
|
||||
const prevTotalBets = totalBets[outcome] ?? 0
|
||||
const newTotalBets = { ...totalBets, [outcome]: prevTotalBets + amount }
|
||||
|
||||
const probBefore = getOutcomeProbability(totalShares, outcome)
|
||||
const probAfter = getOutcomeProbability(newTotalShares, outcome)
|
||||
|
||||
const newBet: Bet = {
|
||||
id: newBetId,
|
||||
userId: user.id,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
outcome,
|
||||
probBefore,
|
||||
probAfter,
|
||||
createdTime: Date.now(),
|
||||
}
|
||||
|
||||
const newBalance = user.balance - amount
|
||||
|
||||
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
|
||||
}
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
import { calcStartPool } from './antes'
|
||||
|
||||
import { Contract } from './contract'
|
||||
import { Contract, outcomeType } from './contract'
|
||||
import { User } from './user'
|
||||
import { parseTags } from './util/parse'
|
||||
import { removeUndefinedProps } from './util/object'
|
||||
|
||||
export function getNewContract(
|
||||
id: string,
|
||||
slug: string,
|
||||
creator: User,
|
||||
question: string,
|
||||
outcomeType: outcomeType,
|
||||
description: string,
|
||||
initialProb: number,
|
||||
ante: number,
|
||||
closeTime: number,
|
||||
extraTags: string[]
|
||||
) {
|
||||
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
|
||||
calcStartPool(initialProb, ante)
|
||||
|
||||
const tags = parseTags(
|
||||
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
|
||||
)
|
||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
||||
|
||||
const contract: Contract = {
|
||||
const propsByOutcomeType =
|
||||
outcomeType === 'BINARY'
|
||||
? getBinaryProps(initialProb, ante)
|
||||
: getFreeAnswerProps(ante)
|
||||
|
||||
const contract: Contract = removeUndefinedProps({
|
||||
id,
|
||||
slug,
|
||||
outcomeType: 'BINARY',
|
||||
mechanism: 'dpm-2',
|
||||
outcomeType,
|
||||
...propsByOutcomeType,
|
||||
|
||||
creatorId: creator.id,
|
||||
creatorName: creator.name,
|
||||
|
@ -38,22 +43,43 @@ export function getNewContract(
|
|||
tags,
|
||||
lowercaseTags,
|
||||
visibility: 'public',
|
||||
isResolved: false,
|
||||
createdTime: Date.now(),
|
||||
lastUpdatedTime: Date.now(),
|
||||
closeTime,
|
||||
|
||||
mechanism: 'dpm-2',
|
||||
volume24Hours: 0,
|
||||
volume7Days: 0,
|
||||
})
|
||||
|
||||
return contract
|
||||
}
|
||||
|
||||
const getBinaryProps = (initialProb: number, ante: number) => {
|
||||
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
|
||||
calcStartPool(initialProb, ante)
|
||||
|
||||
return {
|
||||
phantomShares: { YES: phantomYes, NO: phantomNo },
|
||||
pool: { YES: poolYes, NO: poolNo },
|
||||
totalShares: { YES: sharesYes, NO: sharesNo },
|
||||
totalBets: { YES: poolYes, NO: poolNo },
|
||||
isResolved: false,
|
||||
|
||||
createdTime: Date.now(),
|
||||
lastUpdatedTime: Date.now(),
|
||||
|
||||
volume24Hours: 0,
|
||||
volume7Days: 0,
|
||||
}
|
||||
|
||||
if (closeTime) contract.closeTime = closeTime
|
||||
|
||||
return contract
|
||||
}
|
||||
|
||||
const getFreeAnswerProps = (ante: number) => {
|
||||
return {
|
||||
pool: { '0': ante },
|
||||
totalShares: { '0': ante },
|
||||
totalBets: { '0': ante },
|
||||
answers: [],
|
||||
}
|
||||
}
|
||||
|
||||
const getMultiProps = (
|
||||
outcomes: string[],
|
||||
initialProbs: number[],
|
||||
ante: number
|
||||
) => {
|
||||
// Not implemented.
|
||||
}
|
||||
|
|
|
@ -2,12 +2,12 @@ import * as _ from 'lodash'
|
|||
|
||||
import { Bet } from './bet'
|
||||
import { deductFees, getProbability } from './calculate'
|
||||
import { Contract, outcome } from './contract'
|
||||
import { Contract } from './contract'
|
||||
import { CREATOR_FEE, FEES } from './fees'
|
||||
|
||||
export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
|
||||
const { pool } = contract
|
||||
const poolTotal = pool.YES + pool.NO
|
||||
const poolTotal = _.sum(Object.values(pool))
|
||||
console.log('resolved N/A, pool M$', poolTotal)
|
||||
|
||||
const betSum = _.sumBy(bets, (b) => b.amount)
|
||||
|
@ -19,18 +19,17 @@ export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
|
|||
}
|
||||
|
||||
export const getStandardPayouts = (
|
||||
outcome: 'YES' | 'NO',
|
||||
outcome: string,
|
||||
contract: Contract,
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
|
||||
const winningBets = outcome === 'YES' ? yesBets : noBets
|
||||
const winningBets = bets.filter((bet) => bet.outcome === outcome)
|
||||
|
||||
const pool = contract.pool.YES + contract.pool.NO
|
||||
const poolTotal = _.sum(Object.values(contract.pool))
|
||||
const totalShares = _.sumBy(winningBets, (b) => b.shares)
|
||||
|
||||
const payouts = winningBets.map(({ userId, amount, shares }) => {
|
||||
const winnings = (shares / totalShares) * pool
|
||||
const winnings = (shares / totalShares) * poolTotal
|
||||
const profit = winnings - amount
|
||||
|
||||
// profit can be negative if using phantom shares
|
||||
|
@ -45,7 +44,7 @@ export const getStandardPayouts = (
|
|||
'resolved',
|
||||
outcome,
|
||||
'pool',
|
||||
pool,
|
||||
poolTotal,
|
||||
'profits',
|
||||
profits,
|
||||
'creator fee',
|
||||
|
@ -101,7 +100,7 @@ export const getMktPayouts = (
|
|||
}
|
||||
|
||||
export const getPayouts = (
|
||||
outcome: outcome,
|
||||
outcome: string,
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
resolutionProbability?: number
|
||||
|
@ -114,5 +113,8 @@ export const getPayouts = (
|
|||
return getMktPayouts(contract, bets, resolutionProbability)
|
||||
case 'CANCEL':
|
||||
return getCancelPayouts(contract, bets)
|
||||
default:
|
||||
// Multi outcome.
|
||||
return getStandardPayouts(outcome, contract, bets)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Bet } from './bet'
|
||||
import { calculateShareValue, deductFees, getProbability } from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { CREATOR_FEE, FEES } from './fees'
|
||||
import { CREATOR_FEE } from './fees'
|
||||
import { User } from './user'
|
||||
|
||||
export const getSellBetInfo = (
|
||||
|
@ -10,30 +10,21 @@ export const getSellBetInfo = (
|
|||
contract: Contract,
|
||||
newBetId: string
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
const { id: betId, amount, shares, outcome } = bet
|
||||
|
||||
const { YES: yesPool, NO: noPool } = contract.pool
|
||||
const { YES: yesShares, NO: noShares } = contract.totalShares
|
||||
const { YES: yesBets, NO: noBets } = contract.totalBets
|
||||
|
||||
const adjShareValue = calculateShareValue(contract, bet)
|
||||
|
||||
const newPool =
|
||||
outcome === 'YES'
|
||||
? { YES: yesPool - adjShareValue, NO: noPool }
|
||||
: { YES: yesPool, NO: noPool - adjShareValue }
|
||||
const newPool = { ...pool, [outcome]: pool[outcome] - adjShareValue }
|
||||
|
||||
const newTotalShares =
|
||||
outcome === 'YES'
|
||||
? { YES: yesShares - shares, NO: noShares }
|
||||
: { YES: yesShares, NO: noShares - shares }
|
||||
const newTotalShares = {
|
||||
...totalShares,
|
||||
[outcome]: totalShares[outcome] - shares,
|
||||
}
|
||||
|
||||
const newTotalBets =
|
||||
outcome === 'YES'
|
||||
? { YES: yesBets - amount, NO: noBets }
|
||||
: { YES: yesBets, NO: noBets - amount }
|
||||
const newTotalBets = { ...totalBets, [outcome]: totalBets[outcome] - amount }
|
||||
|
||||
const probBefore = getProbability(contract.totalShares)
|
||||
const probBefore = getProbability(totalShares)
|
||||
const probAfter = getProbability(newTotalShares)
|
||||
|
||||
const profit = adjShareValue - amount
|
||||
|
|
|
@ -6,6 +6,13 @@ export type User = {
|
|||
username: string
|
||||
avatarUrl?: string
|
||||
|
||||
// For their user page
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
totalPnLCached: number
|
||||
|
|
9
common/util/object.ts
Normal file
9
common/util/object.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export const removeUndefinedProps = <T>(obj: T): T => {
|
||||
let newObj: any = {}
|
||||
|
||||
for (let key of Object.keys(obj)) {
|
||||
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
|
||||
}
|
||||
|
||||
return newObj
|
||||
}
|
|
@ -3,10 +3,7 @@ export const randomString = (length = 12) =>
|
|||
.toString(16)
|
||||
.substring(2, length + 2)
|
||||
|
||||
export function createRNG(seed: string) {
|
||||
// https://stackoverflow.com/a/47593316/1592933
|
||||
|
||||
function genHash(str: string) {
|
||||
export function genHash(str: string) {
|
||||
// xmur3
|
||||
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
|
||||
|
@ -17,7 +14,10 @@ export function createRNG(seed: string) {
|
|||
h = Math.imul(h ^ (h >>> 13), 3266489909)
|
||||
return (h ^= h >>> 16) >>> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRNG(seed: string) {
|
||||
// https://stackoverflow.com/a/47593316/1592933
|
||||
|
||||
const gen = genHash(seed)
|
||||
let [a, b, c, d] = [gen(), gen(), gen(), gen()]
|
||||
|
|
|
@ -13,6 +13,9 @@ service cloud.firestore {
|
|||
|
||||
match /users/{userId} {
|
||||
allow read;
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle']);
|
||||
}
|
||||
|
||||
match /private-users/{userId} {
|
||||
|
@ -27,20 +30,16 @@ service cloud.firestore {
|
|||
allow delete: if resource.data.creatorId == request.auth.uid;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/bets/{betId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/bets/{betId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/comments/{commentId} {
|
||||
match /{somePath=**}/comments/{commentId} {
|
||||
allow read;
|
||||
allow create: if request.auth != null;
|
||||
}
|
||||
|
||||
match /{somePath=**}/comments/{commentId} {
|
||||
match /{somePath=**}/answers/{answerId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
|
@ -49,13 +48,9 @@ service cloud.firestore {
|
|||
allow update, delete: if request.auth.uid == resource.data.curatorId;
|
||||
}
|
||||
|
||||
match /folds/{foldId}/followers/{userId} {
|
||||
match /{somePath=**}/followers/{userId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
}
|
||||
|
||||
match /{somePath=**}/followers/{userId} {
|
||||
allow read;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser, removeUndefinedProps } from './utils'
|
||||
import { getUser } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { User } from '../../common/user'
|
||||
import { cleanUsername } from '../../common/util/clean-username'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
||||
export const changeUserInfo = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
|
@ -88,12 +90,23 @@ export const changeUser = async (
|
|||
userAvatarUrl: update.avatarUrl,
|
||||
})
|
||||
|
||||
const answerSnap = await transaction.get(
|
||||
firestore
|
||||
.collectionGroup('answers')
|
||||
.where('username', '==', user.username)
|
||||
)
|
||||
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
|
||||
|
||||
await transaction.update(userRef, userUpdate)
|
||||
|
||||
await Promise.all(
|
||||
commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate))
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate))
|
||||
)
|
||||
|
||||
await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate))
|
||||
})
|
||||
}
|
||||
|
|
111
functions/src/create-answer.ts
Normal file
111
functions/src/create-answer.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { getNewMultiBetInfo } from '../../common/new-bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { getValues } from './utils'
|
||||
|
||||
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
amount: number
|
||||
text: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId, amount, text } = data
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
if (!text || typeof text !== 'string' || text.length > 10000)
|
||||
return { status: 'error', message: 'Invalid text' }
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists)
|
||||
return { status: 'error', message: 'User not found' }
|
||||
const user = userSnap.data() as User
|
||||
|
||||
if (user.balance < amount)
|
||||
return { status: 'error', message: 'Insufficient balance' }
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
if (contract.outcomeType !== 'FREE_RESPONSE')
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Requires a free response contract',
|
||||
}
|
||||
|
||||
const { closeTime } = contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
return { status: 'error', message: 'Trading is closed' }
|
||||
|
||||
const [lastAnswer] = await getValues<Answer>(
|
||||
firestore
|
||||
.collection(`contracts/${contractId}/answers`)
|
||||
.orderBy('number', 'desc')
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if (!lastAnswer)
|
||||
return { status: 'error', message: 'Could not fetch last answer' }
|
||||
|
||||
const number = lastAnswer.number + 1
|
||||
const id = `${number}`
|
||||
|
||||
const newAnswerDoc = firestore
|
||||
.collection(`contracts/${contractId}/answers`)
|
||||
.doc(id)
|
||||
|
||||
const answerId = newAnswerDoc.id
|
||||
const { username, name, avatarUrl } = user
|
||||
|
||||
const answer: Answer = {
|
||||
id,
|
||||
number,
|
||||
contractId,
|
||||
createdTime: Date.now(),
|
||||
userId: user.id,
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
text,
|
||||
}
|
||||
transaction.create(newAnswerDoc, answer)
|
||||
|
||||
const newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
|
||||
getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id)
|
||||
|
||||
transaction.create(newBetDoc, { ...newBet, isAnte: true })
|
||||
transaction.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalShares: newTotalShares,
|
||||
totalBets: newTotalBets,
|
||||
answers: [...(contract.answers ?? []), answer],
|
||||
})
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
|
||||
return { status: 'success', answerId, betId: newBetDoc.id }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -2,11 +2,16 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { chargeUser, getUser } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, outcomeType } from '../../common/contract'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
import { getAnteBets, MINIMUM_ANTE } from '../../common/antes'
|
||||
import {
|
||||
getAnteBets,
|
||||
getFreeAnswerAnte,
|
||||
MINIMUM_ANTE,
|
||||
} from '../../common/antes'
|
||||
import { getNoneAnswer } from '../../common/answer'
|
||||
|
||||
export const createContract = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
|
@ -14,6 +19,7 @@ export const createContract = functions
|
|||
async (
|
||||
data: {
|
||||
question: string
|
||||
outcomeType: outcomeType
|
||||
description: string
|
||||
initialProb: number
|
||||
ante: number
|
||||
|
@ -30,10 +36,17 @@ export const createContract = functions
|
|||
|
||||
const { question, description, initialProb, ante, closeTime, tags } = data
|
||||
|
||||
if (!question || !initialProb)
|
||||
return { status: 'error', message: 'Missing contract attributes' }
|
||||
if (!question)
|
||||
return { status: 'error', message: 'Missing question field' }
|
||||
|
||||
if (initialProb < 1 || initialProb > 99)
|
||||
let outcomeType = data.outcomeType ?? 'BINARY'
|
||||
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
|
||||
return { status: 'error', message: 'Invalid outcomeType' }
|
||||
|
||||
if (
|
||||
outcomeType === 'BINARY' &&
|
||||
(!initialProb || initialProb < 1 || initialProb > 99)
|
||||
)
|
||||
return { status: 'error', message: 'Invalid initial probability' }
|
||||
|
||||
if (
|
||||
|
@ -63,6 +76,7 @@ export const createContract = functions
|
|||
slug,
|
||||
creator,
|
||||
question,
|
||||
outcomeType,
|
||||
description,
|
||||
initialProb,
|
||||
ante,
|
||||
|
@ -75,6 +89,7 @@ export const createContract = functions
|
|||
await contractRef.create(contract)
|
||||
|
||||
if (ante) {
|
||||
if (outcomeType === 'BINARY') {
|
||||
const yesBetDoc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
|
@ -91,6 +106,19 @@ export const createContract = functions
|
|||
)
|
||||
await yesBetDoc.set(yesBet)
|
||||
await noBetDoc.set(noBet)
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
const noneAnswerDoc = firestore
|
||||
.collection(`contracts/${contract.id}/answers`)
|
||||
.doc('0')
|
||||
const noneAnswer = getNoneAnswer(contract.id, creator)
|
||||
await noneAnswerDoc.set(noneAnswer)
|
||||
|
||||
const anteBetDoc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
const anteBet = getFreeAnswerAnte(creator, contract, anteBetDoc.id)
|
||||
await anteBetDoc.set(anteBet)
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'success', contract }
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import _ = require('lodash')
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { CREATOR_FEE } from '../../common/fees'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { formatPercent } from '../../common/util/format'
|
||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
|
||||
|
@ -15,12 +17,23 @@ type market_resolved_template = {
|
|||
url: string
|
||||
}
|
||||
|
||||
const toDisplayResolution = (outcome: string, prob: number) => {
|
||||
const display = {
|
||||
YES: 'YES',
|
||||
NO: 'NO',
|
||||
CANCEL: 'N/A',
|
||||
MKT: formatPercent(prob),
|
||||
}[outcome]
|
||||
|
||||
return display === undefined ? `#${outcome}` : display
|
||||
}
|
||||
|
||||
export const sendMarketResolutionEmail = async (
|
||||
userId: string,
|
||||
payout: number,
|
||||
creator: User,
|
||||
contract: Contract,
|
||||
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT',
|
||||
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
|
||||
resolutionProbability?: number
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
|
@ -36,13 +49,7 @@ export const sendMarketResolutionEmail = async (
|
|||
|
||||
const prob = resolutionProbability ?? getProbability(contract.totalShares)
|
||||
|
||||
const toDisplayResolution = {
|
||||
YES: 'YES',
|
||||
NO: 'NO',
|
||||
CANCEL: 'N/A',
|
||||
MKT: formatPercent(prob),
|
||||
}
|
||||
const outcome = toDisplayResolution[resolution]
|
||||
const outcome = toDisplayResolution(resolution, prob)
|
||||
|
||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||
|
||||
|
@ -88,3 +95,37 @@ Austin from Manifold
|
|||
https://manifold.markets/`
|
||||
)
|
||||
}
|
||||
|
||||
export const sendMarketCloseEmail = async (
|
||||
user: User,
|
||||
privateUser: PrivateUser,
|
||||
contract: Contract
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
privateUser.unsubscribedFromResolutionEmails ||
|
||||
!privateUser.email
|
||||
)
|
||||
return
|
||||
|
||||
const { username, name, id: userId } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const { question, pool: pools, slug } = contract
|
||||
const pool = formatMoney(_.sum(_.values(pools)))
|
||||
const url = `https://manifold.markets/${username}/${slug}`
|
||||
|
||||
await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Your market has closed',
|
||||
'market-close',
|
||||
{
|
||||
name: firstName,
|
||||
question,
|
||||
pool,
|
||||
url,
|
||||
userId,
|
||||
creatorFee: (CREATOR_FEE * 100).toString(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export * from './sell-bet'
|
|||
export * from './create-contract'
|
||||
export * from './create-user'
|
||||
export * from './create-fold'
|
||||
export * from './create-answer'
|
||||
export * from './on-fold-follow'
|
||||
export * from './on-fold-delete'
|
||||
export * from './unsubscribe'
|
||||
|
@ -18,3 +19,4 @@ export * from './update-contract-metrics'
|
|||
export * from './update-user-metrics'
|
||||
export * from './backup-db'
|
||||
export * from './change-user-info'
|
||||
export * from './market-close-emails'
|
||||
|
|
59
functions/src/market-close-emails.ts
Normal file
59
functions/src/market-close-emails.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getUserByUsername } from './utils'
|
||||
import { sendMarketCloseEmail } from './emails'
|
||||
|
||||
export const marketCloseEmails = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
await sendMarketCloseEmails()
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function sendMarketCloseEmails() {
|
||||
const contracts = await firestore.runTransaction(async (transaction) => {
|
||||
const snap = await transaction.get(
|
||||
firestore.collection('contracts').where('isResolved', '!=', true)
|
||||
)
|
||||
|
||||
return snap.docs
|
||||
.map((doc) => {
|
||||
const contract = doc.data() as Contract
|
||||
|
||||
if (
|
||||
contract.resolution ||
|
||||
(contract.closeEmailsSent ?? 0) >= 1 ||
|
||||
contract.closeTime === undefined ||
|
||||
(contract.closeTime ?? 0) > Date.now()
|
||||
)
|
||||
return undefined
|
||||
|
||||
transaction.update(doc.ref, {
|
||||
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
|
||||
})
|
||||
|
||||
return contract
|
||||
})
|
||||
.filter((x) => !!x) as Contract[]
|
||||
})
|
||||
|
||||
for (let contract of contracts) {
|
||||
console.log(
|
||||
'sending close email for',
|
||||
contract.slug,
|
||||
'closed',
|
||||
contract.closeTime
|
||||
)
|
||||
|
||||
const user = await getUserByUsername(contract.creatorUsername)
|
||||
if (!user) continue
|
||||
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) continue
|
||||
|
||||
await sendMarketCloseEmail(user, privateUser, contract)
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
|
|||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { getNewBetInfo } from '../../common/new-bet'
|
||||
import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet'
|
||||
|
||||
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||
async (
|
||||
|
@ -22,7 +22,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
if (outcome !== 'YES' && outcome !== 'NO')
|
||||
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
|
@ -42,16 +42,32 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
const { closeTime } = contract
|
||||
const { closeTime, outcomeType } = contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
return { status: 'error', message: 'Trading is closed' }
|
||||
|
||||
if (outcomeType === 'FREE_RESPONSE') {
|
||||
const answerSnap = await transaction.get(
|
||||
contractDoc.collection('answers').doc(outcome)
|
||||
)
|
||||
if (!answerSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
}
|
||||
|
||||
const newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
|
||||
getNewBetInfo(user, outcome, amount, contract, newBetDoc.id)
|
||||
outcomeType === 'BINARY'
|
||||
? getNewBinaryBetInfo(
|
||||
user,
|
||||
outcome as 'YES' | 'NO',
|
||||
amount,
|
||||
contract,
|
||||
newBetDoc.id
|
||||
)
|
||||
: getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id)
|
||||
|
||||
transaction.create(newBetDoc, newBet)
|
||||
transaction.update(contractDoc, {
|
||||
|
|
|
@ -25,10 +25,25 @@ export const resolveMarket = functions
|
|||
|
||||
const { outcome, contractId, probabilityInt } = data
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId, outcomeType } = contract
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
if (outcome !== 'CANCEL' && isNaN(+outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else {
|
||||
return { status: 'error', message: 'Invalid contract outcomeType' }
|
||||
}
|
||||
|
||||
if (
|
||||
outcomeType === 'BINARY' &&
|
||||
probabilityInt !== undefined &&
|
||||
(probabilityInt < 0 ||
|
||||
probabilityInt > 100 ||
|
||||
|
@ -36,19 +51,13 @@ export const resolveMarket = functions
|
|||
)
|
||||
return { status: 'error', message: 'Invalid probability' }
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
if (contract.creatorId !== userId)
|
||||
if (creatorId !== userId)
|
||||
return { status: 'error', message: 'User not creator of contract' }
|
||||
|
||||
if (contract.resolution)
|
||||
return { status: 'error', message: 'Contract already resolved' }
|
||||
|
||||
const creator = await getUser(contract.creatorId)
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) return { status: 'error', message: 'Creator not found' }
|
||||
|
||||
const resolutionProbability =
|
||||
|
@ -112,7 +121,7 @@ const sendResolutionEmails = async (
|
|||
userPayouts: { [userId: string]: number },
|
||||
creator: User,
|
||||
contract: Contract,
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT',
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
|
||||
resolutionProbability?: number
|
||||
) => {
|
||||
const nonWinners = _.difference(
|
||||
|
|
|
@ -83,11 +83,15 @@ async function recalculateContract(
|
|||
let totalShares = {
|
||||
YES: Math.sqrt(p) * (phantomAnte + realAnte),
|
||||
NO: Math.sqrt(1 - p) * (phantomAnte + realAnte),
|
||||
} as { [outcome: string]: number }
|
||||
|
||||
let pool = { YES: p * realAnte, NO: (1 - p) * realAnte } as {
|
||||
[outcome: string]: number
|
||||
}
|
||||
|
||||
let pool = { YES: p * realAnte, NO: (1 - p) * realAnte }
|
||||
|
||||
let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte }
|
||||
let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } as {
|
||||
[outcome: string]: number
|
||||
}
|
||||
|
||||
const betsRef = contractRef.collection('bets')
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ const pathsToPrivateKey = {
|
|||
stephen:
|
||||
'../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json',
|
||||
stephenDev:
|
||||
'../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json',
|
||||
'../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json',
|
||||
}
|
||||
|
||||
export const initAdmin = (who: keyof typeof pathsToPrivateKey) => {
|
||||
|
|
|
@ -77,13 +77,3 @@ export const chargeUser = (userId: string, charge: number) => {
|
|||
|
||||
return updateUserBalance(userId, -charge)
|
||||
}
|
||||
|
||||
export const removeUndefinedProps = <T>(obj: T): T => {
|
||||
let newObj: any = {}
|
||||
|
||||
for (let key of Object.keys(obj)) {
|
||||
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
|
||||
}
|
||||
|
||||
return newObj
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { IncomingMessage } from "http";
|
||||
import { parse } from "url";
|
||||
import { ParsedRequest } from "./types";
|
||||
import { IncomingMessage } from 'http'
|
||||
import { parse } from 'url'
|
||||
import { ParsedRequest } from './types'
|
||||
|
||||
export function parseRequest(req: IncomingMessage) {
|
||||
console.log("HTTP " + req.url);
|
||||
const { pathname, query } = parse(req.url || "/", true);
|
||||
console.log('HTTP ' + req.url)
|
||||
const { pathname, query } = parse(req.url || '/', true)
|
||||
const {
|
||||
fontSize,
|
||||
images,
|
||||
|
@ -20,73 +20,73 @@ export function parseRequest(req: IncomingMessage) {
|
|||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
} = query || {};
|
||||
} = query || {}
|
||||
|
||||
if (Array.isArray(fontSize)) {
|
||||
throw new Error("Expected a single fontSize");
|
||||
throw new Error('Expected a single fontSize')
|
||||
}
|
||||
if (Array.isArray(theme)) {
|
||||
throw new Error("Expected a single theme");
|
||||
throw new Error('Expected a single theme')
|
||||
}
|
||||
|
||||
const arr = (pathname || "/").slice(1).split(".");
|
||||
let extension = "";
|
||||
let text = "";
|
||||
const arr = (pathname || '/').slice(1).split('.')
|
||||
let extension = ''
|
||||
let text = ''
|
||||
if (arr.length === 0) {
|
||||
text = "";
|
||||
text = ''
|
||||
} else if (arr.length === 1) {
|
||||
text = arr[0];
|
||||
text = arr[0]
|
||||
} else {
|
||||
extension = arr.pop() as string;
|
||||
text = arr.join(".");
|
||||
extension = arr.pop() as string
|
||||
text = arr.join('.')
|
||||
}
|
||||
|
||||
// Take a url query param and return a single string
|
||||
const getString = (stringOrArray: string[] | string | undefined): string => {
|
||||
if (Array.isArray(stringOrArray)) {
|
||||
// If the query param is an array, return the first element
|
||||
return stringOrArray[0];
|
||||
return stringOrArray[0]
|
||||
}
|
||||
return stringOrArray || ''
|
||||
}
|
||||
return stringOrArray || "";
|
||||
};
|
||||
|
||||
const parsedRequest: ParsedRequest = {
|
||||
fileType: extension === "jpeg" ? extension : "png",
|
||||
fileType: extension === 'jpeg' ? extension : 'png',
|
||||
text: decodeURIComponent(text),
|
||||
theme: theme === "dark" ? "dark" : "light",
|
||||
md: md === "1" || md === "true",
|
||||
fontSize: fontSize || "96px",
|
||||
theme: theme === 'dark' ? 'dark' : 'light',
|
||||
md: md === '1' || md === 'true',
|
||||
fontSize: fontSize || '96px',
|
||||
images: getArray(images),
|
||||
widths: getArray(widths),
|
||||
heights: getArray(heights),
|
||||
|
||||
question:
|
||||
getString(question) || "Will you create a prediction market on Manifold?",
|
||||
probability: getString(probability) || "85%",
|
||||
metadata: getString(metadata) || "Jan 1 • M$ 123 pool",
|
||||
creatorName: getString(creatorName) || "Manifold Markets",
|
||||
creatorUsername: getString(creatorUsername) || "ManifoldMarkets",
|
||||
creatorAvatarUrl: getString(creatorAvatarUrl) || "",
|
||||
};
|
||||
parsedRequest.images = getDefaultImages(parsedRequest.images);
|
||||
return parsedRequest;
|
||||
getString(question) || 'Will you create a prediction market on Manifold?',
|
||||
probability: getString(probability),
|
||||
metadata: getString(metadata) || 'Jan 1 • M$ 123 pool',
|
||||
creatorName: getString(creatorName) || 'Manifold Markets',
|
||||
creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
|
||||
creatorAvatarUrl: getString(creatorAvatarUrl) || '',
|
||||
}
|
||||
parsedRequest.images = getDefaultImages(parsedRequest.images)
|
||||
return parsedRequest
|
||||
}
|
||||
|
||||
function getArray(stringOrArray: string[] | string | undefined): string[] {
|
||||
if (typeof stringOrArray === "undefined") {
|
||||
return [];
|
||||
if (typeof stringOrArray === 'undefined') {
|
||||
return []
|
||||
} else if (Array.isArray(stringOrArray)) {
|
||||
return stringOrArray;
|
||||
return stringOrArray
|
||||
} else {
|
||||
return [stringOrArray];
|
||||
return [stringOrArray]
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultImages(images: string[]): string[] {
|
||||
const defaultImage = "https://manifold.markets/logo.png";
|
||||
const defaultImage = 'https://manifold.markets/logo.png'
|
||||
|
||||
if (!images || !images[0]) {
|
||||
return [defaultImage];
|
||||
return [defaultImage]
|
||||
}
|
||||
return images;
|
||||
return images
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { sanitizeHtml } from "./sanitizer";
|
||||
import { ParsedRequest } from "./types";
|
||||
import { sanitizeHtml } from './sanitizer'
|
||||
import { ParsedRequest } from './types'
|
||||
|
||||
function getCss(theme: string, fontSize: string) {
|
||||
let background = "white";
|
||||
let foreground = "black";
|
||||
let radial = "lightgray";
|
||||
let background = 'white'
|
||||
let foreground = 'black'
|
||||
let radial = 'lightgray'
|
||||
|
||||
if (theme === "dark") {
|
||||
background = "black";
|
||||
foreground = "white";
|
||||
radial = "dimgray";
|
||||
if (theme === 'dark') {
|
||||
background = 'black'
|
||||
foreground = 'white'
|
||||
radial = 'dimgray'
|
||||
}
|
||||
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
|
||||
return `
|
||||
|
@ -78,7 +78,7 @@ function getCss(theme: string, fontSize: string) {
|
|||
.text-primary {
|
||||
color: #11b981;
|
||||
}
|
||||
`;
|
||||
`
|
||||
}
|
||||
|
||||
export function getHtml(parsedReq: ParsedRequest) {
|
||||
|
@ -92,8 +92,8 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
} = parsedReq;
|
||||
const hideAvatar = creatorAvatarUrl ? "" : "hidden";
|
||||
} = parsedReq
|
||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
|
@ -145,7 +145,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
</div>
|
||||
<div class="flex flex-col text-primary">
|
||||
<div class="text-8xl">${probability}</div>
|
||||
<div class="text-4xl">chance</div>
|
||||
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -157,5 +157,5 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
</html>`
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import Head from 'next/head'
|
|||
|
||||
export type OgCardProps = {
|
||||
question: string
|
||||
probability: string
|
||||
probability?: string
|
||||
metadata: string
|
||||
creatorName: string
|
||||
creatorUsername: string
|
||||
|
@ -11,11 +11,16 @@ export type OgCardProps = {
|
|||
}
|
||||
|
||||
function buildCardUrl(props: OgCardProps) {
|
||||
const probabilityParam =
|
||||
props.probability === undefined
|
||||
? ''
|
||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
||||
|
||||
// URL encode each of the props, then add them as query params
|
||||
return (
|
||||
`https://manifold-og-image.vercel.app/m.png` +
|
||||
`?question=${encodeURIComponent(props.question)}` +
|
||||
`&probability=${encodeURIComponent(props.probability)}` +
|
||||
probabilityParam +
|
||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}`
|
||||
|
|
546
web/components/answers-panel.tsx
Normal file
546
web/components/answers-panel.tsx
Normal file
|
@ -0,0 +1,546 @@
|
|||
import clsx from 'clsx'
|
||||
import _ from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Answer } from '../../common/answer'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { AmountInput } from './amount-input'
|
||||
import { Col } from './layout/col'
|
||||
import { createAnswer, placeBet, resolveMarket } from '../lib/firebase/api-call'
|
||||
import { Row } from './layout/row'
|
||||
import { Avatar } from './avatar'
|
||||
import { SiteLink } from './site-link'
|
||||
import { DateTimeTooltip } from './datetime-tooltip'
|
||||
import dayjs from 'dayjs'
|
||||
import { BuyButton, ChooseCancelSelector } from './yes-no-selector'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import {
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
} from '../../common/util/format'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import { useUser } from '../hooks/use-user'
|
||||
import {
|
||||
getProbabilityAfterBet,
|
||||
getOutcomeProbability,
|
||||
calculateShares,
|
||||
calculatePayoutAfterCorrectBet,
|
||||
} from '../../common/calculate'
|
||||
import { firebaseLogin } from '../lib/firebase/users'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { useAnswers } from '../hooks/use-answers'
|
||||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { tradingAllowed } from '../lib/firebase/contracts'
|
||||
|
||||
export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) {
|
||||
const { contract } = props
|
||||
const { creatorId, resolution } = contract
|
||||
|
||||
const answers = useAnswers(contract.id) ?? props.answers
|
||||
const [chosenAnswer, otherAnswers] = _.partition(
|
||||
answers.filter((answer) => answer.id !== '0'),
|
||||
(answer) => answer.id === resolution
|
||||
)
|
||||
const sortedAnswers = [
|
||||
...chosenAnswer,
|
||||
..._.sortBy(
|
||||
otherAnswers,
|
||||
(answer) => -1 * getOutcomeProbability(contract.totalShares, answer.id)
|
||||
),
|
||||
]
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [resolveOption, setResolveOption] = useState<
|
||||
'CHOOSE' | 'CANCEL' | undefined
|
||||
>()
|
||||
const [answerChoice, setAnswerChoice] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (resolveOption !== 'CHOOSE' && answerChoice) setAnswerChoice(undefined)
|
||||
}, [answerChoice, resolveOption])
|
||||
|
||||
return (
|
||||
<Col className="gap-3">
|
||||
{sortedAnswers.map((answer) => (
|
||||
<AnswerItem
|
||||
key={answer.id}
|
||||
answer={answer}
|
||||
contract={contract}
|
||||
showChoice={!resolution && resolveOption === 'CHOOSE'}
|
||||
isChosen={answer.id === answerChoice}
|
||||
onChoose={() => setAnswerChoice(answer.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sortedAnswers.length === 0 ? (
|
||||
<div className="text-gray-500 p-4">No answers yet...</div>
|
||||
) : (
|
||||
<div className="text-gray-500 self-end p-4">
|
||||
None of the above:{' '}
|
||||
{formatPercent(getOutcomeProbability(contract.totalShares, '0'))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) && <CreateAnswerInput contract={contract} />}
|
||||
|
||||
{user?.id === creatorId && !resolution && (
|
||||
<AnswerResolvePanel
|
||||
contract={contract}
|
||||
resolveOption={resolveOption}
|
||||
setResolveOption={setResolveOption}
|
||||
answer={answerChoice}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function AnswerItem(props: {
|
||||
answer: Answer
|
||||
contract: Contract
|
||||
showChoice: boolean
|
||||
isChosen: boolean
|
||||
onChoose: () => void
|
||||
}) {
|
||||
const { answer, contract, showChoice, isChosen, onChoose } = props
|
||||
const { resolution, totalShares } = contract
|
||||
const { username, avatarUrl, name, createdTime, number, text } = answer
|
||||
|
||||
const createdDate = dayjs(createdTime).format('MMM D')
|
||||
const prob = getOutcomeProbability(totalShares, answer.id)
|
||||
const probPercent = formatPercent(prob)
|
||||
const wasResolvedTo = resolution === answer.id
|
||||
|
||||
const [isBetting, setIsBetting] = useState(false)
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
'p-4 sm:flex-row rounded gap-4',
|
||||
wasResolvedTo
|
||||
? 'bg-green-50 mb-8'
|
||||
: isChosen
|
||||
? 'bg-green-50'
|
||||
: 'bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Col className="gap-3 flex-1">
|
||||
<div className="whitespace-pre-line break-words">{text}</div>
|
||||
|
||||
<Row className="text-gray-500 text-sm gap-2 items-center">
|
||||
<SiteLink className="relative" href={`/${username}`}>
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar avatarUrl={avatarUrl} size={6} />
|
||||
<div className="truncate">{name}</div>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
|
||||
<div className="">•</div>
|
||||
|
||||
<div className="whitespace-nowrap">
|
||||
<DateTimeTooltip text="" time={contract.createdTime}>
|
||||
{createdDate}
|
||||
</DateTimeTooltip>
|
||||
</div>
|
||||
<div className="">•</div>
|
||||
<div className="text-base">#{number}</div>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
{isBetting ? (
|
||||
<AnswerBetPanel
|
||||
answer={answer}
|
||||
contract={contract}
|
||||
closePanel={() => setIsBetting(false)}
|
||||
/>
|
||||
) : (
|
||||
<Row className="self-end sm:self-start items-center gap-4">
|
||||
{!wasResolvedTo && (
|
||||
<div
|
||||
className={clsx(
|
||||
'text-2xl',
|
||||
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{probPercent}
|
||||
</div>
|
||||
)}
|
||||
{showChoice ? (
|
||||
<div className="form-control py-1">
|
||||
<label className="cursor-pointer label gap-2">
|
||||
<span className="label-text">Choose this answer</span>
|
||||
<input
|
||||
className={clsx('radio', isChosen && '!bg-green-500')}
|
||||
type="radio"
|
||||
name="opt"
|
||||
checked={isChosen}
|
||||
onChange={onChoose}
|
||||
value={answer.id}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tradingAllowed(contract) && (
|
||||
<BuyButton
|
||||
className="justify-end self-end flex-initial btn-md !px-8"
|
||||
onClick={() => {
|
||||
setIsBetting(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{wasResolvedTo && (
|
||||
<Col className="items-end">
|
||||
<div className="text-green-700 text-xl">Chosen</div>
|
||||
<div className="text-2xl text-gray-500">{probPercent}</div>
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
contract: Contract
|
||||
closePanel: () => void
|
||||
}) {
|
||||
const { answer, contract, closePanel } = props
|
||||
const { id: answerId } = answer
|
||||
|
||||
const user = useUser()
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current.focus()
|
||||
}, [])
|
||||
|
||||
async function submitBet() {
|
||||
if (!user || !betAmount) return
|
||||
|
||||
if (user.balance < betAmount) {
|
||||
setError('Insufficient balance')
|
||||
return
|
||||
}
|
||||
|
||||
setError(undefined)
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await placeBet({
|
||||
amount: betAmount,
|
||||
outcome: answerId,
|
||||
contractId: contract.id,
|
||||
}).then((r) => r.data as any)
|
||||
|
||||
console.log('placed bet. Result:', result)
|
||||
|
||||
if (result?.status === 'success') {
|
||||
setIsSubmitting(false)
|
||||
closePanel()
|
||||
} else {
|
||||
setError(result?.error || 'Error placing bet')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const betDisabled = isSubmitting || !betAmount || error
|
||||
|
||||
const initialProb = getOutcomeProbability(contract.totalShares, answer.id)
|
||||
|
||||
const resultProb = getProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
answerId,
|
||||
betAmount ?? 0
|
||||
)
|
||||
|
||||
const shares = calculateShares(contract.totalShares, betAmount ?? 0, answerId)
|
||||
|
||||
const currentPayout = betAmount
|
||||
? calculatePayoutAfterCorrectBet(contract, {
|
||||
outcome: answerId,
|
||||
amount: betAmount,
|
||||
shares,
|
||||
} as Bet)
|
||||
: 0
|
||||
|
||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
|
||||
|
||||
return (
|
||||
<Col className="items-start px-2 pb-2 pt-4 sm:pt-0">
|
||||
<Row className="self-stretch items-center justify-between">
|
||||
<div className="text-xl">Buy this answer</div>
|
||||
|
||||
<button className="btn-ghost btn-circle" onClick={closePanel}>
|
||||
<XIcon className="w-8 h-8 text-gray-500 mx-auto" aria-hidden="true" />
|
||||
</button>
|
||||
</Row>
|
||||
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
|
||||
<AmountInput
|
||||
inputClassName="w-full"
|
||||
amount={betAmount}
|
||||
onChange={setBetAmount}
|
||||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<div className="mt-2 mb-1 text-sm text-gray-500">Implied probability</div>
|
||||
<Row>
|
||||
<div>{formatPercent(initialProb)}</div>
|
||||
<div className="mx-2">→</div>
|
||||
<div>{formatPercent(resultProb)}</div>
|
||||
</Row>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
|
||||
Payout if chosen
|
||||
<InfoTooltip
|
||||
text={`Current payout for ${formatWithCommas(
|
||||
shares
|
||||
)} / ${formatWithCommas(
|
||||
shares + contract.totalShares[answerId]
|
||||
)} shares`}
|
||||
/>
|
||||
</Row>
|
||||
<div>
|
||||
{formatMoney(currentPayout)}
|
||||
<span>(+{currentReturnPercent})</span>
|
||||
</div>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn',
|
||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
||||
isSubmitting ? 'loading' : ''
|
||||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn mt-4 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
|
||||
onClick={firebaseLogin}
|
||||
>
|
||||
Sign in to trade!
|
||||
</button>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAnswerInput(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const [text, setText] = useState('')
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
||||
const [amountError, setAmountError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const canSubmit = text && betAmount && !amountError && !isSubmitting
|
||||
|
||||
const submitAnswer = async () => {
|
||||
if (canSubmit) {
|
||||
setIsSubmitting(true)
|
||||
const result = await createAnswer({
|
||||
contractId: contract.id,
|
||||
text,
|
||||
amount: betAmount,
|
||||
}).then((r) => r.data)
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (result.status === 'success') {
|
||||
setText('')
|
||||
setBetAmount(10)
|
||||
setAmountError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultProb = getProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
'new',
|
||||
betAmount ?? 0
|
||||
)
|
||||
|
||||
const shares = calculateShares(contract.totalShares, betAmount ?? 0, 'new')
|
||||
|
||||
const currentPayout = betAmount
|
||||
? calculatePayoutAfterCorrectBet(contract, {
|
||||
outcome: 'new',
|
||||
amount: betAmount,
|
||||
shares,
|
||||
} as Bet)
|
||||
: 0
|
||||
|
||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
|
||||
|
||||
return (
|
||||
<Col className="gap-4 p-4 bg-gray-50 rounded">
|
||||
<Col className="flex-1 gap-2">
|
||||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="textarea textarea-bordered w-full"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={10000}
|
||||
/>
|
||||
<div />
|
||||
<Col
|
||||
className={clsx(
|
||||
'sm:flex-row gap-4',
|
||||
text ? 'justify-between' : 'self-end'
|
||||
)}
|
||||
>
|
||||
{text && (
|
||||
<>
|
||||
<Col className="gap-2 mt-1">
|
||||
<div className="text-gray-500 text-sm">
|
||||
Ante (cannot be sold)
|
||||
</div>
|
||||
<AmountInput
|
||||
amount={betAmount}
|
||||
onChange={setBetAmount}
|
||||
error={amountError}
|
||||
setError={setAmountError}
|
||||
minimumAmount={10}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Col>
|
||||
<Col className="gap-2 mt-1">
|
||||
<div className="text-sm text-gray-500">Implied probability</div>
|
||||
<Row>
|
||||
<div>{formatPercent(0)}</div>
|
||||
<div className="mx-2">→</div>
|
||||
<div>{formatPercent(resultProb)}</div>
|
||||
</Row>
|
||||
<Row className="mt-2 mb-1 items-center gap-2 text-sm text-gray-500">
|
||||
Payout if chosen
|
||||
<InfoTooltip
|
||||
text={`Current payout for ${formatWithCommas(
|
||||
shares
|
||||
)} / ${formatWithCommas(shares)} shares`}
|
||||
/>
|
||||
</Row>
|
||||
<div>
|
||||
{formatMoney(currentPayout)}
|
||||
<span>(+{currentReturnPercent})</span>
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
'btn self-end mt-2',
|
||||
canSubmit ? 'btn-outline' : 'btn-disabled',
|
||||
isSubmitting && 'loading'
|
||||
)}
|
||||
disabled={!canSubmit}
|
||||
onClick={submitAnswer}
|
||||
>
|
||||
Submit answer & bet
|
||||
</button>
|
||||
</Col>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function AnswerResolvePanel(props: {
|
||||
contract: Contract
|
||||
resolveOption: 'CHOOSE' | 'CANCEL' | undefined
|
||||
setResolveOption: (option: 'CHOOSE' | 'CANCEL' | undefined) => void
|
||||
answer: string | undefined
|
||||
}) {
|
||||
const { contract, resolveOption, setResolveOption, answer } = props
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
const onResolve = async () => {
|
||||
if (resolveOption === 'CHOOSE' && answer === undefined) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await resolveMarket({
|
||||
outcome: resolveOption === 'CHOOSE' ? (answer as string) : 'CANCEL',
|
||||
contractId: contract.id,
|
||||
}).then((r) => r.data as any)
|
||||
|
||||
console.log('resolved', `#${answer}`, 'result:', result)
|
||||
|
||||
if (result?.status !== 'success') {
|
||||
setError(result?.error || 'Error resolving market')
|
||||
}
|
||||
setResolveOption(undefined)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const resolutionButtonClass =
|
||||
resolveOption === 'CANCEL'
|
||||
? 'bg-yellow-400 hover:bg-yellow-500'
|
||||
: resolveOption === 'CHOOSE' && answer
|
||||
? 'btn-primary'
|
||||
: 'btn-disabled'
|
||||
|
||||
return (
|
||||
<Col className="gap-4 p-4 bg-gray-50 rounded">
|
||||
<div>Resolve your market</div>
|
||||
<Col className="sm:flex-row sm:items-center gap-4">
|
||||
<ChooseCancelSelector
|
||||
className="sm:!flex-row sm:items-center"
|
||||
selected={resolveOption}
|
||||
onSelect={setResolveOption}
|
||||
/>
|
||||
|
||||
<Row
|
||||
className={clsx(
|
||||
'flex-1 items-center',
|
||||
resolveOption ? 'justify-between' : 'justify-end'
|
||||
)}
|
||||
>
|
||||
{resolveOption && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setResolveOption(undefined)
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<ResolveConfirmationButton
|
||||
onResolve={onResolve}
|
||||
isSubmitting={isSubmitting}
|
||||
openModelButtonClass={resolutionButtonClass}
|
||||
submitButtonClass={resolutionButtonClass}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
{!!error && <div className="text-red-500">{error}</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -7,8 +7,9 @@ export function Avatar(props: {
|
|||
avatarUrl?: string
|
||||
noLink?: boolean
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
const { username, avatarUrl, noLink, size } = props
|
||||
const { username, avatarUrl, noLink, size, className } = props
|
||||
const s = size || 10
|
||||
|
||||
const onClick =
|
||||
|
@ -25,7 +26,8 @@ export function Avatar(props: {
|
|||
className={clsx(
|
||||
'flex items-center justify-center rounded-full object-cover',
|
||||
`w-${s} h-${s}`,
|
||||
!noLink && 'cursor-pointer'
|
||||
!noLink && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
src={avatarUrl}
|
||||
onClick={onClick}
|
||||
|
|
|
@ -49,6 +49,7 @@ export function BetPanel(props: {
|
|||
}, [])
|
||||
|
||||
const { contract, className, title, selected, onBetSuccess } = props
|
||||
const { totalShares, phantomShares } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
|
@ -108,11 +109,12 @@ export function BetPanel(props: {
|
|||
|
||||
const initialProb = getProbability(contract.totalShares)
|
||||
|
||||
const resultProb = getProbabilityAfterBet(
|
||||
const outcomeProb = getProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
betChoice || 'YES',
|
||||
betAmount ?? 0
|
||||
)
|
||||
const resultProb = betChoice === 'NO' ? 1 - outcomeProb : outcomeProb
|
||||
|
||||
const shares = calculateShares(
|
||||
contract.totalShares,
|
||||
|
@ -179,8 +181,8 @@ export function BetPanel(props: {
|
|||
shares
|
||||
)} / ${formatWithCommas(
|
||||
shares +
|
||||
contract.totalShares[betChoice] -
|
||||
contract.phantomShares[betChoice]
|
||||
totalShares[betChoice] -
|
||||
(phantomShares ? phantomShares[betChoice] : 0)
|
||||
)} ${betChoice} shares`}
|
||||
/>
|
||||
</Row>
|
||||
|
|
|
@ -18,22 +18,24 @@ import {
|
|||
Contract,
|
||||
getContractFromId,
|
||||
contractPath,
|
||||
contractMetrics,
|
||||
getBinaryProbPercent,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Row } from './layout/row'
|
||||
import { UserLink } from './user-page'
|
||||
import {
|
||||
calculateCancelPayout,
|
||||
calculatePayout,
|
||||
calculateSaleAmount,
|
||||
getOutcomeProbability,
|
||||
getProbability,
|
||||
getProbabilityAfterSale,
|
||||
resolvedPayout,
|
||||
} from '../../common/calculate'
|
||||
import { sellBet } from '../lib/firebase/api-call'
|
||||
import { ConfirmationButton } from './confirmation-button'
|
||||
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
type BetSort = 'newest' | 'profit' | 'resolved'
|
||||
type BetSort = 'newest' | 'profit' | 'resolved' | 'value'
|
||||
|
||||
export function BetsList(props: { user: User }) {
|
||||
const { user } = props
|
||||
|
@ -41,7 +43,7 @@ export function BetsList(props: { user: User }) {
|
|||
|
||||
const [contracts, setContracts] = useState<Contract[]>([])
|
||||
|
||||
const [sort, setSort] = useState<BetSort>('profit')
|
||||
const [sort, setSort] = useState<BetSort>('value')
|
||||
|
||||
useEffect(() => {
|
||||
const loadedBets = bets ? bets : []
|
||||
|
@ -50,7 +52,7 @@ export function BetsList(props: { user: User }) {
|
|||
let disposed = false
|
||||
Promise.all(contractIds.map((id) => getContractFromId(id))).then(
|
||||
(contracts) => {
|
||||
if (!disposed) setContracts(contracts.filter(Boolean) as Contract[])
|
||||
if (!disposed) setContracts(filterDefined(contracts))
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -104,6 +106,8 @@ export function BetsList(props: { user: User }) {
|
|||
contracts,
|
||||
(c) => -1 * (contractsCurrentValue[c.id] - contractsInvestment[c.id])
|
||||
)
|
||||
} else if (sort === 'value') {
|
||||
sortedContracts = _.sortBy(contracts, (c) => -contractsCurrentValue[c.id])
|
||||
}
|
||||
|
||||
const [resolved, unresolved] = _.partition(
|
||||
|
@ -161,6 +165,7 @@ export function BetsList(props: { user: User }) {
|
|||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as BetSort)}
|
||||
>
|
||||
<option value="value">By value</option>
|
||||
<option value="profit">By profit</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
|
@ -180,10 +185,13 @@ export function BetsList(props: { user: User }) {
|
|||
|
||||
function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { bets, contract } = props
|
||||
const { resolution } = contract
|
||||
const { resolution, outcomeType } = contract
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const probPercent = getBinaryProbPercent(contract)
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
|
@ -213,6 +221,8 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
|
|||
</Row>
|
||||
|
||||
<Row className="items-center gap-2 text-sm text-gray-500">
|
||||
{isBinary && (
|
||||
<>
|
||||
{resolution ? (
|
||||
<div>
|
||||
Resolved <OutcomeLabel outcome={resolution} />
|
||||
|
@ -221,6 +231,8 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
|
|||
<div className="text-primary text-lg">{probPercent}</div>
|
||||
)}
|
||||
<div>•</div>
|
||||
</>
|
||||
)}
|
||||
<UserLink
|
||||
name={contract.creatorName}
|
||||
username={contract.creatorUsername}
|
||||
|
@ -263,8 +275,8 @@ export function MyBetsSummary(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { bets, contract, onlyMKT, className } = props
|
||||
const { resolution } = contract
|
||||
calculateCancelPayout
|
||||
const { resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount)
|
||||
|
@ -280,6 +292,9 @@ export function MyBetsSummary(props: {
|
|||
calculatePayout(contract, bet, 'NO')
|
||||
)
|
||||
|
||||
// const p = getProbability(contract.totalShares)
|
||||
// const expectation = p * yesWinnings + (1 - p) * noWinnings
|
||||
|
||||
const marketWinnings = _.sumBy(excludeSales, (bet) =>
|
||||
calculatePayout(contract, bet, 'MKT')
|
||||
)
|
||||
|
@ -330,6 +345,14 @@ export function MyBetsSummary(props: {
|
|||
payoutCol
|
||||
) : (
|
||||
<>
|
||||
{/* <Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Expectation
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(expectation)}
|
||||
</div>
|
||||
</Col> */}
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if <YesLabel />
|
||||
|
@ -348,10 +371,16 @@ export function MyBetsSummary(props: {
|
|||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
{isBinary ? (
|
||||
<>
|
||||
Payout at{' '}
|
||||
<span className="text-blue-400">
|
||||
{formatPercent(getProbability(contract.totalShares))}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>Current payout</>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(marketWinnings)}
|
||||
|
@ -469,6 +498,19 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
|||
const { contract, bet } = props
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const initialProb = getOutcomeProbability(
|
||||
contract.totalShares,
|
||||
bet.outcome === 'NO' ? 'YES' : bet.outcome
|
||||
)
|
||||
|
||||
const outcomeProb = getProbabilityAfterSale(
|
||||
contract.totalShares,
|
||||
bet.outcome,
|
||||
bet.shares
|
||||
)
|
||||
|
||||
const saleAmount = calculateSaleAmount(contract, bet)
|
||||
|
||||
return (
|
||||
<ConfirmationButton
|
||||
id={`sell-${bet.id}`}
|
||||
|
@ -488,8 +530,12 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
|||
</div>
|
||||
<div>
|
||||
Do you want to sell {formatWithCommas(bet.shares)} shares of{' '}
|
||||
<OutcomeLabel outcome={bet.outcome} /> for{' '}
|
||||
{formatMoney(calculateSaleAmount(contract, bet))}?
|
||||
<OutcomeLabel outcome={bet.outcome} /> for {formatMoney(saleAmount)}?
|
||||
</div>
|
||||
|
||||
<div className="mt-2 mb-1 text-sm text-gray-500">
|
||||
Implied probability: {formatPercent(initialProb)} →{' '}
|
||||
{formatPercent(outcomeProb)}
|
||||
</div>
|
||||
</ConfirmationButton>
|
||||
)
|
||||
|
|
|
@ -31,7 +31,7 @@ export function ConfirmationButton(props: {
|
|||
<input type="checkbox" id={id} className="modal-toggle" />
|
||||
|
||||
<div className="modal">
|
||||
<div className="modal-box">
|
||||
<div className="modal-box whitespace-normal">
|
||||
{children}
|
||||
|
||||
<div className="modal-action">
|
||||
|
@ -51,3 +51,36 @@ export function ConfirmationButton(props: {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResolveConfirmationButton(props: {
|
||||
onResolve: () => void
|
||||
isSubmitting: boolean
|
||||
openModelButtonClass?: string
|
||||
submitButtonClass?: string
|
||||
}) {
|
||||
const { onResolve, isSubmitting, openModelButtonClass, submitButtonClass } =
|
||||
props
|
||||
return (
|
||||
<ConfirmationButton
|
||||
id="resolution-modal"
|
||||
openModelBtn={{
|
||||
className: clsx(
|
||||
'border-none self-start',
|
||||
openModelButtonClass,
|
||||
isSubmitting && 'btn-disabled loading'
|
||||
),
|
||||
label: 'Resolve',
|
||||
}}
|
||||
cancelBtn={{
|
||||
label: 'Back',
|
||||
}}
|
||||
submitBtn={{
|
||||
label: 'Resolve',
|
||||
className: clsx('border-none', submitButtonClass),
|
||||
}}
|
||||
onSubmit={onResolve}
|
||||
>
|
||||
<p>Are you sure you want to resolve this market?</p>
|
||||
</ConfirmationButton>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,12 +7,13 @@ import {
|
|||
Contract,
|
||||
contractMetrics,
|
||||
contractPath,
|
||||
getBinaryProbPercent,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Col } from './layout/col'
|
||||
import dayjs from 'dayjs'
|
||||
import { TrendingUpIcon } from '@heroicons/react/solid'
|
||||
import { DateTimeTooltip } from './datetime-tooltip'
|
||||
import { ClockIcon } from '@heroicons/react/outline'
|
||||
import { ClockIcon, DatabaseIcon } from '@heroicons/react/outline'
|
||||
import { fromNow } from '../lib/util/time'
|
||||
import { Avatar } from './avatar'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
@ -24,8 +25,7 @@ export function ContractCard(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime, className } = props
|
||||
const { question, resolution } = contract
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
const { question } = contract
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -48,7 +48,7 @@ export function ContractCard(props: {
|
|||
|
||||
<Row className="justify-between gap-4">
|
||||
<p
|
||||
className="font-medium text-indigo-700 break-words"
|
||||
className="break-words font-medium text-indigo-700"
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
>
|
||||
{question}
|
||||
|
@ -66,27 +66,29 @@ export function ResolutionOrChance(props: {
|
|||
className?: string
|
||||
}) {
|
||||
const { contract, large, className } = props
|
||||
const { resolution } = contract
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
const { resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const marketClosed = (contract.closeTime || Infinity) < Date.now()
|
||||
|
||||
const resolutionColor = {
|
||||
const resolutionColor =
|
||||
{
|
||||
YES: 'text-primary',
|
||||
NO: 'text-red-400',
|
||||
MKT: 'text-blue-400',
|
||||
CANCEL: 'text-yellow-400',
|
||||
'': '', // Empty if unresolved
|
||||
}[resolution || '']
|
||||
}[resolution || ''] ?? 'text-primary'
|
||||
|
||||
const probColor = marketClosed ? 'text-gray-400' : 'text-primary'
|
||||
|
||||
const resolutionText = {
|
||||
const resolutionText =
|
||||
{
|
||||
YES: 'YES',
|
||||
NO: 'NO',
|
||||
MKT: probPercent,
|
||||
MKT: getBinaryProbPercent(contract),
|
||||
CANCEL: 'N/A',
|
||||
'': '',
|
||||
}[resolution || '']
|
||||
}[resolution || ''] ?? `#${resolution}`
|
||||
|
||||
return (
|
||||
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
|
||||
|
@ -100,12 +102,14 @@ export function ResolutionOrChance(props: {
|
|||
<div className={resolutionColor}>{resolutionText}</div>
|
||||
</>
|
||||
) : (
|
||||
isBinary && (
|
||||
<>
|
||||
<div className={probColor}>{probPercent}</div>
|
||||
<div className={probColor}>{getBinaryProbPercent(contract)}</div>
|
||||
<div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
|
||||
chance
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
|
@ -137,17 +141,19 @@ function AbbrContractDetails(props: {
|
|||
</Row>
|
||||
|
||||
{showHotVolume ? (
|
||||
<div className="whitespace-nowrap">
|
||||
<TrendingUpIcon className="inline h-5 w-5 text-gray-500" />{' '}
|
||||
{formatMoney(volume24Hours)}
|
||||
</div>
|
||||
<Row className="gap-1">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showCloseTime ? (
|
||||
<div className="whitespace-nowrap">
|
||||
<ClockIcon className="-my-1 inline h-5 w-5 text-gray-500" /> Closes{' '}
|
||||
{fromNow(closeTime || 0)}
|
||||
</div>
|
||||
<Row className="gap-1">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
Closes {fromNow(closeTime || 0)}
|
||||
</Row>
|
||||
) : (
|
||||
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
|
||||
<Row className="gap-1">
|
||||
{/* <DatabaseIcon className="h-5 w-5" /> */}
|
||||
{formatMoney(truePool)} pool
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
|
@ -161,7 +167,8 @@ export function ContractDetails(props: { contract: Contract }) {
|
|||
|
||||
return (
|
||||
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap">
|
||||
<Row className="flex-wrap items-center gap-2">
|
||||
<Row className="flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar
|
||||
username={creatorUsername}
|
||||
avatarUrl={contract.creatorAvatarUrl}
|
||||
|
@ -172,9 +179,11 @@ export function ContractDetails(props: { contract: Contract }) {
|
|||
name={creatorName}
|
||||
username={creatorUsername}
|
||||
/>
|
||||
<div className="">•</div>
|
||||
</Row>
|
||||
|
||||
<Row className="items-center gap-1">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
|
||||
<div className="whitespace-nowrap">
|
||||
<DateTimeTooltip text="Market created:" time={contract.createdTime}>
|
||||
{createdDate}
|
||||
</DateTimeTooltip>
|
||||
|
@ -200,15 +209,18 @@ export function ContractDetails(props: { contract: Contract }) {
|
|||
}
|
||||
time={closeTime}
|
||||
>
|
||||
{dayjs(closeTime).format('MMM D, YYYY')}
|
||||
{dayjs(closeTime).format('MMM D')} ({fromNow(closeTime)})
|
||||
</DateTimeTooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Row className="items-center gap-1">
|
||||
<DatabaseIcon className="h-5 w-5" />
|
||||
|
||||
<div className="">•</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
|
||||
</Row>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
// From https://tailwindui.com/components/application-ui/lists/feeds
|
||||
import { useState } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import _ from 'lodash'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
DotsVerticalIcon,
|
||||
LockClosedIcon,
|
||||
StarIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import dayjs from 'dayjs'
|
||||
import clsx from 'clsx'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { OutcomeLabel } from './outcome-label'
|
||||
import {
|
||||
|
@ -34,11 +34,9 @@ import { Col } from './layout/col'
|
|||
import { UserLink } from './user-page'
|
||||
import { DateTimeTooltip } from './datetime-tooltip'
|
||||
import { useBets } from '../hooks/use-bets'
|
||||
import { Bet, withoutAnteBets } from '../lib/firebase/bets'
|
||||
import { Bet } from '../lib/firebase/bets'
|
||||
import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
|
||||
import { JoinSpans } from './join-spans'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { outcome } from '../../common/contract'
|
||||
import { fromNow } from '../lib/util/time'
|
||||
import BetRow from './bet-row'
|
||||
import { parseTags } from '../../common/util/parse'
|
||||
|
@ -204,7 +202,7 @@ function EditContract(props: {
|
|||
) : (
|
||||
<Row>
|
||||
<button
|
||||
className="btn btn-neutral btn-outline btn-sm mt-4"
|
||||
className="btn btn-neutral btn-outline btn-xs mt-4"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{props.buttonText}
|
||||
|
@ -302,9 +300,10 @@ function TruncatedComment(props: {
|
|||
|
||||
function FeedQuestion(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { creatorName, creatorUsername, createdTime, question, resolution } =
|
||||
const { creatorName, creatorUsername, question, resolution, outcomeType } =
|
||||
contract
|
||||
const { probPercent, truePool } = contractMetrics(contract)
|
||||
const { truePool } = contractMetrics(contract)
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
// Currently hidden on mobile; ideally we'd fit this in somewhere.
|
||||
const closeMessage =
|
||||
|
@ -340,7 +339,9 @@ function FeedQuestion(props: { contract: Contract }) {
|
|||
>
|
||||
{question}
|
||||
</SiteLink>
|
||||
{(isBinary || resolution) && (
|
||||
<ResolutionOrChance className="items-center" contract={contract} />
|
||||
)}
|
||||
</Col>
|
||||
<TruncatedComment
|
||||
comment={contract.description}
|
||||
|
@ -379,7 +380,7 @@ function FeedDescription(props: { contract: Contract }) {
|
|||
)
|
||||
}
|
||||
|
||||
function OutcomeIcon(props: { outcome?: outcome }) {
|
||||
function OutcomeIcon(props: { outcome?: string }) {
|
||||
const { outcome } = props
|
||||
switch (outcome) {
|
||||
case 'YES':
|
||||
|
@ -387,8 +388,9 @@ function OutcomeIcon(props: { outcome?: outcome }) {
|
|||
case 'NO':
|
||||
return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
case 'CANCEL':
|
||||
default:
|
||||
return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
default:
|
||||
return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -536,7 +538,7 @@ function groupBets(
|
|||
return items as ActivityItem[]
|
||||
}
|
||||
|
||||
function BetGroupSpan(props: { bets: Bet[]; outcome: 'YES' | 'NO' }) {
|
||||
function BetGroupSpan(props: { bets: Bet[]; outcome: string }) {
|
||||
const { bets, outcome } = props
|
||||
|
||||
const numberTraders = _.uniqBy(bets, (b) => b.userId).length
|
||||
|
@ -562,7 +564,8 @@ function FeedBetGroup(props: { activityItem: any }) {
|
|||
const { activityItem } = props
|
||||
const bets: Bet[] = activityItem.bets
|
||||
|
||||
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES')
|
||||
const betGroups = _.groupBy(bets, (bet) => bet.outcome)
|
||||
const outcomes = Object.keys(betGroups)
|
||||
|
||||
// Use the time of the last bet for the entire group
|
||||
const createdTime = bets[bets.length - 1].createdTime
|
||||
|
@ -578,9 +581,12 @@ function FeedBetGroup(props: { activityItem: any }) {
|
|||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-gray-500">
|
||||
{yesBets.length > 0 && <BetGroupSpan outcome="YES" bets={yesBets} />}
|
||||
{yesBets.length > 0 && noBets.length > 0 && <br />}
|
||||
{noBets.length > 0 && <BetGroupSpan outcome="NO" bets={noBets} />}
|
||||
{outcomes.map((outcome, index) => (
|
||||
<Fragment key={outcome}>
|
||||
<BetGroupSpan outcome={outcome} bets={betGroups[outcome]} />
|
||||
{index !== outcomes.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
<Timestamp time={createdTime} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -638,12 +644,16 @@ export function ContractFeed(props: {
|
|||
betRowClassName?: string
|
||||
}) {
|
||||
const { contract, feedType, betRowClassName } = props
|
||||
const { id } = contract
|
||||
const { id, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const user = useUser()
|
||||
|
||||
let bets = useBets(id) ?? props.bets
|
||||
bets = withoutAnteBets(contract, bets)
|
||||
let bets = useBets(contract.id) ?? props.bets
|
||||
bets = isBinary
|
||||
? bets.filter((bet) => !bet.isAnte)
|
||||
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
|
||||
|
||||
const comments = useComments(id) ?? props.comments
|
||||
|
||||
|
@ -711,7 +721,7 @@ export function ContractFeed(props: {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{tradingAllowed(contract) && (
|
||||
{isBinary && tradingAllowed(contract) && (
|
||||
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
deleteContract,
|
||||
contractPath,
|
||||
tradingAllowed,
|
||||
getBinaryProbPercent,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Col } from './layout/col'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
@ -28,40 +28,36 @@ export const ContractOverview = (props: {
|
|||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
folds: Fold[]
|
||||
children?: any
|
||||
className?: string
|
||||
}) => {
|
||||
const { contract, bets, comments, folds, className } = props
|
||||
const { resolution, creatorId, creatorName } = contract
|
||||
const { probPercent, truePool } = contractMetrics(contract)
|
||||
const { contract, bets, comments, folds, children, className } = props
|
||||
const { question, resolution, creatorId, outcomeType } = contract
|
||||
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const tweetQuestion = isCreator
|
||||
? contract.question
|
||||
: `${creatorName}: ${contract.question}`
|
||||
const tweetDescription = resolution
|
||||
? `Resolved ${resolution}!`
|
||||
: `Currently ${probPercent} chance, place your bets here:`
|
||||
const url = `https://manifold.markets${contractPath(contract)}`
|
||||
const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
|
||||
const tweetText = getTweetText(contract, isCreator)
|
||||
|
||||
return (
|
||||
<Col className={clsx('mb-6', className)}>
|
||||
<Row className="justify-between gap-4 px-2">
|
||||
<Col className="gap-4">
|
||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||
<Linkify text={contract.question} />
|
||||
<Linkify text={question} />
|
||||
</div>
|
||||
|
||||
<Row className="items-center justify-between gap-4">
|
||||
{(isBinary || resolution) && (
|
||||
<ResolutionOrChance
|
||||
className="md:hidden"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
{isBinary && tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract}
|
||||
className="md:hidden"
|
||||
|
@ -73,16 +69,24 @@ export const ContractOverview = (props: {
|
|||
<ContractDetails contract={contract} />
|
||||
</Col>
|
||||
|
||||
{(isBinary || resolution) && (
|
||||
<Col className="hidden items-end justify-between md:flex">
|
||||
<ResolutionOrChance className="items-end" contract={contract} large />
|
||||
<ResolutionOrChance
|
||||
className="items-end"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}
|
||||
|
||||
<Row className="mt-6 ml-4 hidden items-center justify-between gap-4 sm:flex">
|
||||
{children}
|
||||
|
||||
<Row className="mt-6 hidden items-center justify-between gap-4 sm:flex">
|
||||
{folds.length === 0 ? (
|
||||
<TagsInput className={clsx('mx-4')} contract={contract} />
|
||||
) : (
|
||||
|
@ -91,7 +95,7 @@ export const ContractOverview = (props: {
|
|||
<TweetButton tweetText={tweetText} />
|
||||
</Row>
|
||||
|
||||
<Col className="mt-6 ml-4 gap-4 sm:hidden">
|
||||
<Col className="mt-6 gap-4 sm:hidden">
|
||||
<TweetButton className="self-end" tweetText={tweetText} />
|
||||
{folds.length === 0 ? (
|
||||
<TagsInput contract={contract} />
|
||||
|
@ -101,15 +105,12 @@ export const ContractOverview = (props: {
|
|||
</Col>
|
||||
|
||||
{folds.length > 0 && (
|
||||
<RevealableTagsInput className="mx-4 mt-4" contract={contract} />
|
||||
<RevealableTagsInput className="mt-4" contract={contract} />
|
||||
)}
|
||||
|
||||
<Spacer h={12} />
|
||||
|
||||
{/* Show a delete button for contracts without any trading */}
|
||||
{isCreator && truePool === 0 && (
|
||||
{isCreator && bets.length === 0 && (
|
||||
<>
|
||||
<Spacer h={8} />
|
||||
<button
|
||||
className="btn btn-xs btn-error btn-outline mt-1 max-w-fit self-end"
|
||||
onClick={async (e) => {
|
||||
|
@ -123,13 +124,34 @@ export const ContractOverview = (props: {
|
|||
</>
|
||||
)}
|
||||
|
||||
<Spacer h={12} />
|
||||
|
||||
<ContractFeed
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
comments={comments}
|
||||
feedType="market"
|
||||
betRowClassName="md:hidden !mt-0"
|
||||
betRowClassName="!mt-0"
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
const getTweetText = (contract: Contract, isCreator: boolean) => {
|
||||
const { question, creatorName, resolution, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
|
||||
const tweetQuestion = isCreator
|
||||
? question
|
||||
: `${question} Asked by ${creatorName}.`
|
||||
const tweetDescription = resolution
|
||||
? `Resolved ${resolution}!`
|
||||
: isBinary
|
||||
? `Currently ${getBinaryProbPercent(
|
||||
contract
|
||||
)} chance, place your bets here:`
|
||||
: `Submit your own answer:`
|
||||
const url = `https://manifold.markets${contractPath(contract)}`
|
||||
|
||||
return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}`
|
||||
}
|
||||
|
|
|
@ -13,7 +13,9 @@ export function ContractProbGraph(props: { contract: Contract; bets: Bet[] }) {
|
|||
|
||||
const bets = useBetsWithoutAntes(contract, props.bets)
|
||||
|
||||
const startProb = getProbability(phantomShares)
|
||||
const startProb = getProbability(
|
||||
phantomShares as { [outcome: string]: number }
|
||||
)
|
||||
|
||||
const times = bets
|
||||
? [contract.createdTime, ...bets.map((bet) => bet.createdTime)].map(
|
||||
|
|
|
@ -19,7 +19,7 @@ export function ContractsGrid(props: {
|
|||
showHotVolume?: boolean
|
||||
showCloseTime?: boolean
|
||||
}) {
|
||||
const { showHotVolume, showCloseTime } = props
|
||||
const { showCloseTime } = props
|
||||
|
||||
const [resolvedContracts, activeContracts] = _.partition(
|
||||
props.contracts,
|
||||
|
@ -33,7 +33,10 @@ export function ContractsGrid(props: {
|
|||
if (contracts.length === 0) {
|
||||
return (
|
||||
<p className="mx-2 text-gray-500">
|
||||
No markets found. Why not create one?
|
||||
No markets found. Why not{' '}
|
||||
<SiteLink href="/home" className="font-bold text-gray-700">
|
||||
create one?
|
||||
</SiteLink>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Avatar } from './avatar'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { NewContract } from '../pages/create'
|
||||
import { firebaseLogin, User } from '../lib/firebase/users'
|
||||
|
@ -78,8 +78,22 @@ export default function FeedCreate(props: {
|
|||
)
|
||||
const placeholder = props.placeholder ?? `e.g. ${placeholders[randIndex]}`
|
||||
|
||||
const panelRef = useRef<HTMLElement | null>()
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>()
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = () => {
|
||||
if (
|
||||
panelRef.current &&
|
||||
document.activeElement &&
|
||||
!panelRef.current.contains(document.activeElement)
|
||||
)
|
||||
setFocused(false)
|
||||
}
|
||||
window.addEventListener('click', onClick)
|
||||
return () => window.removeEventListener('click', onClick)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
@ -87,7 +101,8 @@ export default function FeedCreate(props: {
|
|||
question || focused ? 'ring-2 ring-indigo-300' : '',
|
||||
className
|
||||
)}
|
||||
onClick={() => !question && inputRef.current?.focus()}
|
||||
onClick={() => !focused && inputRef.current?.focus()}
|
||||
ref={(elem) => (panelRef.current = elem)}
|
||||
>
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} noLink />
|
||||
|
@ -105,7 +120,6 @@ export default function FeedCreate(props: {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setQuestion(e.target.value.replace('\n', ''))}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
export function OutcomeLabel(props: {
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
|
||||
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string
|
||||
}) {
|
||||
const { outcome } = props
|
||||
|
||||
if (outcome === 'YES') return <YesLabel />
|
||||
if (outcome === 'NO') return <NoLabel />
|
||||
if (outcome === 'MKT') return <ProbLabel />
|
||||
return <CancelLabel />
|
||||
if (outcome === 'CANCEL') return <CancelLabel />
|
||||
return <AnswerNumberLabel number={outcome} />
|
||||
}
|
||||
|
||||
export function YesLabel() {
|
||||
|
@ -24,3 +25,7 @@ export function CancelLabel() {
|
|||
export function ProbLabel() {
|
||||
return <span className="text-blue-400">PROB</span>
|
||||
}
|
||||
|
||||
export function AnswerNumberLabel(props: { number: string }) {
|
||||
return <span className="text-primary">#{props.number}</span>
|
||||
}
|
||||
|
|
|
@ -35,8 +35,8 @@ function getNavigationOptions(
|
|||
href: user ? '/home' : '/',
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
href: '/profile',
|
||||
name: `Your profile`,
|
||||
href: `/${user?.username}`,
|
||||
},
|
||||
...(mobile
|
||||
? [
|
||||
|
@ -54,10 +54,6 @@ function getNavigationOptions(
|
|||
name: 'Your trades',
|
||||
href: '/trades',
|
||||
},
|
||||
{
|
||||
name: 'Your markets',
|
||||
href: `/${user?.username ?? ''}`,
|
||||
},
|
||||
{
|
||||
name: 'Leaderboards',
|
||||
href: '/leaderboards',
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Title } from './title'
|
|||
import { User } from '../lib/firebase/users'
|
||||
import { YesNoCancelSelector } from './yes-no-selector'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ConfirmationButton as ConfirmationButton } from './confirmation-button'
|
||||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { resolveMarket } from '../lib/firebase/api-call'
|
||||
import { ProbabilitySelector } from './probability-selector'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
|
@ -20,7 +20,7 @@ export function ResolutionPanel(props: {
|
|||
}) {
|
||||
useEffect(() => {
|
||||
// warm up cloud function
|
||||
resolveMarket({}).catch()
|
||||
resolveMarket({} as any).catch()
|
||||
}, [])
|
||||
|
||||
const { contract, className } = props
|
||||
|
@ -35,6 +35,8 @@ export function ResolutionPanel(props: {
|
|||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
const resolve = async () => {
|
||||
if (!outcome) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await resolveMarket({
|
||||
|
@ -64,9 +66,9 @@ export function ResolutionPanel(props: {
|
|||
|
||||
return (
|
||||
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}>
|
||||
<Title className="mt-0" text="Resolve market" />
|
||||
<Title className="mt-0 whitespace-nowrap" text="Resolve market" />
|
||||
|
||||
<div className="pt-2 pb-1 text-sm text-gray-500">Outcome</div>
|
||||
<div className="mb-2 text-sm text-gray-500">Outcome</div>
|
||||
|
||||
<YesNoCancelSelector
|
||||
className="mx-auto my-2"
|
||||
|
@ -75,7 +77,7 @@ export function ResolutionPanel(props: {
|
|||
btnClassName={isSubmitting ? 'btn-disabled' : ''}
|
||||
/>
|
||||
|
||||
<Spacer h={3} />
|
||||
<Spacer h={4} />
|
||||
|
||||
<div>
|
||||
{outcome === 'YES' ? (
|
||||
|
@ -95,46 +97,29 @@ export function ResolutionPanel(props: {
|
|||
) : outcome === 'CANCEL' ? (
|
||||
<>The pool will be returned to traders with no fees.</>
|
||||
) : outcome === 'MKT' ? (
|
||||
<>
|
||||
Traders will be paid out at the probability you specify:
|
||||
<Spacer h={2} />
|
||||
<Col className="gap-6">
|
||||
<div>Traders will be paid out at the probability you specify:</div>
|
||||
<ProbabilitySelector
|
||||
probabilityInt={Math.round(prob)}
|
||||
setProbabilityInt={setProb}
|
||||
/>
|
||||
<Spacer h={2} />
|
||||
You earn {CREATOR_FEE * 100}% of trader profits.
|
||||
</>
|
||||
<div>You earn {CREATOR_FEE * 100}% of trader profits.</div>
|
||||
</Col>
|
||||
) : (
|
||||
<>Resolving this market will immediately pay out traders.</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Spacer h={3} />
|
||||
<Spacer h={4} />
|
||||
|
||||
{!!error && <div className="text-red-500">{error}</div>}
|
||||
|
||||
<ConfirmationButton
|
||||
id="resolution-modal"
|
||||
openModelBtn={{
|
||||
className: clsx(
|
||||
'border-none self-start mt-2 w-full',
|
||||
submitButtonClass,
|
||||
isSubmitting && 'btn-disabled loading'
|
||||
),
|
||||
label: 'Resolve',
|
||||
}}
|
||||
cancelBtn={{
|
||||
label: 'Back',
|
||||
}}
|
||||
submitBtn={{
|
||||
label: 'Resolve',
|
||||
className: submitButtonClass,
|
||||
}}
|
||||
onSubmit={resolve}
|
||||
>
|
||||
<p>Are you sure you want to resolve this market?</p>
|
||||
</ConfirmationButton>
|
||||
<ResolveConfirmationButton
|
||||
onResolve={resolve}
|
||||
isSubmitting={isSubmitting}
|
||||
openModelButtonClass={clsx('w-full mt-2', submitButtonClass)}
|
||||
submitButtonClass={submitButtonClass}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,10 +12,11 @@ export const SiteLink = (props: {
|
|||
<a
|
||||
href={href}
|
||||
className={clsx(
|
||||
'break-words z-10 hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
className
|
||||
)}
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
|
@ -24,7 +25,7 @@ export const SiteLink = (props: {
|
|||
<Link href={href}>
|
||||
<a
|
||||
className={clsx(
|
||||
'break-words z-10 hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||
className
|
||||
)}
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import clsx from 'clsx'
|
||||
import { User } from '../lib/firebase/users'
|
||||
import { CreatorContractsList } from './contracts-list'
|
||||
import { Title } from './title'
|
||||
import { SEO } from './SEO'
|
||||
import { Page } from './page'
|
||||
import { SiteLink } from './site-link'
|
||||
import { Avatar } from './avatar'
|
||||
import { Col } from './layout/col'
|
||||
import { Linkify } from './linkify'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { Row } from './layout/row'
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { genHash } from '../../common/util/random'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -24,22 +31,115 @@ export function UserLink(props: {
|
|||
|
||||
export function UserPage(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
|
||||
const isCurrentUser = user.id === currentUser?.id
|
||||
|
||||
const possesive = isCurrentUser ? 'Your ' : `${user.name}'s `
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const placeholderBio = `I... haven't gotten around to writing a bio yet 😛`
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title={possesive + 'markets'}
|
||||
description={possesive + 'markets'}
|
||||
url={`/@${user.username}`}
|
||||
title={`${user.name} (@${user.username})`}
|
||||
description={user.bio ?? placeholderBio}
|
||||
url={`/${user.username}`}
|
||||
/>
|
||||
|
||||
<Title className="mx-4 md:mx-0" text={possesive + 'markets'} />
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
style={{
|
||||
backgroundImage: `url(${bannerUrl})`,
|
||||
}}
|
||||
></div>
|
||||
<div className="relative mb-20">
|
||||
<div className="absolute -top-10 left-4">
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={20}
|
||||
className="bg-white ring-4 ring-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top right buttons (e.g. edit, follow) */}
|
||||
<div className="absolute right-0 top-0 mt-4 mr-4">
|
||||
{isCurrentUser && (
|
||||
<SiteLink className="btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
<Spacer h={4} />
|
||||
|
||||
<div>
|
||||
<Linkify text={user.bio || placeholderBio}></Linkify>
|
||||
</div>
|
||||
<Spacer h={4} />
|
||||
|
||||
<Col className="sm:flex-row sm:gap-4">
|
||||
{user.website && (
|
||||
<SiteLink href={user.website}>
|
||||
<Row className="items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span className="text-sm text-gray-500">{user.website}</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.twitterHandle && (
|
||||
<SiteLink href={`https://twitter.com/${user.twitterHandle}`}>
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/twitter-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Twitter"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.twitterHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
|
||||
{user.discordHandle && (
|
||||
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||
<Row className="items-center gap-1">
|
||||
<img
|
||||
src="/discord-logo.svg"
|
||||
className="h-4 w-4"
|
||||
alt="Discord"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.discordHandle}
|
||||
</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Spacer h={10} />
|
||||
|
||||
<CreatorContractsList creator={user} />
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
// Assign each user to a random default banner based on the hash of userId
|
||||
// TODO: Consider handling banner uploads using our own storage bucket, like user avatars.
|
||||
export function defaultBannerUrl(userId: string) {
|
||||
const defaultBanner = [
|
||||
'https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2131&q=80',
|
||||
'https://images.unsplash.com/photo-1458682625221-3a45f8a844c7?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1974&q=80',
|
||||
'https://images.unsplash.com/photo-1558517259-165ae4b10f7f?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2080&q=80',
|
||||
'https://images.unsplash.com/photo-1563260797-cb5cd70254c8?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80',
|
||||
'https://images.unsplash.com/photo-1603399587513-136aa9398f2d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1467&q=80',
|
||||
]
|
||||
return defaultBanner[genHash(userId)() % defaultBanner.length]
|
||||
}
|
||||
|
|
|
@ -90,6 +90,37 @@ export function YesNoCancelSelector(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function ChooseCancelSelector(props: {
|
||||
selected: 'CHOOSE' | 'CANCEL' | undefined
|
||||
onSelect: (selected: 'CHOOSE' | 'CANCEL') => void
|
||||
className?: string
|
||||
btnClassName?: string
|
||||
}) {
|
||||
const { selected, onSelect, className } = props
|
||||
|
||||
const btnClassName = clsx('px-6 flex-1', props.btnClassName)
|
||||
|
||||
return (
|
||||
<Col className={clsx('gap-2', className)}>
|
||||
<Button
|
||||
color={selected === 'CHOOSE' ? 'green' : 'gray'}
|
||||
onClick={() => onSelect('CHOOSE')}
|
||||
className={clsx('whitespace-nowrap', btnClassName)}
|
||||
>
|
||||
Choose an answer
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={selected === 'CANCEL' ? 'yellow' : 'gray'}
|
||||
onClick={() => onSelect('CANCEL')}
|
||||
className={clsx(btnClassName, '')}
|
||||
>
|
||||
N/A
|
||||
</Button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
const fundAmounts = [500, 1000, 2500, 10000]
|
||||
|
||||
export function FundsSelector(props: {
|
||||
|
@ -117,6 +148,15 @@ export function FundsSelector(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export function BuyButton(props: { className?: string; onClick?: () => void }) {
|
||||
const { className, onClick } = props
|
||||
return (
|
||||
<Button className={className} onClick={onClick} color="green">
|
||||
BUY
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(props: {
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
|
@ -129,7 +169,7 @@ function Button(props: {
|
|||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 text-sm font-medium shadow-sm',
|
||||
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm',
|
||||
color === 'green' && 'btn-primary text-white',
|
||||
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
|
||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||
|
|
13
web/hooks/use-answers.ts
Normal file
13
web/hooks/use-answers.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { listenForAnswers } from '../lib/firebase/answers'
|
||||
|
||||
export const useAnswers = (contractId: string) => {
|
||||
const [answers, setAnswers] = useState<Answer[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) return listenForAnswers(contractId, setAnswers)
|
||||
}, [contractId])
|
||||
|
||||
return answers
|
||||
}
|
|
@ -12,11 +12,8 @@ export const useBets = (contractId: string) => {
|
|||
return bets
|
||||
}
|
||||
|
||||
export const useBetsWithoutAntes = (
|
||||
contract: Contract,
|
||||
initialBets?: Bet[]
|
||||
) => {
|
||||
const [bets, setBets] = useState<Bet[] | undefined>(
|
||||
export const useBetsWithoutAntes = (contract: Contract, initialBets: Bet[]) => {
|
||||
const [bets, setBets] = useState<Bet[]>(
|
||||
withoutAnteBets(contract, initialBets)
|
||||
)
|
||||
|
||||
|
|
28
web/lib/firebase/answers.ts
Normal file
28
web/lib/firebase/answers.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { collection } from 'firebase/firestore'
|
||||
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
import { Answer } from '../../../common/answer'
|
||||
|
||||
function getAnswersCollection(contractId: string) {
|
||||
return collection(db, 'contracts', contractId, 'answers')
|
||||
}
|
||||
|
||||
export async function listAllAnswers(contractId: string) {
|
||||
const answers = await getValues<Answer>(getAnswersCollection(contractId))
|
||||
answers.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
return answers
|
||||
}
|
||||
|
||||
export function listenForAnswers(
|
||||
contractId: string,
|
||||
setAnswers: (answers: Answer[]) => void
|
||||
) {
|
||||
return listenForValues<Answer>(
|
||||
getAnswersCollection(contractId),
|
||||
(answers) => {
|
||||
answers.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
setAnswers(answers)
|
||||
}
|
||||
)
|
||||
}
|
|
@ -18,7 +18,24 @@ export const createFold = cloudFunction<
|
|||
|
||||
export const placeBet = cloudFunction('placeBet')
|
||||
|
||||
export const resolveMarket = cloudFunction('resolveMarket')
|
||||
export const createAnswer = cloudFunction<
|
||||
{ contractId: string; text: string; amount: number },
|
||||
{
|
||||
status: 'error' | 'success'
|
||||
message?: string
|
||||
answerId?: string
|
||||
betId?: string
|
||||
}
|
||||
>('createAnswer')
|
||||
|
||||
export const resolveMarket = cloudFunction<
|
||||
{
|
||||
outcome: string
|
||||
contractId: string
|
||||
probabilityInt?: number
|
||||
},
|
||||
{ status: 'error' | 'success'; message?: string }
|
||||
>('resolveMarket')
|
||||
|
||||
export const sellBet = cloudFunction('sellBet')
|
||||
|
||||
|
|
|
@ -27,21 +27,9 @@ export function contractPath(contract: Contract) {
|
|||
}
|
||||
|
||||
export function contractMetrics(contract: Contract) {
|
||||
const {
|
||||
pool,
|
||||
phantomShares,
|
||||
totalShares,
|
||||
createdTime,
|
||||
resolutionTime,
|
||||
isResolved,
|
||||
resolutionProbability,
|
||||
} = contract
|
||||
const { pool, createdTime, resolutionTime, isResolved } = contract
|
||||
|
||||
const truePool = pool.YES + pool.NO
|
||||
const prob = resolutionProbability ?? getProbability(totalShares)
|
||||
const probPercent = Math.round(prob * 100) + '%'
|
||||
|
||||
const startProb = getProbability(phantomShares)
|
||||
const truePool = _.sum(Object.values(pool))
|
||||
|
||||
const createdDate = dayjs(createdTime).format('MMM D')
|
||||
|
||||
|
@ -49,7 +37,16 @@ export function contractMetrics(contract: Contract) {
|
|||
? dayjs(resolutionTime).format('MMM D')
|
||||
: undefined
|
||||
|
||||
return { truePool, probPercent, startProb, createdDate, resolvedDate }
|
||||
return { truePool, createdDate, resolvedDate }
|
||||
}
|
||||
|
||||
export function getBinaryProbPercent(contract: Contract) {
|
||||
const { totalShares, resolutionProbability } = contract
|
||||
|
||||
const prob = resolutionProbability ?? getProbability(totalShares)
|
||||
const probPercent = Math.round(prob * 100) + '%'
|
||||
|
||||
return probPercent
|
||||
}
|
||||
|
||||
export function tradingAllowed(contract: Contract) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
limit,
|
||||
getDocs,
|
||||
orderBy,
|
||||
updateDoc,
|
||||
} from 'firebase/firestore'
|
||||
import { getAuth } from 'firebase/auth'
|
||||
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
|
||||
|
@ -21,7 +22,8 @@ import {
|
|||
import { app } from './init'
|
||||
import { PrivateUser, User } from '../../../common/user'
|
||||
import { createUser } from './api-call'
|
||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { getValues, listenForValue, listenForValues } from './utils'
|
||||
|
||||
export type { User }
|
||||
|
||||
const db = getFirestore(app)
|
||||
|
@ -45,6 +47,10 @@ export async function setUser(userId: string, user: User) {
|
|||
await setDoc(doc(db, 'users', userId), user)
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, update: Partial<User>) {
|
||||
await updateDoc(doc(db, 'users', userId), { ...update })
|
||||
}
|
||||
|
||||
export function listenForUser(
|
||||
userId: string,
|
||||
setUser: (user: User | null) => void
|
||||
|
|
|
@ -12,10 +12,10 @@ import { Title } from '../../components/title'
|
|||
import { Spacer } from '../../components/layout/spacer'
|
||||
import { User } from '../../lib/firebase/users'
|
||||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
getContractFromSlug,
|
||||
tradingAllowed,
|
||||
getBinaryProbPercent,
|
||||
} from '../../lib/firebase/contracts'
|
||||
import { SEO } from '../../components/SEO'
|
||||
import { Page } from '../../components/page'
|
||||
|
@ -26,6 +26,9 @@ import Custom404 from '../404'
|
|||
import { getFoldsByTags } from '../../lib/firebase/folds'
|
||||
import { Fold } from '../../../common/fold'
|
||||
import { useFoldsWithTags } from '../../hooks/use-fold'
|
||||
import { listAllAnswers } from '../../lib/firebase/answers'
|
||||
import { Answer } from '../../../common/answer'
|
||||
import { AnswersPanel } from '../../components/answers-panel'
|
||||
|
||||
export async function getStaticProps(props: {
|
||||
params: { username: string; contractSlug: string }
|
||||
|
@ -36,9 +39,12 @@ export async function getStaticProps(props: {
|
|||
|
||||
const foldsPromise = getFoldsByTags(contract?.tags ?? [])
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
const [bets, comments, answers] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
contractId && contract.outcomeType === 'FREE_RESPONSE'
|
||||
? listAllAnswers(contractId)
|
||||
: [],
|
||||
])
|
||||
|
||||
const folds = await foldsPromise
|
||||
|
@ -50,6 +56,7 @@ export async function getStaticProps(props: {
|
|||
slug: contractSlug,
|
||||
bets,
|
||||
comments,
|
||||
answers,
|
||||
folds,
|
||||
},
|
||||
|
||||
|
@ -66,6 +73,7 @@ export default function ContractPage(props: {
|
|||
username: string
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers: Answer[]
|
||||
slug: string
|
||||
folds: Fold[]
|
||||
}) {
|
||||
|
@ -74,6 +82,10 @@ export default function ContractPage(props: {
|
|||
const contract = useContractWithPreload(props.slug, props.contract)
|
||||
const { bets, comments } = props
|
||||
|
||||
// Sort for now to see if bug is fixed.
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
|
||||
|
||||
const folds = (useFoldsWithTags(contract?.tags) ?? props.folds).filter(
|
||||
(fold) => fold.followCount > 1 || user?.id === fold.curatorId
|
||||
)
|
||||
|
@ -82,33 +94,26 @@ export default function ContractPage(props: {
|
|||
return <Custom404 />
|
||||
}
|
||||
|
||||
const { creatorId, isResolved, resolution, question } = contract
|
||||
const { creatorId, isResolved, question, outcomeType } = contract
|
||||
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const allowTrade = tradingAllowed(contract)
|
||||
const allowResolve = !isResolved && isCreator && !!user
|
||||
const hasSidePanel = isBinary && (allowTrade || allowResolve)
|
||||
|
||||
const { probPercent } = contractMetrics(contract)
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${contract.description}`
|
||||
: `${probPercent} chance. ${contract.description}`
|
||||
|
||||
const ogCardProps = {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName: contract.creatorName,
|
||||
creatorUsername: contract.creatorUsername,
|
||||
}
|
||||
const ogCardProps = getOpenGraphProps(contract)
|
||||
|
||||
return (
|
||||
<Page wide={allowTrade || allowResolve}>
|
||||
<Page wide={hasSidePanel}>
|
||||
{ogCardProps && (
|
||||
<SEO
|
||||
title={question}
|
||||
description={description}
|
||||
description={ogCardProps.description}
|
||||
url={`/${props.username}/${props.slug}`}
|
||||
ogCardProps={ogCardProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Col className="w-full justify-between md:flex-row">
|
||||
<div className="flex-[3] rounded border-0 border-gray-100 bg-white px-2 py-6 md:px-6 md:py-8">
|
||||
|
@ -117,11 +122,24 @@ export default function ContractPage(props: {
|
|||
bets={bets ?? []}
|
||||
comments={comments ?? []}
|
||||
folds={folds}
|
||||
>
|
||||
{contract.outcomeType === 'FREE_RESPONSE' && (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<AnswersPanel
|
||||
contract={contract as any}
|
||||
answers={props.answers}
|
||||
/>
|
||||
<BetsSection contract={contract} user={user ?? null} />
|
||||
<Spacer h={4} />
|
||||
<div className="divider before:bg-gray-300 after:bg-gray-300" />
|
||||
</>
|
||||
)}
|
||||
</ContractOverview>
|
||||
|
||||
<BetsSection contract={contract} user={user ?? null} bets={bets} />
|
||||
</div>
|
||||
|
||||
{(allowTrade || allowResolve) && (
|
||||
{hasSidePanel && (
|
||||
<>
|
||||
<div className="md:ml-6" />
|
||||
|
||||
|
@ -140,11 +158,14 @@ export default function ContractPage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function BetsSection(props: { contract: Contract; user: User | null }) {
|
||||
function BetsSection(props: {
|
||||
contract: Contract
|
||||
user: User | null
|
||||
bets: Bet[]
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const bets = useBets(contract.id)
|
||||
|
||||
if (!bets || bets.length === 0) return <></>
|
||||
const isBinary = contract.outcomeType === 'BINARY'
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
|
||||
// Decending creation time.
|
||||
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
|
||||
|
@ -156,10 +177,34 @@ function BetsSection(props: { contract: Contract; user: User | null }) {
|
|||
return (
|
||||
<div>
|
||||
<Title className="px-2" text="Your trades" />
|
||||
{isBinary && (
|
||||
<>
|
||||
<MyBetsSummary className="px-2" contract={contract} bets={userBets} />
|
||||
<Spacer h={6} />
|
||||
</>
|
||||
)}
|
||||
<ContractBetsTable contract={contract} bets={userBets} />
|
||||
<Spacer h={12} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getOpenGraphProps = (contract: Contract) => {
|
||||
const { resolution, question, creatorName, creatorUsername, outcomeType } =
|
||||
contract
|
||||
const probPercent =
|
||||
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
|
||||
|
||||
const description = resolution
|
||||
? `Resolved ${resolution}. ${contract.description}`
|
||||
: `${probPercent} chance. ${contract.description}`
|
||||
|
||||
return {
|
||||
question,
|
||||
probability: probPercent,
|
||||
metadata: contractTextDetails(contract),
|
||||
creatorName: creatorName,
|
||||
creatorUsername: creatorUsername,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type Data = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
res.status(200).json({ name: 'John Doe' })
|
||||
}
|
76
web/pages/api/v0/_types.ts
Normal file
76
web/pages/api/v0/_types.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { Bet } from '../../../../common/bet'
|
||||
import { getProbability } from '../../../../common/calculate'
|
||||
import { Comment } from '../../../../common/comment'
|
||||
import { Contract } from '../../../../common/contract'
|
||||
|
||||
export type LiteMarket = {
|
||||
// Unique identifer for this market
|
||||
id: string
|
||||
|
||||
// Attributes about the creator
|
||||
creatorUsername: string
|
||||
creatorName: string
|
||||
createdTime: number
|
||||
creatorAvatarUrl?: string
|
||||
|
||||
// Market attributes. All times are in milliseconds since epoch
|
||||
closeTime?: number
|
||||
question: string
|
||||
description: string
|
||||
tags: string[]
|
||||
url: string
|
||||
|
||||
pool: number
|
||||
probability: number
|
||||
volume7Days: number
|
||||
volume24Hours: number
|
||||
isResolved: boolean
|
||||
resolution?: string
|
||||
}
|
||||
|
||||
export type FullMarket = LiteMarket & {
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
}
|
||||
|
||||
export type ApiError = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export function toLiteMarket({
|
||||
id,
|
||||
creatorUsername,
|
||||
creatorName,
|
||||
createdTime,
|
||||
creatorAvatarUrl,
|
||||
closeTime,
|
||||
question,
|
||||
description,
|
||||
tags,
|
||||
slug,
|
||||
pool,
|
||||
totalShares,
|
||||
volume7Days,
|
||||
volume24Hours,
|
||||
isResolved,
|
||||
resolution,
|
||||
}: Contract): LiteMarket {
|
||||
return {
|
||||
id,
|
||||
creatorUsername,
|
||||
creatorName,
|
||||
createdTime,
|
||||
creatorAvatarUrl,
|
||||
closeTime,
|
||||
question,
|
||||
description,
|
||||
tags,
|
||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||
pool: pool.YES + pool.NO,
|
||||
probability: getProbability(totalShares),
|
||||
volume7Days,
|
||||
volume24Hours,
|
||||
isResolved,
|
||||
resolution,
|
||||
}
|
||||
}
|
32
web/pages/api/v0/market/[id].ts
Normal file
32
web/pages/api/v0/market/[id].ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { listAllBets } from '../../../../lib/firebase/bets'
|
||||
import { listAllComments } from '../../../../lib/firebase/comments'
|
||||
import { getContractFromId } from '../../../../lib/firebase/contracts'
|
||||
import { FullMarket, ApiError, toLiteMarket } from '../_types'
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<FullMarket | ApiError>
|
||||
) {
|
||||
const { id } = req.query
|
||||
const contractId = id as string
|
||||
|
||||
const [contract, bets, comments] = await Promise.all([
|
||||
getContractFromId(contractId),
|
||||
listAllBets(contractId),
|
||||
listAllComments(contractId),
|
||||
])
|
||||
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Contract not found' })
|
||||
return
|
||||
}
|
||||
|
||||
// Cache on Vercel edge servers for 2min
|
||||
res.setHeader('Cache-Control', 'max-age=0, s-maxage=120')
|
||||
return res.status(200).json({
|
||||
...toLiteMarket(contract),
|
||||
bets,
|
||||
comments,
|
||||
})
|
||||
}
|
16
web/pages/api/v0/markets.ts
Normal file
16
web/pages/api/v0/markets.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Next.js API route support: https://vercel.com/docs/concepts/functions/serverless-functions
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { listAllContracts } from '../../../lib/firebase/contracts'
|
||||
import { toLiteMarket } from './_types'
|
||||
|
||||
type Data = any[]
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
const contracts = await listAllContracts()
|
||||
// Serve from Vercel cache, then update. see https://vercel.com/docs/concepts/functions/edge-caching
|
||||
res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate')
|
||||
res.status(200).json(contracts.map(toLiteMarket))
|
||||
}
|
32
web/pages/api/v0/slug/[slug].ts
Normal file
32
web/pages/api/v0/slug/[slug].ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { listAllBets } from '../../../../lib/firebase/bets'
|
||||
import { listAllComments } from '../../../../lib/firebase/comments'
|
||||
import { getContractFromSlug } from '../../../../lib/firebase/contracts'
|
||||
import { FullMarket, ApiError, toLiteMarket } from '../_types'
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<FullMarket | ApiError>
|
||||
) {
|
||||
const { slug } = req.query
|
||||
|
||||
const contract = await getContractFromSlug(slug as string)
|
||||
|
||||
if (!contract) {
|
||||
res.status(404).json({ error: 'Contract not found' })
|
||||
return
|
||||
}
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
listAllBets(contract.id),
|
||||
listAllComments(contract.id),
|
||||
])
|
||||
|
||||
// Cache on Vercel edge servers for 2min
|
||||
res.setHeader('Cache-Control', 'max-age=0, s-maxage=120')
|
||||
return res.status(200).json({
|
||||
...toLiteMarket(contract),
|
||||
bets,
|
||||
comments,
|
||||
})
|
||||
}
|
|
@ -17,6 +17,8 @@ import { Title } from '../components/title'
|
|||
import { ProbabilitySelector } from '../components/probability-selector'
|
||||
import { parseWordsAsTags } from '../../common/util/parse'
|
||||
import { TagsList } from '../components/tags-list'
|
||||
import { Row } from '../components/layout/row'
|
||||
import { outcomeType } from '../../common/contract'
|
||||
|
||||
export default function Create() {
|
||||
const [question, setQuestion] = useState('')
|
||||
|
@ -61,6 +63,7 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
createContract({}).catch() // warm up function
|
||||
}, [])
|
||||
|
||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||
const [initialProb, setInitialProb] = useState(50)
|
||||
const [description, setDescription] = useState('')
|
||||
const [tagText, setTagText] = useState<string>(tag ?? '')
|
||||
|
@ -105,6 +108,7 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
|
||||
const result: any = await createContract({
|
||||
question,
|
||||
outcomeType,
|
||||
description,
|
||||
initialProb,
|
||||
ante,
|
||||
|
@ -120,14 +124,46 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
await router.push(contractPath(result.contract as Contract))
|
||||
}
|
||||
|
||||
const descriptionPlaceholder = `e.g. This market resolves to "YES" if, two weeks after closing, the...`
|
||||
const descriptionPlaceholder =
|
||||
outcomeType === 'BINARY'
|
||||
? `e.g. This market resolves to "YES" if, two weeks after closing, the...`
|
||||
: `e.g. I will choose the answer according to...`
|
||||
|
||||
if (!creator) return <></>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="label">
|
||||
<span className="mb-1">Answer type</span>
|
||||
</label>
|
||||
<Row className="form-control gap-2">
|
||||
<label className="cursor-pointer label gap-2">
|
||||
<input
|
||||
className="radio"
|
||||
type="radio"
|
||||
name="opt"
|
||||
checked={outcomeType === 'BINARY'}
|
||||
value="BINARY"
|
||||
onChange={() => setOutcomeType('BINARY')}
|
||||
/>
|
||||
<span className="label-text">Yes / No</span>
|
||||
</label>
|
||||
|
||||
<label className="cursor-pointer label gap-2">
|
||||
<input
|
||||
className="radio"
|
||||
type="radio"
|
||||
name="opt"
|
||||
checked={outcomeType === 'FREE_RESPONSE'}
|
||||
value="FREE_RESPONSE"
|
||||
onChange={() => setOutcomeType('FREE_RESPONSE')}
|
||||
/>
|
||||
<span className="label-text">Free response</span>
|
||||
</label>
|
||||
</Row>
|
||||
<Spacer h={4} />
|
||||
|
||||
{outcomeType === 'BINARY' && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="mb-1">Initial probability</span>
|
||||
|
@ -138,6 +174,7 @@ export function NewContract(props: { question: string; tag?: string }) {
|
|||
setProbabilityInt={setInitialProb}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
|
|
|
@ -30,10 +30,6 @@ export async function getStaticProps() {
|
|||
listAllFolds().catch(() => []),
|
||||
])
|
||||
|
||||
// TODO(James): Remove this line. We are filtering out non-binary contracts so that
|
||||
// branches other than free-response work.
|
||||
contracts = contracts.filter((contract) => contract.outcomeType === 'BINARY')
|
||||
|
||||
const [contractBets, contractComments] = await Promise.all([
|
||||
Promise.all(contracts.map((contract) => listAllBets(contract.id))),
|
||||
Promise.all(contracts.map((contract) => listAllComments(contract.id))),
|
||||
|
|
|
@ -3,6 +3,7 @@ import dayjs from 'dayjs'
|
|||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { parseWordsAsTags } from '../../common/util/parse'
|
||||
import { AmountInput } from '../components/amount-input'
|
||||
import { InfoTooltip } from '../components/info-tooltip'
|
||||
|
@ -15,11 +16,7 @@ import { Page } from '../components/page'
|
|||
import { Title } from '../components/title'
|
||||
import { useUser } from '../hooks/use-user'
|
||||
import { createContract } from '../lib/firebase/api-call'
|
||||
import {
|
||||
contractMetrics,
|
||||
Contract,
|
||||
contractPath,
|
||||
} from '../lib/firebase/contracts'
|
||||
import { Contract, contractPath } from '../lib/firebase/contracts'
|
||||
|
||||
type Prediction = {
|
||||
question: string
|
||||
|
@ -29,7 +26,7 @@ type Prediction = {
|
|||
}
|
||||
|
||||
function toPrediction(contract: Contract): Prediction {
|
||||
const { startProb } = contractMetrics(contract)
|
||||
const startProb = getProbability(contract.totalShares)
|
||||
return {
|
||||
question: contract.question,
|
||||
description: contract.description,
|
||||
|
|
|
@ -16,6 +16,48 @@ import { changeUserInfo } from '../lib/firebase/api-call'
|
|||
import { uploadImage } from '../lib/firebase/storage'
|
||||
import { Col } from '../components/layout/col'
|
||||
import { Row } from '../components/layout/row'
|
||||
import { User } from '../../common/user'
|
||||
import { updateUser } from '../lib/firebase/users'
|
||||
import { defaultBannerUrl } from '../components/user-page'
|
||||
import { SiteLink } from '../components/site-link'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
function EditUserField(props: {
|
||||
user: User
|
||||
field: 'bio' | 'bannerUrl' | 'twitterHandle' | 'discordHandle'
|
||||
label: string
|
||||
}) {
|
||||
const { user, field, label } = props
|
||||
const [value, setValue] = useState(user[field] ?? '')
|
||||
|
||||
async function updateField() {
|
||||
// Note: We trim whitespace before uploading to Firestore
|
||||
await updateUser(user.id, { [field]: value.trim() })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{label}</label>
|
||||
|
||||
{field === 'bio' ? (
|
||||
<Textarea
|
||||
className="textarea textarea-bordered w-full"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={updateField}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value || '')}
|
||||
onBlur={updateField}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const user = useUser()
|
||||
|
@ -26,8 +68,6 @@ export default function ProfilePage() {
|
|||
const [name, setName] = useState(user?.name || '')
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setAvatarUrl(user.avatarUrl || '')
|
||||
|
@ -95,23 +135,10 @@ export default function ProfilePage() {
|
|||
|
||||
<Col className="max-w-lg rounded bg-white p-6 shadow-md sm:mx-auto">
|
||||
<Row className="justify-between">
|
||||
<Title className="!mt-0" text="Profile" />
|
||||
{isEditing ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<Title className="!mt-0" text="Edit Profile" />
|
||||
<SiteLink className="btn btn-primary" href={`/${user?.username}`}>
|
||||
Done
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</button>
|
||||
)}
|
||||
</SiteLink>
|
||||
</Row>
|
||||
<Col className="gap-4">
|
||||
<Row className="items-center gap-4">
|
||||
|
@ -125,9 +152,7 @@ export default function ProfilePage() {
|
|||
height={80}
|
||||
className="flex items-center justify-center rounded-full bg-gray-400"
|
||||
/>
|
||||
{isEditing && (
|
||||
<input type="file" name="file" onChange={fileHandler} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
|
@ -135,7 +160,6 @@ export default function ProfilePage() {
|
|||
<div>
|
||||
<label className="label">Display name</label>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
|
@ -144,15 +168,11 @@ export default function ProfilePage() {
|
|||
onChange={(e) => setName(e.target.value || '')}
|
||||
onBlur={updateDisplayName}
|
||||
/>
|
||||
) : (
|
||||
<div className="ml-1 text-gray-500">{name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Username</label>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
|
@ -161,11 +181,48 @@ export default function ProfilePage() {
|
|||
onChange={(e) => setUsername(e.target.value || '')}
|
||||
onBlur={updateUsername}
|
||||
/>
|
||||
) : (
|
||||
<div className="ml-1 text-gray-500">{username}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
{/* TODO: Allow users with M$ 2000 of assets to set custom banners */}
|
||||
{/* <EditUserField
|
||||
user={user}
|
||||
field="bannerUrl"
|
||||
label="Banner Url"
|
||||
isEditing={isEditing}
|
||||
/> */}
|
||||
<label className="label">
|
||||
Banner image{' '}
|
||||
<span className="text-sm text-gray-400">
|
||||
Not editable for now
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
user.bannerUrl || defaultBannerUrl(user.id)
|
||||
})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{[
|
||||
['bio', 'Bio'],
|
||||
['website', 'Website URL'],
|
||||
['twitterHandle', 'Twitter'],
|
||||
['discordHandle', 'Discord'],
|
||||
].map(([field, label]) => (
|
||||
<EditUserField
|
||||
user={user}
|
||||
// @ts-ignore
|
||||
field={field}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<div className="ml-1 text-gray-500">
|
||||
|
|
10
web/public/discord-logo.svg
Normal file
10
web/public/discord-logo.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg width="71" height="55" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z" fill="#5865F2"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="71" height="55" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/logo-white-inside.png
Normal file
BIN
web/public/logo-white-inside.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 139 KiB |
16
web/public/twitter-logo.svg
Normal file
16
web/public/twitter-logo.svg
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 248 204" style="enable-background:new 0 0 248 204;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#1D9BF0;}
|
||||
</style>
|
||||
<g id="Logo_1_">
|
||||
<path id="white_background" class="st0" d="M221.95,51.29c0.15,2.17,0.15,4.34,0.15,6.53c0,66.73-50.8,143.69-143.69,143.69v-0.04
|
||||
C50.97,201.51,24.1,193.65,1,178.83c3.99,0.48,8,0.72,12.02,0.73c22.74,0.02,44.83-7.61,62.72-21.66
|
||||
c-21.61-0.41-40.56-14.5-47.18-35.07c7.57,1.46,15.37,1.16,22.8-0.87C27.8,117.2,10.85,96.5,10.85,72.46c0-0.22,0-0.43,0-0.64
|
||||
c7.02,3.91,14.88,6.08,22.92,6.32C11.58,63.31,4.74,33.79,18.14,10.71c25.64,31.55,63.47,50.73,104.08,52.76
|
||||
c-4.07-17.54,1.49-35.92,14.61-48.25c20.34-19.12,52.33-18.14,71.45,2.19c11.31-2.23,22.15-6.38,32.07-12.26
|
||||
c-3.77,11.69-11.66,21.62-22.2,27.93c10.01-1.18,19.79-3.86,29-7.95C240.37,35.29,231.83,44.14,221.95,51.29z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in New Issue
Block a user