Merge branch 'main' into fast-fold-following

This commit is contained in:
mantikoros 2022-02-18 01:27:17 -06:00
commit e469cc6037
63 changed files with 2241 additions and 606 deletions

31
common/answer.ts Normal file
View 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',
}
}

View File

@ -61,3 +61,30 @@ export function getAnteBets(
return { yesBet, noBet } 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
}

View File

@ -4,7 +4,7 @@ export type Bet = {
contractId: string contractId: string
amount: number // bet size; negative if SELL bet amount: number // bet size; negative if SELL bet
outcome: 'YES' | 'NO' outcome: string
shares: number // dynamic parimutuel pool weight; negative if SELL bet shares: number // dynamic parimutuel pool weight; negative if SELL bet
probBefore: number probBefore: number

View File

@ -1,69 +1,86 @@
import * as _ from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { Contract } from './contract' import { Contract } from './contract'
import { FEES } from './fees' import { FEES } from './fees'
export function getProbability(totalShares: { YES: number; NO: number }) { export function getProbability(totalShares: { [outcome: string]: number }) {
const { YES: y, NO: n } = totalShares // For binary contracts only.
return y ** 2 / (y ** 2 + n ** 2) 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( export function getProbabilityAfterBet(
totalShares: { YES: number; NO: number }, totalShares: {
outcome: 'YES' | 'NO', [outcome: string]: number
},
outcome: string,
bet: number bet: number
) { ) {
const shares = calculateShares(totalShares, bet, outcome) const shares = calculateShares(totalShares, bet, outcome)
const [YES, NO] = const prevShares = totalShares[outcome] ?? 0
outcome === 'YES' const newTotalShares = { ...totalShares, [outcome]: prevShares + shares }
? [totalShares.YES + shares, totalShares.NO]
: [totalShares.YES, totalShares.NO + 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( export function calculateShares(
totalShares: { YES: number; NO: number }, totalShares: {
[outcome: string]: number
},
bet: 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' return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares
? 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
} }
export function calculateRawShareValue( export function calculateRawShareValue(
totalShares: { YES: number; NO: number }, totalShares: {
[outcome: string]: number
},
shares: number, shares: number,
betChoice: 'YES' | 'NO' betChoice: string
) { ) {
const [yesShares, noShares] = [totalShares.YES, totalShares.NO] const currentValue = Math.sqrt(
const currentValue = Math.sqrt(yesShares ** 2 + noShares ** 2) _.sumBy(Object.values(totalShares), (shares) => shares ** 2)
)
const postSaleValue = const postSaleValue = Math.sqrt(
betChoice === 'YES' _.sumBy(Object.keys(totalShares), (outcome) =>
? Math.sqrt(Math.max(0, yesShares - shares) ** 2 + noShares ** 2) outcome === betChoice
: Math.sqrt(yesShares ** 2 + Math.max(0, noShares - shares) ** 2) ? Math.max(0, totalShares[outcome] - shares) ** 2
: totalShares[outcome] ** 2
)
)
return currentValue - postSaleValue return currentValue - postSaleValue
} }
@ -73,17 +90,22 @@ export function calculateMoneyRatio(
bet: Bet, bet: Bet,
shareValue: number 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 = const betAmount = p * amount
bet.outcome === 'YES' ? p * bet.amount : (1 - p) * bet.amount
const expected = 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 if (actual <= 0 || expected <= 0) return 0
@ -91,14 +113,13 @@ export function calculateMoneyRatio(
} }
export function calculateShareValue(contract: Contract, bet: Bet) { export function calculateShareValue(contract: Contract, bet: Bet) {
const shareValue = calculateRawShareValue( const { pool, totalShares } = contract
contract.totalShares, const { shares, outcome } = bet
bet.shares,
bet.outcome const shareValue = calculateRawShareValue(totalShares, shares, outcome)
)
const f = calculateMoneyRatio(contract, bet, shareValue) 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) const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool)
return adjShareValue return adjShareValue
} }
@ -109,11 +130,7 @@ export function calculateSaleAmount(contract: Contract, bet: Bet) {
return deductFees(amount, winnings) return deductFees(amount, winnings)
} }
export function calculatePayout( export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
contract: Contract,
bet: Bet,
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT'
) {
if (outcome === 'CANCEL') return calculateCancelPayout(contract, bet) if (outcome === 'CANCEL') return calculateCancelPayout(contract, bet)
if (outcome === 'MKT') return calculateMktPayout(contract, bet) if (outcome === 'MKT') return calculateMktPayout(contract, bet)
@ -121,67 +138,100 @@ export function calculatePayout(
} }
export function calculateCancelPayout(contract: Contract, bet: Bet) { export function calculateCancelPayout(contract: Contract, bet: Bet) {
const totalBets = contract.totalBets.YES + contract.totalBets.NO const { totalBets, pool } = contract
const pool = contract.pool.YES + contract.pool.NO 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( export function calculateStandardPayout(
contract: Contract, contract: Contract,
bet: Bet, bet: Bet,
outcome: 'YES' | 'NO' outcome: string
) { ) {
const { amount, outcome: betOutcome, shares } = bet const { amount, outcome: betOutcome, shares } = bet
if (betOutcome !== outcome) return 0 if (betOutcome !== outcome) return 0
const { totalShares, phantomShares } = contract const { totalShares, phantomShares, pool } = contract
if (totalShares[outcome] === 0) return 0 if (!totalShares[outcome]) return 0
const pool = contract.pool.YES + contract.pool.NO const poolTotal = _.sum(Object.values(pool))
const total = totalShares[outcome] - phantomShares[outcome]
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 // profit can be negative if using phantom shares
return amount + (1 - FEES) * Math.max(0, winnings - amount) return amount + (1 - FEES) * Math.max(0, winnings - amount)
} }
export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) {
const { totalShares, pool, totalBets } = contract const { totalShares, pool, totalBets } = contract
const { shares, amount, outcome } = bet
const ind = bet.outcome === 'YES' ? 1 : 0 const prevShares = totalShares[outcome] ?? 0
const { shares, amount } = bet const prevPool = pool[outcome] ?? 0
const prevTotalBet = totalBets[outcome] ?? 0
const newContract = { const newContract = {
...contract, ...contract,
totalShares: { totalShares: {
YES: totalShares.YES + ind * shares, ...totalShares,
NO: totalShares.NO + (1 - ind) * shares, [outcome]: prevShares + shares,
}, },
pool: { pool: {
YES: pool.YES + ind * amount, ...pool,
NO: pool.NO + (1 - ind) * amount, [outcome]: prevPool + amount,
}, },
totalBets: { totalBets: {
YES: totalBets.YES + ind * amount, ...totalBets,
NO: totalBets.NO + (1 - ind) * amount, [outcome]: prevTotalBet + amount,
}, },
} }
return calculateStandardPayout(newContract, bet, bet.outcome) return calculateStandardPayout(newContract, bet, outcome)
} }
function calculateMktPayout(contract: Contract, bet: Bet) { 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 = const p =
contract.resolutionProbability !== undefined resolutionProbability !== undefined
? contract.resolutionProbability ? resolutionProbability
: getProbability(contract.totalShares) : getProbability(totalShares)
const pool = contract.pool.YES + contract.pool.NO const pool = contract.pool.YES + contract.pool.NO
const weightedShareTotal = const weightedShareTotal =
p * (contract.totalShares.YES - contract.phantomShares.YES) + p * (totalShares.YES - (phantomShares?.YES ?? 0)) +
(1 - p) * (contract.totalShares.NO - contract.phantomShares.NO) (1 - p) * (totalShares.NO - (phantomShares?.NO ?? 0))
const { outcome, amount, shares } = bet const { outcome, amount, shares } = bet
@ -197,15 +247,6 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
throw new Error('Contract was not resolved') 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) => { export const deductFees = (betAmount: number, winnings: number) => {
return winnings > betAmount return winnings > betAmount
? betAmount + (1 - FEES) * (winnings - betAmount) ? betAmount + (1 - FEES) * (winnings - betAmount)

View File

@ -1,3 +1,5 @@
import { Answer } from './answer'
export type Contract = { export type Contract = {
id: string id: string
slug: string // auto-generated; must be unique slug: string // auto-generated; must be unique
@ -11,14 +13,17 @@ export type Contract = {
description: string // More info about what the contract is about description: string // More info about what the contract is about
tags: string[] tags: string[]
lowercaseTags: string[] lowercaseTags: string[]
outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date'
visibility: 'public' | 'unlisted' visibility: 'public' | 'unlisted'
outcomeType: 'BINARY' | 'MULTI' | 'FREE_RESPONSE'
multiOutcomes?: string[] // Used for outcomeType 'MULTI'.
answers?: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
mechanism: 'dpm-2' mechanism: 'dpm-2'
phantomShares: { YES: number; NO: number } phantomShares?: { [outcome: string]: number }
pool: { YES: number; NO: number } pool: { [outcome: string]: number }
totalShares: { YES: number; NO: number } totalShares: { [outcome: string]: number }
totalBets: { YES: number; NO: number } totalBets: { [outcome: string]: number }
createdTime: number // Milliseconds since epoch createdTime: number // Milliseconds since epoch
lastUpdatedTime: number // If the question or description was changed lastUpdatedTime: number // If the question or description was changed
@ -26,11 +31,12 @@ export type Contract = {
isResolved: boolean isResolved: boolean
resolutionTime?: number // When the contract creator resolved the market resolutionTime?: number // When the contract creator resolved the market
resolution?: outcome // Chosen by creator; must be one of outcomes resolution?: string
resolutionProbability?: number resolutionProbability?: number
closeEmailsSent?: number
volume24Hours: number volume24Hours: number
volume7Days: number volume7Days: number
} }
export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT' export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE'

View File

@ -1,9 +1,13 @@
import { Bet } from './bet' import { Bet } from './bet'
import { calculateShares, getProbability } from './calculate' import {
calculateShares,
getProbability,
getOutcomeProbability,
} from './calculate'
import { Contract } from './contract' import { Contract } from './contract'
import { User } from './user' import { User } from './user'
export const getNewBetInfo = ( export const getNewBinaryBetInfo = (
user: User, user: User,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
@ -52,3 +56,43 @@ export const getNewBetInfo = (
return { newBet, newPool, newTotalShares, newTotalBets, newBalance } 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 }
}

View File

@ -1,32 +1,37 @@
import { calcStartPool } from './antes' import { calcStartPool } from './antes'
import { Contract, outcomeType } from './contract'
import { Contract } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags } from './util/parse' import { parseTags } from './util/parse'
import { removeUndefinedProps } from './util/object'
export function getNewContract( export function getNewContract(
id: string, id: string,
slug: string, slug: string,
creator: User, creator: User,
question: string, question: string,
outcomeType: outcomeType,
description: string, description: string,
initialProb: number, initialProb: number,
ante: number, ante: number,
closeTime: number, closeTime: number,
extraTags: string[] extraTags: string[]
) { ) {
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
calcStartPool(initialProb, ante)
const tags = parseTags( const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
) )
const lowercaseTags = tags.map((tag) => tag.toLowerCase()) const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const contract: Contract = { const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryProps(initialProb, ante)
: getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({
id, id,
slug, slug,
outcomeType: 'BINARY', mechanism: 'dpm-2',
outcomeType,
...propsByOutcomeType,
creatorId: creator.id, creatorId: creator.id,
creatorName: creator.name, creatorName: creator.name,
@ -38,22 +43,43 @@ export function getNewContract(
tags, tags,
lowercaseTags, lowercaseTags,
visibility: 'public', 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 }, phantomShares: { YES: phantomYes, NO: phantomNo },
pool: { YES: poolYes, NO: poolNo }, pool: { YES: poolYes, NO: poolNo },
totalShares: { YES: sharesYes, NO: sharesNo }, totalShares: { YES: sharesYes, NO: sharesNo },
totalBets: { YES: poolYes, NO: poolNo }, totalBets: { YES: poolYes, NO: poolNo },
isResolved: false,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
volume24Hours: 0,
volume7Days: 0,
} }
}
if (closeTime) contract.closeTime = closeTime
const getFreeAnswerProps = (ante: number) => {
return contract return {
pool: { '0': ante },
totalShares: { '0': ante },
totalBets: { '0': ante },
answers: [],
}
}
const getMultiProps = (
outcomes: string[],
initialProbs: number[],
ante: number
) => {
// Not implemented.
} }

View File

@ -2,12 +2,12 @@ import * as _ from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { deductFees, getProbability } from './calculate' import { deductFees, getProbability } from './calculate'
import { Contract, outcome } from './contract' import { Contract } from './contract'
import { CREATOR_FEE, FEES } from './fees' import { CREATOR_FEE, FEES } from './fees'
export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
const { pool } = contract const { pool } = contract
const poolTotal = pool.YES + pool.NO const poolTotal = _.sum(Object.values(pool))
console.log('resolved N/A, pool M$', poolTotal) console.log('resolved N/A, pool M$', poolTotal)
const betSum = _.sumBy(bets, (b) => b.amount) const betSum = _.sumBy(bets, (b) => b.amount)
@ -19,18 +19,17 @@ export const getCancelPayouts = (contract: Contract, bets: Bet[]) => {
} }
export const getStandardPayouts = ( export const getStandardPayouts = (
outcome: 'YES' | 'NO', outcome: string,
contract: Contract, contract: Contract,
bets: Bet[] bets: Bet[]
) => { ) => {
const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') const winningBets = bets.filter((bet) => bet.outcome === outcome)
const winningBets = outcome === 'YES' ? yesBets : noBets
const pool = contract.pool.YES + contract.pool.NO const poolTotal = _.sum(Object.values(contract.pool))
const totalShares = _.sumBy(winningBets, (b) => b.shares) const totalShares = _.sumBy(winningBets, (b) => b.shares)
const payouts = winningBets.map(({ userId, amount, shares }) => { const payouts = winningBets.map(({ userId, amount, shares }) => {
const winnings = (shares / totalShares) * pool const winnings = (shares / totalShares) * poolTotal
const profit = winnings - amount const profit = winnings - amount
// profit can be negative if using phantom shares // profit can be negative if using phantom shares
@ -45,7 +44,7 @@ export const getStandardPayouts = (
'resolved', 'resolved',
outcome, outcome,
'pool', 'pool',
pool, poolTotal,
'profits', 'profits',
profits, profits,
'creator fee', 'creator fee',
@ -101,7 +100,7 @@ export const getMktPayouts = (
} }
export const getPayouts = ( export const getPayouts = (
outcome: outcome, outcome: string,
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
resolutionProbability?: number resolutionProbability?: number
@ -114,5 +113,8 @@ export const getPayouts = (
return getMktPayouts(contract, bets, resolutionProbability) return getMktPayouts(contract, bets, resolutionProbability)
case 'CANCEL': case 'CANCEL':
return getCancelPayouts(contract, bets) return getCancelPayouts(contract, bets)
default:
// Multi outcome.
return getStandardPayouts(outcome, contract, bets)
} }
} }

View File

@ -1,7 +1,7 @@
import { Bet } from './bet' import { Bet } from './bet'
import { calculateShareValue, deductFees, getProbability } from './calculate' import { calculateShareValue, deductFees, getProbability } from './calculate'
import { Contract } from './contract' import { Contract } from './contract'
import { CREATOR_FEE, FEES } from './fees' import { CREATOR_FEE } from './fees'
import { User } from './user' import { User } from './user'
export const getSellBetInfo = ( export const getSellBetInfo = (
@ -10,30 +10,21 @@ export const getSellBetInfo = (
contract: Contract, contract: Contract,
newBetId: string newBetId: string
) => { ) => {
const { pool, totalShares, totalBets } = contract
const { id: betId, amount, shares, outcome } = bet 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 adjShareValue = calculateShareValue(contract, bet)
const newPool = const newPool = { ...pool, [outcome]: pool[outcome] - adjShareValue }
outcome === 'YES'
? { YES: yesPool - adjShareValue, NO: noPool }
: { YES: yesPool, NO: noPool - adjShareValue }
const newTotalShares = const newTotalShares = {
outcome === 'YES' ...totalShares,
? { YES: yesShares - shares, NO: noShares } [outcome]: totalShares[outcome] - shares,
: { YES: yesShares, NO: noShares - shares } }
const newTotalBets = const newTotalBets = { ...totalBets, [outcome]: totalBets[outcome] - amount }
outcome === 'YES'
? { YES: yesBets - amount, NO: noBets }
: { YES: yesBets, NO: noBets - amount }
const probBefore = getProbability(contract.totalShares) const probBefore = getProbability(totalShares)
const probAfter = getProbability(newTotalShares) const probAfter = getProbability(newTotalShares)
const profit = adjShareValue - amount const profit = adjShareValue - amount

View File

@ -6,6 +6,13 @@ export type User = {
username: string username: string
avatarUrl?: string avatarUrl?: string
// For their user page
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
balance: number balance: number
totalDeposits: number totalDeposits: number
totalPnLCached: number totalPnLCached: number

9
common/util/object.ts Normal file
View 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
}

View File

@ -3,10 +3,7 @@ export const randomString = (length = 12) =>
.toString(16) .toString(16)
.substring(2, length + 2) .substring(2, length + 2)
export function createRNG(seed: string) { export function genHash(str: string) {
// https://stackoverflow.com/a/47593316/1592933
function genHash(str: string) {
// xmur3 // xmur3
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353) h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
@ -17,7 +14,10 @@ export function createRNG(seed: string) {
h = Math.imul(h ^ (h >>> 13), 3266489909) h = Math.imul(h ^ (h >>> 13), 3266489909)
return (h ^= h >>> 16) >>> 0 return (h ^= h >>> 16) >>> 0
} }
} }
export function createRNG(seed: string) {
// https://stackoverflow.com/a/47593316/1592933
const gen = genHash(seed) const gen = genHash(seed)
let [a, b, c, d] = [gen(), gen(), gen(), gen()] let [a, b, c, d] = [gen(), gen(), gen(), gen()]

View File

@ -13,6 +13,9 @@ service cloud.firestore {
match /users/{userId} { match /users/{userId} {
allow read; 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} { match /private-users/{userId} {
@ -27,20 +30,16 @@ service cloud.firestore {
allow delete: if resource.data.creatorId == request.auth.uid; allow delete: if resource.data.creatorId == request.auth.uid;
} }
match /contracts/{contractId}/bets/{betId} {
allow read;
}
match /{somePath=**}/bets/{betId} { match /{somePath=**}/bets/{betId} {
allow read; allow read;
} }
match /contracts/{contractId}/comments/{commentId} { match /{somePath=**}/comments/{commentId} {
allow read; allow read;
allow create: if request.auth != null; allow create: if request.auth != null;
} }
match /{somePath=**}/comments/{commentId} { match /{somePath=**}/answers/{answerId} {
allow read; allow read;
} }
@ -49,13 +48,9 @@ service cloud.firestore {
allow update, delete: if request.auth.uid == resource.data.curatorId; allow update, delete: if request.auth.uid == resource.data.curatorId;
} }
match /folds/{foldId}/followers/{userId} { match /{somePath=**}/followers/{userId} {
allow read; allow read;
allow write: if request.auth.uid == userId; allow write: if request.auth.uid == userId;
} }
match /{somePath=**}/followers/{userId} {
allow read;
}
} }
} }

View File

@ -1,11 +1,13 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getUser, removeUndefinedProps } from './utils' import { getUser } from './utils'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { User } from '../../common/user' import { User } from '../../common/user'
import { cleanUsername } from '../../common/util/clean-username' import { cleanUsername } from '../../common/util/clean-username'
import { removeUndefinedProps } from '../../common/util/object'
import { Answer } from '../../common/answer'
export const changeUserInfo = functions export const changeUserInfo = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
@ -88,12 +90,23 @@ export const changeUser = async (
userAvatarUrl: update.avatarUrl, 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 transaction.update(userRef, userUpdate)
await Promise.all( await Promise.all(
commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate)) 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)) await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate))
}) })
} }

View 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()

View File

@ -2,11 +2,16 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { chargeUser, getUser } from './utils' import { chargeUser, getUser } from './utils'
import { Contract } from '../../common/contract' import { Contract, outcomeType } from '../../common/contract'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { getNewContract } from '../../common/new-contract' 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 export const createContract = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
@ -14,6 +19,7 @@ export const createContract = functions
async ( async (
data: { data: {
question: string question: string
outcomeType: outcomeType
description: string description: string
initialProb: number initialProb: number
ante: number ante: number
@ -30,10 +36,17 @@ export const createContract = functions
const { question, description, initialProb, ante, closeTime, tags } = data const { question, description, initialProb, ante, closeTime, tags } = data
if (!question || !initialProb) if (!question)
return { status: 'error', message: 'Missing contract attributes' } 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' } return { status: 'error', message: 'Invalid initial probability' }
if ( if (
@ -63,6 +76,7 @@ export const createContract = functions
slug, slug,
creator, creator,
question, question,
outcomeType,
description, description,
initialProb, initialProb,
ante, ante,
@ -75,6 +89,7 @@ export const createContract = functions
await contractRef.create(contract) await contractRef.create(contract)
if (ante) { if (ante) {
if (outcomeType === 'BINARY') {
const yesBetDoc = firestore const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
.doc() .doc()
@ -91,6 +106,19 @@ export const createContract = functions
) )
await yesBetDoc.set(yesBet) await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet) 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 } return { status: 'success', contract }

View File

@ -1,7 +1,9 @@
import _ = require('lodash')
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' 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 { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
@ -15,12 +17,23 @@ type market_resolved_template = {
url: string 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 ( export const sendMarketResolutionEmail = async (
userId: string, userId: string,
payout: number, payout: number,
creator: User, creator: User,
contract: Contract, contract: Contract,
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT', resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
resolutionProbability?: number resolutionProbability?: number
) => { ) => {
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
@ -36,13 +49,7 @@ export const sendMarketResolutionEmail = async (
const prob = resolutionProbability ?? getProbability(contract.totalShares) const prob = resolutionProbability ?? getProbability(contract.totalShares)
const toDisplayResolution = { const outcome = toDisplayResolution(resolution, prob)
YES: 'YES',
NO: 'NO',
CANCEL: 'N/A',
MKT: formatPercent(prob),
}
const outcome = toDisplayResolution[resolution]
const subject = `Resolved ${outcome}: ${contract.question}` const subject = `Resolved ${outcome}: ${contract.question}`
@ -88,3 +95,37 @@ Austin from Manifold
https://manifold.markets/` 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(),
}
)
}

View File

@ -11,6 +11,7 @@ export * from './sell-bet'
export * from './create-contract' export * from './create-contract'
export * from './create-user' export * from './create-user'
export * from './create-fold' export * from './create-fold'
export * from './create-answer'
export * from './on-fold-follow' export * from './on-fold-follow'
export * from './on-fold-delete' export * from './on-fold-delete'
export * from './unsubscribe' export * from './unsubscribe'
@ -18,3 +19,4 @@ export * from './update-contract-metrics'
export * from './update-user-metrics' export * from './update-user-metrics'
export * from './backup-db' export * from './backup-db'
export * from './change-user-info' export * from './change-user-info'
export * from './market-close-emails'

View 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)
}
}

View File

@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' 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( export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
async ( async (
@ -22,7 +22,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
if (amount <= 0 || isNaN(amount) || !isFinite(amount)) if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid 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' } return { status: 'error', message: 'Invalid outcome' }
// run as transaction to prevent race conditions // 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' } return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { closeTime } = contract const { closeTime, outcomeType } = contract
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } 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 const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`) .collection(`contracts/${contractId}/bets`)
.doc() .doc()
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = 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.create(newBetDoc, newBet)
transaction.update(contractDoc, { transaction.update(contractDoc, {

View File

@ -25,10 +25,25 @@ export const resolveMarket = functions
const { outcome, contractId, probabilityInt } = data 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)) if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome))
return { status: 'error', message: 'Invalid 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 ( if (
outcomeType === 'BINARY' &&
probabilityInt !== undefined && probabilityInt !== undefined &&
(probabilityInt < 0 || (probabilityInt < 0 ||
probabilityInt > 100 || probabilityInt > 100 ||
@ -36,19 +51,13 @@ export const resolveMarket = functions
) )
return { status: 'error', message: 'Invalid probability' } return { status: 'error', message: 'Invalid probability' }
const contractDoc = firestore.doc(`contracts/${contractId}`) if (creatorId !== userId)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
if (contract.creatorId !== userId)
return { status: 'error', message: 'User not creator of contract' } return { status: 'error', message: 'User not creator of contract' }
if (contract.resolution) if (contract.resolution)
return { status: 'error', message: 'Contract already resolved' } 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' } if (!creator) return { status: 'error', message: 'Creator not found' }
const resolutionProbability = const resolutionProbability =
@ -112,7 +121,7 @@ const sendResolutionEmails = async (
userPayouts: { [userId: string]: number }, userPayouts: { [userId: string]: number },
creator: User, creator: User,
contract: Contract, contract: Contract,
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT', outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
resolutionProbability?: number resolutionProbability?: number
) => { ) => {
const nonWinners = _.difference( const nonWinners = _.difference(

View File

@ -83,11 +83,15 @@ async function recalculateContract(
let totalShares = { let totalShares = {
YES: Math.sqrt(p) * (phantomAnte + realAnte), YES: Math.sqrt(p) * (phantomAnte + realAnte),
NO: Math.sqrt(1 - 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 } as {
[outcome: string]: number
let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } }
const betsRef = contractRef.collection('bets') const betsRef = contractRef.collection('bets')

View File

@ -14,7 +14,7 @@ const pathsToPrivateKey = {
stephen: stephen:
'../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json', '../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json',
stephenDev: 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) => { export const initAdmin = (who: keyof typeof pathsToPrivateKey) => {

View File

@ -77,13 +77,3 @@ export const chargeUser = (userId: string, charge: number) => {
return updateUserBalance(userId, -charge) 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
}

View File

@ -1,10 +1,10 @@
import { IncomingMessage } from "http"; import { IncomingMessage } from 'http'
import { parse } from "url"; import { parse } from 'url'
import { ParsedRequest } from "./types"; import { ParsedRequest } from './types'
export function parseRequest(req: IncomingMessage) { export function parseRequest(req: IncomingMessage) {
console.log("HTTP " + req.url); console.log('HTTP ' + req.url)
const { pathname, query } = parse(req.url || "/", true); const { pathname, query } = parse(req.url || '/', true)
const { const {
fontSize, fontSize,
images, images,
@ -20,73 +20,73 @@ export function parseRequest(req: IncomingMessage) {
creatorName, creatorName,
creatorUsername, creatorUsername,
creatorAvatarUrl, creatorAvatarUrl,
} = query || {}; } = query || {}
if (Array.isArray(fontSize)) { if (Array.isArray(fontSize)) {
throw new Error("Expected a single fontSize"); throw new Error('Expected a single fontSize')
} }
if (Array.isArray(theme)) { if (Array.isArray(theme)) {
throw new Error("Expected a single theme"); throw new Error('Expected a single theme')
} }
const arr = (pathname || "/").slice(1).split("."); const arr = (pathname || '/').slice(1).split('.')
let extension = ""; let extension = ''
let text = ""; let text = ''
if (arr.length === 0) { if (arr.length === 0) {
text = ""; text = ''
} else if (arr.length === 1) { } else if (arr.length === 1) {
text = arr[0]; text = arr[0]
} else { } else {
extension = arr.pop() as string; extension = arr.pop() as string
text = arr.join("."); text = arr.join('.')
} }
// Take a url query param and return a single string // Take a url query param and return a single string
const getString = (stringOrArray: string[] | string | undefined): string => { const getString = (stringOrArray: string[] | string | undefined): string => {
if (Array.isArray(stringOrArray)) { if (Array.isArray(stringOrArray)) {
// If the query param is an array, return the first element // If the query param is an array, return the first element
return stringOrArray[0]; return stringOrArray[0]
}
return stringOrArray || ''
} }
return stringOrArray || "";
};
const parsedRequest: ParsedRequest = { const parsedRequest: ParsedRequest = {
fileType: extension === "jpeg" ? extension : "png", fileType: extension === 'jpeg' ? extension : 'png',
text: decodeURIComponent(text), text: decodeURIComponent(text),
theme: theme === "dark" ? "dark" : "light", theme: theme === 'dark' ? 'dark' : 'light',
md: md === "1" || md === "true", md: md === '1' || md === 'true',
fontSize: fontSize || "96px", fontSize: fontSize || '96px',
images: getArray(images), images: getArray(images),
widths: getArray(widths), widths: getArray(widths),
heights: getArray(heights), heights: getArray(heights),
question: question:
getString(question) || "Will you create a prediction market on Manifold?", getString(question) || 'Will you create a prediction market on Manifold?',
probability: getString(probability) || "85%", probability: getString(probability),
metadata: getString(metadata) || "Jan 1 &nbsp;•&nbsp; M$ 123 pool", metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool',
creatorName: getString(creatorName) || "Manifold Markets", creatorName: getString(creatorName) || 'Manifold Markets',
creatorUsername: getString(creatorUsername) || "ManifoldMarkets", creatorUsername: getString(creatorUsername) || 'ManifoldMarkets',
creatorAvatarUrl: getString(creatorAvatarUrl) || "", creatorAvatarUrl: getString(creatorAvatarUrl) || '',
}; }
parsedRequest.images = getDefaultImages(parsedRequest.images); parsedRequest.images = getDefaultImages(parsedRequest.images)
return parsedRequest; return parsedRequest
} }
function getArray(stringOrArray: string[] | string | undefined): string[] { function getArray(stringOrArray: string[] | string | undefined): string[] {
if (typeof stringOrArray === "undefined") { if (typeof stringOrArray === 'undefined') {
return []; return []
} else if (Array.isArray(stringOrArray)) { } else if (Array.isArray(stringOrArray)) {
return stringOrArray; return stringOrArray
} else { } else {
return [stringOrArray]; return [stringOrArray]
} }
} }
function getDefaultImages(images: string[]): string[] { function getDefaultImages(images: string[]): string[] {
const defaultImage = "https://manifold.markets/logo.png"; const defaultImage = 'https://manifold.markets/logo.png'
if (!images || !images[0]) { if (!images || !images[0]) {
return [defaultImage]; return [defaultImage]
} }
return images; return images
} }

View File

@ -1,15 +1,15 @@
import { sanitizeHtml } from "./sanitizer"; import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from "./types"; import { ParsedRequest } from './types'
function getCss(theme: string, fontSize: string) { function getCss(theme: string, fontSize: string) {
let background = "white"; let background = 'white'
let foreground = "black"; let foreground = 'black'
let radial = "lightgray"; let radial = 'lightgray'
if (theme === "dark") { if (theme === 'dark') {
background = "black"; background = 'black'
foreground = "white"; foreground = 'white'
radial = "dimgray"; radial = 'dimgray'
} }
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return ` return `
@ -78,7 +78,7 @@ function getCss(theme: string, fontSize: string) {
.text-primary { .text-primary {
color: #11b981; color: #11b981;
} }
`; `
} }
export function getHtml(parsedReq: ParsedRequest) { export function getHtml(parsedReq: ParsedRequest) {
@ -92,8 +92,8 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorName, creatorName,
creatorUsername, creatorUsername,
creatorAvatarUrl, creatorAvatarUrl,
} = parsedReq; } = parsedReq
const hideAvatar = creatorAvatarUrl ? "" : "hidden"; const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@ -145,7 +145,7 @@ export function getHtml(parsedReq: ParsedRequest) {
</div> </div>
<div class="flex flex-col text-primary"> <div class="flex flex-col text-primary">
<div class="text-8xl">${probability}</div> <div class="text-8xl">${probability}</div>
<div class="text-4xl">chance</div> <div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
</div> </div>
</div> </div>
@ -157,5 +157,5 @@ export function getHtml(parsedReq: ParsedRequest) {
</div> </div>
</div> </div>
</body> </body>
</html>`; </html>`
} }

View File

@ -2,7 +2,7 @@ import Head from 'next/head'
export type OgCardProps = { export type OgCardProps = {
question: string question: string
probability: string probability?: string
metadata: string metadata: string
creatorName: string creatorName: string
creatorUsername: string creatorUsername: string
@ -11,11 +11,16 @@ export type OgCardProps = {
} }
function buildCardUrl(props: 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 // URL encode each of the props, then add them as query params
return ( return (
`https://manifold-og-image.vercel.app/m.png` + `https://manifold-og-image.vercel.app/m.png` +
`?question=${encodeURIComponent(props.question)}` + `?question=${encodeURIComponent(props.question)}` +
`&probability=${encodeURIComponent(props.probability)}` + probabilityParam +
`&metadata=${encodeURIComponent(props.metadata)}` + `&metadata=${encodeURIComponent(props.metadata)}` +
`&creatorName=${encodeURIComponent(props.creatorName)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` +
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` `&creatorUsername=${encodeURIComponent(props.creatorUsername)}`

View 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)}
&nbsp; <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)}
&nbsp; <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>
)
}

View File

@ -7,8 +7,9 @@ export function Avatar(props: {
avatarUrl?: string avatarUrl?: string
noLink?: boolean noLink?: boolean
size?: number size?: number
className?: string
}) { }) {
const { username, avatarUrl, noLink, size } = props const { username, avatarUrl, noLink, size, className } = props
const s = size || 10 const s = size || 10
const onClick = const onClick =
@ -25,7 +26,8 @@ export function Avatar(props: {
className={clsx( className={clsx(
'flex items-center justify-center rounded-full object-cover', 'flex items-center justify-center rounded-full object-cover',
`w-${s} h-${s}`, `w-${s} h-${s}`,
!noLink && 'cursor-pointer' !noLink && 'cursor-pointer',
className
)} )}
src={avatarUrl} src={avatarUrl}
onClick={onClick} onClick={onClick}

View File

@ -49,6 +49,7 @@ export function BetPanel(props: {
}, []) }, [])
const { contract, className, title, selected, onBetSuccess } = props const { contract, className, title, selected, onBetSuccess } = props
const { totalShares, phantomShares } = contract
const user = useUser() const user = useUser()
@ -108,11 +109,12 @@ export function BetPanel(props: {
const initialProb = getProbability(contract.totalShares) const initialProb = getProbability(contract.totalShares)
const resultProb = getProbabilityAfterBet( const outcomeProb = getProbabilityAfterBet(
contract.totalShares, contract.totalShares,
betChoice || 'YES', betChoice || 'YES',
betAmount ?? 0 betAmount ?? 0
) )
const resultProb = betChoice === 'NO' ? 1 - outcomeProb : outcomeProb
const shares = calculateShares( const shares = calculateShares(
contract.totalShares, contract.totalShares,
@ -179,8 +181,8 @@ export function BetPanel(props: {
shares shares
)} / ${formatWithCommas( )} / ${formatWithCommas(
shares + shares +
contract.totalShares[betChoice] - totalShares[betChoice] -
contract.phantomShares[betChoice] (phantomShares ? phantomShares[betChoice] : 0)
)} ${betChoice} shares`} )} ${betChoice} shares`}
/> />
</Row> </Row>

View File

@ -18,22 +18,24 @@ import {
Contract, Contract,
getContractFromId, getContractFromId,
contractPath, contractPath,
contractMetrics, getBinaryProbPercent,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { UserLink } from './user-page' import { UserLink } from './user-page'
import { import {
calculateCancelPayout,
calculatePayout, calculatePayout,
calculateSaleAmount, calculateSaleAmount,
getOutcomeProbability,
getProbability, getProbability,
getProbabilityAfterSale,
resolvedPayout, resolvedPayout,
} from '../../common/calculate' } from '../../common/calculate'
import { sellBet } from '../lib/firebase/api-call' import { sellBet } from '../lib/firebase/api-call'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' 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 }) { export function BetsList(props: { user: User }) {
const { user } = props const { user } = props
@ -41,7 +43,7 @@ export function BetsList(props: { user: User }) {
const [contracts, setContracts] = useState<Contract[]>([]) const [contracts, setContracts] = useState<Contract[]>([])
const [sort, setSort] = useState<BetSort>('profit') const [sort, setSort] = useState<BetSort>('value')
useEffect(() => { useEffect(() => {
const loadedBets = bets ? bets : [] const loadedBets = bets ? bets : []
@ -50,7 +52,7 @@ export function BetsList(props: { user: User }) {
let disposed = false let disposed = false
Promise.all(contractIds.map((id) => getContractFromId(id))).then( Promise.all(contractIds.map((id) => getContractFromId(id))).then(
(contracts) => { (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, contracts,
(c) => -1 * (contractsCurrentValue[c.id] - contractsInvestment[c.id]) (c) => -1 * (contractsCurrentValue[c.id] - contractsInvestment[c.id])
) )
} else if (sort === 'value') {
sortedContracts = _.sortBy(contracts, (c) => -contractsCurrentValue[c.id])
} }
const [resolved, unresolved] = _.partition( const [resolved, unresolved] = _.partition(
@ -161,6 +165,7 @@ export function BetsList(props: { user: User }) {
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as BetSort)} onChange={(e) => setSort(e.target.value as BetSort)}
> >
<option value="value">By value</option>
<option value="profit">By profit</option> <option value="profit">By profit</option>
<option value="newest">Newest</option> <option value="newest">Newest</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
@ -180,10 +185,13 @@ export function BetsList(props: { user: User }) {
function MyContractBets(props: { contract: Contract; bets: Bet[] }) { function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
const { bets, contract } = props const { bets, contract } = props
const { resolution } = contract const { resolution, outcomeType } = contract
const [collapsed, setCollapsed] = useState(true) const [collapsed, setCollapsed] = useState(true)
const { probPercent } = contractMetrics(contract)
const isBinary = outcomeType === 'BINARY'
const probPercent = getBinaryProbPercent(contract)
return ( return (
<div <div
tabIndex={0} tabIndex={0}
@ -213,6 +221,8 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
</Row> </Row>
<Row className="items-center gap-2 text-sm text-gray-500"> <Row className="items-center gap-2 text-sm text-gray-500">
{isBinary && (
<>
{resolution ? ( {resolution ? (
<div> <div>
Resolved <OutcomeLabel outcome={resolution} /> Resolved <OutcomeLabel outcome={resolution} />
@ -221,6 +231,8 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) {
<div className="text-primary text-lg">{probPercent}</div> <div className="text-primary text-lg">{probPercent}</div>
)} )}
<div></div> <div></div>
</>
)}
<UserLink <UserLink
name={contract.creatorName} name={contract.creatorName}
username={contract.creatorUsername} username={contract.creatorUsername}
@ -263,8 +275,8 @@ export function MyBetsSummary(props: {
className?: string className?: string
}) { }) {
const { bets, contract, onlyMKT, className } = props const { bets, contract, onlyMKT, className } = props
const { resolution } = contract const { resolution, outcomeType } = contract
calculateCancelPayout const isBinary = outcomeType === 'BINARY'
const excludeSales = bets.filter((b) => !b.isSold && !b.sale) const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount) const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount)
@ -280,6 +292,9 @@ export function MyBetsSummary(props: {
calculatePayout(contract, bet, 'NO') calculatePayout(contract, bet, 'NO')
) )
// const p = getProbability(contract.totalShares)
// const expectation = p * yesWinnings + (1 - p) * noWinnings
const marketWinnings = _.sumBy(excludeSales, (bet) => const marketWinnings = _.sumBy(excludeSales, (bet) =>
calculatePayout(contract, bet, 'MKT') calculatePayout(contract, bet, 'MKT')
) )
@ -330,6 +345,14 @@ export function MyBetsSummary(props: {
payoutCol payoutCol
) : ( ) : (
<> <>
{/* <Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Expectation
</div>
<div className="whitespace-nowrap">
{formatMoney(expectation)}
</div>
</Col> */}
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
Payout if <YesLabel /> Payout if <YesLabel />
@ -348,10 +371,16 @@ export function MyBetsSummary(props: {
</Col> </Col>
<Col> <Col>
<div className="whitespace-nowrap text-sm text-gray-500"> <div className="whitespace-nowrap text-sm text-gray-500">
{isBinary ? (
<>
Payout at{' '} Payout at{' '}
<span className="text-blue-400"> <span className="text-blue-400">
{formatPercent(getProbability(contract.totalShares))} {formatPercent(getProbability(contract.totalShares))}
</span> </span>
</>
) : (
<>Current payout</>
)}
</div> </div>
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{formatMoney(marketWinnings)} {formatMoney(marketWinnings)}
@ -469,6 +498,19 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
const { contract, bet } = props const { contract, bet } = props
const [isSubmitting, setIsSubmitting] = useState(false) 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 ( return (
<ConfirmationButton <ConfirmationButton
id={`sell-${bet.id}`} id={`sell-${bet.id}`}
@ -488,8 +530,12 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
</div> </div>
<div> <div>
Do you want to sell {formatWithCommas(bet.shares)} shares of{' '} Do you want to sell {formatWithCommas(bet.shares)} shares of{' '}
<OutcomeLabel outcome={bet.outcome} /> for{' '} <OutcomeLabel outcome={bet.outcome} /> for {formatMoney(saleAmount)}?
{formatMoney(calculateSaleAmount(contract, bet))}? </div>
<div className="mt-2 mb-1 text-sm text-gray-500">
Implied probability: {formatPercent(initialProb)} {' '}
{formatPercent(outcomeProb)}
</div> </div>
</ConfirmationButton> </ConfirmationButton>
) )

View File

@ -31,7 +31,7 @@ export function ConfirmationButton(props: {
<input type="checkbox" id={id} className="modal-toggle" /> <input type="checkbox" id={id} className="modal-toggle" />
<div className="modal"> <div className="modal">
<div className="modal-box"> <div className="modal-box whitespace-normal">
{children} {children}
<div className="modal-action"> <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>
)
}

View File

@ -7,12 +7,13 @@ import {
Contract, Contract,
contractMetrics, contractMetrics,
contractPath, contractPath,
getBinaryProbPercent,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { Col } from './layout/col' import { Col } from './layout/col'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { TrendingUpIcon } from '@heroicons/react/solid' import { TrendingUpIcon } from '@heroicons/react/solid'
import { DateTimeTooltip } from './datetime-tooltip' 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 { fromNow } from '../lib/util/time'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -24,8 +25,7 @@ export function ContractCard(props: {
className?: string className?: string
}) { }) {
const { contract, showHotVolume, showCloseTime, className } = props const { contract, showHotVolume, showCloseTime, className } = props
const { question, resolution } = contract const { question } = contract
const { probPercent } = contractMetrics(contract)
return ( return (
<div> <div>
@ -48,7 +48,7 @@ export function ContractCard(props: {
<Row className="justify-between gap-4"> <Row className="justify-between gap-4">
<p <p
className="font-medium text-indigo-700 break-words" className="break-words font-medium text-indigo-700"
style={{ /* For iOS safari */ wordBreak: 'break-word' }} style={{ /* For iOS safari */ wordBreak: 'break-word' }}
> >
{question} {question}
@ -66,27 +66,29 @@ export function ResolutionOrChance(props: {
className?: string className?: string
}) { }) {
const { contract, large, className } = props const { contract, large, className } = props
const { resolution } = contract const { resolution, outcomeType } = contract
const { probPercent } = contractMetrics(contract) const isBinary = outcomeType === 'BINARY'
const marketClosed = (contract.closeTime || Infinity) < Date.now() const marketClosed = (contract.closeTime || Infinity) < Date.now()
const resolutionColor = { const resolutionColor =
{
YES: 'text-primary', YES: 'text-primary',
NO: 'text-red-400', NO: 'text-red-400',
MKT: 'text-blue-400', MKT: 'text-blue-400',
CANCEL: 'text-yellow-400', CANCEL: 'text-yellow-400',
'': '', // Empty if unresolved '': '', // Empty if unresolved
}[resolution || ''] }[resolution || ''] ?? 'text-primary'
const probColor = marketClosed ? 'text-gray-400' : 'text-primary' const probColor = marketClosed ? 'text-gray-400' : 'text-primary'
const resolutionText = { const resolutionText =
{
YES: 'YES', YES: 'YES',
NO: 'NO', NO: 'NO',
MKT: probPercent, MKT: getBinaryProbPercent(contract),
CANCEL: 'N/A', CANCEL: 'N/A',
'': '', '': '',
}[resolution || ''] }[resolution || ''] ?? `#${resolution}`
return ( return (
<Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}>
@ -100,12 +102,14 @@ export function ResolutionOrChance(props: {
<div className={resolutionColor}>{resolutionText}</div> <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')}> <div className={clsx(probColor, large ? 'text-xl' : 'text-base')}>
chance chance
</div> </div>
</> </>
)
)} )}
</Col> </Col>
) )
@ -137,17 +141,19 @@ function AbbrContractDetails(props: {
</Row> </Row>
{showHotVolume ? ( {showHotVolume ? (
<div className="whitespace-nowrap"> <Row className="gap-1">
<TrendingUpIcon className="inline h-5 w-5 text-gray-500" />{' '} <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
{formatMoney(volume24Hours)} </Row>
</div>
) : showCloseTime ? ( ) : showCloseTime ? (
<div className="whitespace-nowrap"> <Row className="gap-1">
<ClockIcon className="-my-1 inline h-5 w-5 text-gray-500" /> Closes{' '} <ClockIcon className="h-5 w-5" />
{fromNow(closeTime || 0)} Closes {fromNow(closeTime || 0)}
</div> </Row>
) : ( ) : (
<div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> <Row className="gap-1">
{/* <DatabaseIcon className="h-5 w-5" /> */}
{formatMoney(truePool)} pool
</Row>
)} )}
</Row> </Row>
</Col> </Col>
@ -161,7 +167,8 @@ export function ContractDetails(props: { contract: Contract }) {
return ( return (
<Col className="gap-2 text-sm text-gray-500 sm:flex-row sm:flex-wrap"> <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 <Avatar
username={creatorUsername} username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl} avatarUrl={contract.creatorAvatarUrl}
@ -172,9 +179,11 @@ export function ContractDetails(props: { contract: Contract }) {
name={creatorName} name={creatorName}
username={creatorUsername} 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}> <DateTimeTooltip text="Market created:" time={contract.createdTime}>
{createdDate} {createdDate}
</DateTimeTooltip> </DateTimeTooltip>
@ -200,15 +209,18 @@ export function ContractDetails(props: { contract: Contract }) {
} }
time={closeTime} time={closeTime}
> >
{dayjs(closeTime).format('MMM D, YYYY')} {dayjs(closeTime).format('MMM D')} ({fromNow(closeTime)})
</DateTimeTooltip> </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> <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div>
</Row> </Row>
</Row>
</Col> </Col>
) )
} }

View File

@ -1,18 +1,18 @@
// From https://tailwindui.com/components/application-ui/lists/feeds // From https://tailwindui.com/components/application-ui/lists/feeds
import { useState } from 'react' import { Fragment, useState } from 'react'
import _ from 'lodash' import _ from 'lodash'
import { import {
BanIcon, BanIcon,
CheckIcon, CheckIcon,
DotsVerticalIcon, DotsVerticalIcon,
LockClosedIcon, LockClosedIcon,
StarIcon,
UserIcon, UserIcon,
UsersIcon, UsersIcon,
XIcon, XIcon,
} from '@heroicons/react/solid' } from '@heroicons/react/solid'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import clsx from 'clsx' import clsx from 'clsx'
import Textarea from 'react-expanding-textarea'
import { OutcomeLabel } from './outcome-label' import { OutcomeLabel } from './outcome-label'
import { import {
@ -34,11 +34,9 @@ import { Col } from './layout/col'
import { UserLink } from './user-page' import { UserLink } from './user-page'
import { DateTimeTooltip } from './datetime-tooltip' import { DateTimeTooltip } from './datetime-tooltip'
import { useBets } from '../hooks/use-bets' 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 { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
import { JoinSpans } from './join-spans' import { JoinSpans } from './join-spans'
import Textarea from 'react-expanding-textarea'
import { outcome } from '../../common/contract'
import { fromNow } from '../lib/util/time' import { fromNow } from '../lib/util/time'
import BetRow from './bet-row' import BetRow from './bet-row'
import { parseTags } from '../../common/util/parse' import { parseTags } from '../../common/util/parse'
@ -204,7 +202,7 @@ function EditContract(props: {
) : ( ) : (
<Row> <Row>
<button <button
className="btn btn-neutral btn-outline btn-sm mt-4" className="btn btn-neutral btn-outline btn-xs mt-4"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
> >
{props.buttonText} {props.buttonText}
@ -302,9 +300,10 @@ function TruncatedComment(props: {
function FeedQuestion(props: { contract: Contract }) { function FeedQuestion(props: { contract: Contract }) {
const { contract } = props const { contract } = props
const { creatorName, creatorUsername, createdTime, question, resolution } = const { creatorName, creatorUsername, question, resolution, outcomeType } =
contract 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. // Currently hidden on mobile; ideally we'd fit this in somewhere.
const closeMessage = const closeMessage =
@ -340,7 +339,9 @@ function FeedQuestion(props: { contract: Contract }) {
> >
{question} {question}
</SiteLink> </SiteLink>
{(isBinary || resolution) && (
<ResolutionOrChance className="items-center" contract={contract} /> <ResolutionOrChance className="items-center" contract={contract} />
)}
</Col> </Col>
<TruncatedComment <TruncatedComment
comment={contract.description} 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 const { outcome } = props
switch (outcome) { switch (outcome) {
case 'YES': case 'YES':
@ -387,8 +388,9 @@ function OutcomeIcon(props: { outcome?: outcome }) {
case 'NO': case 'NO':
return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
case 'CANCEL': case 'CANCEL':
default:
return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> 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[] return items as ActivityItem[]
} }
function BetGroupSpan(props: { bets: Bet[]; outcome: 'YES' | 'NO' }) { function BetGroupSpan(props: { bets: Bet[]; outcome: string }) {
const { bets, outcome } = props const { bets, outcome } = props
const numberTraders = _.uniqBy(bets, (b) => b.userId).length const numberTraders = _.uniqBy(bets, (b) => b.userId).length
@ -562,7 +564,8 @@ function FeedBetGroup(props: { activityItem: any }) {
const { activityItem } = props const { activityItem } = props
const bets: Bet[] = activityItem.bets 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 // Use the time of the last bet for the entire group
const createdTime = bets[bets.length - 1].createdTime const createdTime = bets[bets.length - 1].createdTime
@ -578,9 +581,12 @@ function FeedBetGroup(props: { activityItem: any }) {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{yesBets.length > 0 && <BetGroupSpan outcome="YES" bets={yesBets} />} {outcomes.map((outcome, index) => (
{yesBets.length > 0 && noBets.length > 0 && <br />} <Fragment key={outcome}>
{noBets.length > 0 && <BetGroupSpan outcome="NO" bets={noBets} />} <BetGroupSpan outcome={outcome} bets={betGroups[outcome]} />
{index !== outcomes.length - 1 && <br />}
</Fragment>
))}
<Timestamp time={createdTime} /> <Timestamp time={createdTime} />
</div> </div>
</div> </div>
@ -638,12 +644,16 @@ export function ContractFeed(props: {
betRowClassName?: string betRowClassName?: string
}) { }) {
const { contract, feedType, betRowClassName } = props const { contract, feedType, betRowClassName } = props
const { id } = contract const { id, outcomeType } = contract
const isBinary = outcomeType === 'BINARY'
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const user = useUser() const user = useUser()
let bets = useBets(id) ?? props.bets let bets = useBets(contract.id) ?? props.bets
bets = withoutAnteBets(contract, bets) bets = isBinary
? bets.filter((bet) => !bet.isAnte)
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
const comments = useComments(id) ?? props.comments const comments = useComments(id) ?? props.comments
@ -711,7 +721,7 @@ export function ContractFeed(props: {
</li> </li>
))} ))}
</ul> </ul>
{tradingAllowed(contract) && ( {isBinary && tradingAllowed(contract) && (
<BetRow contract={contract} className={clsx('mb-2', betRowClassName)} /> <BetRow contract={contract} className={clsx('mb-2', betRowClassName)} />
)} )}
</div> </div>

View File

@ -1,9 +1,9 @@
import { import {
contractMetrics,
Contract, Contract,
deleteContract, deleteContract,
contractPath, contractPath,
tradingAllowed, tradingAllowed,
getBinaryProbPercent,
} from '../lib/firebase/contracts' } from '../lib/firebase/contracts'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -28,40 +28,36 @@ export const ContractOverview = (props: {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
folds: Fold[] folds: Fold[]
children?: any
className?: string className?: string
}) => { }) => {
const { contract, bets, comments, folds, className } = props const { contract, bets, comments, folds, children, className } = props
const { resolution, creatorId, creatorName } = contract const { question, resolution, creatorId, outcomeType } = contract
const { probPercent, truePool } = contractMetrics(contract)
const user = useUser() const user = useUser()
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const tweetQuestion = isCreator const tweetText = getTweetText(contract, 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}`
return ( return (
<Col className={clsx('mb-6', className)}> <Col className={clsx('mb-6', className)}>
<Row className="justify-between gap-4 px-2"> <Row className="justify-between gap-4 px-2">
<Col className="gap-4"> <Col className="gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl"> <div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={contract.question} /> <Linkify text={question} />
</div> </div>
<Row className="items-center justify-between gap-4"> <Row className="items-center justify-between gap-4">
{(isBinary || resolution) && (
<ResolutionOrChance <ResolutionOrChance
className="md:hidden" className="md:hidden"
contract={contract} contract={contract}
large large
/> />
)}
{tradingAllowed(contract) && ( {isBinary && tradingAllowed(contract) && (
<BetRow <BetRow
contract={contract} contract={contract}
className="md:hidden" className="md:hidden"
@ -73,16 +69,24 @@ export const ContractOverview = (props: {
<ContractDetails contract={contract} /> <ContractDetails contract={contract} />
</Col> </Col>
{(isBinary || resolution) && (
<Col className="hidden items-end justify-between md:flex"> <Col className="hidden items-end justify-between md:flex">
<ResolutionOrChance className="items-end" contract={contract} large /> <ResolutionOrChance
className="items-end"
contract={contract}
large
/>
</Col> </Col>
)}
</Row> </Row>
<Spacer h={4} /> <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 ? ( {folds.length === 0 ? (
<TagsInput className={clsx('mx-4')} contract={contract} /> <TagsInput className={clsx('mx-4')} contract={contract} />
) : ( ) : (
@ -91,7 +95,7 @@ export const ContractOverview = (props: {
<TweetButton tweetText={tweetText} /> <TweetButton tweetText={tweetText} />
</Row> </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} /> <TweetButton className="self-end" tweetText={tweetText} />
{folds.length === 0 ? ( {folds.length === 0 ? (
<TagsInput contract={contract} /> <TagsInput contract={contract} />
@ -101,15 +105,12 @@ export const ContractOverview = (props: {
</Col> </Col>
{folds.length > 0 && ( {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 */} {/* Show a delete button for contracts without any trading */}
{isCreator && truePool === 0 && ( {isCreator && bets.length === 0 && (
<> <>
<Spacer h={8} />
<button <button
className="btn btn-xs btn-error btn-outline mt-1 max-w-fit self-end" className="btn btn-xs btn-error btn-outline mt-1 max-w-fit self-end"
onClick={async (e) => { onClick={async (e) => {
@ -123,13 +124,34 @@ export const ContractOverview = (props: {
</> </>
)} )}
<Spacer h={12} />
<ContractFeed <ContractFeed
contract={contract} contract={contract}
bets={bets} bets={bets}
comments={comments} comments={comments}
feedType="market" feedType="market"
betRowClassName="md:hidden !mt-0" betRowClassName="!mt-0"
/> />
</Col> </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}`
}

View File

@ -13,7 +13,9 @@ export function ContractProbGraph(props: { contract: Contract; bets: Bet[] }) {
const bets = useBetsWithoutAntes(contract, props.bets) const bets = useBetsWithoutAntes(contract, props.bets)
const startProb = getProbability(phantomShares) const startProb = getProbability(
phantomShares as { [outcome: string]: number }
)
const times = bets const times = bets
? [contract.createdTime, ...bets.map((bet) => bet.createdTime)].map( ? [contract.createdTime, ...bets.map((bet) => bet.createdTime)].map(

View File

@ -19,7 +19,7 @@ export function ContractsGrid(props: {
showHotVolume?: boolean showHotVolume?: boolean
showCloseTime?: boolean showCloseTime?: boolean
}) { }) {
const { showHotVolume, showCloseTime } = props const { showCloseTime } = props
const [resolvedContracts, activeContracts] = _.partition( const [resolvedContracts, activeContracts] = _.partition(
props.contracts, props.contracts,
@ -33,7 +33,10 @@ export function ContractsGrid(props: {
if (contracts.length === 0) { if (contracts.length === 0) {
return ( return (
<p className="mx-2 text-gray-500"> <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> </p>
) )
} }

View File

@ -1,5 +1,5 @@
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { NewContract } from '../pages/create' import { NewContract } from '../pages/create'
import { firebaseLogin, User } from '../lib/firebase/users' 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 placeholder = props.placeholder ?? `e.g. ${placeholders[randIndex]}`
const panelRef = useRef<HTMLElement | null>()
const inputRef = useRef<HTMLTextAreaElement | 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 ( return (
<div <div
className={clsx( className={clsx(
@ -87,7 +101,8 @@ export default function FeedCreate(props: {
question || focused ? 'ring-2 ring-indigo-300' : '', question || focused ? 'ring-2 ring-indigo-300' : '',
className 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"> <div className="relative flex items-start space-x-3">
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} noLink /> <Avatar username={user?.username} avatarUrl={user?.avatarUrl} noLink />
@ -105,7 +120,6 @@ export default function FeedCreate(props: {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setQuestion(e.target.value.replace('\n', ''))} onChange={(e) => setQuestion(e.target.value.replace('\n', ''))}
onFocus={() => setFocused(true)} onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/> />
</div> </div>
</div> </div>

View File

@ -1,12 +1,13 @@
export function OutcomeLabel(props: { export function OutcomeLabel(props: {
outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string
}) { }) {
const { outcome } = props const { outcome } = props
if (outcome === 'YES') return <YesLabel /> if (outcome === 'YES') return <YesLabel />
if (outcome === 'NO') return <NoLabel /> if (outcome === 'NO') return <NoLabel />
if (outcome === 'MKT') return <ProbLabel /> if (outcome === 'MKT') return <ProbLabel />
return <CancelLabel /> if (outcome === 'CANCEL') return <CancelLabel />
return <AnswerNumberLabel number={outcome} />
} }
export function YesLabel() { export function YesLabel() {
@ -24,3 +25,7 @@ export function CancelLabel() {
export function ProbLabel() { export function ProbLabel() {
return <span className="text-blue-400">PROB</span> return <span className="text-blue-400">PROB</span>
} }
export function AnswerNumberLabel(props: { number: string }) {
return <span className="text-primary">#{props.number}</span>
}

View File

@ -35,8 +35,8 @@ function getNavigationOptions(
href: user ? '/home' : '/', href: user ? '/home' : '/',
}, },
{ {
name: 'Profile', name: `Your profile`,
href: '/profile', href: `/${user?.username}`,
}, },
...(mobile ...(mobile
? [ ? [
@ -54,10 +54,6 @@ function getNavigationOptions(
name: 'Your trades', name: 'Your trades',
href: '/trades', href: '/trades',
}, },
{
name: 'Your markets',
href: `/${user?.username ?? ''}`,
},
{ {
name: 'Leaderboards', name: 'Leaderboards',
href: '/leaderboards', href: '/leaderboards',

View File

@ -7,7 +7,7 @@ import { Title } from './title'
import { User } from '../lib/firebase/users' import { User } from '../lib/firebase/users'
import { YesNoCancelSelector } from './yes-no-selector' import { YesNoCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer' 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 { resolveMarket } from '../lib/firebase/api-call'
import { ProbabilitySelector } from './probability-selector' import { ProbabilitySelector } from './probability-selector'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
@ -20,7 +20,7 @@ export function ResolutionPanel(props: {
}) { }) {
useEffect(() => { useEffect(() => {
// warm up cloud function // warm up cloud function
resolveMarket({}).catch() resolveMarket({} as any).catch()
}, []) }, [])
const { contract, className } = props const { contract, className } = props
@ -35,6 +35,8 @@ export function ResolutionPanel(props: {
const [error, setError] = useState<string | undefined>(undefined) const [error, setError] = useState<string | undefined>(undefined)
const resolve = async () => { const resolve = async () => {
if (!outcome) return
setIsSubmitting(true) setIsSubmitting(true)
const result = await resolveMarket({ const result = await resolveMarket({
@ -64,9 +66,9 @@ export function ResolutionPanel(props: {
return ( return (
<Col className={clsx('rounded-md bg-white px-8 py-6', className)}> <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 <YesNoCancelSelector
className="mx-auto my-2" className="mx-auto my-2"
@ -75,7 +77,7 @@ export function ResolutionPanel(props: {
btnClassName={isSubmitting ? 'btn-disabled' : ''} btnClassName={isSubmitting ? 'btn-disabled' : ''}
/> />
<Spacer h={3} /> <Spacer h={4} />
<div> <div>
{outcome === 'YES' ? ( {outcome === 'YES' ? (
@ -95,46 +97,29 @@ export function ResolutionPanel(props: {
) : outcome === 'CANCEL' ? ( ) : outcome === 'CANCEL' ? (
<>The pool will be returned to traders with no fees.</> <>The pool will be returned to traders with no fees.</>
) : outcome === 'MKT' ? ( ) : outcome === 'MKT' ? (
<> <Col className="gap-6">
Traders will be paid out at the probability you specify: <div>Traders will be paid out at the probability you specify:</div>
<Spacer h={2} />
<ProbabilitySelector <ProbabilitySelector
probabilityInt={Math.round(prob)} probabilityInt={Math.round(prob)}
setProbabilityInt={setProb} setProbabilityInt={setProb}
/> />
<Spacer h={2} /> <div>You earn {CREATOR_FEE * 100}% of trader profits.</div>
You earn {CREATOR_FEE * 100}% of trader profits. </Col>
</>
) : ( ) : (
<>Resolving this market will immediately pay out traders.</> <>Resolving this market will immediately pay out traders.</>
)} )}
</div> </div>
<Spacer h={3} /> <Spacer h={4} />
{!!error && <div className="text-red-500">{error}</div>} {!!error && <div className="text-red-500">{error}</div>}
<ConfirmationButton <ResolveConfirmationButton
id="resolution-modal" onResolve={resolve}
openModelBtn={{ isSubmitting={isSubmitting}
className: clsx( openModelButtonClass={clsx('w-full mt-2', submitButtonClass)}
'border-none self-start mt-2 w-full', submitButtonClass={submitButtonClass}
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>
</Col> </Col>
) )
} }

View File

@ -12,10 +12,11 @@ export const SiteLink = (props: {
<a <a
href={href} href={href}
className={clsx( 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 className
)} )}
style={{ /* For iOS safari */ wordBreak: 'break-word' }} style={{ /* For iOS safari */ wordBreak: 'break-word' }}
target="_blank"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{children} {children}
@ -24,7 +25,7 @@ export const SiteLink = (props: {
<Link href={href}> <Link href={href}>
<a <a
className={clsx( 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 className
)} )}
style={{ /* For iOS safari */ wordBreak: 'break-word' }} style={{ /* For iOS safari */ wordBreak: 'break-word' }}

View File

@ -1,10 +1,17 @@
import clsx from 'clsx' import clsx from 'clsx'
import { User } from '../lib/firebase/users' import { User } from '../lib/firebase/users'
import { CreatorContractsList } from './contracts-list' import { CreatorContractsList } from './contracts-list'
import { Title } from './title'
import { SEO } from './SEO' import { SEO } from './SEO'
import { Page } from './page' import { Page } from './page'
import { SiteLink } from './site-link' 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: { export function UserLink(props: {
name: string name: string
@ -24,22 +31,115 @@ export function UserLink(props: {
export function UserPage(props: { user: User; currentUser?: User }) { export function UserPage(props: { user: User; currentUser?: User }) {
const { user, currentUser } = props const { user, currentUser } = props
const isCurrentUser = user.id === currentUser?.id const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const possesive = isCurrentUser ? 'Your ' : `${user.name}'s ` const placeholderBio = `I... haven't gotten around to writing a bio yet 😛`
return ( return (
<Page> <Page>
<SEO <SEO
title={possesive + 'markets'} title={`${user.name} (@${user.username})`}
description={possesive + 'markets'} description={user.bio ?? placeholderBio}
url={`/@${user.username}`} 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} /> <CreatorContractsList creator={user} />
</Col>
</Page> </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]
}

View File

@ -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] const fundAmounts = [500, 1000, 2500, 10000]
export function FundsSelector(props: { 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: { function Button(props: {
className?: string className?: string
onClick?: () => void onClick?: () => void
@ -129,7 +169,7 @@ function Button(props: {
<button <button
type="button" type="button"
className={clsx( 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 === 'green' && 'btn-primary text-white',
color === 'red' && 'bg-red-400 text-white hover:bg-red-500', color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',

13
web/hooks/use-answers.ts Normal file
View 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
}

View File

@ -12,11 +12,8 @@ export const useBets = (contractId: string) => {
return bets return bets
} }
export const useBetsWithoutAntes = ( export const useBetsWithoutAntes = (contract: Contract, initialBets: Bet[]) => {
contract: Contract, const [bets, setBets] = useState<Bet[]>(
initialBets?: Bet[]
) => {
const [bets, setBets] = useState<Bet[] | undefined>(
withoutAnteBets(contract, initialBets) withoutAnteBets(contract, initialBets)
) )

View 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)
}
)
}

View File

@ -18,7 +18,24 @@ export const createFold = cloudFunction<
export const placeBet = cloudFunction('placeBet') 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') export const sellBet = cloudFunction('sellBet')

View File

@ -27,21 +27,9 @@ export function contractPath(contract: Contract) {
} }
export function contractMetrics(contract: Contract) { export function contractMetrics(contract: Contract) {
const { const { pool, createdTime, resolutionTime, isResolved } = contract
pool,
phantomShares,
totalShares,
createdTime,
resolutionTime,
isResolved,
resolutionProbability,
} = contract
const truePool = pool.YES + pool.NO const truePool = _.sum(Object.values(pool))
const prob = resolutionProbability ?? getProbability(totalShares)
const probPercent = Math.round(prob * 100) + '%'
const startProb = getProbability(phantomShares)
const createdDate = dayjs(createdTime).format('MMM D') const createdDate = dayjs(createdTime).format('MMM D')
@ -49,7 +37,16 @@ export function contractMetrics(contract: Contract) {
? dayjs(resolutionTime).format('MMM D') ? dayjs(resolutionTime).format('MMM D')
: undefined : 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) { export function tradingAllowed(contract: Contract) {

View File

@ -9,6 +9,7 @@ import {
limit, limit,
getDocs, getDocs,
orderBy, orderBy,
updateDoc,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
@ -21,7 +22,8 @@ import {
import { app } from './init' import { app } from './init'
import { PrivateUser, User } from '../../../common/user' import { PrivateUser, User } from '../../../common/user'
import { createUser } from './api-call' import { createUser } from './api-call'
import { getValue, getValues, listenForValue, listenForValues } from './utils' import { getValues, listenForValue, listenForValues } from './utils'
export type { User } export type { User }
const db = getFirestore(app) const db = getFirestore(app)
@ -45,6 +47,10 @@ export async function setUser(userId: string, user: User) {
await setDoc(doc(db, 'users', userId), 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( export function listenForUser(
userId: string, userId: string,
setUser: (user: User | null) => void setUser: (user: User | null) => void

View File

@ -12,10 +12,10 @@ import { Title } from '../../components/title'
import { Spacer } from '../../components/layout/spacer' import { Spacer } from '../../components/layout/spacer'
import { User } from '../../lib/firebase/users' import { User } from '../../lib/firebase/users'
import { import {
contractMetrics,
Contract, Contract,
getContractFromSlug, getContractFromSlug,
tradingAllowed, tradingAllowed,
getBinaryProbPercent,
} from '../../lib/firebase/contracts' } from '../../lib/firebase/contracts'
import { SEO } from '../../components/SEO' import { SEO } from '../../components/SEO'
import { Page } from '../../components/page' import { Page } from '../../components/page'
@ -26,6 +26,9 @@ import Custom404 from '../404'
import { getFoldsByTags } from '../../lib/firebase/folds' import { getFoldsByTags } from '../../lib/firebase/folds'
import { Fold } from '../../../common/fold' import { Fold } from '../../../common/fold'
import { useFoldsWithTags } from '../../hooks/use-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: { export async function getStaticProps(props: {
params: { username: string; contractSlug: string } params: { username: string; contractSlug: string }
@ -36,9 +39,12 @@ export async function getStaticProps(props: {
const foldsPromise = getFoldsByTags(contract?.tags ?? []) const foldsPromise = getFoldsByTags(contract?.tags ?? [])
const [bets, comments] = await Promise.all([ const [bets, comments, answers] = await Promise.all([
contractId ? listAllBets(contractId) : [], contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [], contractId ? listAllComments(contractId) : [],
contractId && contract.outcomeType === 'FREE_RESPONSE'
? listAllAnswers(contractId)
: [],
]) ])
const folds = await foldsPromise const folds = await foldsPromise
@ -50,6 +56,7 @@ export async function getStaticProps(props: {
slug: contractSlug, slug: contractSlug,
bets, bets,
comments, comments,
answers,
folds, folds,
}, },
@ -66,6 +73,7 @@ export default function ContractPage(props: {
username: string username: string
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
answers: Answer[]
slug: string slug: string
folds: Fold[] folds: Fold[]
}) { }) {
@ -74,6 +82,10 @@ export default function ContractPage(props: {
const contract = useContractWithPreload(props.slug, props.contract) const contract = useContractWithPreload(props.slug, props.contract)
const { bets, comments } = props 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( const folds = (useFoldsWithTags(contract?.tags) ?? props.folds).filter(
(fold) => fold.followCount > 1 || user?.id === fold.curatorId (fold) => fold.followCount > 1 || user?.id === fold.curatorId
) )
@ -82,33 +94,26 @@ export default function ContractPage(props: {
return <Custom404 /> return <Custom404 />
} }
const { creatorId, isResolved, resolution, question } = contract const { creatorId, isResolved, question, outcomeType } = contract
const isCreator = user?.id === creatorId const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const allowTrade = tradingAllowed(contract) const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user const allowResolve = !isResolved && isCreator && !!user
const hasSidePanel = isBinary && (allowTrade || allowResolve)
const { probPercent } = contractMetrics(contract) const ogCardProps = getOpenGraphProps(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,
}
return ( return (
<Page wide={allowTrade || allowResolve}> <Page wide={hasSidePanel}>
{ogCardProps && (
<SEO <SEO
title={question} title={question}
description={description} description={ogCardProps.description}
url={`/${props.username}/${props.slug}`} url={`/${props.username}/${props.slug}`}
ogCardProps={ogCardProps} ogCardProps={ogCardProps}
/> />
)}
<Col className="w-full justify-between md:flex-row"> <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"> <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 ?? []} bets={bets ?? []}
comments={comments ?? []} comments={comments ?? []}
folds={folds} 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> </div>
{(allowTrade || allowResolve) && ( {hasSidePanel && (
<> <>
<div className="md:ml-6" /> <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 { contract, user } = props
const bets = useBets(contract.id) const isBinary = contract.outcomeType === 'BINARY'
const bets = useBets(contract.id) ?? props.bets
if (!bets || bets.length === 0) return <></>
// Decending creation time. // Decending creation time.
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime) bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
@ -156,10 +177,34 @@ function BetsSection(props: { contract: Contract; user: User | null }) {
return ( return (
<div> <div>
<Title className="px-2" text="Your trades" /> <Title className="px-2" text="Your trades" />
{isBinary && (
<>
<MyBetsSummary className="px-2" contract={contract} bets={userBets} /> <MyBetsSummary className="px-2" contract={contract} bets={userBets} />
<Spacer h={6} /> <Spacer h={6} />
</>
)}
<ContractBetsTable contract={contract} bets={userBets} /> <ContractBetsTable contract={contract} bets={userBets} />
<Spacer h={12} /> <Spacer h={12} />
</div> </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,
}
}

View File

@ -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' })
}

View 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,
}
}

View 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,
})
}

View 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))
}

View 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,
})
}

View File

@ -17,6 +17,8 @@ import { Title } from '../components/title'
import { ProbabilitySelector } from '../components/probability-selector' import { ProbabilitySelector } from '../components/probability-selector'
import { parseWordsAsTags } from '../../common/util/parse' import { parseWordsAsTags } from '../../common/util/parse'
import { TagsList } from '../components/tags-list' import { TagsList } from '../components/tags-list'
import { Row } from '../components/layout/row'
import { outcomeType } from '../../common/contract'
export default function Create() { export default function Create() {
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
@ -61,6 +63,7 @@ export function NewContract(props: { question: string; tag?: string }) {
createContract({}).catch() // warm up function createContract({}).catch() // warm up function
}, []) }, [])
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
const [initialProb, setInitialProb] = useState(50) const [initialProb, setInitialProb] = useState(50)
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [tagText, setTagText] = useState<string>(tag ?? '') const [tagText, setTagText] = useState<string>(tag ?? '')
@ -105,6 +108,7 @@ export function NewContract(props: { question: string; tag?: string }) {
const result: any = await createContract({ const result: any = await createContract({
question, question,
outcomeType,
description, description,
initialProb, initialProb,
ante, ante,
@ -120,14 +124,46 @@ export function NewContract(props: { question: string; tag?: string }) {
await router.push(contractPath(result.contract as Contract)) 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 <></> if (!creator) return <></>
return ( return (
<div> <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} /> <Spacer h={4} />
{outcomeType === 'BINARY' && (
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
<span className="mb-1">Initial probability</span> <span className="mb-1">Initial probability</span>
@ -138,6 +174,7 @@ export function NewContract(props: { question: string; tag?: string }) {
setProbabilityInt={setInitialProb} setProbabilityInt={setInitialProb}
/> />
</div> </div>
)}
<Spacer h={4} /> <Spacer h={4} />

View File

@ -30,10 +30,6 @@ export async function getStaticProps() {
listAllFolds().catch(() => []), 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([ const [contractBets, contractComments] = await Promise.all([
Promise.all(contracts.map((contract) => listAllBets(contract.id))), Promise.all(contracts.map((contract) => listAllBets(contract.id))),
Promise.all(contracts.map((contract) => listAllComments(contract.id))), Promise.all(contracts.map((contract) => listAllComments(contract.id))),

View File

@ -3,6 +3,7 @@ import dayjs from 'dayjs'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { getProbability } from '../../common/calculate'
import { parseWordsAsTags } from '../../common/util/parse' import { parseWordsAsTags } from '../../common/util/parse'
import { AmountInput } from '../components/amount-input' import { AmountInput } from '../components/amount-input'
import { InfoTooltip } from '../components/info-tooltip' import { InfoTooltip } from '../components/info-tooltip'
@ -15,11 +16,7 @@ import { Page } from '../components/page'
import { Title } from '../components/title' import { Title } from '../components/title'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { createContract } from '../lib/firebase/api-call' import { createContract } from '../lib/firebase/api-call'
import { import { Contract, contractPath } from '../lib/firebase/contracts'
contractMetrics,
Contract,
contractPath,
} from '../lib/firebase/contracts'
type Prediction = { type Prediction = {
question: string question: string
@ -29,7 +26,7 @@ type Prediction = {
} }
function toPrediction(contract: Contract): Prediction { function toPrediction(contract: Contract): Prediction {
const { startProb } = contractMetrics(contract) const startProb = getProbability(contract.totalShares)
return { return {
question: contract.question, question: contract.question,
description: contract.description, description: contract.description,

View File

@ -16,6 +16,48 @@ import { changeUserInfo } from '../lib/firebase/api-call'
import { uploadImage } from '../lib/firebase/storage' import { uploadImage } from '../lib/firebase/storage'
import { Col } from '../components/layout/col' import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row' 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() { export default function ProfilePage() {
const user = useUser() const user = useUser()
@ -26,8 +68,6 @@ export default function ProfilePage() {
const [name, setName] = useState(user?.name || '') const [name, setName] = useState(user?.name || '')
const [username, setUsername] = useState(user?.username || '') const [username, setUsername] = useState(user?.username || '')
const [isEditing, setIsEditing] = useState(false)
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setAvatarUrl(user.avatarUrl || '') 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"> <Col className="max-w-lg rounded bg-white p-6 shadow-md sm:mx-auto">
<Row className="justify-between"> <Row className="justify-between">
<Title className="!mt-0" text="Profile" /> <Title className="!mt-0" text="Edit Profile" />
{isEditing ? ( <SiteLink className="btn btn-primary" href={`/${user?.username}`}>
<button
className="btn btn-primary"
onClick={() => setIsEditing(false)}
>
Done Done
</button> </SiteLink>
) : (
<button
className="btn btn-ghost"
onClick={() => setIsEditing(true)}
>
<PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div>
</button>
)}
</Row> </Row>
<Col className="gap-4"> <Col className="gap-4">
<Row className="items-center gap-4"> <Row className="items-center gap-4">
@ -125,9 +152,7 @@ export default function ProfilePage() {
height={80} height={80}
className="flex items-center justify-center rounded-full bg-gray-400" className="flex items-center justify-center rounded-full bg-gray-400"
/> />
{isEditing && (
<input type="file" name="file" onChange={fileHandler} /> <input type="file" name="file" onChange={fileHandler} />
)}
</> </>
)} )}
</Row> </Row>
@ -135,7 +160,6 @@ export default function ProfilePage() {
<div> <div>
<label className="label">Display name</label> <label className="label">Display name</label>
{isEditing ? (
<input <input
type="text" type="text"
placeholder="Display name" placeholder="Display name"
@ -144,15 +168,11 @@ export default function ProfilePage() {
onChange={(e) => setName(e.target.value || '')} onChange={(e) => setName(e.target.value || '')}
onBlur={updateDisplayName} onBlur={updateDisplayName}
/> />
) : (
<div className="ml-1 text-gray-500">{name}</div>
)}
</div> </div>
<div> <div>
<label className="label">Username</label> <label className="label">Username</label>
{isEditing ? (
<input <input
type="text" type="text"
placeholder="Username" placeholder="Username"
@ -161,11 +181,48 @@ export default function ProfilePage() {
onChange={(e) => setUsername(e.target.value || '')} onChange={(e) => setUsername(e.target.value || '')}
onBlur={updateUsername} onBlur={updateUsername}
/> />
) : (
<div className="ml-1 text-gray-500">{username}</div>
)}
</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> <div>
<label className="label">Email</label> <label className="label">Email</label>
<div className="ml-1 text-gray-500"> <div className="ml-1 text-gray-500">

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View 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