Merge branch 'main' into badges
This commit is contained in:
commit
66d494e2af
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal file
17
.github/workflows/merge-main-into-main2.yml
vendored
Normal 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 }}
|
|
@ -147,7 +147,8 @@ function calculateAmountToBuyShares(
|
|||
state: CpmmState,
|
||||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
// Search for amount between bounds (0, shares).
|
||||
// Min share price is M$0, and max is M$1 each.
|
||||
|
@ -157,7 +158,8 @@ function calculateAmountToBuyShares(
|
|||
amount,
|
||||
state,
|
||||
undefined,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
const totalShares = sumBy(takers, (taker) => taker.shares)
|
||||
|
@ -169,7 +171,8 @@ export function calculateCpmmSale(
|
|||
state: CpmmState,
|
||||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
if (Math.round(shares) < 0) {
|
||||
throw new Error('Cannot sell non-positive shares')
|
||||
|
@ -180,15 +183,17 @@ export function calculateCpmmSale(
|
|||
state,
|
||||
shares,
|
||||
oppositeOutcome,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
const { cpmmState, makers, takers, totalFees } = computeFills(
|
||||
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
|
||||
oppositeOutcome,
|
||||
buyAmount,
|
||||
state,
|
||||
undefined,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
// Transform buys of opposite outcome into sells.
|
||||
|
@ -211,6 +216,7 @@ export function calculateCpmmSale(
|
|||
fees: totalFees,
|
||||
makers,
|
||||
takers: saleTakers,
|
||||
ordersToCancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,9 +224,16 @@ export function getCpmmProbabilityAfterSale(
|
|||
state: CpmmState,
|
||||
shares: number,
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { last, sortBy, sum, sumBy } from 'lodash'
|
||||
import { last, sortBy, sum, sumBy, uniq } from 'lodash'
|
||||
import { calculatePayout } from './calculate'
|
||||
import { Bet } from './bet'
|
||||
import { Contract } from './contract'
|
||||
import { Bet, LimitBet } from './bet'
|
||||
import { Contract, CPMMContract, DPMContract } from './contract'
|
||||
import { PortfolioMetrics, User } from './user'
|
||||
import { DAY_MS } from './util/time'
|
||||
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
|
||||
import { getCpmmProbability } from './calculate-cpmm'
|
||||
|
||||
const computeInvestmentValue = (
|
||||
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 periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
|
|
|
@ -78,7 +78,8 @@ export function calculateShares(
|
|||
export function calculateSaleAmount(
|
||||
contract: Contract,
|
||||
bet: Bet,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
return contract.mechanism === 'cpmm-1' &&
|
||||
(contract.outcomeType === 'BINARY' ||
|
||||
|
@ -87,7 +88,8 @@ export function calculateSaleAmount(
|
|||
contract,
|
||||
Math.abs(bet.shares),
|
||||
bet.outcome as 'YES' | 'NO',
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
).saleValue
|
||||
: calculateDpmSaleAmount(contract, bet)
|
||||
}
|
||||
|
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
|
|||
contract: Contract,
|
||||
outcome: string,
|
||||
shares: number,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) {
|
||||
return contract.mechanism === 'cpmm-1'
|
||||
? getCpmmProbabilityAfterSale(
|
||||
contract,
|
||||
shares,
|
||||
outcome as 'YES' | 'NO',
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
volume: number
|
||||
volume24Hours: number
|
||||
volume7Days: number
|
||||
elasticity: number
|
||||
|
||||
collectedFees: Fees
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export const FLAT_TRADE_FEE = 0.1 // M$0.1
|
||||
|
||||
export const PLATFORM_FEE = 0
|
||||
export const CREATOR_FEE = 0
|
||||
export const LIQUIDITY_FEE = 0
|
||||
|
|
|
@ -17,8 +17,7 @@ import {
|
|||
import {
|
||||
CPMMBinaryContract,
|
||||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
DPMContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
|
@ -144,7 +143,8 @@ export const computeFills = (
|
|||
betAmount: number,
|
||||
state: CpmmState,
|
||||
limitProb: number | undefined,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) => {
|
||||
if (isNaN(betAmount)) {
|
||||
throw new Error('Invalid bet amount: ${betAmount}')
|
||||
|
@ -166,10 +166,12 @@ export const computeFills = (
|
|||
shares: number
|
||||
timestamp: number
|
||||
}[] = []
|
||||
const ordersToCancel: LimitBet[] = []
|
||||
|
||||
let amount = betAmount
|
||||
let cpmmState = { pool: state.pool, p: state.p }
|
||||
let totalFees = noFees
|
||||
const currentBalanceByUserId = { ...balanceByUserId }
|
||||
|
||||
let i = 0
|
||||
while (true) {
|
||||
|
@ -186,9 +188,20 @@ export const computeFills = (
|
|||
takers.push(taker)
|
||||
} else {
|
||||
// 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)
|
||||
makers.push(maker)
|
||||
i++
|
||||
}
|
||||
|
||||
amount -= taker.amount
|
||||
|
@ -196,7 +209,7 @@ export const computeFills = (
|
|||
if (floatingEqual(amount, 0)) break
|
||||
}
|
||||
|
||||
return { takers, makers, totalFees, cpmmState }
|
||||
return { takers, makers, totalFees, cpmmState, ordersToCancel }
|
||||
}
|
||||
|
||||
export const getBinaryCpmmBetInfo = (
|
||||
|
@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
|
|||
betAmount: number,
|
||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||
limitProb: number | undefined,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
const { takers, makers, cpmmState, totalFees } = computeFills(
|
||||
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
|
||||
outcome,
|
||||
betAmount,
|
||||
{ pool, p },
|
||||
limitProb,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const probBefore = getCpmmProbability(contract.pool, contract.p)
|
||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||
|
@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
|
|||
newP: cpmmState.p,
|
||||
newTotalLiquidity,
|
||||
makers,
|
||||
ordersToCancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -255,14 +271,16 @@ export const getBinaryBetStats = (
|
|||
betAmount: number,
|
||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||
limitProb: number,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
) => {
|
||||
const { newBet } = getBinaryCpmmBetInfo(
|
||||
outcome,
|
||||
betAmount ?? 0,
|
||||
contract,
|
||||
limitProb,
|
||||
unfilledBets as LimitBet[]
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const remainingMatched =
|
||||
((newBet.orderAmount ?? 0) - newBet.amount) /
|
||||
|
@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
export const getNewMultiBetInfo = (
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
contract: DPMContract
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
|
|
|
@ -70,6 +70,7 @@ export function getNewContract(
|
|||
volume: 0,
|
||||
volume24Hours: 0,
|
||||
volume7Days: 0,
|
||||
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
|
||||
|
||||
collectedFees: {
|
||||
creatorFee: 0,
|
||||
|
|
|
@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
|
|||
outcome: 'YES' | 'NO',
|
||||
contract: CPMMContract,
|
||||
unfilledBets: LimitBet[],
|
||||
balanceByUserId: { [userId: string]: number },
|
||||
loanPaid: number
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
|
||||
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
|
||||
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
|
||||
contract,
|
||||
shares,
|
||||
outcome,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId,
|
||||
)
|
||||
|
||||
const probBefore = getCpmmProbability(pool, p)
|
||||
|
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
|
|||
fees,
|
||||
makers,
|
||||
takers,
|
||||
ordersToCancel
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,31 +179,44 @@ export const getNotificationDestinationsForUser = (
|
|||
reason: notification_reason_types | notification_preference
|
||||
) => {
|
||||
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')
|
||||
return {
|
||||
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||
try {
|
||||
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'
|
||||
return {
|
||||
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${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: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,6 +73,7 @@ export const exhibitExts = [
|
|||
Image,
|
||||
Link,
|
||||
Mention,
|
||||
Mention.extend({ name: 'contract-mention' }),
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
TiptapSpoiler,
|
||||
|
|
|
@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
|
|||
|
||||
## 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*
|
||||
|
||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
||||
|
|
|
@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
|||
},
|
||||
} 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
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { slugify } from '../../common/util/slugify'
|
||||
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 { 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 { uniq, zip } from 'lodash'
|
||||
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(() =>
|
||||
z.intersection(
|
||||
|
@ -107,242 +107,229 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
|
|||
visibility = 'public',
|
||||
} = 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') {
|
||||
let initialValue
|
||||
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
|
||||
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
|
||||
throw new APIError(400, 'Invalid range.')
|
||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||
let initialValue
|
||||
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
|
||||
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
|
||||
throw new APIError(400, 'Invalid range.')
|
||||
|
||||
initialProb =
|
||||
getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||
|
||||
if (initialProb < 1 || initialProb > 99)
|
||||
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
|
||||
) {
|
||||
if (initialProb < 1 || initialProb > 99)
|
||||
if (outcomeType === 'PSEUDO_NUMERIC')
|
||||
throw new APIError(
|
||||
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'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const slug = await getSlug(trans, question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
else throw new APIError(400, 'Invalid initial probability.')
|
||||
}
|
||||
|
||||
console.log(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
'on',
|
||||
question,
|
||||
'ante:',
|
||||
ante || 0
|
||||
)
|
||||
if (outcomeType === 'BINARY') {
|
||||
;({ initialProb } = validate(binarySchema, body))
|
||||
}
|
||||
|
||||
// convert string descriptions into JSONContent
|
||||
const newDescription =
|
||||
!description || typeof description === 'string'
|
||||
? {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: description || ' ' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: description
|
||||
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
;({ answers } = validate(multipleChoiceSchema, body))
|
||||
}
|
||||
|
||||
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 userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||
if (!userDoc.exists) {
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
}
|
||||
const user = userDoc.data() as User
|
||||
|
||||
const providerId = deservesFreeMarket
|
||||
? isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: user.id
|
||||
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}.`)
|
||||
|
||||
if (ante) {
|
||||
const delta = FieldValue.increment(-ante)
|
||||
const providerDoc = firestore.collection('users').doc(providerId)
|
||||
await trans.update(providerDoc, { balance: delta, totalDeposits: delta })
|
||||
let group: Group | null = null
|
||||
if (groupId) {
|
||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
}
|
||||
|
||||
if (deservesFreeMarket) {
|
||||
await trans.update(firestore.collection('users').doc(user.id), {
|
||||
freeMarketsCreated: FieldValue.increment(1),
|
||||
group = groupDoc.data() as Group
|
||||
const groupMembersSnap = await firestore
|
||||
.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 groupContractsSnap = await trans.get(
|
||||
firestore.collection(`groups/${groupId}/groupContracts`)
|
||||
)
|
||||
const groupContracts = groupContractsSnap.docs.map(
|
||||
(doc) => doc.data() as { contractId: string; createdTime: number }
|
||||
const lp = getCpmmInitialLiquidity(
|
||||
providerId,
|
||||
contract as CPMMBinaryContract,
|
||||
liquidityDoc.id,
|
||||
ante
|
||||
)
|
||||
|
||||
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)) {
|
||||
await createGroupLinks(trans, group, [contractRef.id], auth.uid)
|
||||
const noneAnswer = getNoneAnswer(contract.id, user)
|
||||
await noneAnswerDoc.set(noneAnswer)
|
||||
|
||||
const groupContractRef = firestore
|
||||
.collection(`groups/${groupId}/groupContracts`)
|
||||
.doc(contract.id)
|
||||
const anteBetDoc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
|
||||
await trans.set(groupContractRef, {
|
||||
contractId: contract.id,
|
||||
createdTime: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
const anteBet = getFreeAnswerAnte(
|
||||
providerId,
|
||||
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 liquidityDoc = firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.doc()
|
||||
const anteBet = getNumericAnte(
|
||||
providerId,
|
||||
contract as NumericContract,
|
||||
ante,
|
||||
anteBetDoc.id
|
||||
)
|
||||
|
||||
const lp = getCpmmInitialLiquidity(
|
||||
providerId,
|
||||
contract as CPMMBinaryContract,
|
||||
liquidityDoc.id,
|
||||
ante
|
||||
)
|
||||
await anteBetDoc.set(anteBet)
|
||||
}
|
||||
|
||||
await trans.set(liquidityDoc, 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 ? 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
|
||||
})
|
||||
return contract
|
||||
}
|
||||
|
||||
const getSlug = async (trans: Transaction, question: string) => {
|
||||
const getSlug = async (question: string) => {
|
||||
const proposedSlug = slugify(question)
|
||||
|
||||
const preexistingContract = await getContractFromSlug(trans, proposedSlug)
|
||||
const preexistingContract = await getContractFromSlug(proposedSlug)
|
||||
|
||||
return preexistingContract
|
||||
? proposedSlug + '-' + randomString()
|
||||
|
@ -351,42 +338,46 @@ const getSlug = async (trans: Transaction, question: string) => {
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function getContractFromSlug(trans: Transaction, slug: string) {
|
||||
const snap = await trans.get(
|
||||
firestore.collection('contracts').where('slug', '==', slug)
|
||||
)
|
||||
export async function getContractFromSlug(slug: string) {
|
||||
const snap = await firestore
|
||||
.collection('contracts')
|
||||
.where('slug', '==', slug)
|
||||
.get()
|
||||
|
||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||
}
|
||||
|
||||
async function createGroupLinks(
|
||||
trans: Transaction,
|
||||
group: Group,
|
||||
contractIds: string[],
|
||||
userId: string
|
||||
) {
|
||||
for (const contractId of contractIds) {
|
||||
const contractRef = firestore.collection('contracts').doc(contractId)
|
||||
const contract = (await trans.get(contractRef)).data() as Contract
|
||||
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract?.groupSlugs?.includes(group.slug)) {
|
||||
await trans.update(contractRef, {
|
||||
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
||||
})
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
||||
})
|
||||
}
|
||||
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
|
||||
await trans.update(contractRef, {
|
||||
groupLinks: [
|
||||
{
|
||||
groupId: group.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
userId,
|
||||
createdTime: Date.now(),
|
||||
} as GroupLink,
|
||||
...(contract?.groupLinks ?? []),
|
||||
],
|
||||
})
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contractId)
|
||||
.update({
|
||||
groupLinks: [
|
||||
{
|
||||
groupId: group.id,
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
userId,
|
||||
createdTime: Date.now(),
|
||||
} as GroupLink,
|
||||
...(contract?.groupLinks ?? []),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
|||
if (question) {
|
||||
const closeTime = Date.now() + DAY_MS * 30 * 3
|
||||
|
||||
const result = await createMarketHelper(
|
||||
{
|
||||
question,
|
||||
closeTime,
|
||||
outcomeType: 'BINARY',
|
||||
visibility: 'unlisted',
|
||||
initialProb: 50,
|
||||
// Dating group!
|
||||
groupId: 'j3ZE8fkeqiKmRGumy3O1',
|
||||
},
|
||||
auth
|
||||
)
|
||||
contractSlug = result.slug
|
||||
try {
|
||||
const result = await createMarketHelper(
|
||||
{
|
||||
question,
|
||||
closeTime,
|
||||
outcomeType: 'BINARY',
|
||||
visibility: 'unlisted',
|
||||
initialProb: 50,
|
||||
// Dating group!
|
||||
groupId: 'j3ZE8fkeqiKmRGumy3O1',
|
||||
},
|
||||
auth
|
||||
)
|
||||
contractSlug = result.slug
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const post: Post = removeUndefinedProps({
|
||||
|
|
|
@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
|||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
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 { notification_reason_types } from '../../common/notification'
|
||||
import { Dictionary } from 'lodash'
|
||||
|
@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async (
|
|||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
if (!sendToEmail) return
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async (
|
|||
privateUser: PrivateUser,
|
||||
sendTime: string
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'onboarding_flow'
|
||||
)
|
||||
if (!sendToEmail) return
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Create your own prediction market',
|
||||
|
@ -279,22 +271,16 @@ export const sendThankYouEmail = async (
|
|||
user: User,
|
||||
privateUser: PrivateUser
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
|
||||
'email'
|
||||
)
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'thank_you_for_purchases'
|
||||
)
|
||||
|
||||
if (!sendToEmail) return
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Thanks for your Manifold purchase',
|
||||
|
@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async (
|
|||
contractsToSend: Contract[],
|
||||
deliveryTime?: string
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.trending_markets.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'trending_markets'
|
||||
)
|
||||
if (!sendToEmail) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
|||
investments: PerContractInvestmentsData[],
|
||||
overallPerformance: OverallPerformanceData
|
||||
) => {
|
||||
if (
|
||||
!privateUser ||
|
||||
!privateUser.email ||
|
||||
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
|
||||
)
|
||||
return
|
||||
if (!privateUser || !privateUser.email) return
|
||||
|
||||
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'profit_loss_updates'
|
||||
)
|
||||
|
||||
if (!sendToEmail) return
|
||||
|
||||
const { name } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
const templateData: Record<string, string> = {
|
||||
|
@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
|||
: 'portfolio-update',
|
||||
templateData
|
||||
)
|
||||
log('Sent portfolio update email to', privateUser.email)
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ export * from './on-create-user'
|
|||
export * from './on-create-bet'
|
||||
export * from './on-create-comment-on-contract'
|
||||
export * from './on-view'
|
||||
export * from './update-metrics'
|
||||
export { scheduleUpdateMetrics } from './update-metrics'
|
||||
export * from './update-stats'
|
||||
export * from './update-loans'
|
||||
export * from './backup-db'
|
||||
|
@ -77,6 +77,7 @@ import { getcurrentuser } from './get-current-user'
|
|||
import { acceptchallenge } from './accept-challenge'
|
||||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
import { updatemetrics } from './update-metrics'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
|||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||
const updateMetricsFunction = toCloudFunction(updatemetrics)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -133,4 +135,5 @@ export {
|
|||
saveTwitchCredentials as savetwitchcredentials,
|
||||
addCommentBounty as addcommentbounty,
|
||||
awardCommentBounty as awardcommentbounty,
|
||||
updateMetricsFunction as updatemetrics,
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
|||
import { createReferralNotification } from './create-notification'
|
||||
import { ReferralTxn } from '../../common/txn'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { LimitBet } from '../../common/bet'
|
||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
||||
import { Group } from '../../common/group'
|
||||
import { REFERRAL_AMOUNT } from '../../common/economy'
|
||||
const firestore = admin.firestore()
|
||||
|
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
|
|||
if (prevUser.referredByUserId !== user.referredByUserId) {
|
||||
await handleUserUpdatedReferral(user, eventId)
|
||||
}
|
||||
|
||||
if (user.balance <= 0) {
|
||||
await cancelLimitOrders(user.id)
|
||||
}
|
||||
})
|
||||
|
||||
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 }))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
|
|||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { FLAT_TRADE_FEE } from '../../common/fees'
|
||||
import {
|
||||
BetInfo,
|
||||
getBinaryCpmmBetInfo,
|
||||
|
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
|
|||
import { redeemShares } from './redeem-shares'
|
||||
import { log } from './utils'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
newTotalLiquidity,
|
||||
newP,
|
||||
makers,
|
||||
ordersToCancel,
|
||||
} = await (async (): Promise<
|
||||
BetInfo & {
|
||||
makers?: maker[]
|
||||
ordersToCancel?: LimitBet[]
|
||||
}
|
||||
> => {
|
||||
if (
|
||||
|
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
limitProb = Math.round(limitProb * 100) / 100
|
||||
}
|
||||
|
||||
const unfilledBetsSnap = await trans.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
)
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
const { unfilledBets, balanceByUserId } =
|
||||
await getUnfilledBetsAndUserBalances(trans, contractDoc)
|
||||
|
||||
return getBinaryCpmmBetInfo(
|
||||
outcome,
|
||||
amount,
|
||||
contract,
|
||||
limitProb,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
} else if (
|
||||
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
|
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
if (makers) {
|
||||
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) {
|
||||
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
|
||||
log('Updated user balance.')
|
||||
|
||||
trans.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
|
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
|
||||
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
|
||||
return contractDoc
|
||||
.collection('bets')
|
||||
.where('isFilled', '==', false)
|
||||
.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 = {
|
||||
bet: LimitBet
|
||||
amount: number
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { getAllPrivateUsers } from 'functions/src/utils'
|
||||
import { filterDefined } from 'common/lib/util/array'
|
||||
import { getPrivateUser } from '../utils'
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function main() {
|
||||
const privateUsers = await getAllPrivateUsers()
|
||||
// const privateUsers = await getAllPrivateUsers()
|
||||
const privateUsers = filterDefined([
|
||||
await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'),
|
||||
])
|
||||
await Promise.all(
|
||||
privateUsers.map((privateUser) => {
|
||||
if (!privateUser.id) return Promise.resolve()
|
||||
|
@ -20,6 +24,19 @@ async function main() {
|
|||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
|
||||
import { isEqual, zip } from 'lodash'
|
||||
import { UpdateSpec } from '../utils'
|
||||
|
||||
export type DocumentValue = {
|
||||
doc: DocumentSnapshot
|
||||
|
@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
|
|||
return {
|
||||
doc: diff.dest.doc.ref,
|
||||
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
|
||||
} as UpdateSpec
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDiff(transaction: Transaction, diff: DocumentDiff) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
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 { Bet } from '../../common/bet'
|
||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { removeUserFromContractFollowers } from './follow-market'
|
||||
|
||||
|
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
|
||||
await Promise.all([
|
||||
transaction.getAll(contractDoc, userDoc),
|
||||
transaction.get(betsQ),
|
||||
transaction.get(getUnfilledBetsQuery(contractDoc)),
|
||||
])
|
||||
const [
|
||||
[contractSnap, userSnap],
|
||||
userBetsSnap,
|
||||
{ unfilledBets, balanceByUserId },
|
||||
] = await Promise.all([
|
||||
transaction.getAll(contractDoc, userDoc),
|
||||
transaction.get(betsQ),
|
||||
getUnfilledBetsAndUserBalances(transaction, contractDoc),
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
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 user = userSnap.data() as User
|
||||
|
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
let loanPaid = saleFrac * loanAmount
|
||||
if (!isFinite(loanPaid)) loanPaid = 0
|
||||
|
||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
unfilledBets,
|
||||
loanPaid
|
||||
)
|
||||
const { newBet, newPool, newP, fees, makers, ordersToCancel } =
|
||||
getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
unfilledBets,
|
||||
balanceByUserId,
|
||||
loanPaid
|
||||
)
|
||||
|
||||
if (
|
||||
!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 }
|
||||
})
|
||||
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract, CPMM } from '../../common/contract'
|
||||
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
|
@ -14,18 +15,41 @@ import {
|
|||
calculateNewPortfolioMetrics,
|
||||
calculateNewProfit,
|
||||
calculateProbChanges,
|
||||
computeElasticity,
|
||||
computeVolume,
|
||||
} from '../../common/calculate-metrics'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { Group } from '../../common/group'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { newEndpointNoAuth } from './api'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '8GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
export const scheduleUpdateMetrics = functions.pubsub
|
||||
.schedule('every 15 minutes')
|
||||
.onRun(async () => {
|
||||
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() {
|
||||
console.log('Loading users')
|
||||
|
@ -103,6 +127,7 @@ export async function updateMetricsCore() {
|
|||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
elasticity: computeElasticity(contractBets, contract),
|
||||
...cpmmFields,
|
||||
},
|
||||
}
|
||||
|
@ -144,7 +169,7 @@ export async function updateMetricsCore() {
|
|||
return 0
|
||||
}
|
||||
const contractRatio =
|
||||
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
|
||||
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
|
||||
|
||||
return contractRatio
|
||||
})
|
||||
|
|
|
@ -47,7 +47,7 @@ export const writeAsync = async (
|
|||
const batch = db.batch()
|
||||
for (const { doc, fields } of chunks[i]) {
|
||||
if (operationType === 'update') {
|
||||
batch.update(doc, fields)
|
||||
batch.update(doc, fields as any)
|
||||
} else {
|
||||
batch.set(doc, fields)
|
||||
}
|
||||
|
|
|
@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
|||
)
|
||||
)
|
||||
)
|
||||
log('Found', contractsUsersBetOn.length, 'contracts')
|
||||
let count = 0
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||
const user = await getUser(privateUser.id)
|
||||
// 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 contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
||||
userBets.some((bet) => bet.contractId === contract.id)
|
||||
|
@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
|||
(differences) => Math.abs(differences.profit)
|
||||
).reverse()
|
||||
|
||||
log(
|
||||
'Found',
|
||||
investmentValueDifferences.length,
|
||||
'investment differences for user',
|
||||
privateUser.id
|
||||
)
|
||||
|
||||
const [winningInvestments, losingInvestments] = partition(
|
||||
investmentValueDifferences.filter(
|
||||
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
||||
|
@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() {
|
|||
usersToContractsCreated[privateUser.id].length === 0
|
||||
) {
|
||||
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({
|
||||
weeklyPortfolioUpdateEmailSent: true,
|
||||
})
|
||||
return
|
||||
return await setEmailFlagAsSent(privateUser.id)
|
||||
}
|
||||
// Set the flag beforehand just to be safe
|
||||
await setEmailFlagAsSent(privateUser.id)
|
||||
await sendWeeklyPortfolioUpdateEmail(
|
||||
user,
|
||||
privateUser,
|
||||
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
||||
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 = {
|
||||
questionTitle: string
|
||||
questionUrl: string
|
||||
|
|
10
web/components/NoSEO.tsx
Normal file
10
web/components/NoSEO.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -6,6 +6,7 @@ import { Col } from './layout/col'
|
|||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { Row } from './layout/row'
|
||||
import { AddFundsModal } from './add-funds-modal'
|
||||
import { Input } from './input'
|
||||
|
||||
export function AmountInput(props: {
|
||||
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">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
<Input
|
||||
className={clsx(
|
||||
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
|
||||
'pl-9',
|
||||
error && 'input-error',
|
||||
'w-24 md:w-auto',
|
||||
inputClassName
|
||||
|
|
|
@ -10,6 +10,7 @@ import { formatPercent } from 'common/util/format'
|
|||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Linkify } from '../linkify'
|
||||
import { Input } from '../input'
|
||||
|
||||
export function AnswerItem(props: {
|
||||
answer: Answer
|
||||
|
@ -74,8 +75,8 @@ export function AnswerItem(props: {
|
|||
<Row className="items-center justify-end gap-4 self-end sm:self-start">
|
||||
{!wasResolvedTo &&
|
||||
(showChoice === 'checkbox' ? (
|
||||
<input
|
||||
className="input input-bordered w-24 justify-self-end text-2xl"
|
||||
<Input
|
||||
className="w-24 justify-self-end !text-2xl"
|
||||
type="number"
|
||||
placeholder={`${roundedProb}`}
|
||||
maxLength={9}
|
||||
|
|
|
@ -157,11 +157,9 @@ export function AnswersPanel(props: {
|
|||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' &&
|
||||
tradingAllowed(contract) &&
|
||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
||||
<CreateAnswerPanel contract={contract} />
|
||||
)}
|
||||
{outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && (
|
||||
<CreateAnswerPanel contract={contract} />
|
||||
)}
|
||||
|
||||
{(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) &&
|
||||
!resolution && (
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import React, { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { findBestMatch } from 'string-similarity'
|
||||
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
|
@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
|
|||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { lowerCase } from 'lodash'
|
||||
import { Button } from '../button'
|
||||
import { ExpandingInput } from '../expanding-input'
|
||||
|
||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||
const { contract } = props
|
||||
|
@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
<Col className="gap-4 rounded">
|
||||
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
||||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
value={text}
|
||||
onChange={(e) => changeAnswer(e.target.value)}
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
className="w-full"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { ExpandingInput } from '../expanding-input'
|
||||
|
||||
export function MultipleChoiceAnswers(props: {
|
||||
answers: string[]
|
||||
|
@ -27,10 +27,10 @@ export function MultipleChoiceAnswers(props: {
|
|||
{answers.map((answer, i) => (
|
||||
<Row className="mb-2 items-center gap-2 align-middle">
|
||||
{i + 1}.{' '}
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
value={answer}
|
||||
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..."
|
||||
rows={1}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
|
|
|
@ -2,12 +2,12 @@ import clsx from 'clsx'
|
|||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
|
||||
import { MenuIcon } from '@heroicons/react/solid'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { XCircleIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Subtitle } from 'web/components/subtitle'
|
||||
import { keyBy } from 'lodash'
|
||||
import { XCircleIcon } from '@heroicons/react/outline'
|
||||
import { Button } from './button'
|
||||
import { updateUser } from 'web/lib/firebase/users'
|
||||
import { leaveGroup } from 'web/lib/firebase/groups'
|
||||
|
|
|
@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
|
|||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
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'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
|
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
|
|||
user: User
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||
contract.id
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
|
|||
contract={contract as CPMMBinaryContract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
mobileView={true}
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
|
|||
import { Button } from './button'
|
||||
import { Row } from './layout/row'
|
||||
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 { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||
|
@ -34,14 +34,17 @@ export function BetInline(props: {
|
|||
const [error, setError] = useState<string>()
|
||||
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||
contract.id
|
||||
)
|
||||
|
||||
const { newPool, newP } = getBinaryCpmmBetInfo(
|
||||
outcome ?? 'YES',
|
||||
amount ?? 0,
|
||||
contract,
|
||||
undefined,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const resultProb = getCpmmProbability(newPool, newP)
|
||||
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])
|
||||
|
|
|
@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
|
|||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { ProbabilityOrNumericInput } from './probability-input'
|
||||
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 { PillButton } from './buttons/pill-button'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
|
@ -55,7 +55,9 @@ export function BetPanel(props: {
|
|||
const { contract, className } = props
|
||||
const user = useUser()
|
||||
const userBets = useUserContractBets(user?.id, contract.id)
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||
contract.id
|
||||
)
|
||||
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
|
||||
|
||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||
|
@ -86,12 +88,14 @@ export function BetPanel(props: {
|
|||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
/>
|
||||
<LimitOrderPanel
|
||||
hidden={!isLimitOrder}
|
||||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
@ -117,7 +121,9 @@ export function SimpleBetPanel(props: {
|
|||
const user = useUser()
|
||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||
contract.id
|
||||
)
|
||||
|
||||
return (
|
||||
<Col className={className}>
|
||||
|
@ -142,6 +148,7 @@ export function SimpleBetPanel(props: {
|
|||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
onBuySuccess={onBetSuccess}
|
||||
/>
|
||||
<LimitOrderPanel
|
||||
|
@ -149,6 +156,7 @@ export function SimpleBetPanel(props: {
|
|||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
onBuySuccess={onBetSuccess}
|
||||
/>
|
||||
|
||||
|
@ -167,13 +175,21 @@ export function SimpleBetPanel(props: {
|
|||
export function BuyPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
user: User | null | undefined
|
||||
unfilledBets: Bet[]
|
||||
unfilledBets: LimitBet[]
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
hidden: boolean
|
||||
onBuySuccess?: () => void
|
||||
mobileView?: boolean
|
||||
}) {
|
||||
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
|
||||
props
|
||||
const {
|
||||
contract,
|
||||
user,
|
||||
unfilledBets,
|
||||
balanceByUserId,
|
||||
hidden,
|
||||
onBuySuccess,
|
||||
mobileView,
|
||||
} = props
|
||||
|
||||
const initialProb = getProbability(contract)
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
@ -261,7 +277,8 @@ export function BuyPanel(props: {
|
|||
betAmount ?? 0,
|
||||
contract,
|
||||
undefined,
|
||||
unfilledBets as LimitBet[]
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
const [seeLimit, setSeeLimit] = useState(false)
|
||||
|
@ -416,6 +433,7 @@ export function BuyPanel(props: {
|
|||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
/>
|
||||
<LimitBets
|
||||
contract={contract}
|
||||
|
@ -431,11 +449,19 @@ export function BuyPanel(props: {
|
|||
function LimitOrderPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
user: User | null | undefined
|
||||
unfilledBets: Bet[]
|
||||
unfilledBets: LimitBet[]
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
hidden: boolean
|
||||
onBuySuccess?: () => void
|
||||
}) {
|
||||
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
|
||||
const {
|
||||
contract,
|
||||
user,
|
||||
unfilledBets,
|
||||
balanceByUserId,
|
||||
hidden,
|
||||
onBuySuccess,
|
||||
} = props
|
||||
|
||||
const initialProb = getProbability(contract)
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
@ -581,7 +607,8 @@ function LimitOrderPanel(props: {
|
|||
yesAmount,
|
||||
contract,
|
||||
yesLimitProb ?? initialProb,
|
||||
unfilledBets as LimitBet[]
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const yesReturnPercent = formatPercent(yesReturn)
|
||||
|
||||
|
@ -595,7 +622,8 @@ function LimitOrderPanel(props: {
|
|||
noAmount,
|
||||
contract,
|
||||
noLimitProb ?? initialProb,
|
||||
unfilledBets as LimitBet[]
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const noReturnPercent = formatPercent(noReturn)
|
||||
|
||||
|
@ -830,7 +858,9 @@ export function SellPanel(props: {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
||||
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||
contract.id
|
||||
)
|
||||
|
||||
const betDisabled = isSubmitting || !amount || error !== undefined
|
||||
|
||||
|
@ -889,7 +919,8 @@ export function SellPanel(props: {
|
|||
contract,
|
||||
sellQuantity ?? 0,
|
||||
sharesOutcome,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const netProceeds = saleValue - loanPaid
|
||||
const profit = saleValue - costBasis
|
||||
|
|
|
@ -4,7 +4,7 @@ import dayjs from 'dayjs'
|
|||
import { useMemo, useState } from 'react'
|
||||
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 {
|
||||
formatMoney,
|
||||
|
@ -17,6 +17,7 @@ import {
|
|||
Contract,
|
||||
contractPath,
|
||||
getBinaryProbPercent,
|
||||
MAX_USER_BET_CONTRACTS_LOADED,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { Row } from './layout/row'
|
||||
import { sellBet } from 'web/lib/firebase/api'
|
||||
|
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
|
|||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
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 { Pagination } from './pagination'
|
||||
import { LimitOrderTable } from './limit-bets'
|
||||
|
@ -50,6 +51,7 @@ import {
|
|||
usePersistentState,
|
||||
} from 'web/hooks/use-persistent-state'
|
||||
import { safeLocalStorage } from 'web/lib/util/local'
|
||||
import { ExclamationIcon } from '@heroicons/react/outline'
|
||||
|
||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||
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
|
||||
}, [contractList])
|
||||
|
||||
const loadedPartialData =
|
||||
userBets?.length === MAX_USER_BETS_LOADED ||
|
||||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
|
||||
|
||||
const [sort, setSort] = usePersistentState<BetSort>('newest', {
|
||||
key: 'bets-list-sort',
|
||||
store: storageStore(safeLocalStorage()),
|
||||
|
@ -167,6 +173,13 @@ export function BetsList(props: { user: User }) {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<Row className="gap-4">
|
||||
<Col>
|
||||
|
@ -412,7 +425,9 @@ export function ContractBetsTable(props: {
|
|||
const isNumeric = outcomeType === 'NUMERIC'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
|
||||
contract.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
|
@ -461,6 +476,7 @@ export function ContractBetsTable(props: {
|
|||
contract={contract}
|
||||
isYourBet={isYourBets}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -475,8 +491,10 @@ function BetRow(props: {
|
|||
saleBet?: Bet
|
||||
isYourBet: boolean
|
||||
unfilledBets: LimitBet[]
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
}) {
|
||||
const { bet, saleBet, contract, isYourBet, unfilledBets } = props
|
||||
const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
|
||||
props
|
||||
const {
|
||||
amount,
|
||||
outcome,
|
||||
|
@ -504,9 +522,9 @@ function BetRow(props: {
|
|||
} else if (contract.isResolved) {
|
||||
return resolvedPayout(contract, bet)
|
||||
} else {
|
||||
return calculateSaleAmount(contract, bet, unfilledBets)
|
||||
return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
|
||||
}
|
||||
}, [contract, bet, saleBet, unfilledBets])
|
||||
}, [contract, bet, saleBet, unfilledBets, balanceByUserId])
|
||||
|
||||
const saleDisplay = isAnte ? (
|
||||
'ANTE'
|
||||
|
@ -545,6 +563,7 @@ function BetRow(props: {
|
|||
contract={contract}
|
||||
bet={bet}
|
||||
unfilledBets={unfilledBets}
|
||||
balanceByUserId={balanceByUserId}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
@ -590,8 +609,9 @@ function SellButton(props: {
|
|||
contract: Contract
|
||||
bet: Bet
|
||||
unfilledBets: LimitBet[]
|
||||
balanceByUserId: { [userId: string]: number }
|
||||
}) {
|
||||
const { contract, bet, unfilledBets } = props
|
||||
const { contract, bet, unfilledBets, balanceByUserId } = props
|
||||
const { outcome, shares, loanAmount } = bet
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
@ -605,10 +625,16 @@ function SellButton(props: {
|
|||
contract,
|
||||
outcome,
|
||||
shares,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
|
||||
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets)
|
||||
const saleAmount = calculateSaleAmount(
|
||||
contract,
|
||||
bet,
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
const profit = saleAmount - bet.amount
|
||||
|
||||
return (
|
||||
|
|
|
@ -20,11 +20,11 @@ import { getProbability } from 'common/calculate'
|
|||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { FIXED_ANTE } from 'common/economy'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { useTextEditor } from 'web/components/editor'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { CopyLinkButton } from '../copy-link-button'
|
||||
import { ExpandingInput } from '../expanding-input'
|
||||
|
||||
type challengeInfo = {
|
||||
amount: number
|
||||
|
@ -153,9 +153,9 @@ function CreateChallengeForm(props: {
|
|||
{contract ? (
|
||||
<span className="underline">{contract.question}</span>
|
||||
) : (
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
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}
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={challengeInfo.question}
|
||||
|
|
|
@ -41,6 +41,7 @@ import { AdjustmentsIcon } from '@heroicons/react/solid'
|
|||
import { Button } from './button'
|
||||
import { Modal } from './layout/modal'
|
||||
import { Title } from './title'
|
||||
import { Input } from './input'
|
||||
|
||||
export const SORTS = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
|
@ -48,6 +49,7 @@ export const SORTS = [
|
|||
{ label: 'Daily trending', value: 'daily-score' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: 'Most popular', value: 'most-popular' },
|
||||
{ label: 'Liquidity', value: 'liquidity' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
{ label: 'Closing soon', value: 'close-date' },
|
||||
{ label: 'Resolve date', value: 'resolve-date' },
|
||||
|
@ -437,13 +439,13 @@ function ContractSearchControls(props: {
|
|||
return (
|
||||
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => updateQuery(e.target.value)}
|
||||
onBlur={trackCallback('search', { query: query })}
|
||||
placeholder={'Search'}
|
||||
className="input input-bordered w-full"
|
||||
placeholder="Search"
|
||||
className="w-full"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{!isMobile && !query && (
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CurrencyDollarIcon } from '@heroicons/react/outline'
|
||||
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 { formatMoney } from 'common/util/format'
|
||||
import { Tooltip } from 'web/components/tooltip'
|
||||
import { CommentBountyDialog } from './comment-bounty-dialog'
|
||||
|
||||
export function BountiedContractBadge() {
|
||||
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
|
||||
</span>
|
||||
)
|
||||
|
@ -18,30 +22,59 @@ export function BountiedContractSmallBadge(props: {
|
|||
}) {
|
||||
const { contract, showAmount } = props
|
||||
const { openCommentBounties } = contract
|
||||
if (!openCommentBounties) return <div />
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={CommentBountiesTooltipText(
|
||||
contract.creatorName,
|
||||
openCommentBounties
|
||||
)}
|
||||
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>
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!openCommentBounties && !showAmount) return <></>
|
||||
|
||||
const modal = (
|
||||
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
|
||||
)
|
||||
}
|
||||
|
||||
export const CommentBountiesTooltipText = (
|
||||
creator: string,
|
||||
openCommentBounties: number
|
||||
) =>
|
||||
`${creator} may award ${formatMoney(
|
||||
const bountiesClosed =
|
||||
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
|
||||
|
||||
if (!openCommentBounties) {
|
||||
if (bountiesClosed) return <></>
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tooltip = `${contract.creatorName} may award ${formatMoney(
|
||||
COMMENT_BOUNTY_AMOUNT
|
||||
)} for good comments. ${formatMoney(
|
||||
openCommentBounties
|
||||
)} 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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,9 +8,16 @@ import clsx from 'clsx'
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
|
||||
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 }) {
|
||||
const { contract } = props
|
||||
export function CommentBountyDialog(props: {
|
||||
contract: Contract
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { contract, open, setOpen } = props
|
||||
const { id: contractId, slug } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
@ -45,30 +52,34 @@ export function AddCommentBountyPanel(props: { contract: Contract }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Add a {formatMoney(amount)} bounty for good comments that the creator
|
||||
can award.{' '}
|
||||
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
|
||||
</div>
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Col className="gap-4 rounded bg-white p-6">
|
||||
<Title className="!mt-0 !mb-0" text="Comment bounty" />
|
||||
|
||||
<Row className={'items-center gap-2'}>
|
||||
<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>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Add a {formatMoney(amount)} bounty for good comments that the creator
|
||||
can award.{' '}
|
||||
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
|
||||
</div>
|
||||
|
||||
{isSuccess && amount && (
|
||||
<div>Success! Added {formatMoney(amount)} in bounties.</div>
|
||||
)}
|
||||
<Row className={'items-center gap-2'}>
|
||||
<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>
|
||||
)
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
|
@ -15,6 +13,7 @@ import { Button } from '../button'
|
|||
import { Spacer } from '../layout/spacer'
|
||||
import { Editor, Content as ContentType } from '@tiptap/react'
|
||||
import { insertContent } from '../editor/utils'
|
||||
import { ExpandingInput } from '../expanding-input'
|
||||
|
||||
export function ContractDescription(props: {
|
||||
contract: Contract
|
||||
|
@ -138,8 +137,8 @@ function EditQuestion(props: {
|
|||
|
||||
return editing ? (
|
||||
<div className="mt-4">
|
||||
<Textarea
|
||||
className="textarea textarea-bordered mb-1 h-24 w-full resize-none"
|
||||
<ExpandingInput
|
||||
className="mb-1 h-24 w-full"
|
||||
rows={2}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value || '')}
|
||||
|
|
|
@ -40,6 +40,7 @@ import {
|
|||
BountiedContractBadge,
|
||||
BountiedContractSmallBadge,
|
||||
} from 'web/components/contract/bountied-contract-badge'
|
||||
import { Input } from '../input'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -445,17 +446,17 @@ function EditableCloseDate(props: {
|
|||
<Col className="rounded bg-white px-8 pb-8">
|
||||
<Subtitle text="Edit market close time" />
|
||||
<Row className="z-10 mr-2 mt-4 w-full shrink-0 flex-wrap items-center gap-2">
|
||||
<input
|
||||
<Input
|
||||
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()}
|
||||
onChange={(e) => setCloseDate(e.target.value)}
|
||||
min={Date.now()}
|
||||
value={closeDate}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
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()}
|
||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||
min="00:00"
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useState } from 'react'
|
|||
import { capitalize } from 'lodash'
|
||||
|
||||
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 { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
|
||||
import { Col } from '../layout/col'
|
||||
|
@ -54,6 +54,7 @@ export function ContractInfoDialog(props: {
|
|||
mechanism,
|
||||
outcomeType,
|
||||
id,
|
||||
elasticity,
|
||||
} = contract
|
||||
|
||||
const typeDisplay =
|
||||
|
@ -142,7 +143,10 @@ export function ContractInfoDialog(props: {
|
|||
)}
|
||||
|
||||
<tr>
|
||||
<td>Volume</td>
|
||||
<td>
|
||||
<span className="mr-1">Volume</span>
|
||||
<InfoTooltip text="Total amount bought or sold" />
|
||||
</td>
|
||||
<td>{formatMoney(contract.volume)}</td>
|
||||
</tr>
|
||||
|
||||
|
@ -151,6 +155,22 @@ export function ContractInfoDialog(props: {
|
|||
<td>{uniqueBettorCount ?? '0'}</td>
|
||||
</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>
|
||||
<td>
|
||||
{mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'}
|
||||
|
|
|
@ -80,7 +80,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
|
|||
const { contract } = props
|
||||
const tips = useTipTxns({ contractId: contract.id })
|
||||
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`,
|
||||
store: storageStore(safeLocalStorage()),
|
||||
})
|
||||
|
@ -177,8 +177,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
|
|||
<Col className="mt-8 flex w-full">
|
||||
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
|
||||
<div className="mb-4 w-full border-b border-gray-200" />
|
||||
{sortRow}
|
||||
<ContractCommentInput className="mb-5" contract={contract} />
|
||||
{sortRow}
|
||||
|
||||
{generalTopLevelComments.map((comment) => (
|
||||
<FeedCommentThread
|
||||
key={comment.id}
|
||||
|
@ -194,8 +195,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
|
|||
} else {
|
||||
return (
|
||||
<>
|
||||
{sortRow}
|
||||
<ContractCommentInput className="mb-5" contract={contract} />
|
||||
{sortRow}
|
||||
|
||||
{topLevelComments.map((parent) => (
|
||||
<FeedCommentThread
|
||||
key={parent.id}
|
||||
|
|
|
@ -16,7 +16,6 @@ import { InfoTooltip } from 'web/components/info-tooltip'
|
|||
import { BETTORS, PRESENT_BET } from 'common/user'
|
||||
import { buildArray } from 'common/util/array'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
|
||||
|
||||
export function LiquidityBountyPanel(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
@ -36,13 +35,11 @@ export function LiquidityBountyPanel(props: { contract: Contract }) {
|
|||
const isCreator = user?.id === contract.creatorId
|
||||
const isAdmin = useAdmin()
|
||||
|
||||
if (!isCreator && !isAdmin && !showWithdrawal) return <></>
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
tabs={buildArray(
|
||||
{
|
||||
title: 'Bounty Comments',
|
||||
content: <AddCommentBountyPanel contract={contract} />,
|
||||
},
|
||||
(isCreator || isAdmin) &&
|
||||
isCPMM && {
|
||||
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
|
||||
|
|
|
@ -37,12 +37,12 @@ export function ProbChangeTable(props: {
|
|||
|
||||
return (
|
||||
<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) => (
|
||||
<ContractCardProbChange key={contract.id} contract={contract} />
|
||||
))}
|
||||
</Col>
|
||||
<Col className="flex-1 gap-4">
|
||||
<Col className="flex-1">
|
||||
{filteredNegativeChanges.map((contract) => (
|
||||
<ContractCardProbChange key={contract.id} contract={contract} />
|
||||
))}
|
||||
|
|
|
@ -33,7 +33,7 @@ import { sellShares } from 'web/lib/firebase/api'
|
|||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
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'
|
||||
|
||||
const BET_SIZE = 10
|
||||
|
@ -48,7 +48,10 @@ export function QuickBet(props: {
|
|||
const isCpmm = mechanism === 'cpmm-1'
|
||||
|
||||
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 } =
|
||||
useSaveBinaryShares(contract, userBets)
|
||||
|
@ -94,7 +97,8 @@ export function QuickBet(props: {
|
|||
contract,
|
||||
sharesSold,
|
||||
sellOutcome,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
balanceByUserId
|
||||
)
|
||||
saleAmount = saleValue
|
||||
previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Title } from 'web/components/title'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { TextEditor, useTextEditor } from 'web/components/editor'
|
||||
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 { postPath } from 'web/lib/firebase/posts'
|
||||
import { Group } from 'common/group'
|
||||
import { ExpandingInput } from './expanding-input'
|
||||
|
||||
export function CreatePost(props: { group?: Group }) {
|
||||
const [title, setTitle] = useState('')
|
||||
|
@ -60,9 +60,8 @@ export function CreatePost(props: { group?: Group }) {
|
|||
Title<span className={'text-red-700'}> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
placeholder="e.g. Elon Mania Post"
|
||||
className="input input-bordered resize-none"
|
||||
autoFocus
|
||||
maxLength={MAX_POST_TITLE_LENGTH}
|
||||
value={title}
|
||||
|
@ -74,9 +73,8 @@ export function CreatePost(props: { group?: Group }) {
|
|||
Subtitle<span className={'text-red-700'}> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
placeholder="e.g. How Elon Musk is getting everyone's attention"
|
||||
className="input input-bordered resize-none"
|
||||
autoFocus
|
||||
maxLength={MAX_POST_TITLE_LENGTH}
|
||||
value={subtitle}
|
||||
|
|
|
@ -6,16 +6,28 @@ import {
|
|||
} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
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 ContractMentionComponent = (props: any) => {
|
||||
const contract = useContract(props.node.attrs.id)
|
||||
const { label, id } = props.node.attrs
|
||||
const contract = useContract(id)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -29,8 +41,5 @@ export const DisplayContractMention = Mention.extend({
|
|||
name: 'contract-mention',
|
||||
parseHTML: () => [{ tag: name }],
|
||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () =>
|
||||
ReactNodeViewRenderer(ContractMentionComponent, {
|
||||
// On desktop, render cards below half-width so you can stack two
|
||||
}),
|
||||
addNodeView: () => ReactNodeViewRenderer(ContractMentionComponent),
|
||||
})
|
||||
|
|
16
web/components/expanding-input.tsx
Normal file
16
web/components/expanding-input.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -64,11 +64,11 @@ export function BetStatusText(props: {
|
|||
}, [challengeSlug, contract.id])
|
||||
|
||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||
const money = formatMoney(Math.abs(amount))
|
||||
const outOfTotalAmount =
|
||||
bet.limitProb !== undefined && bet.orderAmount !== undefined
|
||||
? ` / ${formatMoney(bet.orderAmount)}`
|
||||
? ` of ${bet.isCancelled ? money : formatMoney(bet.orderAmount)}`
|
||||
: ''
|
||||
const money = formatMoney(Math.abs(amount))
|
||||
|
||||
const hadPoolMatch =
|
||||
(bet.limitProb === undefined ||
|
||||
|
@ -105,7 +105,6 @@ export function BetStatusText(props: {
|
|||
{!hideOutcome && (
|
||||
<>
|
||||
{' '}
|
||||
of{' '}
|
||||
<OutcomeLabel
|
||||
outcome={outcome}
|
||||
value={(bet as any).value}
|
||||
|
|
|
@ -109,12 +109,18 @@ export const FeedComment = memo(function FeedComment(props: {
|
|||
}
|
||||
const totalAwarded = bountiesAwarded ?? 0
|
||||
|
||||
const router = useRouter()
|
||||
const highlighted = router.asPath.endsWith(`#${comment.id}`)
|
||||
const { isReady, asPath } = useRouter()
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
const commentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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)
|
||||
}
|
||||
}, [highlighted])
|
||||
|
@ -126,7 +132,7 @@ export const FeedComment = memo(function FeedComment(props: {
|
|||
className={clsx(
|
||||
'relative',
|
||||
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:*/}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { Input } from './input'
|
||||
|
||||
export function FilterSelectUsers(props: {
|
||||
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">
|
||||
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
name="user name"
|
||||
id="user name"
|
||||
value={query}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Title } from '../title'
|
|||
import { User } from 'common/user'
|
||||
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
|
||||
import { createGroup } from 'web/lib/firebase/api'
|
||||
import { Input } from '../input'
|
||||
|
||||
export function CreateGroupButton(props: {
|
||||
user: User
|
||||
|
@ -104,9 +105,8 @@ export function CreateGroupButton(props: {
|
|||
|
||||
<div className="form-control w-full">
|
||||
<label className="mb-2 ml-1 mt-0">Group name</label>
|
||||
<input
|
||||
<Input
|
||||
placeholder={'Your group name'}
|
||||
className="input input-bordered resize-none"
|
||||
disabled={isSubmitting}
|
||||
value={name}
|
||||
maxLength={MAX_GROUP_NAME_LENGTH}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Modal } from 'web/components/layout/modal'
|
|||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { User } from 'common/user'
|
||||
import { useMemberIds } from 'web/hooks/use-group'
|
||||
import { Input } from '../input'
|
||||
|
||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||
const { group, className } = props
|
||||
|
@ -54,9 +55,8 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
<span className="mb-1">Group name</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
<Input
|
||||
placeholder="Your group name"
|
||||
className="input input-bordered resize-none"
|
||||
disabled={isSubmitting}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value || '')}
|
||||
|
|
|
@ -145,8 +145,6 @@ function GroupOverviewPinned(props: {
|
|||
}) {
|
||||
const { group, posts, isEditable } = props
|
||||
const [pinned, setPinned] = useState<JSX.Element[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function getPinned() {
|
||||
|
@ -185,100 +183,127 @@ function GroupOverviewPinned(props: {
|
|||
...(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) ? (
|
||||
pinned.length > 0 || isEditable ? (
|
||||
<div>
|
||||
<Row className="mb-3 items-center justify-between">
|
||||
<SectionHeader label={'Pinned'} />
|
||||
{isEditable && (
|
||||
<Button
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode)
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
<PinnedItems
|
||||
posts={posts}
|
||||
group={group}
|
||||
isEditable={isEditable}
|
||||
pinned={pinned}
|
||||
onDeleteClicked={onDeleteClicked}
|
||||
onSubmit={onSubmit}
|
||||
modalMessage={'Pin posts or markets to the overview of this group.'}
|
||||
/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
}
|
||||
|
||||
{editMode && (
|
||||
<CrossIcon
|
||||
onClick={() => {
|
||||
const newPinned = group.pinnedItems.filter((item) => {
|
||||
return item.itemId !== group.pinnedItems[index].itemId
|
||||
})
|
||||
updateGroup(group, { pinnedItems: newPinned })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{editMode && group.pinnedItems && 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>
|
||||
export function PinnedItems(props: {
|
||||
posts: Post[]
|
||||
isEditable: boolean
|
||||
pinned: JSX.Element[]
|
||||
onDeleteClicked: (index: number) => void
|
||||
onSubmit: (selectedItems: { itemId: string; type: string }[]) => void
|
||||
group?: Group
|
||||
modalMessage: string
|
||||
}) {
|
||||
const {
|
||||
isEditable,
|
||||
pinned,
|
||||
onDeleteClicked,
|
||||
onSubmit,
|
||||
posts,
|
||||
group,
|
||||
modalMessage,
|
||||
} = props
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return pinned.length > 0 || isEditable ? (
|
||||
<div>
|
||||
<Row className="mb-3 items-center justify-between">
|
||||
<SectionHeader label={'Pinned'} />
|
||||
{isEditable && (
|
||||
<Button
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode)
|
||||
}}
|
||||
>
|
||||
{editMode ? (
|
||||
'Done'
|
||||
) : (
|
||||
<>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Masonry>
|
||||
</div>
|
||||
<PinnedSelectModal
|
||||
open={open}
|
||||
group={group}
|
||||
posts={posts}
|
||||
setOpen={setOpen}
|
||||
title="Pin a post or market"
|
||||
description={
|
||||
<div className={'text-md my-4 text-gray-600'}>
|
||||
Pin posts or markets to the overview of this group.
|
||||
</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>
|
||||
}
|
||||
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>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)
|
||||
<PinnedSelectModal
|
||||
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
22
web/components/input.tsx
Normal 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
|
||||
*/
|
|
@ -7,12 +7,13 @@ import { User } from 'common/user'
|
|||
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
|
||||
import { createManalink } from 'web/lib/firebase/manalinks'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button } from '../button'
|
||||
import { getManalinkUrl } from 'web/pages/links'
|
||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||
import { QRCode } from '../qr-code'
|
||||
import { Input } from '../input'
|
||||
import { ExpandingInput } from '../expanding-input'
|
||||
|
||||
export function CreateLinksButton(props: {
|
||||
user: User
|
||||
|
@ -120,8 +121,8 @@ function CreateManalinkForm(props: {
|
|||
<span className="absolute mx-3 mt-3.5 text-sm text-gray-400">
|
||||
M$
|
||||
</span>
|
||||
<input
|
||||
className="input input-bordered w-full pl-10"
|
||||
<Input
|
||||
className="w-full pl-10"
|
||||
type="number"
|
||||
min="1"
|
||||
value={newManalink.amount}
|
||||
|
@ -136,8 +137,7 @@ function CreateManalinkForm(props: {
|
|||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="form-control w-full md:w-1/2">
|
||||
<label className="label">Uses</label>
|
||||
<input
|
||||
className="input input-bordered"
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={newManalink.maxUses ?? ''}
|
||||
|
@ -146,7 +146,7 @@ function CreateManalinkForm(props: {
|
|||
return { ...m, maxUses: parseInt(e.target.value) }
|
||||
})
|
||||
}
|
||||
></input>
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full md:w-1/2">
|
||||
<label className="label">Expires in</label>
|
||||
|
@ -165,10 +165,9 @@ function CreateManalinkForm(props: {
|
|||
</div>
|
||||
<div className="form-control w-full">
|
||||
<label className="label">Message</label>
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
placeholder={defaultMessage}
|
||||
maxLength={200}
|
||||
className="input input-bordered resize-none"
|
||||
autoFocus
|
||||
value={newManalink.message}
|
||||
rows="3"
|
||||
|
|
|
@ -22,6 +22,7 @@ export function ManifoldLogo(props: {
|
|||
src={darkBackground ? '/logo-white.svg' : '/logo.svg'}
|
||||
width={45}
|
||||
height={45}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
{!hideText &&
|
||||
|
|
|
@ -11,13 +11,13 @@ function SidebarButton(props: {
|
|||
}) {
|
||||
const { text, children } = props
|
||||
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
|
||||
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{text}</span>
|
||||
{children}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -156,7 +156,7 @@ function getMoreDesktopNavigation(user?: User | null) {
|
|||
return buildArray(
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Refer a friend', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Labs', href: '/labs' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
|
@ -215,7 +215,7 @@ function getMoreMobileNav() {
|
|||
|
||||
return buildArray<MenuItem>(
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Refer a friend', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Labs', href: '/labs' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ReactNode } from 'react'
|
|||
import React from 'react'
|
||||
import { Col } from './layout/col'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { Input } from './input'
|
||||
|
||||
export function NumberInput(props: {
|
||||
numberString: string
|
||||
|
@ -32,9 +33,9 @@ export function NumberInput(props: {
|
|||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group">
|
||||
<input
|
||||
<Input
|
||||
className={clsx(
|
||||
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
||||
'max-w-[200px] !text-lg',
|
||||
error && 'input-error',
|
||||
inputClassName
|
||||
)}
|
||||
|
|
|
@ -20,8 +20,8 @@ export function PinnedSelectModal(props: {
|
|||
selectedItems: { itemId: string; type: string }[]
|
||||
) => void | Promise<void>
|
||||
contractSearchOptions?: Partial<Parameters<typeof ContractSearch>[0]>
|
||||
group: Group
|
||||
posts: Post[]
|
||||
group?: Group
|
||||
}) {
|
||||
const {
|
||||
title,
|
||||
|
@ -134,8 +134,8 @@ export function PinnedSelectModal(props: {
|
|||
highlightClassName:
|
||||
'!bg-indigo-100 outline outline-2 outline-indigo-300',
|
||||
}}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
additionalFilter={group ? { groupSlug: group.slug } : undefined}
|
||||
persistPrefix={group ? `group-${group.slug}` : undefined}
|
||||
headerClassName="bg-white sticky"
|
||||
{...contractSearchOptions}
|
||||
/>
|
||||
|
@ -152,7 +152,7 @@ export function PinnedSelectModal(props: {
|
|||
'!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>
|
||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
|||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||
import { BucketInput } from './bucket-input'
|
||||
import { Input } from './input'
|
||||
import { Col } from './layout/col'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
||||
|
@ -30,11 +31,8 @@ export function ProbabilityInput(props: {
|
|||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group">
|
||||
<input
|
||||
className={clsx(
|
||||
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
||||
inputClassName
|
||||
)}
|
||||
<Input
|
||||
className={clsx('max-w-[200px] !text-lg', inputClassName)}
|
||||
type="number"
|
||||
max={99}
|
||||
min={1}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Input } from './input'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function ProbabilitySelector(props: {
|
||||
|
@ -10,10 +11,10 @@ export function ProbabilitySelector(props: {
|
|||
return (
|
||||
<Row className="items-center gap-2">
|
||||
<label className="input-group input-group-lg text-lg">
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={probabilityInt}
|
||||
className="input input-bordered input-md w-28 text-lg"
|
||||
className="input-md w-28 !text-lg"
|
||||
disabled={isSubmitting}
|
||||
min={1}
|
||||
max={99}
|
||||
|
|
|
@ -35,8 +35,8 @@ export function LoansModal(props: {
|
|||
</span>
|
||||
<span className={'text-indigo-700'}>• What is an example?</span>
|
||||
<span className={'ml-2'}>
|
||||
For example, if you bet M$1000 on "Will I become a millionare?"
|
||||
today, you will get M$20 back tomorrow.
|
||||
For example, if you bet M$1000 on "Will I become a millionare?", you
|
||||
will get M$20 back tomorrow.
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
Previous loans count against your total bet amount. So on the next
|
||||
|
|
|
@ -7,6 +7,7 @@ import { CPMMBinaryContract } from 'common/contract'
|
|||
import { Customize, USAMap } from './usa-map'
|
||||
import { listenForContract } from 'web/lib/firebase/contracts'
|
||||
import { interpolateColor } from 'common/util/color'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export interface StateElectionMarket {
|
||||
creatorUsername: string
|
||||
|
@ -35,8 +36,13 @@ export function StateElectionMap(props: {
|
|||
market.state,
|
||||
{
|
||||
fill: probToColor(prob, market.isWinRepublican),
|
||||
clickHandler: () =>
|
||||
Router.push(`/${market.creatorUsername}/${market.slug}`),
|
||||
clickHandler: () => {
|
||||
Router.push(`/${market.creatorUsername}/${market.slug}`)
|
||||
track('state election map click', {
|
||||
state: market.state,
|
||||
slug: market.slug,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
|
|
|
@ -34,6 +34,17 @@ export function UserLink(props: {
|
|||
)}
|
||||
>
|
||||
{shortName}
|
||||
{BOT_USERNAMES.includes(username) && <BotBadge />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
withoutAnteBets,
|
||||
} from 'web/lib/firebase/bets'
|
||||
import { LimitBet } from 'common/bet'
|
||||
import { getUser } from 'web/lib/firebase/users'
|
||||
|
||||
export const useBets = (
|
||||
contractId: string,
|
||||
|
@ -62,3 +63,31 @@ export const useUnfilledBets = (contractId: string) => {
|
|||
)
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Post } from 'common/post'
|
||||
import { listenForPost } from 'web/lib/firebase/posts'
|
||||
import { DateDoc, Post } from 'common/post'
|
||||
import { listenForDateDocs, listenForPost } from 'web/lib/firebase/posts'
|
||||
|
||||
export const usePost = (postId: string | undefined) => {
|
||||
const [post, setPost] = useState<Post | null | undefined>()
|
||||
|
@ -37,3 +37,13 @@ export const usePosts = (postIds: string[]) => {
|
|||
)
|
||||
.sort((a, b) => b.createdTime - a.createdTime)
|
||||
}
|
||||
|
||||
export const useDateDocs = () => {
|
||||
const [dateDocs, setDateDocs] = useState<DateDoc[]>()
|
||||
|
||||
useEffect(() => {
|
||||
return listenForDateDocs(setDateDocs)
|
||||
}, [])
|
||||
|
||||
return dateDocs
|
||||
}
|
||||
|
|
|
@ -74,11 +74,13 @@ export async function getUserBets(userId: string) {
|
|||
return getValues<Bet>(getUserBetsQuery(userId))
|
||||
}
|
||||
|
||||
export const MAX_USER_BETS_LOADED = 10000
|
||||
export function getUserBetsQuery(userId: string) {
|
||||
return query(
|
||||
collectionGroup(db, 'bets'),
|
||||
where('userId', '==', userId),
|
||||
orderBy('createdTime', 'desc')
|
||||
orderBy('createdTime', 'desc'),
|
||||
limit(MAX_USER_BETS_LOADED)
|
||||
) as Query<Bet>
|
||||
}
|
||||
|
||||
|
|
|
@ -168,10 +168,12 @@ export function getUserBetContracts(userId: string) {
|
|||
return getValues<Contract>(getUserBetContractsQuery(userId))
|
||||
}
|
||||
|
||||
export const MAX_USER_BET_CONTRACTS_LOADED = 1000
|
||||
export function getUserBetContractsQuery(userId: string) {
|
||||
return query(
|
||||
contracts,
|
||||
where('uniqueBettorIds', 'array-contains', userId)
|
||||
where('uniqueBettorIds', 'array-contains', userId),
|
||||
limit(MAX_USER_BET_CONTRACTS_LOADED)
|
||||
) as Query<Contract>
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,13 @@ import {
|
|||
where,
|
||||
} from 'firebase/firestore'
|
||||
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'
|
||||
|
||||
export const posts = coll<Post>('posts')
|
||||
|
@ -51,6 +57,11 @@ export async function getDateDocs() {
|
|||
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) {
|
||||
const user = await getUserByUsername(username)
|
||||
if (!user) return null
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
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 {
|
||||
;(function (l, e, a, p) {
|
||||
if (window.Sprig) return
|
||||
|
@ -20,7 +20,8 @@ if (ENV_CONFIG.domain === PROD_CONFIG.domain) {
|
|||
a.async = 1
|
||||
a.src = e + '?id=' + S.appId
|
||||
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)
|
||||
} catch (error) {
|
||||
console.log('Error initializing Sprig, please complain to Barak', error)
|
||||
|
|
|
@ -74,10 +74,7 @@ function MyApp({ Component, pageProps }: AppProps<ManifoldPageProps>) {
|
|||
content="https://manifold.markets/logo-bg-white.png"
|
||||
key="image2"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</Head>
|
||||
<AuthProvider serverUser={pageProps.auth}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
|||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html data-theme="mantic" className="min-h-screen">
|
||||
<Html lang="en" data-theme="mantic" className="min-h-screen">
|
||||
<Head>
|
||||
<link rel="icon" href={ENV_CONFIG.faviconPath} />
|
||||
<link
|
||||
|
|
|
@ -24,6 +24,7 @@ import { getUser } from 'web/lib/firebase/users'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { User } from 'common/user'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Input } from 'web/components/input'
|
||||
|
||||
export async function getStaticProps() {
|
||||
let txns = await getAllCharityTxns()
|
||||
|
@ -171,11 +172,11 @@ export default function Charity(props: {
|
|||
/>
|
||||
<Spacer h={10} />
|
||||
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Find a charity"
|
||||
className="input input-bordered mb-6 w-full"
|
||||
className="mb-6 w-full"
|
||||
/>
|
||||
</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">
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
urlParamStore,
|
||||
} from 'web/hooks/use-persistent-state'
|
||||
import { PAST_BETS } from 'common/user'
|
||||
import { Input } from 'web/components/input'
|
||||
|
||||
const MAX_CONTRACTS_RENDERED = 100
|
||||
|
||||
|
@ -88,12 +89,12 @@ export default function ContractSearchFirestore(props: {
|
|||
<div>
|
||||
{/* Show a search input next to a sort dropdown */}
|
||||
<div className="mt-2 mb-8 flex justify-between gap-2">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search markets"
|
||||
className="input input-bordered w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
|
|
|
@ -2,7 +2,6 @@ import router, { useRouter } from 'next/router'
|
|||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
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 { AddFundsModal } from 'web/components/add-funds-modal'
|
||||
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) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
|
||||
|
@ -104,9 +105,8 @@ export default function Create(props: { auth: { user: User } }) {
|
|||
</span>
|
||||
</label>
|
||||
|
||||
<Textarea
|
||||
<ExpandingInput
|
||||
placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
|
||||
className="input input-bordered resize-none"
|
||||
autoFocus
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={question}
|
||||
|
@ -329,9 +329,9 @@ export function NewContract(props: {
|
|||
</label>
|
||||
|
||||
<Row className="gap-2">
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
className="input input-bordered w-32"
|
||||
className="w-32"
|
||||
placeholder="LOW"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setMinString(e.target.value)}
|
||||
|
@ -340,9 +340,9 @@ export function NewContract(props: {
|
|||
disabled={isSubmitting}
|
||||
value={minString ?? ''}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
className="input input-bordered w-32"
|
||||
className="w-32"
|
||||
placeholder="HIGH"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setMaxString(e.target.value)}
|
||||
|
@ -374,9 +374,8 @@ export function NewContract(props: {
|
|||
</label>
|
||||
|
||||
<Row className="gap-2">
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
className="input input-bordered"
|
||||
placeholder="Initial value"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setInitialValueString(e.target.value)}
|
||||
|
@ -446,19 +445,17 @@ export function NewContract(props: {
|
|||
className={'col-span-4 sm:col-span-2'}
|
||||
/>
|
||||
</Row>
|
||||
<Row>
|
||||
<input
|
||||
<Row className="mt-4 gap-2">
|
||||
<Input
|
||||
type={'date'}
|
||||
className="input input-bordered mt-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCloseDate(e.target.value)}
|
||||
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
||||
disabled={isSubmitting}
|
||||
value={closeDate}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type={'time'}
|
||||
className="input input-bordered mt-4 ml-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||
min={'00:00'}
|
||||
|
|
|
@ -22,6 +22,7 @@ import { PostCommentsActivity, RichEditPost } from '../post/[...slugs]'
|
|||
import { usePost } from 'web/hooks/use-post'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useCommentsOnPost } from 'web/hooks/use-comments'
|
||||
import { NoSEO } from 'web/components/NoSEO'
|
||||
|
||||
export async function getStaticProps(props: { params: { username: string } }) {
|
||||
const { username } = props.params
|
||||
|
@ -62,6 +63,7 @@ function DateDocPage(props: { creator: User; post: DateDoc }) {
|
|||
|
||||
return (
|
||||
<Page>
|
||||
<NoSEO />
|
||||
<Col className="mx-auto w-full max-w-xl gap-6 sm:mb-6">
|
||||
<SiteLink href="/date-docs">
|
||||
<Row className="items-center gap-2">
|
||||
|
@ -140,15 +142,17 @@ export function DateDocPost(props: {
|
|||
) : (
|
||||
<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">
|
||||
<iframe
|
||||
height="405"
|
||||
src={marketUrl}
|
||||
title=""
|
||||
frameBorder="0"
|
||||
className="w-full rounded-xl bg-white p-10"
|
||||
></iframe>
|
||||
</div>
|
||||
{contractSlug && (
|
||||
<div className="mt-4 w-full max-w-lg self-center rounded-xl bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-300 p-3">
|
||||
<iframe
|
||||
height="405"
|
||||
src={marketUrl}
|
||||
title=""
|
||||
frameBorder="0"
|
||||
className="w-full rounded-xl bg-white p-10"
|
||||
></iframe>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import Router from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { DateDoc } from 'common/post'
|
||||
import { useTextEditor, TextEditor } from 'web/components/editor'
|
||||
import { Page } from 'web/components/page'
|
||||
|
@ -14,6 +12,11 @@ import dayjs from 'dayjs'
|
|||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
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() {
|
||||
const user = useUser()
|
||||
|
@ -25,6 +28,7 @@ export default function CreateDateDocPage() {
|
|||
const title = `${user?.name}'s Date Doc`
|
||||
const subtitle = 'Manifold dating docs'
|
||||
const [birthday, setBirthday] = useState<undefined | string>(undefined)
|
||||
const [createMarket, setCreateMarket] = useState(true)
|
||||
const [question, setQuestion] = useState(
|
||||
'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 isValid =
|
||||
user && birthday && editor && editor.isEmpty === false && question
|
||||
user &&
|
||||
birthday &&
|
||||
editor &&
|
||||
editor.isEmpty === false &&
|
||||
(question || !createMarket)
|
||||
|
||||
async function saveDateDoc() {
|
||||
if (!user || !editor || !birthdayTime) return
|
||||
|
@ -45,15 +53,15 @@ export default function CreateDateDocPage() {
|
|||
const newPost: Omit<
|
||||
DateDoc,
|
||||
'id' | 'creatorId' | 'createdTime' | 'slug' | 'contractSlug'
|
||||
> & { question: string } = {
|
||||
> & { question?: string } = removeUndefinedProps({
|
||||
title,
|
||||
subtitle,
|
||||
content: editor.getJSON(),
|
||||
bounty: 0,
|
||||
birthday: birthdayTime,
|
||||
type: 'date-doc',
|
||||
question,
|
||||
}
|
||||
question: createMarket ? question : undefined,
|
||||
})
|
||||
|
||||
const result = await createPost(newPost)
|
||||
|
||||
|
@ -64,6 +72,7 @@ export default function CreateDateDocPage() {
|
|||
|
||||
return (
|
||||
<Page>
|
||||
<NoSEO />
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="rounded-lg px-6 py-4 pb-4 sm:py-0">
|
||||
<Row className="mb-8 items-center justify-between">
|
||||
|
@ -85,9 +94,8 @@ export default function CreateDateDocPage() {
|
|||
<Col className="gap-8">
|
||||
<Col className="max-w-[160px] justify-start gap-4">
|
||||
<div className="">Birthday</div>
|
||||
<input
|
||||
<Input
|
||||
type={'date'}
|
||||
className="input input-bordered"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setBirthday(e.target.value)}
|
||||
max={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
||||
|
@ -104,16 +112,20 @@ export default function CreateDateDocPage() {
|
|||
</Col>
|
||||
|
||||
<Col className="gap-4">
|
||||
<div className="">
|
||||
Finally, we'll create an (unlisted) prediction market!
|
||||
</div>
|
||||
<Row className="items-center gap-4">
|
||||
<ShortToggle
|
||||
on={createMarket}
|
||||
setOn={(on) => setCreateMarket(on)}
|
||||
/>
|
||||
Create an (unlisted) prediction market attached to the date doc
|
||||
</Row>
|
||||
|
||||
<Col className="gap-2">
|
||||
<Textarea
|
||||
className="input input-bordered resize-none"
|
||||
<ExpandingInput
|
||||
maxLength={MAX_QUESTION_LENGTH}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value || '')}
|
||||
disabled={!createMarket}
|
||||
/>
|
||||
<div className="ml-2 text-gray-500">Cost: M$100</div>
|
||||
</Col>
|
||||
|
|
|
@ -12,6 +12,9 @@ import { Button } from 'web/components/button'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { getUser, User } from 'web/lib/firebase/users'
|
||||
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() {
|
||||
const dateDocs = await getDateDocs()
|
||||
|
@ -33,13 +36,16 @@ export default function DatePage(props: {
|
|||
dateDocs: DateDoc[]
|
||||
docCreators: User[]
|
||||
}) {
|
||||
const { dateDocs, docCreators } = props
|
||||
const { docCreators } = props
|
||||
const user = useUser()
|
||||
|
||||
const dateDocs = useDateDocs() ?? props.dateDocs
|
||||
const hasDoc = dateDocs.some((d) => d.creatorId === user?.id)
|
||||
useTracking('view date docs page')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<NoSEO />
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<Row className="items-center justify-between p-4 sm:p-0">
|
||||
<Title className="!my-0 px-2 text-blue-500" text="Date docs" />
|
||||
|
|
|
@ -20,6 +20,7 @@ import { SEO } from 'web/components/SEO'
|
|||
import { GetServerSideProps } from 'next'
|
||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Input } from 'web/components/input'
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
|
@ -106,12 +107,12 @@ export default function Groups(props: {
|
|||
title: 'All',
|
||||
content: (
|
||||
<Col>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search groups"
|
||||
value={query}
|
||||
className="input input-bordered mb-4 w-full"
|
||||
className="mb-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
|
@ -134,12 +135,12 @@ export default function Groups(props: {
|
|||
title: 'My Groups',
|
||||
content: (
|
||||
<Col>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
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">
|
||||
|
|
|
@ -48,6 +48,7 @@ import {
|
|||
} from 'web/hooks/use-contracts'
|
||||
import { ProfitBadge } from 'web/components/profit-badge'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { Input } from 'web/components/input'
|
||||
|
||||
export default function Home() {
|
||||
const user = useUser()
|
||||
|
@ -99,10 +100,10 @@ export default function Home() {
|
|||
<Row
|
||||
className={'mb-2 w-full items-center justify-between gap-4 sm:gap-8'}
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={'Search'}
|
||||
className="input input-bordered w-full"
|
||||
className="w-full"
|
||||
onClick={() => Router.push('/search')}
|
||||
/>
|
||||
<CustomizeButton justIcon />
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
StateElectionMarket,
|
||||
StateElectionMap,
|
||||
} from 'web/components/usa-map/state-election-map'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
||||
|
||||
const senateMidterms: StateElectionMarket[] = [
|
||||
|
@ -203,6 +204,8 @@ const App = (props: {
|
|||
}) => {
|
||||
const { senateContracts, governorContracts } = props
|
||||
|
||||
useTracking('view midterms 2022')
|
||||
|
||||
return (
|
||||
<Page className="">
|
||||
<Col className="items-center justify-center">
|
||||
|
|
|
@ -465,8 +465,11 @@ function IncomeNotificationItem(props: {
|
|||
simple ? (
|
||||
<span className={'ml-1 font-bold'}>🏦 Loan</span>
|
||||
) : (
|
||||
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
|
||||
🏦 Loan
|
||||
<SiteLink
|
||||
className={'relative ml-1 font-bold'}
|
||||
href={`/${sourceUserUsername}/?show=loans`}
|
||||
>
|
||||
🏦 Loan <span className="font-normal">(learn more)</span>
|
||||
</SiteLink>
|
||||
)
|
||||
) : sourceType === 'betting_streak_bonus' ? (
|
||||
|
@ -474,8 +477,8 @@ function IncomeNotificationItem(props: {
|
|||
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||
) : (
|
||||
<SiteLink
|
||||
className={'ml-1 font-bold'}
|
||||
href={'/betting-streak-bonus'}
|
||||
className={'relative ml-1 font-bold'}
|
||||
href={`/${sourceUserUsername}/?show=betting-streak`}
|
||||
>
|
||||
{bettingStreakText}
|
||||
</SiteLink>
|
||||
|
|
|
@ -3,8 +3,9 @@ import { PrivateUser, User } from 'common/user'
|
|||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
||||
import Link from 'next/link'
|
||||
import React, { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
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 { Row } from 'web/components/layout/row'
|
||||
import { Page } from 'web/components/page'
|
||||
|
@ -43,16 +44,15 @@ function EditUserField(props: {
|
|||
<label className="label">{label}</label>
|
||||
|
||||
{field === 'bio' ? (
|
||||
<Textarea
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
<ExpandingInput
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={updateField}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value || '')}
|
||||
onBlur={updateField}
|
||||
|
@ -152,10 +152,9 @@ export default function ProfilePage(props: {
|
|||
|
||||
<div>
|
||||
<label className="label">Display name</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
className="input input-bordered"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value || '')}
|
||||
onBlur={updateDisplayName}
|
||||
|
@ -164,10 +163,9 @@ export default function ProfilePage(props: {
|
|||
|
||||
<div>
|
||||
<label className="label">Username</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="input input-bordered"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value || '')}
|
||||
onBlur={updateUsername}
|
||||
|
@ -199,10 +197,9 @@ export default function ProfilePage(props: {
|
|||
<div>
|
||||
<label className="label">API key</label>
|
||||
<div className="input-group w-full">
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Click refresh to generate key"
|
||||
className="input input-bordered w-full"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
/>
|
||||
|
|
|
@ -10,6 +10,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
|||
import { InfoBox } from 'web/components/info-box'
|
||||
import { QRCode } from 'web/components/qr-code'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||
|
||||
|
@ -23,15 +24,17 @@ export default function ReferralsPage() {
|
|||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title="Referrals"
|
||||
description={`Manifold's referral program. Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
|
||||
title="Refer a friend"
|
||||
description={`Invite new users to Manifold and get ${formatMoney(
|
||||
REFERRAL_AMOUNT
|
||||
)} if they
|
||||
sign up!`}
|
||||
url="/referrals"
|
||||
/>
|
||||
|
||||
<Col className="items-center">
|
||||
<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
|
||||
className="mb-6 block -scale-x-100 self-center"
|
||||
src="/logo-flapping-with-money.gif"
|
||||
|
@ -40,8 +43,8 @@ export default function ReferralsPage() {
|
|||
/>
|
||||
|
||||
<div className={'mb-4'}>
|
||||
Invite new users to Manifold and get M${REFERRAL_AMOUNT} if they
|
||||
sign up!
|
||||
Invite new users to Manifold and get {formatMoney(REFERRAL_AMOUNT)}{' '}
|
||||
if they sign up!
|
||||
</div>
|
||||
|
||||
<CopyLinkButton
|
||||
|
|
Loading…
Reference in New Issue
Block a user