Merge branch 'main' into austin/dc-hackathon

This commit is contained in:
Austin Chen 2022-10-06 20:19:05 -04:00
commit 0d86059bc6
27 changed files with 523 additions and 386 deletions

View File

@ -1,9 +1,11 @@
import { last, sortBy, sum, sumBy } from 'lodash' import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate' import { calculatePayout } from './calculate'
import { Bet } from './bet' import { Bet, LimitBet } from './bet'
import { Contract } from './contract' import { Contract, CPMMContract, DPMContract } from './contract'
import { PortfolioMetrics, User } from './user' import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time' import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
const computeInvestmentValue = ( const computeInvestmentValue = (
bets: Bet[], bets: Bet[],
@ -40,6 +42,58 @@ export const computeInvestmentValueCustomProb = (
}) })
} }
export const computeElasticity = (
bets: Bet[],
contract: Contract,
betAmount = 50
) => {
const { mechanism, outcomeType } = contract
return mechanism === 'cpmm-1' &&
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC')
? computeBinaryCpmmElasticity(bets, contract, betAmount)
: computeDpmElasticity(contract, betAmount)
}
export const computeBinaryCpmmElasticity = (
bets: Bet[],
contract: CPMMContract,
betAmount: number
) => {
const limitBets = bets
.filter(
(b) =>
!b.isFilled && !b.isSold && !b.isRedemption && !b.sale && !b.isCancelled
)
.sort((a, b) => a.createdTime - b.createdTime)
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
'YES',
betAmount,
contract,
undefined,
limitBets as LimitBet[]
)
const resultYes = getCpmmProbability(poolY, pY)
const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo(
'NO',
betAmount,
contract,
undefined,
limitBets as LimitBet[]
)
const resultNo = getCpmmProbability(poolN, pN)
return resultYes - resultNo
}
export const computeDpmElasticity = (
contract: DPMContract,
betAmount: number
) => {
return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime

View File

@ -49,6 +49,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume: number volume: number
volume24Hours: number volume24Hours: number
volume7Days: number volume7Days: number
elasticity: number
collectedFees: Fees collectedFees: Fees

View File

@ -17,8 +17,7 @@ import {
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, DPMContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -325,7 +324,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract | MultipleChoiceContract contract: DPMContract
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -70,6 +70,7 @@ export function getNewContract(
volume: 0, volume: 0,
volume24Hours: 0, volume24Hours: 0,
volume7Days: 0, volume7Days: 0,
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
collectedFees: { collectedFees: {
creatorFee: 0, creatorFee: 0,

View File

@ -15,7 +15,7 @@ import {
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { isProd } from './utils' import { chargeUser, getContract, isProd } from './utils'
import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
@ -36,7 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash' import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { FieldValue, Transaction } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -107,242 +107,229 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
visibility = 'public', visibility = 'public',
} = validate(bodySchema, body) } = validate(bodySchema, body)
return await firestore.runTransaction(async (trans) => { let min, max, initialProb, isLogScale, answers
let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max) if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.') throw new APIError(400, 'Invalid range.')
initialProb = initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
getPseudoProbability(initialValue, min, max, isLogScale) * 100
if (initialProb < 1 || initialProb > 99) if (initialProb < 1 || initialProb > 99)
if (outcomeType === 'PSEUDO_NUMERIC') if (outcomeType === 'PSEUDO_NUMERIC')
throw new APIError(
400,
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
)
else throw new APIError(400, 'Invalid initial probability.')
}
if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, body))
}
if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, body))
}
const userDoc = await trans.get(firestore.collection('users').doc(auth.uid))
if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
const user = userDoc.data() as User
const ante = FIXED_ANTE
const deservesFreeMarket =
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
// TODO: this is broken because it's not in a transaction
if (ante > user.balance && !deservesFreeMarket)
throw new APIError(400, `Balance must be at least ${ante}.`)
let group: Group | null = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await trans.get(groupDocRef)
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
const groupMembersSnap = await trans.get(
firestore.collection(`groups/${groupId}/groupMembers`)
)
const groupMemberDocs = groupMembersSnap.docs.map(
(doc) => doc.data() as { userId: string; createdTime: number }
)
if (
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
throw new APIError( throw new APIError(
400, 400,
'User must be a member/creator of the group or group must be open to add markets to it.' `Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
) )
} else throw new APIError(400, 'Invalid initial probability.')
} }
const slug = await getSlug(trans, question)
const contractRef = firestore.collection('contracts').doc()
console.log( if (outcomeType === 'BINARY') {
'creating contract for', ;({ initialProb } = validate(binarySchema, body))
user.username, }
'on',
question,
'ante:',
ante || 0
)
// convert string descriptions into JSONContent if (outcomeType === 'MULTIPLE_CHOICE') {
const newDescription = ;({ answers } = validate(multipleChoiceSchema, body))
!description || typeof description === 'string' }
? {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: description || ' ' }],
},
],
}
: description
const contract = getNewContract( const userDoc = await firestore.collection('users').doc(auth.uid).get()
contractRef.id, if (!userDoc.exists) {
slug, throw new APIError(400, 'No user exists with the authenticated user ID.')
user, }
question, const user = userDoc.data() as User
outcomeType,
newDescription,
initialProb ?? 0,
ante,
closeTime.getTime(),
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0,
isLogScale ?? false,
answers ?? [],
visibility
)
const providerId = deservesFreeMarket const ante = FIXED_ANTE
? isProd() const deservesFreeMarket =
? HOUSE_LIQUIDITY_PROVIDER_ID (user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID // TODO: this is broken because it's not in a transaction
: user.id if (ante > user.balance && !deservesFreeMarket)
throw new APIError(400, `Balance must be at least ${ante}.`)
if (ante) { let group: Group | null = null
const delta = FieldValue.increment(-ante) if (groupId) {
const providerDoc = firestore.collection('users').doc(providerId) const groupDocRef = firestore.collection('groups').doc(groupId)
await trans.update(providerDoc, { balance: delta, totalDeposits: delta }) const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
} }
if (deservesFreeMarket) { group = groupDoc.data() as Group
await trans.update(firestore.collection('users').doc(user.id), { const groupMembersSnap = await firestore
freeMarketsCreated: FieldValue.increment(1), .collection(`groups/${groupId}/groupMembers`)
.get()
const groupMemberDocs = groupMembersSnap.docs.map(
(doc) => doc.data() as { userId: string; createdTime: number }
)
if (
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
throw new APIError(
400,
'User must be a member/creator of the group or group must be open to add markets to it.'
)
}
}
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
console.log(
'creating contract for',
user.username,
'on',
question,
'ante:',
ante || 0
)
// convert string descriptions into JSONContent
const newDescription =
!description || typeof description === 'string'
? {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: description || ' ' }],
},
],
}
: description
const contract = getNewContract(
contractRef.id,
slug,
user,
question,
outcomeType,
newDescription,
initialProb ?? 0,
ante,
closeTime.getTime(),
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0,
isLogScale ?? false,
answers ?? [],
visibility
)
const providerId = deservesFreeMarket
? isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
: user.id
if (ante) await chargeUser(providerId, ante, true)
if (deservesFreeMarket)
await firestore
.collection('users')
.doc(user.id)
.update({ freeMarketsCreated: FieldValue.increment(1) })
await contractRef.create(contract)
if (group != null) {
const groupContractsSnap = await firestore
.collection(`groups/${groupId}/groupContracts`)
.get()
const groupContracts = groupContractsSnap.docs.map(
(doc) => doc.data() as { contractId: string; createdTime: number }
)
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid)
const groupContractRef = firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
await groupContractRef.set({
contractId: contract.id,
createdTime: Date.now(),
}) })
} }
}
await contractRef.create(contract) if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
if (group != null) { const lp = getCpmmInitialLiquidity(
const groupContractsSnap = await trans.get( providerId,
firestore.collection(`groups/${groupId}/groupContracts`) contract as CPMMBinaryContract,
) liquidityDoc.id,
const groupContracts = groupContractsSnap.docs.map( ante
(doc) => doc.data() as { contractId: string; createdTime: number } )
await liquidityDoc.set(lp)
} else if (outcomeType === 'MULTIPLE_CHOICE') {
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
const betDocs = (answers ?? []).map(() => betCol.doc())
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
const answerDocs = (answers ?? []).map((_, i) =>
answerCol.doc(i.toString())
)
const { bets, answerObjects } = getMultipleChoiceAntes(
user,
contract as MultipleChoiceContract,
answers ?? [],
betDocs.map((bd) => bd.id)
)
await Promise.all(
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
)
await Promise.all(
zip(answerObjects, answerDocs).map(([answer, doc]) =>
doc?.create(answer as Answer)
) )
)
await contractRef.update({ answers: answerObjects })
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { const noneAnswer = getNoneAnswer(contract.id, user)
await createGroupLinks(trans, group, [contractRef.id], auth.uid) await noneAnswerDoc.set(noneAnswer)
const groupContractRef = firestore const anteBetDoc = firestore
.collection(`groups/${groupId}/groupContracts`) .collection(`contracts/${contract.id}/bets`)
.doc(contract.id) .doc()
await trans.set(groupContractRef, { const anteBet = getFreeAnswerAnte(
contractId: contract.id, providerId,
createdTime: Date.now(), contract as FreeResponseContract,
}) anteBetDoc.id
} )
} await anteBetDoc.set(anteBet)
} else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const anteBet = getNumericAnte(
const liquidityDoc = firestore providerId,
.collection(`contracts/${contract.id}/liquidity`) contract as NumericContract,
.doc() ante,
anteBetDoc.id
)
const lp = getCpmmInitialLiquidity( await anteBetDoc.set(anteBet)
providerId, }
contract as CPMMBinaryContract,
liquidityDoc.id,
ante
)
await trans.set(liquidityDoc, lp) return contract
} else if (outcomeType === 'MULTIPLE_CHOICE') {
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
const betDocs = (answers ?? []).map(() => betCol.doc())
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
const answerDocs = (answers ?? []).map((_, i) =>
answerCol.doc(i.toString())
)
const { bets, answerObjects } = getMultipleChoiceAntes(
user,
contract as MultipleChoiceContract,
answers ?? [],
betDocs.map((bd) => bd.id)
)
await Promise.all(
zip(bets, betDocs).map(([bet, doc]) =>
doc ? trans.create(doc, bet as Bet) : undefined
)
)
await Promise.all(
zip(answerObjects, answerDocs).map(([answer, doc]) =>
doc ? trans.create(doc, answer as Answer) : undefined
)
)
await trans.update(contractRef, { answers: answerObjects })
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
const noneAnswer = getNoneAnswer(contract.id, user)
await trans.set(noneAnswerDoc, noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(
providerId,
contract as FreeResponseContract,
anteBetDoc.id
)
await trans.set(anteBetDoc, anteBet)
} else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getNumericAnte(
providerId,
contract as NumericContract,
ante,
anteBetDoc.id
)
await trans.set(anteBetDoc, anteBet)
}
return contract
})
} }
const getSlug = async (trans: Transaction, question: string) => { const getSlug = async (question: string) => {
const proposedSlug = slugify(question) const proposedSlug = slugify(question)
const preexistingContract = await getContractFromSlug(trans, proposedSlug) const preexistingContract = await getContractFromSlug(proposedSlug)
return preexistingContract return preexistingContract
? proposedSlug + '-' + randomString() ? proposedSlug + '-' + randomString()
@ -351,42 +338,46 @@ const getSlug = async (trans: Transaction, question: string) => {
const firestore = admin.firestore() const firestore = admin.firestore()
async function getContractFromSlug(trans: Transaction, slug: string) { export async function getContractFromSlug(slug: string) {
const snap = await trans.get( const snap = await firestore
firestore.collection('contracts').where('slug', '==', slug) .collection('contracts')
) .where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Contract) return snap.empty ? undefined : (snap.docs[0].data() as Contract)
} }
async function createGroupLinks( async function createGroupLinks(
trans: Transaction,
group: Group, group: Group,
contractIds: string[], contractIds: string[],
userId: string userId: string
) { ) {
for (const contractId of contractIds) { for (const contractId of contractIds) {
const contractRef = firestore.collection('contracts').doc(contractId) const contract = await getContract(contractId)
const contract = (await trans.get(contractRef)).data() as Contract
if (!contract?.groupSlugs?.includes(group.slug)) { if (!contract?.groupSlugs?.includes(group.slug)) {
await trans.update(contractRef, { await firestore
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), .collection('contracts')
}) .doc(contractId)
.update({
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
})
} }
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
await trans.update(contractRef, { await firestore
groupLinks: [ .collection('contracts')
{ .doc(contractId)
groupId: group.id, .update({
name: group.name, groupLinks: [
slug: group.slug, {
userId, groupId: group.id,
createdTime: Date.now(), name: group.name,
} as GroupLink, slug: group.slug,
...(contract?.groupLinks ?? []), userId,
], createdTime: Date.now(),
}) } as GroupLink,
...(contract?.groupLinks ?? []),
],
})
} }
} }
} }

View File

@ -71,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => {
if (question) { if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3 const closeTime = Date.now() + DAY_MS * 30 * 3
const result = await createMarketHelper( try {
{ const result = await createMarketHelper(
question, {
closeTime, question,
outcomeType: 'BINARY', closeTime,
visibility: 'unlisted', outcomeType: 'BINARY',
initialProb: 50, visibility: 'unlisted',
// Dating group! initialProb: 50,
groupId: 'j3ZE8fkeqiKmRGumy3O1', // Dating group!
}, groupId: 'j3ZE8fkeqiKmRGumy3O1',
auth },
) auth
contractSlug = result.slug )
contractSlug = result.slug
} catch (e) {
console.error(e)
}
} }
const post: Post = removeUndefinedProps({ const post: Post = removeUndefinedProps({

View File

@ -3,7 +3,6 @@
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash' import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = { export type DocumentValue = {
doc: DocumentSnapshot doc: DocumentSnapshot
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
return { return {
doc: diff.dest.doc.ref, doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} as UpdateSpec }
} }
export function applyDiff(transaction: Transaction, diff: DocumentDiff) { export function applyDiff(transaction: Transaction, diff: DocumentDiff) {

View File

@ -14,6 +14,7 @@ import {
calculateNewPortfolioMetrics, calculateNewPortfolioMetrics,
calculateNewProfit, calculateNewProfit,
calculateProbChanges, calculateProbChanges,
computeElasticity,
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
@ -103,6 +104,7 @@ export async function updateMetricsCore() {
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
elasticity: computeElasticity(contractBets, contract),
...cpmmFields, ...cpmmFields,
}, },
} }

View File

@ -47,7 +47,7 @@ export const writeAsync = async (
const batch = db.batch() const batch = db.batch()
for (const { doc, fields } of chunks[i]) { for (const { doc, fields } of chunks[i]) {
if (operationType === 'update') { if (operationType === 'update') {
batch.update(doc, fields) batch.update(doc, fields as any)
} else { } else {
batch.set(doc, fields) batch.set(doc, fields)
} }

10
web/components/NoSEO.tsx Normal file
View File

@ -0,0 +1,10 @@
import Head from 'next/head'
/** Exclude page from search results */
export function NoSEO() {
return (
<Head>
<meta name="robots" content="noindex,follow" />
</Head>
)
}

View File

@ -157,11 +157,9 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{outcomeType === 'FREE_RESPONSE' && {outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
tradingAllowed(contract) && <CreateAnswerPanel contract={contract} />
(!resolveOption || resolveOption === 'CANCEL') && ( )}
<CreateAnswerPanel contract={contract} />
)}
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
!resolution && ( !resolution && (

View File

@ -48,6 +48,7 @@ export const SORTS = [
{ label: 'Daily trending', value: 'daily-score' }, { label: 'Daily trending', value: 'daily-score' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: 'Most popular', value: 'most-popular' }, { label: 'Most popular', value: 'most-popular' },
{ label: 'Liquidity', value: 'liquidity' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Closing soon', value: 'close-date' }, { label: 'Closing soon', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },

View File

@ -37,12 +37,12 @@ export function ProbChangeTable(props: {
return ( return (
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row"> <Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
<Col className="flex-1 gap-4"> <Col className="flex-1">
{filteredPositiveChanges.map((contract) => ( {filteredPositiveChanges.map((contract) => (
<ContractCardProbChange key={contract.id} contract={contract} /> <ContractCardProbChange key={contract.id} contract={contract} />
))} ))}
</Col> </Col>
<Col className="flex-1 gap-4"> <Col className="flex-1">
{filteredNegativeChanges.map((contract) => ( {filteredNegativeChanges.map((contract) => (
<ContractCardProbChange key={contract.id} contract={contract} /> <ContractCardProbChange key={contract.id} contract={contract} />
))} ))}

View File

@ -64,11 +64,11 @@ export function BetStatusText(props: {
}, [challengeSlug, contract.id]) }, [challengeSlug, contract.id])
const bought = amount >= 0 ? 'bought' : 'sold' const bought = amount >= 0 ? 'bought' : 'sold'
const money = formatMoney(Math.abs(amount))
const outOfTotalAmount = const outOfTotalAmount =
bet.limitProb !== undefined && bet.orderAmount !== undefined bet.limitProb !== undefined && bet.orderAmount !== undefined
? ` / ${formatMoney(bet.orderAmount)}` ? ` of ${bet.isCancelled ? money : formatMoney(bet.orderAmount)}`
: '' : ''
const money = formatMoney(Math.abs(amount))
const hadPoolMatch = const hadPoolMatch =
(bet.limitProb === undefined || (bet.limitProb === undefined ||
@ -105,7 +105,6 @@ export function BetStatusText(props: {
{!hideOutcome && ( {!hideOutcome && (
<> <>
{' '} {' '}
of{' '}
<OutcomeLabel <OutcomeLabel
outcome={outcome} outcome={outcome}
value={(bet as any).value} value={(bet as any).value}

View File

@ -145,8 +145,6 @@ function GroupOverviewPinned(props: {
}) { }) {
const { group, posts, isEditable } = props const { group, posts, isEditable } = props
const [pinned, setPinned] = useState<JSX.Element[]>([]) const [pinned, setPinned] = useState<JSX.Element[]>([])
const [open, setOpen] = useState(false)
const [editMode, setEditMode] = useState(false)
useEffect(() => { useEffect(() => {
async function getPinned() { async function getPinned() {
@ -185,100 +183,127 @@ function GroupOverviewPinned(props: {
...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]), ...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
], ],
}) })
setOpen(false) }
function onDeleteClicked(index: number) {
const newPinned = group.pinnedItems.filter((item) => {
return item.itemId !== group.pinnedItems[index].itemId
})
updateGroup(group, { pinnedItems: newPinned })
} }
return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? ( return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? (
pinned.length > 0 || isEditable ? ( <PinnedItems
<div> posts={posts}
<Row className="mb-3 items-center justify-between"> group={group}
<SectionHeader label={'Pinned'} /> isEditable={isEditable}
{isEditable && ( pinned={pinned}
<Button onDeleteClicked={onDeleteClicked}
color="gray" onSubmit={onSubmit}
size="xs" modalMessage={'Pin posts or markets to the overview of this group.'}
onClick={() => { />
setEditMode(!editMode) ) : (
}} <LoadingIndicator />
> )
{editMode ? ( }
'Done'
) : (
<>
<PencilIcon className="inline h-4 w-4" />
Edit
</>
)}
</Button>
)}
</Row>
<div>
<Masonry
breakpointCols={{ default: 2, 768: 1 }}
className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{pinned.length == 0 && !editMode && (
<div className="flex flex-col items-center justify-center">
<p className="text-center text-gray-400">
No pinned items yet. Click the edit button to add some!
</p>
</div>
)}
{pinned.map((element, index) => (
<div className="relative my-2">
{element}
{editMode && ( export function PinnedItems(props: {
<CrossIcon posts: Post[]
onClick={() => { isEditable: boolean
const newPinned = group.pinnedItems.filter((item) => { pinned: JSX.Element[]
return item.itemId !== group.pinnedItems[index].itemId onDeleteClicked: (index: number) => void
}) onSubmit: (selectedItems: { itemId: string; type: string }[]) => void
updateGroup(group, { pinnedItems: newPinned }) group?: Group
}} modalMessage: string
/> }) {
)} const {
</div> isEditable,
))} pinned,
{editMode && group.pinnedItems && pinned.length < 6 && ( onDeleteClicked,
<div className=" py-2"> onSubmit,
<Row posts,
className={ group,
'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100' modalMessage,
} } = props
> const [editMode, setEditMode] = useState(false)
<button const [open, setOpen] = useState(false)
className="flex w-full justify-center"
onClick={() => setOpen(true)} return pinned.length > 0 || isEditable ? (
> <div>
<PlusCircleIcon <Row className="mb-3 items-center justify-between">
className="h-12 w-12 text-gray-600" <SectionHeader label={'Pinned'} />
aria-hidden="true" {isEditable && (
/> <Button
</button> color="gray"
</Row> size="xs"
</div> onClick={() => {
setEditMode(!editMode)
}}
>
{editMode ? (
'Done'
) : (
<>
<PencilIcon className="inline h-4 w-4" />
Edit
</>
)} )}
</Masonry> </Button>
</div> )}
<PinnedSelectModal </Row>
open={open} <div>
group={group} <Masonry
posts={posts} breakpointCols={{ default: 2, 768: 1 }}
setOpen={setOpen} className="-ml-4 flex w-auto"
title="Pin a post or market" columnClassName="pl-4 bg-clip-padding"
description={ >
<div className={'text-md my-4 text-gray-600'}> {pinned.length == 0 && !editMode && (
Pin posts or markets to the overview of this group. <div className="flex flex-col items-center justify-center">
<p className="text-center text-gray-400">
No pinned items yet. Click the edit button to add some!
</p>
</div> </div>
} )}
onSubmit={onSubmit} {pinned.map((element, index) => (
/> <div className="relative my-2">
{element}
{editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />}
</div>
))}
{editMode && pinned.length < 6 && (
<div className=" py-2">
<Row
className={
'relative gap-3 rounded-lg border-4 border-dotted p-2 hover:cursor-pointer hover:bg-gray-100'
}
>
<button
className="flex w-full justify-center"
onClick={() => setOpen(true)}
>
<PlusCircleIcon
className="h-12 w-12 text-gray-600"
aria-hidden="true"
/>
</button>
</Row>
</div>
)}
</Masonry>
</div> </div>
) : ( <PinnedSelectModal
<LoadingIndicator /> open={open}
) group={group}
posts={posts}
setOpen={setOpen}
title="Pin a post or market"
description={
<div className={'text-md my-4 text-gray-600'}>{modalMessage}</div>
}
onSubmit={onSubmit}
/>
</div>
) : ( ) : (
<></> <></>
) )

View File

@ -22,6 +22,7 @@ export function ManifoldLogo(props: {
src={darkBackground ? '/logo-white.svg' : '/logo.svg'} src={darkBackground ? '/logo-white.svg' : '/logo.svg'}
width={45} width={45}
height={45} height={45}
alt=""
/> />
{!hideText && {!hideText &&

View File

@ -11,13 +11,13 @@ function SidebarButton(props: {
}) { }) {
const { text, children } = props const { text, children } = props
return ( return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"> <button className="group flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon <props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500" className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true" aria-hidden="true"
/> />
<span className="truncate">{text}</span> <span className="truncate">{text}</span>
{children} {children}
</a> </button>
) )
} }

View File

@ -20,8 +20,8 @@ export function PinnedSelectModal(props: {
selectedItems: { itemId: string; type: string }[] selectedItems: { itemId: string; type: string }[]
) => void | Promise<void> ) => void | Promise<void>
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]> contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
group: Group
posts: Post[] posts: Post[]
group?: Group
}) { }) {
const { const {
title, title,
@ -134,8 +134,8 @@ export function PinnedSelectModal(props: {
highlightClassName: highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!bg-indigo-100 outline outline-2 outline-indigo-300',
}} }}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={group ? { groupSlug: group.slug } : undefined}
persistPrefix={`group-${group.slug}`} persistPrefix={group ? `group-${group.slug}` : undefined}
headerClassName="bg-white sticky" headerClassName="bg-white sticky"
{...contractSearchOptions} {...contractSearchOptions}
/> />
@ -152,7 +152,7 @@ export function PinnedSelectModal(props: {
'!bg-indigo-100 outline outline-2 outline-indigo-300', '!bg-indigo-100 outline outline-2 outline-indigo-300',
}} }}
/> />
{posts.length === 0 && ( {posts.length == 0 && (
<div className="text-center text-gray-500">No posts yet</div> <div className="text-center text-gray-500">No posts yet</div>
)} )}
</div> </div>

View File

@ -34,6 +34,17 @@ export function UserLink(props: {
)} )}
> >
{shortName} {shortName}
{BOT_USERNAMES.includes(username) && <BotBadge />}
</SiteLink> </SiteLink>
) )
} }
const BOT_USERNAMES = ['v', 'ArbitrageBot']
function BotBadge() {
return (
<span className="ml-1.5 inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
Bot
</span>
)
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Post } from 'common/post' import { DateDoc, Post } from 'common/post'
import { listenForPost } from 'web/lib/firebase/posts' import { listenForDateDocs, listenForPost } from 'web/lib/firebase/posts'
export const usePost = (postId: string | undefined) => { export const usePost = (postId: string | undefined) => {
const [post, setPost] = useState<Post | null | undefined>() const [post, setPost] = useState<Post | null | undefined>()
@ -37,3 +37,13 @@ export const usePosts = (postIds: string[]) => {
) )
.sort((a, b) => b.createdTime - a.createdTime) .sort((a, b) => b.createdTime - a.createdTime)
} }
export const useDateDocs = () => {
const [dateDocs, setDateDocs] = useState<DateDoc[]>()
useEffect(() => {
return listenForDateDocs(setDateDocs)
}, [])
return dateDocs
}

View File

@ -7,7 +7,13 @@ import {
where, where,
} from 'firebase/firestore' } from 'firebase/firestore'
import { DateDoc, Post } from 'common/post' import { DateDoc, Post } from 'common/post'
import { coll, getValue, getValues, listenForValue } from './utils' import {
coll,
getValue,
getValues,
listenForValue,
listenForValues,
} from './utils'
import { getUserByUsername } from './users' import { getUserByUsername } from './users'
export const posts = coll<Post>('posts') export const posts = coll<Post>('posts')
@ -51,6 +57,11 @@ export async function getDateDocs() {
return getValues<DateDoc>(q) return getValues<DateDoc>(q)
} }
export function listenForDateDocs(setDateDocs: (dateDocs: DateDoc[]) => void) {
const q = query(posts, where('type', '==', 'date-doc'))
return listenForValues<DateDoc>(q, setDateDocs)
}
export async function getDateDoc(username: string) { export async function getDateDoc(username: string) {
const user = await getUserByUsername(username) const user = await getUserByUsername(username)
if (!user) return null if (!user) return null

View File

@ -5,7 +5,7 @@
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { PROD_CONFIG } from 'common/envs/prod' import { PROD_CONFIG } from 'common/envs/prod'
if (ENV_CONFIG.domain === PROD_CONFIG.domain) { if (ENV_CONFIG.domain === PROD_CONFIG.domain && typeof window !== 'undefined') {
try { try {
;(function (l, e, a, p) { ;(function (l, e, a, p) {
if (window.Sprig) return if (window.Sprig) return
@ -20,7 +20,8 @@ if (ENV_CONFIG.domain === PROD_CONFIG.domain) {
a.async = 1 a.async = 1
a.src = e + '?id=' + S.appId a.src = e + '?id=' + S.appId
p = l.getElementsByTagName('script')[0] p = l.getElementsByTagName('script')[0]
p.parentNode.insertBefore(a, p) ENV_CONFIG.domain === PROD_CONFIG.domain &&
p.parentNode.insertBefore(a, p)
})(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId) })(document, 'https://cdn.sprig.com/shim.js', ENV_CONFIG.sprigEnvironmentId)
} catch (error) { } catch (error) {
console.log('Error initializing Sprig, please complain to Barak', error) console.log('Error initializing Sprig, please complain to Barak', error)

View File

@ -74,10 +74,7 @@ function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) {
content="https://manifold.markets/logo-bg-white.png" content="https://manifold.markets/logo-bg-white.png"
key="image2" key="image2"
/> />
<meta <meta name="viewport" content="width=device-width, initial-scale=1" />
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
</Head> </Head>
<AuthProvider serverUser={pageProps.auth}> <AuthProvider serverUser={pageProps.auth}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@ -3,7 +3,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
export default function Document() { export default function Document() {
return ( return (
<Html data-theme="mantic" className="min-h-screen"> <Html lang="en" data-theme="mantic" className="min-h-screen">
<Head> <Head>
<link rel="icon" href={ENV_CONFIG.faviconPath} /> <link rel="icon" href={ENV_CONFIG.faviconPath} />
<link <link

View File

@ -22,6 +22,7 @@ import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]'
import { usePost } from 'web/hooks/use-post' import { usePost } from 'web/hooks/use-post'
import { useTipTxns } from 'web/hooks/use-tip-txns' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useCommentsOnPost } from 'web/hooks/use-comments' import { useCommentsOnPost } from 'web/hooks/use-comments'
import { NoSEO } from 'web/components/NoSEO'
export async function getStaticProps(props: { params: { username: string } }) { export async function getStaticProps(props: { params: { username: string } }) {
const { username } = props.params const { username } = props.params
@ -62,6 +63,7 @@ function DateDocPage(props: { creator: User; post: DateDoc }) {
return ( return (
<Page> <Page>
<NoSEO />
<Col className="mx-auto w-full max-w-xl gap-6 sm:mb-6"> <Col className="mx-auto w-full max-w-xl gap-6 sm:mb-6">
<SiteLink href="/date-docs"> <SiteLink href="/date-docs">
<Row className="items-center gap-2"> <Row className="items-center gap-2">
@ -140,15 +142,17 @@ export function DateDocPost(props: {
) : ( ) : (
<Content content={content} /> <Content content={content} />
)} )}
<div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3"> {contractSlug && (
<iframe <div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3">
height="405" <iframe
src={marketUrl} height="405"
title="" src={marketUrl}
frameBorder="0" title=""
className="w-full rounded-xl bg-white p-10" frameBorder="0"
></iframe> className="w-full rounded-xl bg-white p-10"
</div> ></iframe>
</div>
)}
</Col> </Col>
) )
} }

View File

@ -14,6 +14,9 @@ import dayjs from 'dayjs'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS } from 'common/util/time'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { MAX_QUESTION_LENGTH } from 'common/contract' import { MAX_QUESTION_LENGTH } from 'common/contract'
import { NoSEO } from 'web/components/NoSEO'
import ShortToggle from 'web/components/widgets/short-toggle'
import { removeUndefinedProps } from 'common/util/object'
export default function CreateDateDocPage() { export default function CreateDateDocPage() {
const user = useUser() const user = useUser()
@ -25,6 +28,7 @@ export default function CreateDateDocPage() {
const title = `${user?.name}'s Date Doc` const title = `${user?.name}'s Date Doc`
const subtitle = 'Manifold dating docs' const subtitle = 'Manifold dating docs'
const [birthday, setBirthday] = useState<undefined | string>(undefined) const [birthday, setBirthday] = useState<undefined | string>(undefined)
const [createMarket, setCreateMarket] = useState(true)
const [question, setQuestion] = useState( const [question, setQuestion] = useState(
'Will I find a partner in the next 3 months?' 'Will I find a partner in the next 3 months?'
) )
@ -37,7 +41,11 @@ export default function CreateDateDocPage() {
const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined const birthdayTime = birthday ? dayjs(birthday).valueOf() : undefined
const isValid = const isValid =
user && birthday && editor && editor.isEmpty === false && question user &&
birthday &&
editor &&
editor.isEmpty === false &&
(question || !createMarket)
async function saveDateDoc() { async function saveDateDoc() {
if (!user || !editor || !birthdayTime) return if (!user || !editor || !birthdayTime) return
@ -45,15 +53,15 @@ export default function CreateDateDocPage() {
const newPost: Omit< const newPost: Omit<
DateDoc, DateDoc,
'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug' 'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug'
> & { question: string } = { > & { question?: string } = removeUndefinedProps({
title, title,
subtitle, subtitle,
content: editor.getJSON(), content: editor.getJSON(),
bounty: 0, bounty: 0,
birthday: birthdayTime, birthday: birthdayTime,
type: 'date-doc', type: 'date-doc',
question, question: createMarket ? question : undefined,
} })
const result = await createPost(newPost) const result = await createPost(newPost)
@ -64,6 +72,7 @@ export default function CreateDateDocPage() {
return ( return (
<Page> <Page>
<NoSEO />
<div className="mx-auto w-full max-w-3xl"> <div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 pb-4 sm:py-0"> <div className="rounded-lg px-6 py-4 pb-4 sm:py-0">
<Row className="mb-8 items-center justify-between"> <Row className="mb-8 items-center justify-between">
@ -104,9 +113,13 @@ export default function CreateDateDocPage() {
</Col> </Col>
<Col className="gap-4"> <Col className="gap-4">
<div className=""> <Row className="items-center gap-4">
Finally, we'll create an (unlisted) prediction market! <ShortToggle
</div> on={createMarket}
setOn={(on) => setCreateMarket(on)}
/>
Create an (unlisted) prediction market attached to the date doc
</Row>
<Col className="gap-2"> <Col className="gap-2">
<Textarea <Textarea
@ -114,6 +127,7 @@ export default function CreateDateDocPage() {
maxLength={MAX_QUESTION_LENGTH} maxLength={MAX_QUESTION_LENGTH}
value={question} value={question}
onChange={(e) => setQuestion(e.target.value || '')} onChange={(e) => setQuestion(e.target.value || '')}
disabled={!createMarket}
/> />
<div className="ml-2 text-gray-500">Cost: M$100</div> <div className="ml-2 text-gray-500">Cost: M$100</div>
</Col> </Col>

View File

@ -12,6 +12,8 @@ import { Button } from 'web/components/button'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { getUser, User } from 'web/lib/firebase/users' import { getUser, User } from 'web/lib/firebase/users'
import { DateDocPost } from './[username]' import { DateDocPost } from './[username]'
import { NoSEO } from 'web/components/NoSEO'
import { useDateDocs } from 'web/hooks/use-post'
export async function getStaticProps() { export async function getStaticProps() {
const dateDocs = await getDateDocs() const dateDocs = await getDateDocs()
@ -33,13 +35,15 @@ export default function DatePage(props: {
dateDocs: DateDoc[] dateDocs: DateDoc[]
docCreators: User[] docCreators: User[]
}) { }) {
const { dateDocs, docCreators } = props const { docCreators } = props
const user = useUser() const user = useUser()
const dateDocs = useDateDocs() ?? props.dateDocs
const hasDoc = dateDocs.some((d) => d.creatorId === user?.id) const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
return ( return (
<Page> <Page>
<NoSEO />
<div className="mx-auto w-full max-w-xl"> <div className="mx-auto w-full max-w-xl">
<Row className="items-center justify-between p-4 sm:p-0"> <Row className="items-center justify-between p-4 sm:p-0">
<Title className="!my-0 px-2 text-blue-500" text="Date docs" /> <Title className="!my-0 px-2 text-blue-500" text="Date docs" />