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,7 +107,6 @@ 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') {
@ -116,8 +115,7 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
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')
@ -136,7 +134,7 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
;({ answers } = validate(multipleChoiceSchema, body)) ;({ answers } = validate(multipleChoiceSchema, body))
} }
const userDoc = await trans.get(firestore.collection('users').doc(auth.uid)) const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) { if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.') throw new APIError(400, 'No user exists with the authenticated user ID.')
} }
@ -152,15 +150,15 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
let group: Group | null = null let group: Group | null = null
if (groupId) { if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId) const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await trans.get(groupDocRef) const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) { if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.') throw new APIError(400, 'No group exists with the given group ID.')
} }
group = groupDoc.data() as Group group = groupDoc.data() as Group
const groupMembersSnap = await trans.get( const groupMembersSnap = await firestore
firestore.collection(`groups/${groupId}/groupMembers`) .collection(`groups/${groupId}/groupMembers`)
) .get()
const groupMemberDocs = groupMembersSnap.docs.map( const groupMemberDocs = groupMembersSnap.docs.map(
(doc) => doc.data() as { userId: string; createdTime: number } (doc) => doc.data() as { userId: string; createdTime: number }
) )
@ -175,7 +173,7 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
) )
} }
} }
const slug = await getSlug(trans, question) const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc() const contractRef = firestore.collection('contracts').doc()
console.log( console.log(
@ -226,36 +224,28 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID : DEV_HOUSE_LIQUIDITY_PROVIDER_ID
: user.id : user.id
if (ante) { if (ante) await chargeUser(providerId, ante, true)
const delta = FieldValue.increment(-ante) if (deservesFreeMarket)
const providerDoc = firestore.collection('users').doc(providerId) await firestore
await trans.update(providerDoc, { balance: delta, totalDeposits: delta }) .collection('users')
} .doc(user.id)
.update({ freeMarketsCreated: FieldValue.increment(1) })
if (deservesFreeMarket) {
await trans.update(firestore.collection('users').doc(user.id), {
freeMarketsCreated: FieldValue.increment(1),
})
}
await contractRef.create(contract) await contractRef.create(contract)
if (group != null) { if (group != null) {
const groupContractsSnap = await trans.get( const groupContractsSnap = await firestore
firestore.collection(`groups/${groupId}/groupContracts`) .collection(`groups/${groupId}/groupContracts`)
) .get()
const groupContracts = groupContractsSnap.docs.map( const groupContracts = groupContractsSnap.docs.map(
(doc) => doc.data() as { contractId: string; createdTime: number } (doc) => doc.data() as { contractId: string; createdTime: number }
) )
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
await createGroupLinks(trans, group, [contractRef.id], auth.uid) await createGroupLinks(group, [contractRef.id], auth.uid)
const groupContractRef = firestore const groupContractRef = firestore
.collection(`groups/${groupId}/groupContracts`) .collection(`groups/${groupId}/groupContracts`)
.doc(contract.id) .doc(contract.id)
await groupContractRef.set({
await trans.set(groupContractRef, {
contractId: contract.id, contractId: contract.id,
createdTime: Date.now(), createdTime: Date.now(),
}) })
@ -274,7 +264,7 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
ante ante
) )
await trans.set(liquidityDoc, lp) await liquidityDoc.set(lp)
} else if (outcomeType === 'MULTIPLE_CHOICE') { } else if (outcomeType === 'MULTIPLE_CHOICE') {
const betCol = firestore.collection(`contracts/${contract.id}/bets`) const betCol = firestore.collection(`contracts/${contract.id}/bets`)
const betDocs = (answers ?? []).map(() => betCol.doc()) const betDocs = (answers ?? []).map(() => betCol.doc())
@ -292,23 +282,21 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
) )
await Promise.all( await Promise.all(
zip(bets, betDocs).map(([bet, doc]) => zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
doc ? trans.create(doc, bet as Bet) : undefined
)
) )
await Promise.all( await Promise.all(
zip(answerObjects, answerDocs).map(([answer, doc]) => zip(answerObjects, answerDocs).map(([answer, doc]) =>
doc ? trans.create(doc, answer as Answer) : undefined doc?.create(answer as Answer)
) )
) )
await trans.update(contractRef, { answers: answerObjects }) await contractRef.update({ answers: answerObjects })
} else if (outcomeType === 'FREE_RESPONSE') { } else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`) .collection(`contracts/${contract.id}/answers`)
.doc('0') .doc('0')
const noneAnswer = getNoneAnswer(contract.id, user) const noneAnswer = getNoneAnswer(contract.id, user)
await trans.set(noneAnswerDoc, noneAnswer) await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
@ -319,7 +307,7 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
contract as FreeResponseContract, contract as FreeResponseContract,
anteBetDoc.id anteBetDoc.id
) )
await trans.set(anteBetDoc, anteBet) await anteBetDoc.set(anteBet)
} else if (outcomeType === 'NUMERIC') { } else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
@ -332,17 +320,16 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
anteBetDoc.id anteBetDoc.id
) )
await trans.set(anteBetDoc, anteBet) await anteBetDoc.set(anteBet)
} }
return contract 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,31 +338,35 @@ 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
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), 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
.collection('contracts')
.doc(contractId)
.update({
groupLinks: [ groupLinks: [
{ {
groupId: group.id, groupId: group.id,

View File

@ -71,6 +71,7 @@ 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
try {
const result = await createMarketHelper( const result = await createMarketHelper(
{ {
question, question,
@ -84,6 +85,9 @@ export const createpost = newEndpoint({}, async (req, auth) => {
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,9 +157,7 @@ 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) &&
(!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
)} )}

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,11 +183,52 @@ 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
posts={posts}
group={group}
isEditable={isEditable}
pinned={pinned}
onDeleteClicked={onDeleteClicked}
onSubmit={onSubmit}
modalMessage={'Pin posts or markets to the overview of this group.'}
/>
) : (
<LoadingIndicator />
)
}
export function PinnedItems(props: {
posts: Post[]
isEditable: boolean
pinned: JSX.Element[]
onDeleteClicked: (index: number) => void
onSubmit: (selectedItems: { itemId: string; type: string }[]) => void
group?: Group
modalMessage: string
}) {
const {
isEditable,
pinned,
onDeleteClicked,
onSubmit,
posts,
group,
modalMessage,
} = props
const [editMode, setEditMode] = useState(false)
const [open, setOpen] = useState(false)
return pinned.length > 0 || isEditable ? (
<div> <div>
<Row className="mb-3 items-center justify-between"> <Row className="mb-3 items-center justify-between">
<SectionHeader label={'Pinned'} /> <SectionHeader label={'Pinned'} />
@ -229,19 +268,10 @@ function GroupOverviewPinned(props: {
<div className="relative my-2"> <div className="relative my-2">
{element} {element}
{editMode && ( {editMode && <CrossIcon onClick={() => onDeleteClicked(index)} />}
<CrossIcon
onClick={() => {
const newPinned = group.pinnedItems.filter((item) => {
return item.itemId !== group.pinnedItems[index].itemId
})
updateGroup(group, { pinnedItems: newPinned })
}}
/>
)}
</div> </div>
))} ))}
{editMode && group.pinnedItems && pinned.length < 6 && ( {editMode && pinned.length < 6 && (
<div className=" py-2"> <div className=" py-2">
<Row <Row
className={ className={
@ -269,16 +299,11 @@ function GroupOverviewPinned(props: {
setOpen={setOpen} setOpen={setOpen}
title="Pin a post or market" title="Pin a post or market"
description={ description={
<div className={'text-md my-4 text-gray-600'}> <div className={'text-md my-4 text-gray-600'}>{modalMessage}</div>
Pin posts or markets to the overview of this group.
</div>
} }
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</div> </div>
) : (
<LoadingIndicator />
)
) : ( ) : (
<></> <></>
) )

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,6 +20,7 @@ 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]
ENV_CONFIG.domain === PROD_CONFIG.domain &&
p.parentNode.insertBefore(a, p) 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) {

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,6 +142,7 @@ export function DateDocPost(props: {
) : ( ) : (
<Content content={content} /> <Content content={content} />
)} )}
{contractSlug && (
<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"> <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">
<iframe <iframe
height="405" height="405"
@ -149,6 +152,7 @@ export function DateDocPost(props: {
className="w-full rounded-xl bg-white p-10" className="w-full rounded-xl bg-white p-10"
></iframe> ></iframe>
</div> </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" />