Merge branch 'main' into badges

This commit is contained in:
Ian Philips 2022-10-10 08:19:14 -06:00 committed by GitHub
commit 66d494e2af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 1293 additions and 754 deletions

View File

@ -0,0 +1,17 @@
name: Merge main into main2 on every commit
on:
push:
branches:
- 'main'
jobs:
merge-branch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Merge main -> main2
uses: devmasx/merge-branch@master
with:
type: now
target_branch: main2
github_token: ${{ github.token }}

View File

@ -147,7 +147,8 @@ function calculateAmountToBuyShares(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
// Search for amount between bounds (0, shares). // Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each. // Min share price is M$0, and max is M$1 each.
@ -157,7 +158,8 @@ function calculateAmountToBuyShares(
amount, amount,
state, state,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
const totalShares = sumBy(takers, (taker) => taker.shares) const totalShares = sumBy(takers, (taker) => taker.shares)
@ -169,7 +171,8 @@ export function calculateCpmmSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
if (Math.round(shares) < 0) { if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares') throw new Error('Cannot sell non-positive shares')
@ -180,15 +183,17 @@ export function calculateCpmmSale(
state, state,
shares, shares,
oppositeOutcome, oppositeOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
const { cpmmState, makers, takers, totalFees } = computeFills( const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
oppositeOutcome, oppositeOutcome,
buyAmount, buyAmount,
state, state,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
// Transform buys of opposite outcome into sells. // Transform buys of opposite outcome into sells.
@ -211,6 +216,7 @@ export function calculateCpmmSale(
fees: totalFees, fees: totalFees,
makers, makers,
takers: saleTakers, takers: saleTakers,
ordersToCancel,
} }
} }
@ -218,9 +224,16 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) const { cpmmState } = calculateCpmmSale(
state,
shares,
outcome,
unfilledBets,
balanceByUserId
)
return getCpmmProbability(cpmmState.pool, cpmmState.p) return getCpmmProbability(cpmmState.pool, cpmmState.p)
} }

View File

@ -1,9 +1,11 @@
import { last, sortBy, sum, sumBy } from 'lodash' import { last, sortBy, sum, sumBy, uniq } 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,75 @@ 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 &&
b.limitProb !== undefined
)
.sort((a, b) => a.createdTime - b.createdTime) as LimitBet[]
const userIds = uniq(limitBets.map((b) => b.userId))
// Assume all limit orders are good.
const userBalances = Object.fromEntries(
userIds.map((id) => [id, Number.MAX_SAFE_INTEGER])
)
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
'YES',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultYes = getCpmmProbability(poolY, pY)
const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo(
'NO',
betAmount,
contract,
undefined,
limitBets,
userBalances
)
const resultNo = getCpmmProbability(poolN, pN)
// handle AMM overflow
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
return safeYes - safeNo
}
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

@ -78,7 +78,8 @@ export function calculateShares(
export function calculateSaleAmount( export function calculateSaleAmount(
contract: Contract, contract: Contract,
bet: Bet, bet: Bet,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' && return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' || (contract.outcomeType === 'BINARY' ||
@ -87,7 +88,8 @@ export function calculateSaleAmount(
contract, contract,
Math.abs(bet.shares), Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO', bet.outcome as 'YES' | 'NO',
unfilledBets unfilledBets,
balanceByUserId
).saleValue ).saleValue
: calculateDpmSaleAmount(contract, bet) : calculateDpmSaleAmount(contract, bet)
} }
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
contract: Contract, contract: Contract,
outcome: string, outcome: string,
shares: number, shares: number,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale( ? getCpmmProbabilityAfterSale(
contract, contract,
shares, shares,
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
unfilledBets unfilledBets,
balanceByUserId
) )
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
} }

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

@ -1,3 +1,5 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0 export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0 export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0 export const LIQUIDITY_FEE = 0

View File

@ -17,8 +17,7 @@ import {
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, DPMContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -144,7 +143,8 @@ export const computeFills = (
betAmount: number, betAmount: number,
state: CpmmState, state: CpmmState,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
if (isNaN(betAmount)) { if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}') throw new Error('Invalid bet amount: ${betAmount}')
@ -166,10 +166,12 @@ export const computeFills = (
shares: number shares: number
timestamp: number timestamp: number
}[] = [] }[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p } let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0 let i = 0
while (true) { while (true) {
@ -186,9 +188,20 @@ export const computeFills = (
takers.push(taker) takers.push(taker)
} else { } else {
// Matched against bet. // Matched against bet.
i++
const { userId } = maker.bet
const makerBalance = currentBalanceByUserId[userId]
if (floatingGreaterEqual(makerBalance, maker.amount)) {
currentBalanceByUserId[userId] = makerBalance - maker.amount
} else {
// Insufficient balance. Cancel maker bet.
ordersToCancel.push(maker.bet)
continue
}
takers.push(taker) takers.push(taker)
makers.push(maker) makers.push(maker)
i++
} }
amount -= taker.amount amount -= taker.amount
@ -196,7 +209,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break if (floatingEqual(amount, 0)) break
} }
return { takers, makers, totalFees, cpmmState } return { takers, makers, totalFees, cpmmState, ordersToCancel }
} }
export const getBinaryCpmmBetInfo = ( export const getBinaryCpmmBetInfo = (
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills( const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
outcome, outcome,
betAmount, betAmount,
{ pool, p }, { pool, p },
limitProb, limitProb,
unfilledBets unfilledBets,
balanceByUserId
) )
const probBefore = getCpmmProbability(contract.pool, contract.p) const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p, newP: cpmmState.p,
newTotalLiquidity, newTotalLiquidity,
makers, makers,
ordersToCancel,
} }
} }
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number, limitProb: number,
unfilledBets: LimitBet[] unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { newBet } = getBinaryCpmmBetInfo( const { newBet } = getBinaryCpmmBetInfo(
outcome, outcome,
betAmount ?? 0, betAmount ?? 0,
contract, contract,
limitProb, limitProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const remainingMatched = const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) / ((newBet.orderAmount ?? 0) - newBet.amount) /
@ -325,7 +343,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

@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
contract: CPMMContract, contract: CPMMContract,
unfilledBets: LimitBet[], unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number loanPaid: number
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
contract, contract,
shares, shares,
outcome, outcome,
unfilledBets unfilledBets,
balanceByUserId,
) )
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
fees, fees,
makers, makers,
takers, takers,
ordersToCancel
} }
} }

View File

@ -179,31 +179,44 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference reason: notification_reason_types | notification_preference
) => { ) => {
const notificationSettings = privateUser.notificationPreferences const notificationSettings = privateUser.notificationPreferences
let destinations
let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) {
subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe') const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return { try {
sendToEmail: destinations.includes('email') && !optedOutOfEmail, let destinations
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, let subscriptionType: notification_preference | undefined
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, if (Object.keys(notificationSettings).includes(reason)) {
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`, subscriptionType = reason as notification_preference
destinations = notificationSettings[subscriptionType]
} else {
const key = reason as notification_reason_types
subscriptionType = notificationReasonToSubscriptionType[key]
destinations = subscriptionType
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}
} catch (e) {
// Fail safely
console.log(
`couldn't get notification destinations for type ${reason} for user ${privateUser.id}`
)
return {
sendToEmail: false,
sendToBrowser: false,
unsubscribeUrl: '',
urlToManageThisNotification: '',
}
} }
} }

View File

@ -73,6 +73,7 @@ export const exhibitExts = [
Image, Image,
Link, Link,
Mention, Mention,
Mention.extend({ name: 'contract-mention' }),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler, TiptapSpoiler,

View File

@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties ## Awarded bounties
💥 *Awarded on 2022-10-07*
**[Pepe](https://manifold.markets/Pepe): M$10,000**
**[Jack](https://manifold.markets/jack): M$2,000**
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
**[Yev](https://manifold.markets/Yev): M$2,000**
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
**[Matt](https://manifold.markets/MattP): M$5,000**
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
**[Yev](https://manifold.markets/Yev): M$5,000**
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
🎈 *Awarded on 2022-06-14* 🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
}, },
} as EndpointDefinition } as EndpointDefinition
} }
export const newEndpointNoAuth = (
endpointOpts: EndpointOptions,
fn: (req: Request) => Promise<Output>
) => {
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return {
opts,
handler: async (req: Request, res: Response) => {
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
try {
if (opts.method !== req.method) {
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
}
res.status(200).json(await fn(req))
} catch (e) {
writeResponseError(e, res)
}
},
} as EndpointDefinition
}

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

@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { contractUrl, getUser } from './utils' import { contractUrl, getUser, log } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'onboarding_flow' 'onboarding_flow'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
sendTime: string sendTime: string
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'onboarding_flow' 'onboarding_flow'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Create your own prediction market', 'Create your own prediction market',
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'thank_you_for_purchases' 'thank_you_for_purchases'
) )
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Thanks for your Manifold purchase', 'Thanks for your Manifold purchase',
@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[], contractsToSend: Contract[],
deliveryTime?: string deliveryTime?: string
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.trending_markets.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'trending_markets' 'trending_markets'
) )
if (!sendToEmail) return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
investments: PerContractInvestmentsData[], investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData overallPerformance: OverallPerformanceData
) => { ) => {
if ( if (!privateUser || !privateUser.email) return
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
)
return
const { unsubscribeUrl } = getNotificationDestinationsForUser( const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, privateUser,
'profit_loss_updates' 'profit_loss_updates'
) )
if (!sendToEmail) return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const templateData: Record<string, string> = { const templateData: Record<string, string> = {
@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
: 'portfolio-update', : 'portfolio-update',
templateData templateData
) )
log('Sent portfolio update email to', privateUser.email)
} }

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet' export * from './on-create-bet'
export * from './on-create-comment-on-contract' export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export * from './update-metrics' export { scheduleUpdateMetrics } from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans' export * from './update-loans'
export * from './backup-db' export * from './backup-db'
@ -77,6 +77,7 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost) const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export { export {
healthFunction as health, healthFunction as health,
@ -133,4 +135,5 @@ export {
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
addCommentBounty as addcommentbounty, addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty, awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
} }

View File

@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification' import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy' import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) { if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId) await handleUserUpdatedReferral(user, eventId)
} }
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
}) })
async function handleUserUpdatedReferral(user: User, eventId: string) { async function handleUserUpdatedReferral(user: User, eventId: string) {
@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
) )
}) })
} }
async function cancelLimitOrders(userId: string) {
const snapshot = (await firestore
.collectionGroup('bets')
.where('userId', '==', userId)
.where('isFilled', '==', false)
.get()) as QuerySnapshot<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import { import {
BetInfo, BetInfo,
getBinaryCpmmBetInfo, getBinaryCpmmBetInfo,
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity, newTotalLiquidity,
newP, newP,
makers, makers,
ordersToCancel,
} = await (async (): Promise< } = await (async (): Promise<
BetInfo & { BetInfo & {
makers?: maker[] makers?: maker[]
ordersToCancel?: LimitBet[]
} }
> => { > => {
if ( if (
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100 limitProb = Math.round(limitProb * 100) / 100
} }
const unfilledBetsSnap = await trans.get( const { unfilledBets, balanceByUserId } =
getUnfilledBetsQuery(contractDoc) await getUnfilledBetsAndUserBalances(trans, contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
return getBinaryCpmmBetInfo( return getBinaryCpmmBetInfo(
outcome, outcome,
amount, amount,
contract, contract,
limitProb, limitProb,
unfilledBets unfilledBets,
balanceByUserId
) )
} else if ( } else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) { if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans) updateMakers(makers, betDoc.id, contractDoc, trans)
} }
if (ordersToCancel) {
for (const bet of ordersToCancel) {
trans.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
}
const balanceChange =
newBet.amount !== 0
? // quick bet
newBet.amount + FLAT_TRADE_FEE
: // limit order
FLAT_TRADE_FEE
trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) })
log('Updated user balance.')
if (newBet.amount !== 0) { if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update( trans.update(
contractDoc, contractDoc,
removeUndefinedProps({ removeUndefinedProps({
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc return contractDoc
.collection('bets') .collection('bets')
.where('isFilled', '==', false) .where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet> .where('isCancelled', '==', false) as Query<LimitBet>
} }
export const getUnfilledBetsAndUserBalances = async (
trans: Transaction,
contractDoc: DocumentReference
) => {
const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc))
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
// Get balance of all users with open limit orders.
const userIds = uniq(unfilledBets.map((bet) => bet.userId))
const userDocs =
userIds.length === 0
? []
: await trans.getAll(
...userIds.map((userId) => firestore.doc(`users/${userId}`))
)
const users = filterDefined(userDocs.map((doc) => doc.data() as User))
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
return { unfilledBets, balanceByUserId }
}
type maker = { type maker = {
bet: LimitBet bet: LimitBet
amount: number amount: number

View File

@ -1,13 +1,17 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
import { getAllPrivateUsers } from 'functions/src/utils' import { filterDefined } from 'common/lib/util/array'
import { getPrivateUser } from '../utils'
initAdmin() initAdmin()
const firestore = admin.firestore() const firestore = admin.firestore()
async function main() { async function main() {
const privateUsers = await getAllPrivateUsers() // const privateUsers = await getAllPrivateUsers()
const privateUsers = filterDefined([
await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'),
])
await Promise.all( await Promise.all(
privateUsers.map((privateUser) => { privateUsers.map((privateUser) => {
if (!privateUser.id) return Promise.resolve() if (!privateUser.id) return Promise.resolve()
@ -20,6 +24,19 @@ async function main() {
badges_awarded: ['browser'], badges_awarded: ['browser'],
}, },
}) })
if (privateUser.notificationPreferences.opt_out_all === undefined) {
console.log('updating opt out all', privateUser.id)
return firestore
.collection('private-users')
.doc(privateUser.id)
.update({
notificationPreferences: {
...privateUser.notificationPreferences,
opt_out_all: [],
},
})
}
return
}) })
) )
} }

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

@ -1,6 +1,7 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash' import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils' import { log } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market' import { removeUserFromContractFollowers } from './follow-market'
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] = const [
await Promise.all([ [contractSnap, userSnap],
transaction.getAll(contractDoc, userDoc), userBetsSnap,
transaction.get(betsQ), { unfilledBets, balanceByUserId },
transaction.get(getUnfilledBetsQuery(contractDoc)), ] = await Promise.all([
]) transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
getUnfilledBetsAndUserBalances(transaction, contractDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const user = userSnap.data() as User const user = userSnap.data() as User
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0 if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( const { newBet, newPool, newP, fees, makers, ordersToCancel } =
soldShares, getCpmmSellBetInfo(
chosenOutcome, soldShares,
contract, chosenOutcome,
unfilledBets, contract,
loanPaid unfilledBets,
) balanceByUserId,
loanPaid
)
if ( if (
!newP || !newP ||
@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
}) })
) )
for (const bet of ordersToCancel) {
transaction.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
return { newBet, makers, maxShares, soldShares } return { newBet, makers, maxShares, soldShares }
}) })

View File

@ -1,10 +1,11 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
@ -14,18 +15,41 @@ 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'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise' import { batchedWaitAll } from '../../common/util/promise'
import { newEndpointNoAuth } from './api'
import { getFunctionUrl } from '../../common/api'
const firestore = admin.firestore() const firestore = admin.firestore()
export const updateMetrics = functions export const scheduleUpdateMetrics = functions.pubsub
.runWith({ memory: '8GB', timeoutSeconds: 540 }) .schedule('every 15 minutes')
.pubsub.schedule('every 15 minutes') .onRun(async () => {
.onRun(updateMetricsCore) const response = await fetch(getFunctionUrl('updatemetrics'), {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({}),
})
const json = await response.json()
if (response.ok) console.log(json)
else console.error(json)
})
export const updatemetrics = newEndpointNoAuth(
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
async (_req) => {
await updateMetricsCore()
return { success: true }
}
)
export async function updateMetricsCore() { export async function updateMetricsCore() {
console.log('Loading users') console.log('Loading users')
@ -103,6 +127,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,
}, },
} }
@ -144,7 +169,7 @@ export async function updateMetricsCore() {
return 0 return 0
} }
const contractRatio = const contractRatio =
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
return contractRatio return contractRatio
}) })

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

View File

@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
) )
) )
) )
log('Found', contractsUsersBetOn.length, 'contracts')
let count = 0
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => { privateUsersToSendEmailsTo.map(async (privateUser) => {
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
// Don't send to a user unless they're over 5 days old // Don't send to a user unless they're over 5 days old
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return if (!user || user.createdTime > Date.now() - 5 * DAY_MS)
return await setEmailFlagAsSent(privateUser.id)
const userBets = usersBets[privateUser.id] as Bet[] const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) => const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id) userBets.some((bet) => bet.contractId === contract.id)
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
(differences) => Math.abs(differences.profit) (differences) => Math.abs(differences.profit)
).reverse() ).reverse()
log(
'Found',
investmentValueDifferences.length,
'investment differences for user',
privateUser.id
)
const [winningInvestments, losingInvestments] = partition( const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter( investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
usersToContractsCreated[privateUser.id].length === 0 usersToContractsCreated[privateUser.id].length === 0
) { ) {
log( log(
'No bets in last week, no market movers, no markets created. Not sending an email.' `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
) )
await firestore.collection('private-users').doc(privateUser.id).update({ return await setEmailFlagAsSent(privateUser.id)
weeklyPortfolioUpdateEmailSent: true,
})
return
} }
// Set the flag beforehand just to be safe
await setEmailFlagAsSent(privateUser.id)
await sendWeeklyPortfolioUpdateEmail( await sendWeeklyPortfolioUpdateEmail(
user, user,
privateUser, privateUser,
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
performanceData performanceData
) )
await firestore.collection('private-users').doc(privateUser.id).update({
weeklyPortfolioUpdateEmailSent: true,
})
log('Sent weekly portfolio update email to', privateUser.email)
count++
log('sent out emails to users:', count)
}) })
) )
} }
async function setEmailFlagAsSent(privateUserId: string) {
await firestore.collection('private-users').doc(privateUserId).update({
weeklyPortfolioUpdateEmailSent: true,
})
}
export type PerContractInvestmentsData = { export type PerContractInvestmentsData = {
questionTitle: string questionTitle: string
questionUrl: string questionUrl: string

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

@ -6,6 +6,7 @@ import { Col } from './layout/col'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { Row } from './layout/row' import { Row } from './layout/row'
import { AddFundsModal } from './add-funds-modal' import { AddFundsModal } from './add-funds-modal'
import { Input } from './input'
export function AmountInput(props: { export function AmountInput(props: {
amount: number | undefined amount: number | undefined
@ -44,9 +45,9 @@ export function AmountInput(props: {
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2"> <span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label} {label}
</span> </span>
<input <Input
className={clsx( className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9', 'pl-9',
error && 'input-error', error && 'input-error',
'w-24 md:w-auto', 'w-24 md:w-auto',
inputClassName inputClassName

View File

@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
import { Input } from '../input'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
@ -74,8 +75,8 @@ export function AnswerItem(props: {
<Row className="items-center justify-end gap-4 self-end sm:self-start"> <Row className="items-center justify-end gap-4 self-end sm:self-start">
{!wasResolvedTo && {!wasResolvedTo &&
(showChoice === 'checkbox' ? ( (showChoice === 'checkbox' ? (
<input <Input
className="input input-bordered w-24 justify-self-end text-2xl" className="w-24 justify-self-end !text-2xl"
type="number" type="number"
placeholder={`${roundedProb}`} placeholder={`${roundedProb}`}
maxLength={9} maxLength={9}

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

@ -1,6 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React, { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract } from 'common/contract'
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash' import { lowerCase } from 'lodash'
import { Button } from '../button' import { Button } from '../button'
import { ExpandingInput } from '../expanding-input'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props const { contract } = props
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Col className="flex-1 gap-2 px-4 xl:px-0"> <Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div> <div className="mb-1">Add your answer</div>
<Textarea <ExpandingInput
value={text} value={text}
onChange={(e) => changeAnswer(e.target.value)} onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none" className="w-full"
placeholder="Type your answer..." placeholder="Type your answer..."
rows={1} rows={1}
maxLength={MAX_ANSWER_LENGTH} maxLength={MAX_ANSWER_LENGTH}

View File

@ -1,8 +1,8 @@
import { MAX_ANSWER_LENGTH } from 'common/answer' import { MAX_ANSWER_LENGTH } from 'common/answer'
import Textarea from 'react-expanding-textarea'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { ExpandingInput } from '../expanding-input'
export function MultipleChoiceAnswers(props: { export function MultipleChoiceAnswers(props: {
answers: string[] answers: string[]
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
{answers.map((answer, i) => ( {answers.map((answer, i) => (
<Row className="mb-2 items-center gap-2 align-middle"> <Row className="mb-2 items-center gap-2 align-middle">
{i + 1}.{' '} {i + 1}.{' '}
<Textarea <ExpandingInput
value={answer} value={answer}
onChange={(e) => setAnswer(i, e.target.value)} onChange={(e) => setAnswer(i, e.target.value)}
className="textarea textarea-bordered ml-2 w-full resize-none" className="ml-2 w-full"
placeholder="Type your answer..." placeholder="Type your answer..."
rows={1} rows={1}
maxLength={MAX_ANSWER_LENGTH} maxLength={MAX_ANSWER_LENGTH}

View File

@ -2,12 +2,12 @@ import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid' import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { XCircleIcon } from '@heroicons/react/outline'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash' import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button' import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users' import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups' import { leaveGroup } from 'web/lib/firebase/groups'

View File

@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
user: User user: User
}) { }) {
const { contract, user } = props const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<> <>
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
mobileView={true} mobileView={true}
/> />
</Col> </Col>

View File

@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
import { Button } from './button' import { Button } from './button'
import { Row } from './layout/row' import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm' import { getCpmmProbability } from 'common/calculate-cpmm'
@ -34,14 +34,17 @@ export function BetInline(props: {
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { newPool, newP } = getBinaryCpmmBetInfo( const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES', outcome ?? 'YES',
amount ?? 0, amount ?? 0,
contract, contract,
undefined, undefined,
unfilledBets unfilledBets,
balanceByUserId
) )
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb]) useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])

View File

@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input' import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets' import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
@ -55,7 +55,9 @@ export function BetPanel(props: {
const { contract, className } = props const { contract, className } = props
const user = useUser() const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id) const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -86,12 +88,14 @@ export function BetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
<LimitOrderPanel <LimitOrderPanel
hidden={!isLimitOrder} hidden={!isLimitOrder}
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
</> </>
) : ( ) : (
@ -117,7 +121,9 @@ export function SimpleBetPanel(props: {
const user = useUser() const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false) const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<Col className={className}> <Col className={className}>
@ -142,6 +148,7 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<LimitOrderPanel <LimitOrderPanel
@ -149,6 +156,7 @@ export function SimpleBetPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
@ -167,13 +175,21 @@ export function SimpleBetPanel(props: {
export function BuyPanel(props: { export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean hidden: boolean
onBuySuccess?: () => void onBuySuccess?: () => void
mobileView?: boolean mobileView?: boolean
}) { }) {
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } = const {
props contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
mobileView,
} = props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -261,7 +277,8 @@ export function BuyPanel(props: {
betAmount ?? 0, betAmount ?? 0,
contract, contract,
undefined, undefined,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const [seeLimit, setSeeLimit] = useState(false) const [seeLimit, setSeeLimit] = useState(false)
@ -416,6 +433,7 @@ export function BuyPanel(props: {
contract={contract} contract={contract}
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
<LimitBets <LimitBets
contract={contract} contract={contract}
@ -431,11 +449,19 @@ export function BuyPanel(props: {
function LimitOrderPanel(props: { function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined user: User | null | undefined
unfilledBets: Bet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean hidden: boolean
onBuySuccess?: () => void onBuySuccess?: () => void
}) { }) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
} = props
const initialProb = getProbability(contract) const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -581,7 +607,8 @@ function LimitOrderPanel(props: {
yesAmount, yesAmount,
contract, contract,
yesLimitProb ?? initialProb, yesLimitProb ?? initialProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const yesReturnPercent = formatPercent(yesReturn) const yesReturnPercent = formatPercent(yesReturn)
@ -595,7 +622,8 @@ function LimitOrderPanel(props: {
noAmount, noAmount,
contract, contract,
noLimitProb ?? initialProb, noLimitProb ?? initialProb,
unfilledBets as LimitBet[] unfilledBets,
balanceByUserId
) )
const noReturnPercent = formatPercent(noReturn) const noReturnPercent = formatPercent(noReturn)
@ -830,7 +858,9 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false) const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const betDisabled = isSubmitting || !amount || error !== undefined const betDisabled = isSubmitting || !amount || error !== undefined
@ -889,7 +919,8 @@ export function SellPanel(props: {
contract, contract,
sellQuantity ?? 0, sellQuantity ?? 0,
sharesOutcome, sharesOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
const netProceeds = saleValue - loanPaid const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis const profit = saleValue - costBasis

View File

@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid' import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { import {
formatMoney, formatMoney,
@ -17,6 +17,7 @@ import {
Contract, Contract,
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
MAX_USER_BET_CONTRACTS_LOADED,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api' import { sellBet } from 'web/lib/firebase/api'
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { Pagination } from './pagination' import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets' import { LimitOrderTable } from './limit-bets'
@ -50,6 +51,7 @@ import {
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -80,6 +82,10 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList]) }, [contractList])
const loadedPartialData =
userBets?.length === MAX_USER_BETS_LOADED ||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
const [sort, setSort] = usePersistentState<BetSort>('newest', { const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort', key: 'bets-list-sort',
store: storageStore(safeLocalStorage()), store: storageStore(safeLocalStorage()),
@ -167,6 +173,13 @@ export function BetsList(props: { user: User }) {
return ( return (
<Col> <Col>
{loadedPartialData && (
<Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4">
<ExclamationIcon className="h-5 w-5" />
<div>Partial trade data only</div>
</Row>
)}
<Col className="justify-between gap-4 sm:flex-row"> <Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4"> <Row className="gap-4">
<Col> <Col>
@ -412,7 +425,9 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? [] const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -461,6 +476,7 @@ export function ContractBetsTable(props: {
contract={contract} contract={contract}
isYourBet={isYourBets} isYourBet={isYourBets}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
))} ))}
</tbody> </tbody>
@ -475,8 +491,10 @@ function BetRow(props: {
saleBet?: Bet saleBet?: Bet
isYourBet: boolean isYourBet: boolean
unfilledBets: LimitBet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) { }) {
const { bet, saleBet, contract, isYourBet, unfilledBets } = props const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
props
const { const {
amount, amount,
outcome, outcome,
@ -504,9 +522,9 @@ function BetRow(props: {
} else if (contract.isResolved) { } else if (contract.isResolved) {
return resolvedPayout(contract, bet) return resolvedPayout(contract, bet)
} else { } else {
return calculateSaleAmount(contract, bet, unfilledBets) return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
} }
}, [contract, bet, saleBet, unfilledBets]) }, [contract, bet, saleBet, unfilledBets, balanceByUserId])
const saleDisplay = isAnte ? ( const saleDisplay = isAnte ? (
'ANTE' 'ANTE'
@ -545,6 +563,7 @@ function BetRow(props: {
contract={contract} contract={contract}
bet={bet} bet={bet}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/> />
)} )}
</td> </td>
@ -590,8 +609,9 @@ function SellButton(props: {
contract: Contract contract: Contract
bet: Bet bet: Bet
unfilledBets: LimitBet[] unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) { }) {
const { contract, bet, unfilledBets } = props const { contract, bet, unfilledBets, balanceByUserId } = props
const { outcome, shares, loanAmount } = bet const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -605,10 +625,16 @@ function SellButton(props: {
contract, contract,
outcome, outcome,
shares, shares,
unfilledBets unfilledBets,
balanceByUserId
) )
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets) const saleAmount = calculateSaleAmount(
contract,
bet,
unfilledBets,
balanceByUserId
)
const profit = saleAmount - bet.amount const profit = saleAmount - bet.amount
return ( return (

View File

@ -20,11 +20,11 @@ import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy' import { FIXED_ANTE } from 'common/economy'
import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor' import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { CopyLinkButton } from '../copy-link-button' import { CopyLinkButton } from '../copy-link-button'
import { ExpandingInput } from '../expanding-input'
type challengeInfo = { type challengeInfo = {
amount: number amount: number
@ -153,9 +153,9 @@ function CreateChallengeForm(props: {
{contract ? ( {contract ? (
<span className="underline">{contract.question}</span> <span className="underline">{contract.question}</span>
) : ( ) : (
<Textarea <ExpandingInput
placeholder="e.g. Will a Democrat be the next president?" placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none" className="mt-1 w-full"
autoFocus={true} autoFocus={true}
maxLength={MAX_QUESTION_LENGTH} maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question} value={challengeInfo.question}

View File

@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
import { Button } from './button' import { Button } from './button'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Title } from './title' import { Title } from './title'
import { Input } from './input'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
@ -48,6 +49,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' },
@ -437,13 +439,13 @@ function ContractSearchControls(props: {
return ( return (
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}> <Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2"> <Row className="gap-1 sm:gap-2">
<input <Input
type="text" type="text"
value={query} value={query}
onChange={(e) => updateQuery(e.target.value)} onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query: query })} onBlur={trackCallback('search', { query: query })}
placeholder={'Search'} placeholder="Search"
className="input input-bordered w-full" className="w-full"
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
{!isMobile && !query && ( {!isMobile && !query && (

View File

@ -1,12 +1,16 @@
import clsx from 'clsx'
import { useState } from 'react'
import { CurrencyDollarIcon } from '@heroicons/react/outline' import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
import { Tooltip } from 'web/components/tooltip'
import { CommentBountyDialog } from './comment-bounty-dialog'
export function BountiedContractBadge() { export function BountiedContractBadge() {
return ( return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800"> <span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3 py-0.5 text-sm font-medium text-white">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty <CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span> </span>
) )
@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
}) { }) {
const { contract, showAmount } = props const { contract, showAmount } = props
const { openCommentBounties } = contract const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return ( const [open, setOpen] = useState(false)
<Tooltip
text={CommentBountiesTooltipText( if (!openCommentBounties && !showAmount) return <></>
contract.creatorName,
openCommentBounties const modal = (
)} <CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
) )
}
export const CommentBountiesTooltipText = ( const bountiesClosed =
creator: string, contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
openCommentBounties: number
) => if (!openCommentBounties) {
`${creator} may award ${formatMoney( if (bountiesClosed) return <></>
return (
<>
{modal}
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
</>
)
}
const tooltip = `${contract.creatorName} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney( )} for good comments. ${formatMoney(
openCommentBounties openCommentBounties
)} currently available.` )} currently available.`
return (
<Tooltip text={tooltip} placement="bottom">
{modal}
<SmallBadge
text={`${formatMoney(openCommentBounties)} bounty`}
onClick={bountiesClosed ? undefined : () => setOpen(true)}
/>
</Tooltip>
)
}
function SmallBadge(props: { text: string; onClick?: () => void }) {
const { text, onClick } = props
return (
<button
onClick={onClick}
className={clsx(
'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white',
!onClick && 'cursor-default'
)}
>
<CurrencyDollarIcon className={'h4 w-4'} />
{text}
</button>
)
}

View File

@ -8,9 +8,16 @@ import clsx from 'clsx'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Title } from '../title'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
export function AddCommentBountyPanel(props: { contract: Contract }) { export function CommentBountyDialog(props: {
const { contract } = props contract: Contract
open: boolean
setOpen: (open: boolean) => void
}) {
const { contract, open, setOpen } = props
const { id: contractId, slug } = contract const { id: contractId, slug } = contract
const user = useUser() const user = useUser()
@ -45,30 +52,34 @@ export function AddCommentBountyPanel(props: { contract: Contract }) {
} }
return ( return (
<> <Modal open={open} setOpen={setOpen}>
<div className="mb-4 text-gray-500"> <Col className="gap-4 rounded bg-white p-6">
Add a {formatMoney(amount)} bounty for good comments that the creator <Title className="!mt-0 !mb-0" text="Comment bounty" />
can award.{' '}
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
</div>
<Row className={'items-center gap-2'}> <div className="mb-4 text-gray-500">
<Button Add a {formatMoney(amount)} bounty for good comments that the creator
className={clsx('ml-2', isLoading && 'btn-disabled')} can award.{' '}
onClick={submit} {totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
disabled={isLoading} </div>
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
{isSuccess && amount && ( <Row className={'items-center gap-2'}>
<div>Success! Added {formatMoney(amount)} in bounties.</div> <Button
)} className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
{isLoading && <div>Processing...</div>} {isSuccess && amount && (
</> <div>Success! Added {formatMoney(amount)} in bounties.</div>
)}
{isLoading && <div>Processing...</div>}
</Col>
</Modal>
) )
} }

View File

@ -1,8 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
@ -15,6 +13,7 @@ import { Button } from '../button'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Editor, Content as ContentType } from '@tiptap/react' import { Editor, Content as ContentType } from '@tiptap/react'
import { insertContent } from '../editor/utils' import { insertContent } from '../editor/utils'
import { ExpandingInput } from '../expanding-input'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
@ -138,8 +137,8 @@ function EditQuestion(props: {
return editing ? ( return editing ? (
<div className="mt-4"> <div className="mt-4">
<Textarea <ExpandingInput
className="textarea textarea-bordered mb-1 h-24 w-full resize-none" className="mb-1 h-24 w-full"
rows={2} rows={2}
value={text} value={text}
onChange={(e) => setText(e.target.value || '')} onChange={(e) => setText(e.target.value || '')}

View File

@ -40,6 +40,7 @@ import {
BountiedContractBadge, BountiedContractBadge,
BountiedContractSmallBadge, BountiedContractSmallBadge,
} from 'web/components/contract/bountied-contract-badge' } from 'web/components/contract/bountied-contract-badge'
import { Input } from '../input'
export type ShowTime = 'resolve-date' | 'close-date' export type ShowTime = 'resolve-date' | 'close-date'
@ -445,17 +446,17 @@ function EditableCloseDate(props: {
<Col className="rounded bg-white px-8 pb-8"> <Col className="rounded bg-white px-8 pb-8">
<Subtitle text="Edit market close time" /> <Subtitle text="Edit market close time" />
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2"> <Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
<input <Input
type="date" type="date"
className="input input-bordered w-full shrink-0 sm:w-fit" className="w-full shrink-0 sm:w-fit"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)} onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()} min={Date.now()}
value={closeDate} value={closeDate}
/> />
<input <Input
type="time" type="time"
className="input input-bordered w-full shrink-0 sm:w-max" className="w-full shrink-0 sm:w-max"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)} onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00" min="00:00"

View File

@ -5,7 +5,7 @@ import { useState } from 'react'
import { capitalize } from 'lodash' import { capitalize } from 'lodash'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts' import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel' import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -54,6 +54,7 @@ export function ContractInfoDialog(props: {
mechanism, mechanism,
outcomeType, outcomeType,
id, id,
elasticity,
} = contract } = contract
const typeDisplay = const typeDisplay =
@ -142,7 +143,10 @@ export function ContractInfoDialog(props: {
)} )}
<tr> <tr>
<td>Volume</td> <td>
<span className="mr-1">Volume</span>
<InfoTooltip text="Total amount bought or sold" />
</td>
<td>{formatMoney(contract.volume)}</td> <td>{formatMoney(contract.volume)}</td>
</tr> </tr>
@ -151,6 +155,22 @@ export function ContractInfoDialog(props: {
<td>{uniqueBettorCount ?? '0'}</td> <td>{uniqueBettorCount ?? '0'}</td>
</tr> </tr>
<tr>
<td>
<Row>
<span className="mr-1">Elasticity</span>
<InfoTooltip
text={
mechanism === 'cpmm-1'
? 'Probability change between a M$50 bet on YES and NO'
: 'Probability change from a M$100 bet'
}
/>
</Row>
</td>
<td>{formatPercent(elasticity)}</td>
</tr>
<tr> <tr>
<td> <td>
{mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'} {mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'}

View File

@ -80,7 +80,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const { contract } = props const { contract } = props
const tips = useTipTxns({ contractId: contract.id }) const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Best', { const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
key: `contract-comments-sort`, key: `contract-comments-sort`,
store: storageStore(safeLocalStorage()), store: storageStore(safeLocalStorage()),
}) })
@ -177,8 +177,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<Col className="mt-8 flex w-full"> <Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div> <div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" /> <div className="mb-4 w-full border-b border-gray-200" />
{sortRow}
<ContractCommentInput className="mb-5" contract={contract} /> <ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{generalTopLevelComments.map((comment) => ( {generalTopLevelComments.map((comment) => (
<FeedCommentThread <FeedCommentThread
key={comment.id} key={comment.id}
@ -194,8 +195,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
} else { } else {
return ( return (
<> <>
{sortRow}
<ContractCommentInput className="mb-5" contract={contract} /> <ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{topLevelComments.map((parent) => ( {topLevelComments.map((parent) => (
<FeedCommentThread <FeedCommentThread
key={parent.id} key={parent.id}

View File

@ -16,7 +16,6 @@ import { InfoTooltip } from 'web/components/info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user' import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
export function LiquidityBountyPanel(props: { contract: Contract }) { export function LiquidityBountyPanel(props: { contract: Contract }) {
const { contract } = props const { contract } = props
@ -36,13 +35,11 @@ export function LiquidityBountyPanel(props: { contract: Contract }) {
const isCreator = user?.id === contract.creatorId const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin() const isAdmin = useAdmin()
if (!isCreator && !isAdmin && !showWithdrawal) return <></>
return ( return (
<Tabs <Tabs
tabs={buildArray( tabs={buildArray(
{
title: 'Bounty Comments',
content: <AddCommentBountyPanel contract={contract} />,
},
(isCreator || isAdmin) && (isCreator || isAdmin) &&
isCPMM && { isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',

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

@ -33,7 +33,7 @@ import { sellShares } from 'web/lib/firebase/api'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric' import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { getBinaryProb } from 'common/contract-details' import { getBinaryProb } from 'common/contract-details'
const BET_SIZE = 10 const BET_SIZE = 10
@ -48,7 +48,10 @@ export function QuickBet(props: {
const isCpmm = mechanism === 'cpmm-1' const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id) const userBets = useUserContractBets(user.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? [] // TODO: Below hook fetches a decent amount of data. Maybe not worth it to show prob change on hover?
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { hasYesShares, hasNoShares, yesShares, noShares } = const { hasYesShares, hasNoShares, yesShares, noShares } =
useSaveBinaryShares(contract, userBets) useSaveBinaryShares(contract, userBets)
@ -94,7 +97,8 @@ export function QuickBet(props: {
contract, contract,
sharesSold, sharesSold,
sellOutcome, sellOutcome,
unfilledBets unfilledBets,
balanceByUserId
) )
saleAmount = saleValue saleAmount = saleValue
previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p) previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -1,7 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor' import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api' import { createPost } from 'web/lib/firebase/api'
@ -10,6 +9,7 @@ import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post' import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts' import { postPath } from 'web/lib/firebase/posts'
import { Group } from 'common/group' import { Group } from 'common/group'
import { ExpandingInput } from './expanding-input'
export function CreatePost(props: { group?: Group }) { export function CreatePost(props: { group?: Group }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@ -60,9 +60,8 @@ export function CreatePost(props: { group?: Group }) {
Title<span className={'text-red-700'}> *</span> Title<span className={'text-red-700'}> *</span>
</span> </span>
</label> </label>
<Textarea <ExpandingInput
placeholder="e.g. Elon Mania Post" placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus autoFocus
maxLength={MAX_POST_TITLE_LENGTH} maxLength={MAX_POST_TITLE_LENGTH}
value={title} value={title}
@ -74,9 +73,8 @@ export function CreatePost(props: { group?: Group }) {
Subtitle<span className={'text-red-700'}> *</span> Subtitle<span className={'text-red-700'}> *</span>
</span> </span>
</label> </label>
<Textarea <ExpandingInput
placeholder="e.g. How Elon Musk is getting everyone's attention" placeholder="e.g. How Elon Musk is getting everyone's attention"
className="input input-bordered resize-none"
autoFocus autoFocus
maxLength={MAX_POST_TITLE_LENGTH} maxLength={MAX_POST_TITLE_LENGTH}
value={subtitle} value={subtitle}

View File

@ -6,16 +6,28 @@ import {
} from '@tiptap/react' } from '@tiptap/react'
import clsx from 'clsx' import clsx from 'clsx'
import { useContract } from 'web/hooks/use-contract' import { useContract } from 'web/hooks/use-contract'
import { ContractMention } from '../contract/contract-mention' import { ContractMention } from 'web/components/contract/contract-mention'
import Link from 'next/link'
const name = 'contract-mention-component' const name = 'contract-mention-component'
const ContractMentionComponent = (props: any) => { const ContractMentionComponent = (props: any) => {
const contract = useContract(props.node.attrs.id) const { label, id } = props.node.attrs
const contract = useContract(id)
return ( return (
<NodeViewWrapper className={clsx(name, 'not-prose inline')}> <NodeViewWrapper className={clsx(name, 'not-prose inline')}>
{contract && <ContractMention contract={contract} />} {contract ? (
<ContractMention contract={contract} />
) : label ? (
<Link href={label}>
<a className="rounded-sm !text-indigo-700 hover:bg-indigo-50">
{label}
</a>
</Link>
) : (
'[loading...]'
)}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }
@ -29,8 +41,5 @@ export const DisplayContractMention = Mention.extend({
name: 'contract-mention', name: 'contract-mention',
parseHTML: () => [{ tag: name }], parseHTML: () => [{ tag: name }],
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)], renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
addNodeView: () => addNodeView: () => ReactNodeViewRenderer(ContractMentionComponent),
ReactNodeViewRenderer(ContractMentionComponent, {
// On desktop, render cards below half-width so you can stack two
}),
}) })

View File

@ -0,0 +1,16 @@
import clsx from 'clsx'
import Textarea from 'react-expanding-textarea'
/** Expanding `<textarea>` with same style as input.tsx */
export const ExpandingInput = (props: Parameters<typeof Textarea>[0]) => {
const { className, ...rest } = props
return (
<Textarea
className={clsx(
'textarea textarea-bordered resize-none text-[16px] md:text-[14px]',
className
)}
{...rest}
/>
)
}

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

@ -109,12 +109,18 @@ export const FeedComment = memo(function FeedComment(props: {
} }
const totalAwarded = bountiesAwarded ?? 0 const totalAwarded = bountiesAwarded ?? 0
const router = useRouter() const { isReady, asPath } = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`) const [highlighted, setHighlighted] = useState(false)
const commentRef = useRef<HTMLDivElement>(null) const commentRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (highlighted && commentRef.current != null) { if (isReady && asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [isReady, asPath, comment.id])
useEffect(() => {
if (highlighted && commentRef.current) {
commentRef.current.scrollIntoView(true) commentRef.current.scrollIntoView(true)
} }
}, [highlighted]) }, [highlighted])
@ -126,7 +132,7 @@ export const FeedComment = memo(function FeedComment(props: {
className={clsx( className={clsx(
'relative', 'relative',
indent ? 'ml-6' : '', indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : '' highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : ''
)} )}
> >
{/*draw a gray line from the comment to the left:*/} {/*draw a gray line from the comment to the left:*/}

View File

@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { Input } from './input'
export function FilterSelectUsers(props: { export function FilterSelectUsers(props: {
setSelectedUsers: (users: User[]) => void setSelectedUsers: (users: User[]) => void
@ -50,13 +51,13 @@ export function FilterSelectUsers(props: {
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> <UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div> </div>
<input <Input
type="text" type="text"
name="user name" name="user name"
id="user name" id="user name"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="input input-bordered block w-full pl-10 focus:border-gray-300 " className="block w-full pl-10"
placeholder="Austin Chen" placeholder="Austin Chen"
/> />
</div> </div>

View File

@ -8,6 +8,7 @@ import { Title } from '../title'
import { User } from 'common/user' import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group' import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api' import { createGroup } from 'web/lib/firebase/api'
import { Input } from '../input'
export function CreateGroupButton(props: { export function CreateGroupButton(props: {
user: User user: User
@ -104,9 +105,8 @@ export function CreateGroupButton(props: {
<div className="form-control w-full"> <div className="form-control w-full">
<label className="mb-2 ml-1 mt-0">Group name</label> <label className="mb-2 ml-1 mt-0">Group name</label>
<input <Input
placeholder={'Your group name'} placeholder={'Your group name'}
className="input input-bordered resize-none"
disabled={isSubmitting} disabled={isSubmitting}
value={name} value={name}
maxLength={MAX_GROUP_NAME_LENGTH} maxLength={MAX_GROUP_NAME_LENGTH}

View File

@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users' import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user' import { User } from 'common/user'
import { useMemberIds } from 'web/hooks/use-group' import { useMemberIds } from 'web/hooks/use-group'
import { Input } from '../input'
export function EditGroupButton(props: { group: Group; className?: string }) { export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props const { group, className } = props
@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<span className="mb-1">Group name</span> <span className="mb-1">Group name</span>
</label> </label>
<input <Input
placeholder="Your group name" placeholder="Your group name"
className="input input-bordered resize-none"
disabled={isSubmitting} disabled={isSubmitting}
value={name} value={name}
onChange={(e) => setName(e.target.value || '')} onChange={(e) => setName(e.target.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>
) : ( ) : (
<></> <></>
) )

22
web/components/input.tsx Normal file
View File

@ -0,0 +1,22 @@
import clsx from 'clsx'
import React from 'react'
/** Text input. Wraps html `<input>` */
export const Input = (props: JSX.IntrinsicElements['input']) => {
const { className, ...rest } = props
return (
<input
className={clsx('input input-bordered text-base md:text-sm', className)}
{...rest}
/>
)
}
/*
TODO: replace daisyui style with our own. For reference:
james: text-lg placeholder:text-gray-400
inga: placeholder:text-greyscale-4 border-greyscale-2 rounded-md
austin: border-gray-300 text-gray-400 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm
*/

View File

@ -7,12 +7,13 @@ import { User } from 'common/user'
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card' import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
import { createManalink } from 'web/lib/firebase/manalinks' import { createManalink } from 'web/lib/firebase/manalinks'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import Textarea from 'react-expanding-textarea'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Button } from '../button' import { Button } from '../button'
import { getManalinkUrl } from 'web/pages/links' import { getManalinkUrl } from 'web/pages/links'
import { DuplicateIcon } from '@heroicons/react/outline' import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code' import { QRCode } from '../qr-code'
import { Input } from '../input'
import { ExpandingInput } from '../expanding-input'
export function CreateLinksButton(props: { export function CreateLinksButton(props: {
user: User user: User
@ -120,8 +121,8 @@ function CreateManalinkForm(props: {
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400"> <span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
M$ M$
</span> </span>
<input <Input
className="input input-bordered w-full pl-10" className="w-full pl-10"
type="number" type="number"
min="1" min="1"
value={newManalink.amount} value={newManalink.amount}
@ -136,8 +137,7 @@ function CreateManalinkForm(props: {
<div className="flex flex-col gap-2 md:flex-row"> <div className="flex flex-col gap-2 md:flex-row">
<div className="form-control w-full md:w-1/2"> <div className="form-control w-full md:w-1/2">
<label className="label">Uses</label> <label className="label">Uses</label>
<input <Input
className="input input-bordered"
type="number" type="number"
min="1" min="1"
value={newManalink.maxUses ?? ''} value={newManalink.maxUses ?? ''}
@ -146,7 +146,7 @@ function CreateManalinkForm(props: {
return { ...m, maxUses: parseInt(e.target.value) } return { ...m, maxUses: parseInt(e.target.value) }
}) })
} }
></input> />
</div> </div>
<div className="form-control w-full md:w-1/2"> <div className="form-control w-full md:w-1/2">
<label className="label">Expires in</label> <label className="label">Expires in</label>
@ -165,10 +165,9 @@ function CreateManalinkForm(props: {
</div> </div>
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label">Message</label> <label className="label">Message</label>
<Textarea <ExpandingInput
placeholder={defaultMessage} placeholder={defaultMessage}
maxLength={200} maxLength={200}
className="input input-bordered resize-none"
autoFocus autoFocus
value={newManalink.message} value={newManalink.message}
rows="3" rows="3"

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"> <div 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> </div>
) )
} }

View File

@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) {
return buildArray( return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Refer a friend', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' }, { name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -215,7 +215,7 @@ function getMoreMobileNav() {
return buildArray<MenuItem>( return buildArray<MenuItem>(
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Refer a friend', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Labs', href: '/labs' }, { name: 'Labs', href: '/labs' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },

View File

@ -4,6 +4,7 @@ import { ReactNode } from 'react'
import React from 'react' import React from 'react'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { Input } from './input'
export function NumberInput(props: { export function NumberInput(props: {
numberString: string numberString: string
@ -32,9 +33,9 @@ export function NumberInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group"> <label className="input-group">
<input <Input
className={clsx( className={clsx(
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400', 'max-w-[200px] !text-lg',
error && 'input-error', error && 'input-error',
inputClassName inputClassName
)} )}

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

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { getPseudoProbability } from 'common/pseudo-numeric' import { getPseudoProbability } from 'common/pseudo-numeric'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
import { Input } from './input'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -30,11 +31,8 @@ export function ProbabilityInput(props: {
return ( return (
<Col className={className}> <Col className={className}>
<label className="input-group"> <label className="input-group">
<input <Input
className={clsx( className={clsx('max-w-[200px] !text-lg', inputClassName)}
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
inputClassName
)}
type="number" type="number"
max={99} max={99}
min={1} min={1}

View File

@ -1,3 +1,4 @@
import { Input } from './input'
import { Row } from './layout/row' import { Row } from './layout/row'
export function ProbabilitySelector(props: { export function ProbabilitySelector(props: {
@ -10,10 +11,10 @@ export function ProbabilitySelector(props: {
return ( return (
<Row className="items-center gap-2"> <Row className="items-center gap-2">
<label className="input-group input-group-lg text-lg"> <label className="input-group input-group-lg text-lg">
<input <Input
type="number" type="number"
value={probabilityInt} value={probabilityInt}
className="input input-bordered input-md w-28 text-lg" className="input-md w-28 !text-lg"
disabled={isSubmitting} disabled={isSubmitting}
min={1} min={1}
max={99} max={99}

View File

@ -35,8 +35,8 @@ export function LoansModal(props: {
</span> </span>
<span className={'text-indigo-700'}> What is an example?</span> <span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}> <span className={'ml-2'}>
For example, if you bet M$1000 on "Will I become a millionare?" For example, if you bet M$1000 on "Will I become a millionare?", you
today, you will get M$20 back tomorrow. will get M$20 back tomorrow.
</span> </span>
<span className={'ml-2'}> <span className={'ml-2'}>
Previous loans count against your total bet amount. So on the next Previous loans count against your total bet amount. So on the next

View File

@ -7,6 +7,7 @@ import { CPMMBinaryContract } from 'common/contract'
import { Customize, USAMap } from './usa-map' import { Customize, USAMap } from './usa-map'
import { listenForContract } from 'web/lib/firebase/contracts' import { listenForContract } from 'web/lib/firebase/contracts'
import { interpolateColor } from 'common/util/color' import { interpolateColor } from 'common/util/color'
import { track } from 'web/lib/service/analytics'
export interface StateElectionMarket { export interface StateElectionMarket {
creatorUsername: string creatorUsername: string
@ -35,8 +36,13 @@ export function StateElectionMap(props: {
market.state, market.state,
{ {
fill: probToColor(prob, market.isWinRepublican), fill: probToColor(prob, market.isWinRepublican),
clickHandler: () => clickHandler: () => {
Router.push(`/${market.creatorUsername}/${market.slug}`), Router.push(`/${market.creatorUsername}/${market.slug}`)
track('state election map click', {
state: market.state,
slug: market.slug,
})
},
}, },
]) ])

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

@ -8,6 +8,7 @@ import {
withoutAnteBets, withoutAnteBets,
} from 'web/lib/firebase/bets' } from 'web/lib/firebase/bets'
import { LimitBet } from 'common/bet' import { LimitBet } from 'common/bet'
import { getUser } from 'web/lib/firebase/users'
export const useBets = ( export const useBets = (
contractId: string, contractId: string,
@ -62,3 +63,31 @@ export const useUnfilledBets = (contractId: string) => {
) )
return unfilledBets return unfilledBets
} }
export const useUnfilledBetsAndBalanceByUserId = (contractId: string) => {
const [data, setData] = useState<{
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}>({ unfilledBets: [], balanceByUserId: {} })
useEffect(() => {
let requestCount = 0
return listenForUnfilledBets(contractId, (unfilledBets) => {
requestCount++
const count = requestCount
Promise.all(unfilledBets.map((bet) => getUser(bet.userId))).then(
(users) => {
if (count === requestCount) {
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
setData({ unfilledBets, balanceByUserId })
}
}
)
})
}, [contractId])
return data
}

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

@ -74,11 +74,13 @@ export async function getUserBets(userId: string) {
return getValues<Bet>(getUserBetsQuery(userId)) return getValues<Bet>(getUserBetsQuery(userId))
} }
export const MAX_USER_BETS_LOADED = 10000
export function getUserBetsQuery(userId: string) { export function getUserBetsQuery(userId: string) {
return query( return query(
collectionGroup(db, 'bets'), collectionGroup(db, 'bets'),
where('userId', '==', userId), where('userId', '==', userId),
orderBy('createdTime', 'desc') orderBy('createdTime', 'desc'),
limit(MAX_USER_BETS_LOADED)
) as Query<Bet> ) as Query<Bet>
} }

View File

@ -168,10 +168,12 @@ export function getUserBetContracts(userId: string) {
return getValues<Contract>(getUserBetContractsQuery(userId)) return getValues<Contract>(getUserBetContractsQuery(userId))
} }
export const MAX_USER_BET_CONTRACTS_LOADED = 1000
export function getUserBetContractsQuery(userId: string) { export function getUserBetContractsQuery(userId: string) {
return query( return query(
contracts, contracts,
where('uniqueBettorIds', 'array-contains', userId) where('uniqueBettorIds', 'array-contains', userId),
limit(MAX_USER_BET_CONTRACTS_LOADED)
) as Query<Contract> ) as Query<Contract>
} }

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

@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { User } from 'common/user' import { User } from 'common/user'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Input } from 'web/components/input'
export async function getStaticProps() { export async function getStaticProps() {
let txns = await getAllCharityTxns() let txns = await getAllCharityTxns()
@ -171,11 +172,11 @@ export default function Charity(props: {
/> />
<Spacer h={10} /> <Spacer h={10} />
<input <Input
type="text" type="text"
onChange={(e) => debouncedQuery(e.target.value)} onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Find a charity" placeholder="Find a charity"
className="input input-bordered mb-6 w-full" className="mb-6 w-full"
/> />
</Col> </Col>
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 self-center lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">

View File

@ -9,6 +9,7 @@ import {
urlParamStore, urlParamStore,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
import { Input } from 'web/components/input'
const MAX_CONTRACTS_RENDERED = 100 const MAX_CONTRACTS_RENDERED = 100
@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: {
<div> <div>
{/* Show a search input next to a sort dropdown */} {/* Show a search input next to a sort dropdown */}
<div className="mt-2 mb-8 flex justify-between gap-2"> <div className="mt-2 mb-8 flex justify-between gap-2">
<input <Input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search markets" placeholder="Search markets"
className="input input-bordered w-full" className="w-full"
/> />
<select <select
className="select select-bordered" className="select select-bordered"

View File

@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Textarea from 'react-expanding-textarea'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { getUserAndPrivateUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts' import { Contract, contractPath } from 'web/lib/firebase/contracts'
@ -39,6 +38,8 @@ import { SiteLink } from 'web/components/site-link'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { AddFundsModal } from 'web/components/add-funds-modal' import { AddFundsModal } from 'web/components/add-funds-modal'
import ShortToggle from 'web/components/widgets/short-toggle' import ShortToggle from 'web/components/widgets/short-toggle'
import { Input } from 'web/components/input'
import { ExpandingInput } from 'web/components/expanding-input'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } } return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -104,9 +105,8 @@ export default function Create(props: { auth: { user: User } }) {
</span> </span>
</label> </label>
<Textarea <ExpandingInput
placeholder="e.g. Will the Democrats win the 2024 US presidential election?" placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
className="input input-bordered resize-none"
autoFocus autoFocus
maxLength={MAX_QUESTION_LENGTH} maxLength={MAX_QUESTION_LENGTH}
value={question} value={question}
@ -329,9 +329,9 @@ export function NewContract(props: {
</label> </label>
<Row className="gap-2"> <Row className="gap-2">
<input <Input
type="number" type="number"
className="input input-bordered w-32" className="w-32"
placeholder="LOW" placeholder="LOW"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)} onChange={(e) => setMinString(e.target.value)}
@ -340,9 +340,9 @@ export function NewContract(props: {
disabled={isSubmitting} disabled={isSubmitting}
value={minString ?? ''} value={minString ?? ''}
/> />
<input <Input
type="number" type="number"
className="input input-bordered w-32" className="w-32"
placeholder="HIGH" placeholder="HIGH"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setMaxString(e.target.value)} onChange={(e) => setMaxString(e.target.value)}
@ -374,9 +374,8 @@ export function NewContract(props: {
</label> </label>
<Row className="gap-2"> <Row className="gap-2">
<input <Input
type="number" type="number"
className="input input-bordered"
placeholder="Initial value" placeholder="Initial value"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setInitialValueString(e.target.value)} onChange={(e) => setInitialValueString(e.target.value)}
@ -446,19 +445,17 @@ export function NewContract(props: {
className={'col-span-4 sm:col-span-2'} className={'col-span-4 sm:col-span-2'}
/> />
</Row> </Row>
<Row> <Row className="mt-4 gap-2">
<input <Input
type={'date'} type={'date'}
className="input input-bordered mt-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)} onChange={(e) => setCloseDate(e.target.value)}
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
disabled={isSubmitting} disabled={isSubmitting}
value={closeDate} value={closeDate}
/> />
<input <Input
type={'time'} type={'time'}
className="input input-bordered mt-4 ml-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)} onChange={(e) => setCloseHoursMinutes(e.target.value)}
min={'00:00'} min={'00:00'}

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

@ -1,7 +1,5 @@
import Router from 'next/router' import Router from 'next/router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { DateDoc } from 'common/post' import { DateDoc } from 'common/post'
import { useTextEditor, TextEditor } from 'web/components/editor' import { useTextEditor, TextEditor } from 'web/components/editor'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -14,6 +12,11 @@ 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'
import { Input } from 'web/components/input'
import { ExpandingInput } from 'web/components/expanding-input'
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">
@ -85,9 +94,8 @@ export default function CreateDateDocPage() {
<Col className="gap-8"> <Col className="gap-8">
<Col className="max-w-[160px] justify-start gap-4"> <Col className="max-w-[160px] justify-start gap-4">
<div className="">Birthday</div> <div className="">Birthday</div>
<input <Input
type={'date'} type={'date'}
className="input input-bordered"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setBirthday(e.target.value)} onChange={(e) => setBirthday(e.target.value)}
max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS} max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
@ -104,16 +112,20 @@ 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 <ExpandingInput
className="input input-bordered resize-none"
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,9 @@ 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'
import { useTracking } from 'web/hooks/use-tracking'
export async function getStaticProps() { export async function getStaticProps() {
const dateDocs = await getDateDocs() const dateDocs = await getDateDocs()
@ -33,13 +36,16 @@ 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)
useTracking('view date docs page')
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" />

View File

@ -20,6 +20,7 @@ import { SEO } from 'web/components/SEO'
import { GetServerSideProps } from 'next' import { GetServerSideProps } from 'next'
import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Input } from 'web/components/input'
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx) const creds = await authenticateOnServer(ctx)
@ -106,12 +107,12 @@ export default function Groups(props: {
title: 'All', title: 'All',
content: ( content: (
<Col> <Col>
<input <Input
type="text" type="text"
onChange={(e) => debouncedQuery(e.target.value)} onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups" placeholder="Search groups"
value={query} value={query}
className="input input-bordered mb-4 w-full" className="mb-4 w-full"
/> />
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">
@ -134,12 +135,12 @@ export default function Groups(props: {
title: 'My Groups', title: 'My Groups',
content: ( content: (
<Col> <Col>
<input <Input
type="text" type="text"
value={query} value={query}
onChange={(e) => debouncedQuery(e.target.value)} onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your groups" placeholder="Search your groups"
className="input input-bordered mb-4 w-full" className="mb-4 w-full"
/> />
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">

View File

@ -48,6 +48,7 @@ import {
} from 'web/hooks/use-contracts' } from 'web/hooks/use-contracts'
import { ProfitBadge } from 'web/components/profit-badge' import { ProfitBadge } from 'web/components/profit-badge'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { Input } from 'web/components/input'
export default function Home() { export default function Home() {
const user = useUser() const user = useUser()
@ -99,10 +100,10 @@ export default function Home() {
<Row <Row
className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'} className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
> >
<input <Input
type="text" type="text"
placeholder={'Search'} placeholder={'Search'}
className="input input-bordered w-full" className="w-full"
onClick={() => Router.push('/search')} onClick={() => Router.push('/search')}
/> />
<CustomizeButton justIcon /> <CustomizeButton justIcon />

View File

@ -8,6 +8,7 @@ import {
StateElectionMarket, StateElectionMarket,
StateElectionMap, StateElectionMap,
} from 'web/components/usa-map/state-election-map' } from 'web/components/usa-map/state-election-map'
import { useTracking } from 'web/hooks/use-tracking'
import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getContractFromSlug } from 'web/lib/firebase/contracts'
const senateMidterms: StateElectionMarket[] = [ const senateMidterms: StateElectionMarket[] = [
@ -203,6 +204,8 @@ const App = (props: {
}) => { }) => {
const { senateContracts, governorContracts } = props const { senateContracts, governorContracts } = props
useTracking('view midterms 2022')
return ( return (
<Page className=""> <Page className="">
<Col className="items-center justify-center"> <Col className="items-center justify-center">

View File

@ -465,8 +465,11 @@ function IncomeNotificationItem(props: {
simple ? ( simple ? (
<span className={'ml-1 font-bold'}>🏦 Loan</span> <span className={'ml-1 font-bold'}>🏦 Loan</span>
) : ( ) : (
<SiteLink className={'ml-1 font-bold'} href={'/loans'}> <SiteLink
🏦 Loan className={'relative ml-1 font-bold'}
href={`/${sourceUserUsername}/?show=loans`}
>
🏦 Loan <span className="font-normal">(learn more)</span>
</SiteLink> </SiteLink>
) )
) : sourceType === 'betting_streak_bonus' ? ( ) : sourceType === 'betting_streak_bonus' ? (
@ -474,8 +477,8 @@ function IncomeNotificationItem(props: {
<span className={'ml-1 font-bold'}>{bettingStreakText}</span> <span className={'ml-1 font-bold'}>{bettingStreakText}</span>
) : ( ) : (
<SiteLink <SiteLink
className={'ml-1 font-bold'} className={'relative ml-1 font-bold'}
href={'/betting-streak-bonus'} href={`/${sourceUserUsername}/?show=betting-streak`}
> >
{bettingStreakText} {bettingStreakText}
</SiteLink> </SiteLink>

View File

@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import Link from 'next/link' import Link from 'next/link'
import React, { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { ConfirmationButton } from 'web/components/confirmation-button' import { ConfirmationButton } from 'web/components/confirmation-button'
import { ExpandingInput } from 'web/components/expanding-input'
import { Input } from 'web/components/input'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -43,16 +44,15 @@ function EditUserField(props: {
<label className="label">{label}</label> <label className="label">{label}</label>
{field === 'bio' ? ( {field === 'bio' ? (
<Textarea <ExpandingInput
className="textarea textarea-bordered w-full resize-none" className="w-full"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onBlur={updateField} onBlur={updateField}
/> />
) : ( ) : (
<input <Input
type="text" type="text"
className="input input-bordered"
value={value} value={value}
onChange={(e) => setValue(e.target.value || '')} onChange={(e) => setValue(e.target.value || '')}
onBlur={updateField} onBlur={updateField}
@ -152,10 +152,9 @@ export default function ProfilePage(props: {
<div> <div>
<label className="label">Display name</label> <label className="label">Display name</label>
<input <Input
type="text" type="text"
placeholder="Display name" placeholder="Display name"
className="input input-bordered"
value={name} value={name}
onChange={(e) => setName(e.target.value || '')} onChange={(e) => setName(e.target.value || '')}
onBlur={updateDisplayName} onBlur={updateDisplayName}
@ -164,10 +163,9 @@ export default function ProfilePage(props: {
<div> <div>
<label className="label">Username</label> <label className="label">Username</label>
<input <Input
type="text" type="text"
placeholder="Username" placeholder="Username"
className="input input-bordered"
value={username} value={username}
onChange={(e) => setUsername(e.target.value || '')} onChange={(e) => setUsername(e.target.value || '')}
onBlur={updateUsername} onBlur={updateUsername}
@ -199,10 +197,9 @@ export default function ProfilePage(props: {
<div> <div>
<label className="label">API key</label> <label className="label">API key</label>
<div className="input-group w-full"> <div className="input-group w-full">
<input <Input
type="text" type="text"
placeholder="Click refresh to generate key" placeholder="Click refresh to generate key"
className="input input-bordered w-full"
value={apiKey} value={apiKey}
readOnly readOnly
/> />

View File

@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
import { InfoBox } from 'web/components/info-box' import { InfoBox } from 'web/components/info-box'
import { QRCode } from 'web/components/qr-code' import { QRCode } from 'web/components/qr-code'
import { REFERRAL_AMOUNT } from 'common/economy' import { REFERRAL_AMOUNT } from 'common/economy'
import { formatMoney } from 'common/util/format'
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
@ -23,15 +24,17 @@ export default function ReferralsPage() {
return ( return (
<Page> <Page>
<SEO <SEO
title="Referrals" title="Refer a friend"
description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they description={`Invite new users to Manifold and get ${formatMoney(
REFERRAL_AMOUNT
)} if they
sign up!`} sign up!`}
url="/referrals" url="/referrals"
/> />
<Col className="items-center"> <Col className="items-center">
<Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md"> <Col className="h-full rounded bg-white p-4 py-8 sm:p-8 sm:shadow-md">
<Title className="!mt-0" text="Referrals" /> <Title className="!mt-0" text="Refer a friend" />
<img <img
className="mb-6 block -scale-x-100 self-center" className="mb-6 block -scale-x-100 self-center"
src="/logo-flapping-with-money.gif" src="/logo-flapping-with-money.gif"
@ -40,8 +43,8 @@ export default function ReferralsPage() {
/> />
<div className={'mb-4'}> <div className={'mb-4'}>
Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '}
sign up! if they sign up!
</div> </div>
<CopyLinkButton <CopyLinkButton