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 }
}
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
amount: number // bet size; negative if SELL bet
outcome: 'YES' | 'NO'
outcome: string
shares: number // dynamic parimutuel pool weight; negative if SELL bet
probBefore: number

View File

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

View File

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

View File

@ -1,9 +1,13 @@
import { Bet } from './bet'
import { calculateShares, getProbability } from './calculate'
import {
calculateShares,
getProbability,
getOutcomeProbability,
} from './calculate'
import { Contract } from './contract'
import { User } from './user'
export const getNewBetInfo = (
export const getNewBinaryBetInfo = (
user: User,
outcome: 'YES' | 'NO',
amount: number,
@ -52,3 +56,43 @@ export const getNewBetInfo = (
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
}
export const getNewMultiBetInfo = (
user: User,
outcome: string,
amount: number,
contract: Contract,
newBetId: string
) => {
const { pool, totalShares, totalBets } = contract
const prevOutcomePool = pool[outcome] ?? 0
const newPool = { ...pool, [outcome]: prevOutcomePool + amount }
const shares = calculateShares(contract.totalShares, amount, outcome)
const prevShares = totalShares[outcome] ?? 0
const newTotalShares = { ...totalShares, [outcome]: prevShares + shares }
const prevTotalBets = totalBets[outcome] ?? 0
const newTotalBets = { ...totalBets, [outcome]: prevTotalBets + amount }
const probBefore = getOutcomeProbability(totalShares, outcome)
const probAfter = getOutcomeProbability(newTotalShares, outcome)
const newBet: Bet = {
id: newBetId,
userId: user.id,
contractId: contract.id,
amount,
shares,
outcome,
probBefore,
probAfter,
createdTime: Date.now(),
}
const newBalance = user.balance - amount
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
}

View File

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

View File

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

View File

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

View File

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

9
common/util/object.ts Normal file
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)
.substring(2, length + 2)
export function createRNG(seed: string) {
// https://stackoverflow.com/a/47593316/1592933
function genHash(str: string) {
export function genHash(str: string) {
// xmur3
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
@ -17,7 +14,10 @@ export function createRNG(seed: string) {
h = Math.imul(h ^ (h >>> 13), 3266489909)
return (h ^= h >>> 16) >>> 0
}
}
}
export function createRNG(seed: string) {
// https://stackoverflow.com/a/47593316/1592933
const gen = genHash(seed)
let [a, b, c, d] = [gen(), gen(), gen(), gen()]

View File

@ -13,6 +13,9 @@ service cloud.firestore {
match /users/{userId} {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle']);
}
match /private-users/{userId} {
@ -27,20 +30,16 @@ service cloud.firestore {
allow delete: if resource.data.creatorId == request.auth.uid;
}
match /contracts/{contractId}/bets/{betId} {
allow read;
}
match /{somePath=**}/bets/{betId} {
allow read;
}
match /contracts/{contractId}/comments/{commentId} {
match /{somePath=**}/comments/{commentId} {
allow read;
allow create: if request.auth != null;
}
match /{somePath=**}/comments/{commentId} {
match /{somePath=**}/answers/{answerId} {
allow read;
}
@ -49,13 +48,9 @@ service cloud.firestore {
allow update, delete: if request.auth.uid == resource.data.curatorId;
}
match /folds/{foldId}/followers/{userId} {
match /{somePath=**}/followers/{userId} {
allow read;
allow write: if request.auth.uid == userId;
}
match /{somePath=**}/followers/{userId} {
allow read;
}
}
}

View File

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

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 { chargeUser, getUser } from './utils'
import { Contract } from '../../common/contract'
import { Contract, outcomeType } from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { getNewContract } from '../../common/new-contract'
import { getAnteBets, MINIMUM_ANTE } from '../../common/antes'
import {
getAnteBets,
getFreeAnswerAnte,
MINIMUM_ANTE,
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
export const createContract = functions
.runWith({ minInstances: 1 })
@ -14,6 +19,7 @@ export const createContract = functions
async (
data: {
question: string
outcomeType: outcomeType
description: string
initialProb: number
ante: number
@ -30,10 +36,17 @@ export const createContract = functions
const { question, description, initialProb, ante, closeTime, tags } = data
if (!question || !initialProb)
return { status: 'error', message: 'Missing contract attributes' }
if (!question)
return { status: 'error', message: 'Missing question field' }
if (initialProb < 1 || initialProb > 99)
let outcomeType = data.outcomeType ?? 'BINARY'
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
return { status: 'error', message: 'Invalid outcomeType' }
if (
outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99)
)
return { status: 'error', message: 'Invalid initial probability' }
if (
@ -63,6 +76,7 @@ export const createContract = functions
slug,
creator,
question,
outcomeType,
description,
initialProb,
ante,
@ -75,6 +89,7 @@ export const createContract = functions
await contractRef.create(contract)
if (ante) {
if (outcomeType === 'BINARY') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
@ -91,6 +106,19 @@ export const createContract = functions
)
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
const noneAnswer = getNoneAnswer(contract.id, creator)
await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(creator, contract, anteBetDoc.id)
await anteBetDoc.set(anteBet)
}
}
return { status: 'success', contract }

View File

@ -1,7 +1,9 @@
import _ = require('lodash')
import { getProbability } from '../../common/calculate'
import { Contract } from '../../common/contract'
import { CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user'
import { formatPercent } from '../../common/util/format'
import { formatMoney, formatPercent } from '../../common/util/format'
import { sendTemplateEmail, sendTextEmail } from './send-email'
import { getPrivateUser, getUser } from './utils'
@ -15,12 +17,23 @@ type market_resolved_template = {
url: string
}
const toDisplayResolution = (outcome: string, prob: number) => {
const display = {
YES: 'YES',
NO: 'NO',
CANCEL: 'N/A',
MKT: formatPercent(prob),
}[outcome]
return display === undefined ? `#${outcome}` : display
}
export const sendMarketResolutionEmail = async (
userId: string,
payout: number,
creator: User,
contract: Contract,
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT',
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string,
resolutionProbability?: number
) => {
const privateUser = await getPrivateUser(userId)
@ -36,13 +49,7 @@ export const sendMarketResolutionEmail = async (
const prob = resolutionProbability ?? getProbability(contract.totalShares)
const toDisplayResolution = {
YES: 'YES',
NO: 'NO',
CANCEL: 'N/A',
MKT: formatPercent(prob),
}
const outcome = toDisplayResolution[resolution]
const outcome = toDisplayResolution(resolution, prob)
const subject = `Resolved ${outcome}: ${contract.question}`
@ -88,3 +95,37 @@ Austin from Manifold
https://manifold.markets/`
)
}
export const sendMarketCloseEmail = async (
user: User,
privateUser: PrivateUser,
contract: Contract
) => {
if (
!privateUser ||
privateUser.unsubscribedFromResolutionEmails ||
!privateUser.email
)
return
const { username, name, id: userId } = user
const firstName = name.split(' ')[0]
const { question, pool: pools, slug } = contract
const pool = formatMoney(_.sum(_.values(pools)))
const url = `https://manifold.markets/${username}/${slug}`
await sendTemplateEmail(
privateUser.email,
'Your market has closed',
'market-close',
{
name: firstName,
question,
pool,
url,
userId,
creatorFee: (CREATOR_FEE * 100).toString(),
}
)
}

View File

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

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 { User } from '../../common/user'
import { getNewBetInfo } from '../../common/new-bet'
import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet'
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
async (
@ -22,7 +22,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
if (outcome !== 'YES' && outcome !== 'NO')
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
return { status: 'error', message: 'Invalid outcome' }
// run as transaction to prevent race conditions
@ -42,16 +42,32 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract
const { closeTime } = contract
const { closeTime, outcomeType } = contract
if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' }
if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome)
)
if (!answerSnap.exists)
return { status: 'error', message: 'Invalid contract' }
}
const newBetDoc = firestore
.collection(`contracts/${contractId}/bets`)
.doc()
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
getNewBetInfo(user, outcome, amount, contract, newBetDoc.id)
outcomeType === 'BINARY'
? getNewBinaryBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
newBetDoc.id
)
: getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id)
transaction.create(newBetDoc, newBet)
transaction.update(contractDoc, {

View File

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

View File

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

View File

@ -14,7 +14,7 @@ const pathsToPrivateKey = {
stephen:
'../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json',
stephenDev:
'../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json',
'../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json',
}
export const initAdmin = (who: keyof typeof pathsToPrivateKey) => {

View File

@ -77,13 +77,3 @@ export const chargeUser = (userId: string, charge: number) => {
return updateUserBalance(userId, -charge)
}
export const removeUndefinedProps = <T>(obj: T): T => {
let newObj: any = {}
for (let key of Object.keys(obj)) {
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
}
return newObj
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -31,7 +31,7 @@ export function ConfirmationButton(props: {
<input type="checkbox" id={id} className="modal-toggle" />
<div className="modal">
<div className="modal-box">
<div className="modal-box whitespace-normal">
{children}
<div className="modal-action">
@ -51,3 +51,36 @@ export function ConfirmationButton(props: {
</>
)
}
export function ResolveConfirmationButton(props: {
onResolve: () => void
isSubmitting: boolean
openModelButtonClass?: string
submitButtonClass?: string
}) {
const { onResolve, isSubmitting, openModelButtonClass, submitButtonClass } =
props
return (
<ConfirmationButton
id="resolution-modal"
openModelBtn={{
className: clsx(
'border-none self-start',
openModelButtonClass,
isSubmitting && 'btn-disabled loading'
),
label: 'Resolve',
}}
cancelBtn={{
label: 'Back',
}}
submitBtn={{
label: 'Resolve',
className: clsx('border-none', submitButtonClass),
}}
onSubmit={onResolve}
>
<p>Are you sure you want to resolve this market?</p>
</ConfirmationButton>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export function ContractsGrid(props: {
showHotVolume?: boolean
showCloseTime?: boolean
}) {
const { showHotVolume, showCloseTime } = props
const { showCloseTime } = props
const [resolvedContracts, activeContracts] = _.partition(
props.contracts,
@ -33,7 +33,10 @@ export function ContractsGrid(props: {
if (contracts.length === 0) {
return (
<p className="mx-2 text-gray-500">
No markets found. Why not create one?
No markets found. Why not{' '}
<SiteLink href="/home" className="font-bold text-gray-700">
create one?
</SiteLink>
</p>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -12,10 +12,11 @@ export const SiteLink = (props: {
<a
href={href}
className={clsx(
'break-words z-10 hover:underline hover:decoration-indigo-400 hover:decoration-2',
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
{children}
@ -24,7 +25,7 @@ export const SiteLink = (props: {
<Link href={href}>
<a
className={clsx(
'break-words z-10 hover:underline hover:decoration-indigo-400 hover:decoration-2',
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
className
)}
style={{ /* For iOS safari */ wordBreak: 'break-word' }}

View File

@ -1,10 +1,17 @@
import clsx from 'clsx'
import { User } from '../lib/firebase/users'
import { CreatorContractsList } from './contracts-list'
import { Title } from './title'
import { SEO } from './SEO'
import { Page } from './page'
import { SiteLink } from './site-link'
import { Avatar } from './avatar'
import { Col } from './layout/col'
import { Linkify } from './linkify'
import { Spacer } from './layout/spacer'
import { Row } from './layout/row'
import { LinkIcon } from '@heroicons/react/solid'
import { genHash } from '../../common/util/random'
import { PencilIcon } from '@heroicons/react/outline'
export function UserLink(props: {
name: string
@ -24,22 +31,115 @@ export function UserLink(props: {
export function UserPage(props: { user: User; currentUser?: User }) {
const { user, currentUser } = props
const isCurrentUser = user.id === currentUser?.id
const possesive = isCurrentUser ? 'Your ' : `${user.name}'s `
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const placeholderBio = `I... haven't gotten around to writing a bio yet 😛`
return (
<Page>
<SEO
title={possesive + 'markets'}
description={possesive + 'markets'}
url={`/@${user.username}`}
title={`${user.name} (@${user.username})`}
description={user.bio ?? placeholderBio}
url={`/${user.username}`}
/>
<Title className="mx-4 md:mx-0" text={possesive + 'markets'} />
{/* Banner image up top, with an circle avatar overlaid */}
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
style={{
backgroundImage: `url(${bannerUrl})`,
}}
></div>
<div className="relative mb-20">
<div className="absolute -top-10 left-4">
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={20}
className="bg-white ring-4 ring-white"
/>
</div>
{/* Top right buttons (e.g. edit, follow) */}
<div className="absolute right-0 top-0 mt-4 mr-4">
{isCurrentUser && (
<SiteLink className="btn" href="/profile">
<PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div>
</SiteLink>
)}
</div>
</div>
{/* Profile details: name, username, bio, and link to twitter/discord */}
<Col className="mx-4 -mt-6">
<span className="text-2xl font-bold">{user.name}</span>
<span className="text-gray-500">@{user.username}</span>
<Spacer h={4} />
<div>
<Linkify text={user.bio || placeholderBio}></Linkify>
</div>
<Spacer h={4} />
<Col className="sm:flex-row sm:gap-4">
{user.website && (
<SiteLink href={user.website}>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
<span className="text-sm text-gray-500">{user.website}</span>
</Row>
</SiteLink>
)}
{user.twitterHandle && (
<SiteLink href={`https://twitter.com/${user.twitterHandle}`}>
<Row className="items-center gap-1">
<img
src="/twitter-logo.svg"
className="h-4 w-4"
alt="Twitter"
/>
<span className="text-sm text-gray-500">
{user.twitterHandle}
</span>
</Row>
</SiteLink>
)}
{user.discordHandle && (
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
<Row className="items-center gap-1">
<img
src="/discord-logo.svg"
className="h-4 w-4"
alt="Discord"
/>
<span className="text-sm text-gray-500">
{user.discordHandle}
</span>
</Row>
</SiteLink>
)}
</Col>
<Spacer h={10} />
<CreatorContractsList creator={user} />
</Col>
</Page>
)
}
// Assign each user to a random default banner based on the hash of userId
// TODO: Consider handling banner uploads using our own storage bucket, like user avatars.
export function defaultBannerUrl(userId: string) {
const defaultBanner = [
'https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2131&q=80',
'https://images.unsplash.com/photo-1458682625221-3a45f8a844c7?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1974&q=80',
'https://images.unsplash.com/photo-1558517259-165ae4b10f7f?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2080&q=80',
'https://images.unsplash.com/photo-1563260797-cb5cd70254c8?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80',
'https://images.unsplash.com/photo-1603399587513-136aa9398f2d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1467&q=80',
]
return defaultBanner[genHash(userId)() % defaultBanner.length]
}

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]
export function FundsSelector(props: {
@ -117,6 +148,15 @@ export function FundsSelector(props: {
)
}
export function BuyButton(props: { className?: string; onClick?: () => void }) {
const { className, onClick } = props
return (
<Button className={className} onClick={onClick} color="green">
BUY
</Button>
)
}
function Button(props: {
className?: string
onClick?: () => void
@ -129,7 +169,7 @@ function Button(props: {
<button
type="button"
className={clsx(
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 text-sm font-medium shadow-sm',
'inline-flex flex-1 items-center justify-center rounded-md border border-transparent px-8 py-3 font-medium shadow-sm',
color === 'green' && 'btn-primary text-white',
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',

13
web/hooks/use-answers.ts Normal file
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
}
export const useBetsWithoutAntes = (
contract: Contract,
initialBets?: Bet[]
) => {
const [bets, setBets] = useState<Bet[] | undefined>(
export const useBetsWithoutAntes = (contract: Contract, initialBets: Bet[]) => {
const [bets, setBets] = useState<Bet[]>(
withoutAnteBets(contract, initialBets)
)

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

View File

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

View File

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

View File

@ -12,10 +12,10 @@ import { Title } from '../../components/title'
import { Spacer } from '../../components/layout/spacer'
import { User } from '../../lib/firebase/users'
import {
contractMetrics,
Contract,
getContractFromSlug,
tradingAllowed,
getBinaryProbPercent,
} from '../../lib/firebase/contracts'
import { SEO } from '../../components/SEO'
import { Page } from '../../components/page'
@ -26,6 +26,9 @@ import Custom404 from '../404'
import { getFoldsByTags } from '../../lib/firebase/folds'
import { Fold } from '../../../common/fold'
import { useFoldsWithTags } from '../../hooks/use-fold'
import { listAllAnswers } from '../../lib/firebase/answers'
import { Answer } from '../../../common/answer'
import { AnswersPanel } from '../../components/answers-panel'
export async function getStaticProps(props: {
params: { username: string; contractSlug: string }
@ -36,9 +39,12 @@ export async function getStaticProps(props: {
const foldsPromise = getFoldsByTags(contract?.tags ?? [])
const [bets, comments] = await Promise.all([
const [bets, comments, answers] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
contractId && contract.outcomeType === 'FREE_RESPONSE'
? listAllAnswers(contractId)
: [],
])
const folds = await foldsPromise
@ -50,6 +56,7 @@ export async function getStaticProps(props: {
slug: contractSlug,
bets,
comments,
answers,
folds,
},
@ -66,6 +73,7 @@ export default function ContractPage(props: {
username: string
bets: Bet[]
comments: Comment[]
answers: Answer[]
slug: string
folds: Fold[]
}) {
@ -74,6 +82,10 @@ export default function ContractPage(props: {
const contract = useContractWithPreload(props.slug, props.contract)
const { bets, comments } = props
// Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
const folds = (useFoldsWithTags(contract?.tags) ?? props.folds).filter(
(fold) => fold.followCount > 1 || user?.id === fold.curatorId
)
@ -82,33 +94,26 @@ export default function ContractPage(props: {
return <Custom404 />
}
const { creatorId, isResolved, resolution, question } = contract
const { creatorId, isResolved, question, outcomeType } = contract
const isCreator = user?.id === creatorId
const isBinary = outcomeType === 'BINARY'
const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user
const hasSidePanel = isBinary && (allowTrade || allowResolve)
const { probPercent } = contractMetrics(contract)
const description = resolution
? `Resolved ${resolution}. ${contract.description}`
: `${probPercent} chance. ${contract.description}`
const ogCardProps = {
question,
probability: probPercent,
metadata: contractTextDetails(contract),
creatorName: contract.creatorName,
creatorUsername: contract.creatorUsername,
}
const ogCardProps = getOpenGraphProps(contract)
return (
<Page wide={allowTrade || allowResolve}>
<Page wide={hasSidePanel}>
{ogCardProps && (
<SEO
title={question}
description={description}
description={ogCardProps.description}
url={`/${props.username}/${props.slug}`}
ogCardProps={ogCardProps}
/>
)}
<Col className="w-full justify-between md:flex-row">
<div className="flex-[3] rounded border-0 border-gray-100 bg-white px-2 py-6 md:px-6 md:py-8">
@ -117,11 +122,24 @@ export default function ContractPage(props: {
bets={bets ?? []}
comments={comments ?? []}
folds={folds}
>
{contract.outcomeType === 'FREE_RESPONSE' && (
<>
<Spacer h={4} />
<AnswersPanel
contract={contract as any}
answers={props.answers}
/>
<BetsSection contract={contract} user={user ?? null} />
<Spacer h={4} />
<div className="divider before:bg-gray-300 after:bg-gray-300" />
</>
)}
</ContractOverview>
<BetsSection contract={contract} user={user ?? null} bets={bets} />
</div>
{(allowTrade || allowResolve) && (
{hasSidePanel && (
<>
<div className="md:ml-6" />
@ -140,11 +158,14 @@ export default function ContractPage(props: {
)
}
function BetsSection(props: { contract: Contract; user: User | null }) {
function BetsSection(props: {
contract: Contract
user: User | null
bets: Bet[]
}) {
const { contract, user } = props
const bets = useBets(contract.id)
if (!bets || bets.length === 0) return <></>
const isBinary = contract.outcomeType === 'BINARY'
const bets = useBets(contract.id) ?? props.bets
// Decending creation time.
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
@ -156,10 +177,34 @@ function BetsSection(props: { contract: Contract; user: User | null }) {
return (
<div>
<Title className="px-2" text="Your trades" />
{isBinary && (
<>
<MyBetsSummary className="px-2" contract={contract} bets={userBets} />
<Spacer h={6} />
</>
)}
<ContractBetsTable contract={contract} bets={userBets} />
<Spacer h={12} />
</div>
)
}
const getOpenGraphProps = (contract: Contract) => {
const { resolution, question, creatorName, creatorUsername, outcomeType } =
contract
const probPercent =
outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined
const description = resolution
? `Resolved ${resolution}. ${contract.description}`
: `${probPercent} chance. ${contract.description}`
return {
question,
probability: probPercent,
metadata: contractTextDetails(contract),
creatorName: creatorName,
creatorUsername: creatorUsername,
description,
}
}

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 { parseWordsAsTags } from '../../common/util/parse'
import { TagsList } from '../components/tags-list'
import { Row } from '../components/layout/row'
import { outcomeType } from '../../common/contract'
export default function Create() {
const [question, setQuestion] = useState('')
@ -61,6 +63,7 @@ export function NewContract(props: { question: string; tag?: string }) {
createContract({}).catch() // warm up function
}, [])
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
const [initialProb, setInitialProb] = useState(50)
const [description, setDescription] = useState('')
const [tagText, setTagText] = useState<string>(tag ?? '')
@ -105,6 +108,7 @@ export function NewContract(props: { question: string; tag?: string }) {
const result: any = await createContract({
question,
outcomeType,
description,
initialProb,
ante,
@ -120,14 +124,46 @@ export function NewContract(props: { question: string; tag?: string }) {
await router.push(contractPath(result.contract as Contract))
}
const descriptionPlaceholder = `e.g. This market resolves to "YES" if, two weeks after closing, the...`
const descriptionPlaceholder =
outcomeType === 'BINARY'
? `e.g. This market resolves to "YES" if, two weeks after closing, the...`
: `e.g. I will choose the answer according to...`
if (!creator) return <></>
return (
<div>
<label className="label">
<span className="mb-1">Answer type</span>
</label>
<Row className="form-control gap-2">
<label className="cursor-pointer label gap-2">
<input
className="radio"
type="radio"
name="opt"
checked={outcomeType === 'BINARY'}
value="BINARY"
onChange={() => setOutcomeType('BINARY')}
/>
<span className="label-text">Yes / No</span>
</label>
<label className="cursor-pointer label gap-2">
<input
className="radio"
type="radio"
name="opt"
checked={outcomeType === 'FREE_RESPONSE'}
value="FREE_RESPONSE"
onChange={() => setOutcomeType('FREE_RESPONSE')}
/>
<span className="label-text">Free response</span>
</label>
</Row>
<Spacer h={4} />
{outcomeType === 'BINARY' && (
<div className="form-control">
<label className="label">
<span className="mb-1">Initial probability</span>
@ -138,6 +174,7 @@ export function NewContract(props: { question: string; tag?: string }) {
setProbabilityInt={setInitialProb}
/>
</div>
)}
<Spacer h={4} />

View File

@ -30,10 +30,6 @@ export async function getStaticProps() {
listAllFolds().catch(() => []),
])
// TODO(James): Remove this line. We are filtering out non-binary contracts so that
// branches other than free-response work.
contracts = contracts.filter((contract) => contract.outcomeType === 'BINARY')
const [contractBets, contractComments] = await Promise.all([
Promise.all(contracts.map((contract) => listAllBets(contract.id))),
Promise.all(contracts.map((contract) => listAllComments(contract.id))),

View File

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

View File

@ -16,6 +16,48 @@ import { changeUserInfo } from '../lib/firebase/api-call'
import { uploadImage } from '../lib/firebase/storage'
import { Col } from '../components/layout/col'
import { Row } from '../components/layout/row'
import { User } from '../../common/user'
import { updateUser } from '../lib/firebase/users'
import { defaultBannerUrl } from '../components/user-page'
import { SiteLink } from '../components/site-link'
import Textarea from 'react-expanding-textarea'
function EditUserField(props: {
user: User
field: 'bio' | 'bannerUrl' | 'twitterHandle' | 'discordHandle'
label: string
}) {
const { user, field, label } = props
const [value, setValue] = useState(user[field] ?? '')
async function updateField() {
// Note: We trim whitespace before uploading to Firestore
await updateUser(user.id, { [field]: value.trim() })
}
return (
<div>
<label className="label">{label}</label>
{field === 'bio' ? (
<Textarea
className="textarea textarea-bordered w-full"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={updateField}
/>
) : (
<input
type="text"
className="input input-bordered"
value={value}
onChange={(e) => setValue(e.target.value || '')}
onBlur={updateField}
/>
)}
</div>
)
}
export default function ProfilePage() {
const user = useUser()
@ -26,8 +68,6 @@ export default function ProfilePage() {
const [name, setName] = useState(user?.name || '')
const [username, setUsername] = useState(user?.username || '')
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
if (user) {
setAvatarUrl(user.avatarUrl || '')
@ -95,23 +135,10 @@ export default function ProfilePage() {
<Col className="max-w-lg rounded bg-white p-6 shadow-md sm:mx-auto">
<Row className="justify-between">
<Title className="!mt-0" text="Profile" />
{isEditing ? (
<button
className="btn btn-primary"
onClick={() => setIsEditing(false)}
>
<Title className="!mt-0" text="Edit Profile" />
<SiteLink className="btn btn-primary" href={`/${user?.username}`}>
Done
</button>
) : (
<button
className="btn btn-ghost"
onClick={() => setIsEditing(true)}
>
<PencilIcon className="h-5 w-5" />{' '}
<div className="ml-2">Edit</div>
</button>
)}
</SiteLink>
</Row>
<Col className="gap-4">
<Row className="items-center gap-4">
@ -125,9 +152,7 @@ export default function ProfilePage() {
height={80}
className="flex items-center justify-center rounded-full bg-gray-400"
/>
{isEditing && (
<input type="file" name="file" onChange={fileHandler} />
)}
</>
)}
</Row>
@ -135,7 +160,6 @@ export default function ProfilePage() {
<div>
<label className="label">Display name</label>
{isEditing ? (
<input
type="text"
placeholder="Display name"
@ -144,15 +168,11 @@ export default function ProfilePage() {
onChange={(e) => setName(e.target.value || '')}
onBlur={updateDisplayName}
/>
) : (
<div className="ml-1 text-gray-500">{name}</div>
)}
</div>
<div>
<label className="label">Username</label>
{isEditing ? (
<input
type="text"
placeholder="Username"
@ -161,11 +181,48 @@ export default function ProfilePage() {
onChange={(e) => setUsername(e.target.value || '')}
onBlur={updateUsername}
/>
) : (
<div className="ml-1 text-gray-500">{username}</div>
)}
</div>
{user && (
<>
{/* TODO: Allow users with M$ 2000 of assets to set custom banners */}
{/* <EditUserField
user={user}
field="bannerUrl"
label="Banner Url"
isEditing={isEditing}
/> */}
<label className="label">
Banner image{' '}
<span className="text-sm text-gray-400">
Not editable for now
</span>
</label>
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
style={{
backgroundImage: `url(${
user.bannerUrl || defaultBannerUrl(user.id)
})`,
}}
/>
{[
['bio', 'Bio'],
['website', 'Website URL'],
['twitterHandle', 'Twitter'],
['discordHandle', 'Discord'],
].map(([field, label]) => (
<EditUserField
user={user}
// @ts-ignore
field={field}
label={label}
/>
))}
</>
)}
<div>
<label className="label">Email</label>
<div className="ml-1 text-gray-500">

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