Merge branch 'main' into austin/dc-hackathon
This commit is contained in:
commit
cd73baad8c
|
@ -1,4 +1,4 @@
|
|||
import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
|
||||
import { getCpmmLiquidity } from './calculate-cpmm'
|
||||
import { CPMMContract } from './contract'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
|
||||
|
@ -8,25 +8,23 @@ export const getNewLiquidityProvision = (
|
|||
contract: CPMMContract,
|
||||
newLiquidityProvisionId: string
|
||||
) => {
|
||||
const { pool, p, totalLiquidity } = contract
|
||||
const { pool, p, totalLiquidity, subsidyPool } = contract
|
||||
|
||||
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
|
||||
|
||||
const liquidity =
|
||||
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
|
||||
const liquidity = getCpmmLiquidity(pool, p)
|
||||
|
||||
const newLiquidityProvision: LiquidityProvision = {
|
||||
id: newLiquidityProvisionId,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
pool,
|
||||
p,
|
||||
liquidity,
|
||||
createdTime: Date.now(),
|
||||
}
|
||||
|
||||
const newTotalLiquidity = (totalLiquidity ?? 0) + amount
|
||||
const newSubsidyPool = (subsidyPool ?? 0) + amount
|
||||
|
||||
return { newLiquidityProvision, newPool, newP, newTotalLiquidity }
|
||||
return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool }
|
||||
}
|
||||
|
|
123
common/badge.ts
Normal file
123
common/badge.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
import { User } from './user'
|
||||
|
||||
export type Badge = {
|
||||
type: BadgeTypes
|
||||
createdTime: number
|
||||
data: { [key: string]: any }
|
||||
name: 'Proven Correct' | 'Streaker' | 'Market Creator'
|
||||
}
|
||||
|
||||
export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR'
|
||||
|
||||
export type ProvenCorrectBadgeData = {
|
||||
type: 'PROVEN_CORRECT'
|
||||
data: {
|
||||
contractSlug: string
|
||||
contractCreatorUsername: string
|
||||
contractTitle: string
|
||||
commentId: string
|
||||
betAmount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type MarketCreatorBadgeData = {
|
||||
type: 'MARKET_CREATOR'
|
||||
data: {
|
||||
totalContractsCreated: number
|
||||
}
|
||||
}
|
||||
|
||||
export type StreakerBadgeData = {
|
||||
type: 'STREAKER'
|
||||
data: {
|
||||
totalBettingStreak: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData
|
||||
export type StreakerBadge = Badge & StreakerBadgeData
|
||||
export type MarketCreatorBadge = Badge & MarketCreatorBadgeData
|
||||
|
||||
export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5
|
||||
export const provenCorrectRarityThresholds = [1, 1000, 10000]
|
||||
const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => {
|
||||
const { betAmount } = badge.data
|
||||
const thresholdArray = provenCorrectRarityThresholds
|
||||
let i = thresholdArray.length - 1
|
||||
while (i >= 0) {
|
||||
if (betAmount >= thresholdArray[i]) {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export const streakerBadgeRarityThresholds = [1, 50, 250]
|
||||
const calculateStreakerBadgeRarity = (badge: StreakerBadge) => {
|
||||
const { totalBettingStreak } = badge.data
|
||||
const thresholdArray = streakerBadgeRarityThresholds
|
||||
let i = thresholdArray.length - 1
|
||||
while (i >= 0) {
|
||||
if (totalBettingStreak == thresholdArray[i]) {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export const marketCreatorBadgeRarityThresholds = [1, 75, 300]
|
||||
const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => {
|
||||
const { totalContractsCreated } = badge.data
|
||||
const thresholdArray = marketCreatorBadgeRarityThresholds
|
||||
let i = thresholdArray.length - 1
|
||||
while (i >= 0) {
|
||||
if (totalContractsCreated == thresholdArray[i]) {
|
||||
return i + 1
|
||||
}
|
||||
i--
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export type rarities = 'bronze' | 'silver' | 'gold'
|
||||
|
||||
const rarityRanks: { [key: number]: rarities } = {
|
||||
1: 'bronze',
|
||||
2: 'silver',
|
||||
3: 'gold',
|
||||
}
|
||||
|
||||
export const calculateBadgeRarity = (badge: Badge) => {
|
||||
switch (badge.type) {
|
||||
case 'PROVEN_CORRECT':
|
||||
return rarityRanks[
|
||||
calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge)
|
||||
]
|
||||
case 'MARKET_CREATOR':
|
||||
return rarityRanks[
|
||||
calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge)
|
||||
]
|
||||
case 'STREAKER':
|
||||
return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)]
|
||||
default:
|
||||
return rarityRanks[0]
|
||||
}
|
||||
}
|
||||
|
||||
export const getBadgesByRarity = (user: User | null | undefined) => {
|
||||
const rarities: { [key in rarities]: number } = {
|
||||
bronze: 0,
|
||||
silver: 0,
|
||||
gold: 0,
|
||||
}
|
||||
if (!user) return rarities
|
||||
Object.values(user.achievements).map((value) => {
|
||||
value.badges.map((badge) => {
|
||||
rarities[calculateBadgeRarity(badge)] =
|
||||
(rarities[calculateBadgeRarity(badge)] ?? 0) + 1
|
||||
})
|
||||
})
|
||||
return rarities
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import { sum, groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { LimitBet } from './bet'
|
||||
|
||||
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { computeFills } from './new-bet'
|
||||
import { binarySearch } from './util/algos'
|
||||
import { addObjects } from './util/object'
|
||||
|
||||
export type CpmmState = {
|
||||
pool: { [outcome: string]: number }
|
||||
|
@ -267,48 +266,22 @@ export function addCpmmLiquidity(
|
|||
return { newPool, liquidity, newP }
|
||||
}
|
||||
|
||||
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
|
||||
const oldLiquidity = getCpmmLiquidity(l.pool, p)
|
||||
export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) {
|
||||
const userAmounts = groupBy(liquidities, (w) => w.userId)
|
||||
const totalAmount = sumBy(liquidities, (w) => w.amount)
|
||||
|
||||
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
|
||||
const newLiquidity = getCpmmLiquidity(newPool, p)
|
||||
|
||||
const liquidity = newLiquidity - oldLiquidity
|
||||
return liquidity
|
||||
}
|
||||
|
||||
export function getCpmmLiquidityPoolWeights(
|
||||
state: CpmmState,
|
||||
liquidities: LiquidityProvision[],
|
||||
excludeAntes: boolean
|
||||
) {
|
||||
const calcLiqudity = calculateLiquidityDelta(state.p)
|
||||
const liquidityShares = liquidities.map(calcLiqudity)
|
||||
const shareSum = sum(liquidityShares)
|
||||
|
||||
const weights = liquidityShares.map((shares, i) => ({
|
||||
weight: shares / shareSum,
|
||||
providerId: liquidities[i].userId,
|
||||
}))
|
||||
|
||||
const includedWeights = excludeAntes
|
||||
? weights.filter((_, i) => !liquidities[i].isAnte)
|
||||
: weights
|
||||
|
||||
const userWeights = groupBy(includedWeights, (w) => w.providerId)
|
||||
const totalUserWeights = mapValues(userWeights, (userWeight) =>
|
||||
sumBy(userWeight, (w) => w.weight)
|
||||
return mapValues(
|
||||
userAmounts,
|
||||
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
|
||||
)
|
||||
return totalUserWeights
|
||||
}
|
||||
|
||||
export function getUserLiquidityShares(
|
||||
userId: string,
|
||||
state: CpmmState,
|
||||
liquidities: LiquidityProvision[],
|
||||
excludeAntes: boolean
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
|
||||
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||
const userWeight = weights[userId] ?? 0
|
||||
|
||||
return mapValues(state.pool, (shares) => userWeight * shares)
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
import { last, sortBy, sum, sumBy, uniq } from 'lodash'
|
||||
import { calculatePayout } from './calculate'
|
||||
import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash'
|
||||
import { calculatePayout, getContractBetMetrics } from './calculate'
|
||||
import { Bet, LimitBet } from './bet'
|
||||
import { Contract, CPMMContract, DPMContract } from './contract'
|
||||
import {
|
||||
Contract,
|
||||
CPMMBinaryContract,
|
||||
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'
|
||||
import { removeUndefinedProps } from './util/object'
|
||||
|
||||
const computeInvestmentValue = (
|
||||
bets: Bet[],
|
||||
|
@ -35,8 +41,7 @@ export const computeInvestmentValueCustomProb = (
|
|||
|
||||
const betP = outcome === 'YES' ? p : 1 - p
|
||||
|
||||
const payout = betP * shares
|
||||
const value = payout - (bet.loanAmount ?? 0)
|
||||
const value = betP * shares
|
||||
if (isNaN(value)) return 0
|
||||
return value
|
||||
})
|
||||
|
@ -97,7 +102,11 @@ export const computeBinaryCpmmElasticity = (
|
|||
)
|
||||
const resultNo = getCpmmProbability(poolN, pN)
|
||||
|
||||
return resultYes - resultNo
|
||||
// handle AMM overflow
|
||||
const safeYes = Number.isFinite(resultYes) ? resultYes : 1
|
||||
const safeNo = Number.isFinite(resultNo) ? resultNo : 0
|
||||
|
||||
return safeYes - safeNo
|
||||
}
|
||||
|
||||
export const computeDpmElasticity = (
|
||||
|
@ -190,14 +199,9 @@ export const calculateNewPortfolioMetrics = (
|
|||
}
|
||||
|
||||
const calculateProfitForPeriod = (
|
||||
startTime: number,
|
||||
descendingPortfolio: PortfolioMetrics[],
|
||||
startingPortfolio: PortfolioMetrics | undefined,
|
||||
currentProfit: number
|
||||
) => {
|
||||
const startingPortfolio = descendingPortfolio.find(
|
||||
(p) => p.timestamp < startTime
|
||||
)
|
||||
|
||||
if (startingPortfolio === undefined) {
|
||||
return currentProfit
|
||||
}
|
||||
|
@ -212,33 +216,100 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
|
|||
}
|
||||
|
||||
export const calculateNewProfit = (
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
portfolioHistory: Record<
|
||||
'current' | 'day' | 'week' | 'month',
|
||||
PortfolioMetrics | undefined
|
||||
>,
|
||||
newPortfolio: PortfolioMetrics
|
||||
) => {
|
||||
const allTimeProfit = calculatePortfolioProfit(newPortfolio)
|
||||
const descendingPortfolio = sortBy(
|
||||
portfolioHistory,
|
||||
(p) => p.timestamp
|
||||
).reverse()
|
||||
|
||||
const newProfit = {
|
||||
daily: calculateProfitForPeriod(
|
||||
Date.now() - 1 * DAY_MS,
|
||||
descendingPortfolio,
|
||||
allTimeProfit
|
||||
),
|
||||
weekly: calculateProfitForPeriod(
|
||||
Date.now() - 7 * DAY_MS,
|
||||
descendingPortfolio,
|
||||
allTimeProfit
|
||||
),
|
||||
monthly: calculateProfitForPeriod(
|
||||
Date.now() - 30 * DAY_MS,
|
||||
descendingPortfolio,
|
||||
allTimeProfit
|
||||
),
|
||||
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit),
|
||||
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit),
|
||||
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit),
|
||||
allTime: allTimeProfit,
|
||||
}
|
||||
|
||||
return newProfit
|
||||
}
|
||||
|
||||
export const calculateMetricsByContract = (
|
||||
bets: Bet[],
|
||||
contractsById: Dictionary<Contract>
|
||||
) => {
|
||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||
const unresolvedContracts = Object.keys(betsByContract)
|
||||
.map((cid) => contractsById[cid])
|
||||
.filter((c) => c && !c.isResolved)
|
||||
|
||||
return unresolvedContracts.map((c) => {
|
||||
const bets = betsByContract[c.id] ?? []
|
||||
const current = getContractBetMetrics(c, bets)
|
||||
|
||||
let periodMetrics
|
||||
if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') {
|
||||
const periods = ['day', 'week', 'month'] as const
|
||||
periodMetrics = Object.fromEntries(
|
||||
periods.map((period) => [
|
||||
period,
|
||||
calculatePeriodProfit(c, bets, period),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return removeUndefinedProps({
|
||||
contractId: c.id,
|
||||
...current,
|
||||
from: periodMetrics,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export type ContractMetrics = ReturnType<
|
||||
typeof calculateMetricsByContract
|
||||
>[number]
|
||||
|
||||
const calculatePeriodProfit = (
|
||||
contract: CPMMBinaryContract,
|
||||
bets: Bet[],
|
||||
period: 'day' | 'week' | 'month'
|
||||
) => {
|
||||
const days = period === 'day' ? 1 : period === 'week' ? 7 : 30
|
||||
const fromTime = Date.now() - days * DAY_MS
|
||||
const [previousBets, recentBets] = partition(
|
||||
bets,
|
||||
(b) => b.createdTime < fromTime
|
||||
)
|
||||
|
||||
const prevProb = contract.prob - contract.probChanges[period]
|
||||
const prob = contract.resolutionProbability
|
||||
? contract.resolutionProbability
|
||||
: contract.prob
|
||||
|
||||
const previousBetsValue = computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
prevProb
|
||||
)
|
||||
const currentBetsValue = computeInvestmentValueCustomProb(
|
||||
previousBets,
|
||||
contract,
|
||||
prob
|
||||
)
|
||||
|
||||
const { profit: recentProfit, invested: recentInvested } =
|
||||
getContractBetMetrics(contract, recentBets)
|
||||
|
||||
const profit = currentBetsValue - previousBetsValue + recentProfit
|
||||
const invested = previousBetsValue + recentInvested
|
||||
const profitPercent = invested === 0 ? 0 : 100 * (profit / invested)
|
||||
|
||||
return {
|
||||
profit,
|
||||
profitPercent,
|
||||
invested,
|
||||
prevValue: previousBetsValue,
|
||||
value: currentBetsValue,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) {
|
|||
})
|
||||
}
|
||||
|
||||
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
|
||||
|
||||
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||
const { resolution } = contract
|
||||
const isCpmm = contract.mechanism === 'cpmm-1'
|
||||
|
@ -215,7 +217,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
}
|
||||
|
||||
const profit = payout + saleValue + redeemed - totalInvested
|
||||
const profitPercent = (profit / totalInvested) * 100
|
||||
const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100
|
||||
|
||||
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
|
||||
const hasShares = Object.values(totalShares).some(
|
||||
|
|
|
@ -91,7 +91,8 @@ export type CPMM = {
|
|||
mechanism: 'cpmm-1'
|
||||
pool: { [outcome: string]: number }
|
||||
p: number // probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity: number // in M$
|
||||
totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$
|
||||
subsidyPool: number // current value of subsidy pool in M$
|
||||
prob: number
|
||||
probChanges: {
|
||||
day: number
|
||||
|
|
|
@ -16,3 +16,5 @@ export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
|
|||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
|
||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
|
||||
|
||||
export const UNIQUE_BETTOR_LIQUIDITY = 20
|
||||
|
|
3
common/globalConfig.ts
Normal file
3
common/globalConfig.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export type GlobalConfig = {
|
||||
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
|
||||
}
|
|
@ -63,6 +63,7 @@ export function getNewContract(
|
|||
tags: [],
|
||||
lowercaseTags: [],
|
||||
visibility,
|
||||
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
|
||||
isResolved: false,
|
||||
createdTime: Date.now(),
|
||||
closeTime,
|
||||
|
@ -111,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
|||
mechanism: 'cpmm-1',
|
||||
outcomeType: 'BINARY',
|
||||
totalLiquidity: ante,
|
||||
subsidyPool: 0,
|
||||
initialProbability: p,
|
||||
p,
|
||||
pool: pool,
|
||||
|
|
|
@ -4,7 +4,7 @@ export type Notification = {
|
|||
id: string
|
||||
userId: string
|
||||
reasonText?: string
|
||||
reason?: notification_reason_types
|
||||
reason?: notification_reason_types | notification_preference
|
||||
createdTime: number
|
||||
viewTime?: number
|
||||
isSeen: boolean
|
||||
|
@ -46,6 +46,7 @@ export type notification_source_types =
|
|||
| 'loan'
|
||||
| 'like'
|
||||
| 'tip_and_like'
|
||||
| 'badge'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -237,6 +238,10 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
|||
simple: `Only on markets you're invested in`,
|
||||
detailed: `Answers on markets that you're watching and that you're invested in`,
|
||||
},
|
||||
badges_awarded: {
|
||||
simple: 'New badges awarded',
|
||||
detailed: 'New badges you have earned',
|
||||
},
|
||||
opt_out_all: {
|
||||
simple: 'Opt out of all notifications (excludes when your markets close)',
|
||||
detailed:
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { Bet } from './bet'
|
||||
import { getProbability } from './calculate'
|
||||
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
|
||||
|
@ -56,10 +55,10 @@ export const getLiquidityPoolPayouts = (
|
|||
outcome: string,
|
||||
liquidities: LiquidityProvision[]
|
||||
) => {
|
||||
const { pool } = contract
|
||||
const finalPool = pool[outcome]
|
||||
const { pool, subsidyPool } = contract
|
||||
const finalPool = pool[outcome] + subsidyPool
|
||||
|
||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
|
||||
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||
|
||||
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||
userId: providerId,
|
||||
|
@ -95,10 +94,10 @@ export const getLiquidityPoolProbPayouts = (
|
|||
p: number,
|
||||
liquidities: LiquidityProvision[]
|
||||
) => {
|
||||
const { pool } = contract
|
||||
const finalPool = p * pool.YES + (1 - p) * pool.NO
|
||||
const { pool, subsidyPool } = contract
|
||||
const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool
|
||||
|
||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
|
||||
const weights = getCpmmLiquidityPoolWeights(liquidities)
|
||||
|
||||
return Object.entries(weights).map(([providerId, weight]) => ({
|
||||
userId: providerId,
|
||||
|
|
|
@ -8,6 +8,10 @@ export type Post = {
|
|||
creatorId: string // User id
|
||||
createdTime: number
|
||||
slug: string
|
||||
|
||||
// denormalized user fields
|
||||
creatorName: string
|
||||
creatorUsername: string
|
||||
}
|
||||
|
||||
export type DateDoc = Post & {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { groupBy, sumBy, mapValues } from 'lodash'
|
||||
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash'
|
||||
|
||||
import { Bet } from './bet'
|
||||
import { getContractBetMetrics } from './calculate'
|
||||
import { getContractBetMetrics, resolvedPayout } from './calculate'
|
||||
import { Contract } from './contract'
|
||||
import { ContractComment } from './comment'
|
||||
|
||||
export function scoreCreators(contracts: Contract[]) {
|
||||
const creatorScore = mapValues(
|
||||
|
@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
|
|||
}
|
||||
|
||||
export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||
const betsByUser = groupBy(bets, bet => bet.userId)
|
||||
return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
return mapValues(
|
||||
betsByUser,
|
||||
(bets) => getContractBetMetrics(contract, bets).profit
|
||||
)
|
||||
}
|
||||
|
||||
export function addUserScores(
|
||||
|
@ -43,3 +47,47 @@ export function addUserScores(
|
|||
dest[userId] += score
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreCommentorsAndBettors(
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: ContractComment[]
|
||||
) {
|
||||
const commentsById = keyBy(comments, 'id')
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
// Otherwise, we record the profit at resolution time
|
||||
const profitById: Record<string, number> = {}
|
||||
for (const bet of bets) {
|
||||
if (bet.sale) {
|
||||
const originalBet = betsById[bet.sale.betId]
|
||||
const profit = bet.sale.amount - originalBet.amount
|
||||
profitById[bet.id] = profit
|
||||
profitById[originalBet.id] = profit
|
||||
} else {
|
||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the betId with the highest profit
|
||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = betsById[topBetId]?.userName
|
||||
|
||||
// And also the commentId of the comment with the highest profit
|
||||
const topCommentId = sortBy(
|
||||
comments,
|
||||
(c) => c.betId && -profitById[c.betId]
|
||||
)[0]?.id
|
||||
const topCommentBetId = commentsById[topCommentId]?.betId
|
||||
|
||||
return {
|
||||
topCommentId,
|
||||
topBetId,
|
||||
topBettor,
|
||||
profitById,
|
||||
commentsById,
|
||||
betsById,
|
||||
topCommentBetId,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ export type notification_preferences = {
|
|||
profit_loss_updates: notification_destination_types[]
|
||||
onboarding_flow: notification_destination_types[]
|
||||
thank_you_for_purchases: notification_destination_types[]
|
||||
|
||||
badges_awarded: notification_destination_types[]
|
||||
opt_out_all: notification_destination_types[]
|
||||
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
|
||||
}
|
||||
|
@ -126,6 +126,7 @@ export const getDefaultNotificationPreferences = (
|
|||
onboarding_flow: constructPref(false, false),
|
||||
|
||||
opt_out_all: [],
|
||||
badges_awarded: constructPref(true, false),
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
@ -178,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: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { notification_preferences } from './user-notification-preferences'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
|
@ -11,7 +12,6 @@ export type User = {
|
|||
|
||||
// For their user page
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
@ -51,6 +51,18 @@ export type User = {
|
|||
hasSeenContractFollowModal?: boolean
|
||||
freeMarketsCreated?: number
|
||||
isBannedFromPosting?: boolean
|
||||
|
||||
achievements: {
|
||||
provenCorrect?: {
|
||||
badges: ProvenCorrectBadge[]
|
||||
}
|
||||
marketCreator?: {
|
||||
badges: MarketCreatorBadge[]
|
||||
}
|
||||
streaker?: {
|
||||
badges: StreakerBadge[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PrivateUser = {
|
||||
|
@ -81,7 +93,8 @@ export type PortfolioMetrics = {
|
|||
userId: string
|
||||
}
|
||||
|
||||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||
|
||||
// TODO: remove. Hardcoding the strings would be better.
|
||||
|
|
|
@ -60,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
|
|||
return `${numStr}${suffix[i] ?? ''}`
|
||||
}
|
||||
|
||||
export function shortFormatNumber(num: number): string {
|
||||
if (num < 1000) return showPrecision(num, 3)
|
||||
|
||||
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
|
||||
const i = Math.floor(Math.log10(num) / 3)
|
||||
|
||||
const numStr = showPrecision(num / Math.pow(10, 3 * i), 2)
|
||||
return `${numStr}${suffix[i] ?? ''}`
|
||||
}
|
||||
|
||||
export function toCamelCase(words: string) {
|
||||
const camelCase = words
|
||||
.split(' ')
|
||||
|
|
|
@ -53,7 +53,7 @@ export function parseMentions(data: JSONContent): string[] {
|
|||
}
|
||||
|
||||
// can't just do [StarterKit, Image...] because it doesn't work with cjs imports
|
||||
export const exhibitExts = [
|
||||
const stringParseExts = [
|
||||
Blockquote,
|
||||
Bold,
|
||||
BulletList,
|
||||
|
@ -73,7 +73,8 @@ export const exhibitExts = [
|
|||
|
||||
Image,
|
||||
Link,
|
||||
Mention,
|
||||
Mention, // user @mention
|
||||
Mention.extend({ name: 'contract-mention' }), // market %mention
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
TiptapSpoiler,
|
||||
|
@ -97,7 +98,7 @@ export function richTextToString(text?: JSONContent) {
|
|||
current.type = 'text'
|
||||
}
|
||||
})
|
||||
return generateText(newText, exhibitExts)
|
||||
return generateText(newText, stringParseExts)
|
||||
}
|
||||
|
||||
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
|
||||
|
|
|
@ -23,11 +23,17 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /globalConfig/globalConfig {
|
||||
allow read;
|
||||
allow update: if isAdmin()
|
||||
allow create: if isAdmin()
|
||||
}
|
||||
|
||||
match /users/{userId} {
|
||||
allow read;
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||
.hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||
// User referral rules
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -44,6 +50,10 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/contract-metrics/{contractId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/challenges/{challengeId}{
|
||||
allow read;
|
||||
}
|
||||
|
|
3
functions/.env.dev
Normal file
3
functions/.env.dev
Normal file
|
@ -0,0 +1,3 @@
|
|||
# This sets which EnvConfig is deployed to Firebase Cloud Functions
|
||||
|
||||
NEXT_PUBLIC_FIREBASE_ENV=DEV
|
|
@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
3. `$ firebase login` to authenticate the CLI tools to Firebase
|
||||
4. `$ firebase use dev` to choose the dev project
|
||||
|
||||
### For local development
|
||||
#### (Installing) For local development
|
||||
|
||||
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
|
||||
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
|
||||
|
@ -35,10 +35,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
|
||||
## Developing locally
|
||||
|
||||
0. `$ firebase use dev` if you haven't already
|
||||
1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
|
||||
Note: You have to kill and restart emulators when you change code; no hot reload =(
|
||||
2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown
|
||||
0. `$ ./dev.sh localdb` to start the local emulator and front end
|
||||
1. If you change db trigger code, you have to start (doesn't have to complete) the deploy of it to dev to cause a hard emulator code refresh `$ firebase deploy --only functions:dbTriggerNameHere`
|
||||
- There's surely a better way to cause/react to a db trigger update but just adding this here for now as it works
|
||||
2. If you want to test a scheduled function replace your function in `test-scheduled-function.ts` and send a GET to `http://localhost:8088/testscheduledfunction` (Best user experience is via [Postman](https://www.postman.com/downloads/)!)
|
||||
|
||||
## Firestore Commands
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"firestore": "dev-mantic-markets.appspot.com"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env.prod dist && cp .env.dev dist",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
|
|
|
@ -3,24 +3,18 @@ import { z } from 'zod'
|
|||
|
||||
import { Contract, CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { getNewLiquidityProvision } from '../../common/add-liquidity'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { isProd } from './utils'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
amount: z.number().gt(0),
|
||||
})
|
||||
|
||||
export const addliquidity = newEndpoint({}, async (req, auth) => {
|
||||
export const addsubsidy = newEndpoint({}, async (req, auth) => {
|
||||
const { amount, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
|
||||
if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount')
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
|
@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
.collection(`contracts/${contractId}/liquidity`)
|
||||
.doc()
|
||||
|
||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
|
||||
getNewLiquidityProvision(
|
||||
user.id,
|
||||
amount,
|
||||
|
@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
newLiquidityProvisionDoc.id
|
||||
)
|
||||
|
||||
if (newP !== undefined && !isFinite(newP)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Liquidity injection rejected due to overflow error.',
|
||||
}
|
||||
}
|
||||
|
||||
transaction.update(
|
||||
contractDoc,
|
||||
removeUndefinedProps({
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
)
|
||||
transaction.update(contractDoc, {
|
||||
subsidyPool: newSubsidyPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
} as Partial<CPMMContract>)
|
||||
|
||||
const newBalance = user.balance - amount
|
||||
const newTotalDeposits = user.totalDeposits - amount
|
||||
|
@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => {
|
|||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const addHouseLiquidity = (contract: CPMMContract, amount: number) => {
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
const newLiquidityProvisionDoc = firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.doc()
|
||||
|
||||
const providerId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
|
||||
getNewLiquidityProvision(
|
||||
providerId,
|
||||
amount,
|
||||
contract,
|
||||
newLiquidityProvisionDoc.id
|
||||
)
|
||||
|
||||
if (newP !== undefined && !isFinite(newP)) {
|
||||
throw new APIError(
|
||||
500,
|
||||
'Liquidity injection rejected due to overflow error.'
|
||||
)
|
||||
}
|
||||
|
||||
transaction.update(
|
||||
firestore.doc(`contracts/${contract.id}`),
|
||||
removeUndefinedProps({
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
)
|
||||
|
||||
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
|
||||
})
|
||||
}
|
|
@ -6,7 +6,13 @@ import {
|
|||
Notification,
|
||||
notification_reason_types,
|
||||
} from '../../common/notification'
|
||||
import { User } from '../../common/user'
|
||||
import {
|
||||
MANIFOLD_AVATAR_URL,
|
||||
MANIFOLD_USER_NAME,
|
||||
MANIFOLD_USER_USERNAME,
|
||||
PrivateUser,
|
||||
User,
|
||||
} from '../../common/user'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getValues } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
|
@ -30,27 +36,26 @@ import {
|
|||
import { filterDefined } from '../../common/util/array'
|
||||
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||
import { ContractFollow } from '../../common/follow'
|
||||
import { Badge } from 'common/badge'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type recipients_to_reason_texts = {
|
||||
[userId: string]: { reason: notification_reason_types }
|
||||
}
|
||||
|
||||
export const createNotification = async (
|
||||
export const createFollowOrMarketSubsidizedNotification = async (
|
||||
sourceId: string,
|
||||
sourceType: 'contract' | 'liquidity' | 'follow',
|
||||
sourceUpdateType: 'closed' | 'created',
|
||||
sourceType: 'liquidity' | 'follow',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
miscData?: {
|
||||
contract?: Contract
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
) => {
|
||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||
const { contract: sourceContract, recipients } = miscData ?? {}
|
||||
|
||||
const shouldReceiveNotification = (
|
||||
userId: string,
|
||||
|
@ -94,23 +99,15 @@ export const createNotification = async (
|
|||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||
sourceContractTitle: sourceContract?.question,
|
||||
sourceContractSlug: sourceContract?.slug,
|
||||
sourceSlug: slug ? slug : sourceContract?.slug,
|
||||
sourceTitle: title ? title : sourceContract?.question,
|
||||
sourceSlug: sourceContract?.slug,
|
||||
sourceTitle: sourceContract?.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
if (!sendToEmail) continue
|
||||
|
||||
if (reason === 'your_contract_closed' && privateUser && sourceContract) {
|
||||
// TODO: include number and names of bettors waiting for creator to resolve their market
|
||||
await sendMarketCloseEmail(
|
||||
reason,
|
||||
sourceUser,
|
||||
privateUser,
|
||||
sourceContract
|
||||
)
|
||||
} else if (reason === 'subsidized_your_market') {
|
||||
if (reason === 'subsidized_your_market') {
|
||||
// TODO: send email to creator of market that was subsidized
|
||||
} else if (reason === 'on_new_follow') {
|
||||
// TODO: send email to user who was followed
|
||||
|
@ -127,20 +124,7 @@ export const createNotification = async (
|
|||
reason: 'on_new_follow',
|
||||
}
|
||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'closed' &&
|
||||
sourceContract
|
||||
) {
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'your_contract_closed',
|
||||
}
|
||||
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
|
||||
} else if (
|
||||
sourceType === 'liquidity' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
} else if (sourceType === 'liquidity' && sourceContract) {
|
||||
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'subsidized_your_market',
|
||||
|
@ -1087,6 +1071,81 @@ export const createBountyNotification = async (
|
|||
sourceTitle: contract.question,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
|
||||
// maybe TODO: send email notification to comment creator
|
||||
}
|
||||
|
||||
export const createBadgeAwardedNotification = async (
|
||||
user: User,
|
||||
badge: Badge
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'badges_awarded'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${user.id}/notifications`)
|
||||
.doc()
|
||||
const notification: Notification = {
|
||||
id: notificationRef.id,
|
||||
userId: user.id,
|
||||
reason: 'badges_awarded',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: badge.type,
|
||||
sourceType: 'badge',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: MANIFOLD_USER_NAME,
|
||||
sourceUserUsername: MANIFOLD_USER_USERNAME,
|
||||
sourceUserAvatarUrl: MANIFOLD_AVATAR_URL,
|
||||
sourceText: `You earned a new ${badge.name} badge!`,
|
||||
sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`,
|
||||
sourceTitle: badge.name,
|
||||
data: {
|
||||
badge,
|
||||
},
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
|
||||
// TODO send email notification
|
||||
}
|
||||
|
||||
export const createMarketClosedNotification = async (
|
||||
contract: Contract,
|
||||
creator: User,
|
||||
privateUser: PrivateUser,
|
||||
idempotencyKey: string
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${creator.id}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: creator.id,
|
||||
reason: 'your_contract_closed',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: contract.id,
|
||||
sourceType: 'contract',
|
||||
sourceUpdateType: 'closed',
|
||||
sourceContractId: contract?.id,
|
||||
sourceUserName: creator.name,
|
||||
sourceUserUsername: creator.username,
|
||||
sourceUserAvatarUrl: creator.avatarUrl,
|
||||
sourceText: contract.closeTime?.toString() ?? new Date().toString(),
|
||||
sourceContractCreatorUsername: creator.username,
|
||||
sourceContractTitle: contract.question,
|
||||
sourceContractSlug: contract.slug,
|
||||
sourceSlug: contract.slug,
|
||||
sourceTitle: contract.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
await sendMarketCloseEmail(
|
||||
'your_contract_closed',
|
||||
creator,
|
||||
privateUser,
|
||||
contract
|
||||
)
|
||||
}
|
||||
|
|
|
@ -100,6 +100,8 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
|||
createdTime: Date.now(),
|
||||
content: content,
|
||||
contractSlug,
|
||||
creatorName: creator.name,
|
||||
creatorUsername: creator.username,
|
||||
})
|
||||
|
||||
await postRef.create(post)
|
||||
|
|
|
@ -70,6 +70,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
followedCategories: DEFAULT_CATEGORIES,
|
||||
shouldShowWelcome: true,
|
||||
fractionResolvedCorrectly: 1,
|
||||
achievements: {},
|
||||
}
|
||||
|
||||
await firestore.collection('users').doc(auth.uid).create(user)
|
||||
|
|
69
functions/src/drizzle-liquidity.ts
Normal file
69
functions/src/drizzle-liquidity.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { APIError } from '../../common/api'
|
||||
import { addCpmmLiquidity } from '../../common/calculate-cpmm'
|
||||
import { formatMoneyWithDecimals } from '../../common/util/format'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const drizzleLiquidity = async () => {
|
||||
const snap = await firestore
|
||||
.collection('contracts')
|
||||
.where('subsidyPool', '>', 1e-7)
|
||||
.get()
|
||||
|
||||
const contractIds = snap.docs.map((doc) => doc.id)
|
||||
console.log('found', contractIds.length, 'markets to drizzle')
|
||||
console.log()
|
||||
|
||||
await batchedWaitAll(
|
||||
contractIds.map((cid) => () => drizzleMarket(cid)),
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
export const drizzleLiquidityScheduler = functions.pubsub
|
||||
.schedule('* * * * *') // every minute
|
||||
.onRun(drizzleLiquidity)
|
||||
|
||||
const drizzleMarket = async (contractId: string) => {
|
||||
await firestore.runTransaction(async (trans) => {
|
||||
const snap = await trans.get(firestore.doc(`contracts/${contractId}`))
|
||||
const contract = snap.data() as CPMMContract
|
||||
const { subsidyPool, pool, p, slug, popularityScore } = contract
|
||||
if ((subsidyPool ?? 0) < 1e-7) return
|
||||
|
||||
const r = Math.random()
|
||||
const logPopularity = Math.log10((popularityScore ?? 0) + 1)
|
||||
const v = Math.max(1, Math.min(5, logPopularity))
|
||||
const amount = subsidyPool <= 0.5 ? subsidyPool : r * v * 0.01 * subsidyPool
|
||||
|
||||
const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
|
||||
|
||||
if (!isFinite(newP)) {
|
||||
throw new APIError(
|
||||
500,
|
||||
'Liquidity injection rejected due to overflow error.'
|
||||
)
|
||||
}
|
||||
|
||||
await trans.update(firestore.doc(`contracts/${contract.id}`), {
|
||||
pool: newPool,
|
||||
p: newP,
|
||||
subsidyPool: subsidyPool - amount,
|
||||
})
|
||||
|
||||
console.log(
|
||||
'added subsidy',
|
||||
formatMoneyWithDecimals(amount),
|
||||
'of',
|
||||
formatMoneyWithDecimals(subsidyPool),
|
||||
'pool to',
|
||||
slug
|
||||
)
|
||||
console.log()
|
||||
})
|
||||
}
|
|
@ -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',
|
||||
|
@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async (
|
|||
privateUser: PrivateUser,
|
||||
contract: Contract
|
||||
) => {
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
if (!privateUser.email) return
|
||||
|
||||
const { username, name, id: userId } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async (
|
|||
|
||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||
|
||||
// We ignore if they were able to unsubscribe from market close emails, this is a necessary email
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
'Your market has closed',
|
||||
|
@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async (
|
|||
{
|
||||
question,
|
||||
url,
|
||||
unsubscribeUrl,
|
||||
unsubscribeUrl: '',
|
||||
userId,
|
||||
name: firstName,
|
||||
volume: formatMoney(volume),
|
||||
|
@ -466,17 +448,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 +598,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 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async (
|
|||
: 'portfolio-update',
|
||||
templateData
|
||||
)
|
||||
log('Sent portfolio update email to', privateUser.email)
|
||||
}
|
||||
|
|
42
functions/src/helpers/add-house-subsidy.ts
Normal file
42
functions/src/helpers/add-house-subsidy.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../../common/contract'
|
||||
import { isProd } from '../utils'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../../common/antes'
|
||||
import { getNewLiquidityProvision } from '../../../common/add-liquidity'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const addHouseSubsidy = (contractId: string, amount: number) => {
|
||||
return firestore.runTransaction(async (transaction) => {
|
||||
const newLiquidityProvisionDoc = firestore
|
||||
.collection(`contracts/${contractId}/liquidity`)
|
||||
.doc()
|
||||
|
||||
const providerId = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const snap = await contractDoc.get()
|
||||
const contract = snap.data() as CPMMContract
|
||||
|
||||
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } =
|
||||
getNewLiquidityProvision(
|
||||
providerId,
|
||||
amount,
|
||||
contract,
|
||||
newLiquidityProvisionDoc.id
|
||||
)
|
||||
|
||||
transaction.update(contractDoc, {
|
||||
subsidyPool: newSubsidyPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
} as Partial<CPMMContract>)
|
||||
|
||||
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
|
||||
})
|
||||
}
|
|
@ -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 { updateMetrics } from './update-metrics'
|
||||
export { scheduleUpdateMetrics } from './update-metrics'
|
||||
export * from './update-stats'
|
||||
export * from './update-loans'
|
||||
export * from './backup-db'
|
||||
|
@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags'
|
|||
export * from './on-update-contract-follow'
|
||||
export * from './on-update-like'
|
||||
export * from './weekly-portfolio-emails'
|
||||
export * from './drizzle-liquidity'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -44,8 +45,6 @@ export * from './sell-bet'
|
|||
export * from './sell-shares'
|
||||
export * from './claim-manalink'
|
||||
export * from './create-market'
|
||||
export * from './add-liquidity'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './unsubscribe'
|
||||
|
@ -53,6 +52,7 @@ export * from './stripe'
|
|||
export * from './mana-bonus-email'
|
||||
export * from './close-market'
|
||||
export * from './update-comment-bounty'
|
||||
export * from './add-subsidy'
|
||||
|
||||
import { health } from './health'
|
||||
import { transact } from './transact'
|
||||
|
@ -68,7 +68,6 @@ import { createmarket } from './create-market'
|
|||
import { addliquidity } from './add-liquidity'
|
||||
import { createcomment } from './create-comment'
|
||||
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
import { resolvemarket } from './resolve-market'
|
||||
import { closemarket } from './close-market'
|
||||
|
@ -79,6 +78,7 @@ import { acceptchallenge } from './accept-challenge'
|
|||
import { createpost } from './create-post'
|
||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||
import { updatemetrics } from './update-metrics'
|
||||
import { addsubsidy } from './add-subsidy'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -94,11 +94,10 @@ const sellBetFunction = toCloudFunction(sellbet)
|
|||
const sellSharesFunction = toCloudFunction(sellshares)
|
||||
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
||||
const createMarketFunction = toCloudFunction(createmarket)
|
||||
const addLiquidityFunction = toCloudFunction(addliquidity)
|
||||
const addSubsidyFunction = toCloudFunction(addsubsidy)
|
||||
const addCommentBounty = toCloudFunction(addcommentbounty)
|
||||
const createCommentFunction = toCloudFunction(createcomment)
|
||||
const awardCommentBounty = toCloudFunction(awardcommentbounty)
|
||||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
||||
const createGroupFunction = toCloudFunction(creategroup)
|
||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||
const closeMarketFunction = toCloudFunction(closemarket)
|
||||
|
@ -123,8 +122,7 @@ export {
|
|||
sellSharesFunction as sellshares,
|
||||
claimManalinkFunction as claimmanalink,
|
||||
createMarketFunction as createmarket,
|
||||
addLiquidityFunction as addliquidity,
|
||||
withdrawLiquidityFunction as withdrawliquidity,
|
||||
addSubsidyFunction as addsubsidy,
|
||||
createGroupFunction as creategroup,
|
||||
resolveMarketFunction as resolvemarket,
|
||||
closeMarketFunction as closemarket,
|
||||
|
|
|
@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
|
|||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getPrivateUser, getUserByUsername } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createMarketClosedNotification } from './create-notification'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
|
||||
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
|
||||
export const marketCloseNotifications = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.pubsub.schedule('every 1 hours')
|
||||
|
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function sendMarketCloseEmails() {
|
||||
export async function sendMarketCloseEmails() {
|
||||
const contracts = await firestore.runTransaction(async (transaction) => {
|
||||
const snap = await transaction.get(
|
||||
firestore.collection('contracts').where('isResolved', '!=', true)
|
||||
)
|
||||
const contracts = snap.docs.map((doc) => doc.data() as Contract)
|
||||
const now = Date.now()
|
||||
const closeContracts = contracts.filter(
|
||||
(contract) =>
|
||||
contract.closeTime &&
|
||||
contract.closeTime < now &&
|
||||
shouldSendFirstOrFollowUpCloseNotification(contract)
|
||||
)
|
||||
|
||||
return snap.docs
|
||||
.map((doc) => {
|
||||
const contract = doc.data() as Contract
|
||||
|
||||
if (
|
||||
contract.resolution ||
|
||||
(contract.closeEmailsSent ?? 0) >= 1 ||
|
||||
contract.closeTime === undefined ||
|
||||
(contract.closeTime ?? 0) > Date.now()
|
||||
await Promise.all(
|
||||
closeContracts.map(async (contract) => {
|
||||
await transaction.update(
|
||||
firestore.collection('contracts').doc(contract.id),
|
||||
{
|
||||
closeEmailsSent: admin.firestore.FieldValue.increment(1),
|
||||
}
|
||||
)
|
||||
return undefined
|
||||
|
||||
transaction.update(doc.ref, {
|
||||
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
|
||||
})
|
||||
|
||||
return contract
|
||||
})
|
||||
.filter((x) => !!x) as Contract[]
|
||||
)
|
||||
return closeContracts
|
||||
})
|
||||
|
||||
for (const contract of contracts) {
|
||||
|
@ -55,14 +57,40 @@ async function sendMarketCloseEmails() {
|
|||
const privateUser = await getPrivateUser(user.id)
|
||||
if (!privateUser) continue
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'closed',
|
||||
await createMarketClosedNotification(
|
||||
contract,
|
||||
user,
|
||||
contract.id + '-closed-at-' + contract.closeTime,
|
||||
contract.closeTime?.toString() ?? new Date().toString(),
|
||||
{ contract }
|
||||
privateUser,
|
||||
contract.id + '-closed-at-' + contract.closeTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The downside of this approach is if this function goes down for the entire
|
||||
// day of a multiple of the time period after the market has closed, it won't
|
||||
// keep sending them notifications bc when it comes back online the time period will have passed
|
||||
function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) {
|
||||
if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true
|
||||
const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } =
|
||||
marketClosedMultipleOfNDaysAgo(contract)
|
||||
return (
|
||||
contract.closeEmailsSent > 0 &&
|
||||
closedMultipleOfNDaysAgo &&
|
||||
contract.closeEmailsSent === fullTimePeriodsSinceClose
|
||||
)
|
||||
}
|
||||
|
||||
function marketClosedMultipleOfNDaysAgo(contract: Contract) {
|
||||
const now = Date.now()
|
||||
const closeTime = contract.closeTime
|
||||
if (!closeTime)
|
||||
return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 }
|
||||
const daysSinceClose = Math.floor((now - closeTime) / DAY_MS)
|
||||
return {
|
||||
closedMultipleOfNDaysAgo:
|
||||
daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0,
|
||||
fullTimePeriodsSinceClose: Math.floor(
|
||||
daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
revalidateStaticProps,
|
||||
} from './utils'
|
||||
import {
|
||||
createBadgeAwardedNotification,
|
||||
createBetFillNotification,
|
||||
createBettingStreakBonusNotification,
|
||||
createUniqueBettorBonusNotification,
|
||||
|
@ -24,6 +25,7 @@ import {
|
|||
BETTING_STREAK_BONUS_MAX,
|
||||
BETTING_STREAK_RESET_HOUR,
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
UNIQUE_BETTOR_LIQUIDITY,
|
||||
} from '../../common/economy'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
@ -33,6 +35,11 @@ import { APIError } from '../../common/api'
|
|||
import { User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||
import { addHouseSubsidy } from './helpers/add-house-subsidy'
|
||||
import {
|
||||
StreakerBadge,
|
||||
streakerBadgeRarityThresholds,
|
||||
} from '../../common/badge'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||
|
@ -103,7 +110,7 @@ const updateBettingStreak = async (
|
|||
|
||||
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
|
||||
// Otherwise, add 1 to their betting streak
|
||||
await trans.update(userDoc, {
|
||||
trans.update(userDoc, {
|
||||
currentBettingStreak: newBettingStreak,
|
||||
lastBetTime: bet.createdTime,
|
||||
})
|
||||
|
@ -143,7 +150,7 @@ const updateBettingStreak = async (
|
|||
log('message:', result.message)
|
||||
return
|
||||
}
|
||||
if (result.txn)
|
||||
if (result.txn) {
|
||||
await createBettingStreakBonusNotification(
|
||||
user,
|
||||
result.txn.id,
|
||||
|
@ -153,6 +160,8 @@ const updateBettingStreak = async (
|
|||
newBettingStreak,
|
||||
eventId
|
||||
)
|
||||
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
||||
}
|
||||
}
|
||||
|
||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||
|
@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||
|
||||
await trans.update(contractDoc, {
|
||||
trans.update(contractDoc, {
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
})
|
||||
|
@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
return { newUniqueBettorIds }
|
||||
}
|
||||
)
|
||||
|
||||
if (!newUniqueBettorIds) return
|
||||
|
||||
if (oldContract.mechanism === 'cpmm-1') {
|
||||
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
|
||||
}
|
||||
|
||||
const bonusTxnDetails = {
|
||||
contractId: oldContract.id,
|
||||
uniqueNewBettorId: bettor.id,
|
||||
|
@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
|
@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
description: JSON.stringify(bonusTxnDetails),
|
||||
data: bonusTxnDetails,
|
||||
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||
|
||||
const { status, message, txn } = await runTxn(trans, bonusTxn)
|
||||
|
||||
return { status, newUniqueBettorIds, message, txn }
|
||||
})
|
||||
|
||||
|
@ -296,3 +314,39 @@ const notifyFills = async (
|
|||
const currentDateBettingStreakResetTime = () => {
|
||||
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0)
|
||||
}
|
||||
|
||||
async function handleBettingStreakBadgeAward(
|
||||
user: User,
|
||||
newBettingStreak: number
|
||||
) {
|
||||
const alreadyHasBadgeForFirstStreak =
|
||||
user.achievements?.streaker?.badges.some(
|
||||
(badge) => badge.data.totalBettingStreak === 1
|
||||
)
|
||||
// TODO: check if already awarded 50th streak as well
|
||||
if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return
|
||||
|
||||
if (streakerBadgeRarityThresholds.includes(newBettingStreak)) {
|
||||
const badge = {
|
||||
type: 'STREAKER',
|
||||
name: 'Streaker',
|
||||
data: {
|
||||
totalBettingStreak: newBettingStreak,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as StreakerBadge
|
||||
// update user
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({
|
||||
achievements: {
|
||||
...user.achievements,
|
||||
streaker: {
|
||||
badges: [...(user.achievements?.streaker?.badges ?? []), badge],
|
||||
},
|
||||
},
|
||||
})
|
||||
await createBadgeAwardedNotification(user, badge)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { createNewContractNotification } from './create-notification'
|
||||
import { getUser, getValues } from './utils'
|
||||
import {
|
||||
createBadgeAwardedNotification,
|
||||
createNewContractNotification,
|
||||
} from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { User } from '../../common/user'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {
|
||||
MarketCreatorBadge,
|
||||
marketCreatorBadgeRarityThresholds,
|
||||
} from '../../common/badge'
|
||||
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -28,4 +37,43 @@ export const onCreateContract = functions
|
|||
richTextToString(desc),
|
||||
mentioned
|
||||
)
|
||||
await handleMarketCreatorBadgeAward(contractCreator)
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function handleMarketCreatorBadgeAward(contractCreator: User) {
|
||||
// get all contracts by user and calculate size of array
|
||||
const contracts = await getValues<Contract>(
|
||||
firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', contractCreator.id)
|
||||
.where('resolution', '!=', 'CANCEL')
|
||||
)
|
||||
if (marketCreatorBadgeRarityThresholds.includes(contracts.length)) {
|
||||
const badge = {
|
||||
type: 'MARKET_CREATOR',
|
||||
name: 'Market Creator',
|
||||
data: {
|
||||
totalContractsCreated: contracts.length,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as MarketCreatorBadge
|
||||
// update user
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(contractCreator.id)
|
||||
.update({
|
||||
achievements: {
|
||||
...contractCreator.achievements,
|
||||
marketCreator: {
|
||||
badges: [
|
||||
...(contractCreator.achievements?.marketCreator?.badges ?? []),
|
||||
badge,
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
await createBadgeAwardedNotification(contractCreator, badge)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser, log } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { FIXED_ANTE } from '../../common/economy'
|
||||
|
@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||
|
||||
await createNotification(
|
||||
await createFollowOrMarketSubsidizedNotification(
|
||||
contract.id,
|
||||
'liquidity',
|
||||
'created',
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createFollowOrMarketSubsidizedNotification } from './create-notification'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
export const onFollowUser = functions.firestore
|
||||
|
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
|
|||
followerCountCached: FieldValue.increment(1),
|
||||
})
|
||||
|
||||
await createNotification(
|
||||
await createFollowOrMarketSubsidizedNotification(
|
||||
followingUser.id,
|
||||
'follow',
|
||||
'created',
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { getUser, getValues } from './utils'
|
||||
import {
|
||||
createBadgeAwardedNotification,
|
||||
createCommentOrAnswerOrUpdatedContractNotification,
|
||||
} from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { GroupContractDoc } from '../../common/group'
|
||||
import { Bet } from '../../common/bet'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { ContractComment } from '../../common/comment'
|
||||
import { scoreCommentorsAndBettors } from '../../common/scoring'
|
||||
import {
|
||||
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
|
||||
ProvenCorrectBadge,
|
||||
} from '../../common/badge'
|
||||
import { GroupContractDoc } from '../../common/group'
|
||||
|
||||
export const onUpdateContract = functions.firestore
|
||||
.document('contracts/{contractId}')
|
||||
|
@ -15,7 +25,7 @@ export const onUpdateContract = functions.firestore
|
|||
|
||||
if (!previousContract.isResolved && contract.isResolved) {
|
||||
// No need to notify users of resolution, that's handled in resolve-market
|
||||
return
|
||||
return await handleResolvedContract(contract)
|
||||
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
|
||||
await handleContractGroupUpdated(previousContract, contract)
|
||||
} else if (
|
||||
|
@ -26,6 +36,64 @@ export const onUpdateContract = functions.firestore
|
|||
}
|
||||
})
|
||||
|
||||
async function handleResolvedContract(contract: Contract) {
|
||||
if (
|
||||
(contract.uniqueBettorCount ?? 0) <
|
||||
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE ||
|
||||
contract.resolution === 'CANCEL'
|
||||
)
|
||||
return
|
||||
|
||||
// get all bets on this contract
|
||||
const bets = await getValues<Bet>(
|
||||
firestore.collection(`contracts/${contract.id}/bets`)
|
||||
)
|
||||
|
||||
// get comments on this contract
|
||||
const comments = await getValues<ContractComment>(
|
||||
firestore.collection(`contracts/${contract.id}/comments`)
|
||||
)
|
||||
|
||||
const { topCommentId, profitById, commentsById, betsById, topCommentBetId } =
|
||||
scoreCommentorsAndBettors(contract, bets, comments)
|
||||
if (topCommentBetId && profitById[topCommentBetId] > 0) {
|
||||
// award proven correct badge to user
|
||||
const comment = commentsById[topCommentId]
|
||||
const bet = betsById[topCommentBetId]
|
||||
|
||||
const user = await getUser(comment.userId)
|
||||
if (!user) return
|
||||
const newProvenCorrectBadge = {
|
||||
createdTime: Date.now(),
|
||||
type: 'PROVEN_CORRECT',
|
||||
name: 'Proven Correct',
|
||||
data: {
|
||||
contractSlug: contract.slug,
|
||||
contractCreatorUsername: contract.creatorUsername,
|
||||
commentId: comment.id,
|
||||
betAmount: bet.amount,
|
||||
contractTitle: contract.question,
|
||||
},
|
||||
} as ProvenCorrectBadge
|
||||
// update user
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({
|
||||
achievements: {
|
||||
...user.achievements,
|
||||
provenCorrect: {
|
||||
badges: [
|
||||
...(user.achievements?.provenCorrect?.badges ?? []),
|
||||
newProvenCorrectBadge,
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
await createBadgeAwardedNotification(user, newProvenCorrectBadge)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatedCloseTime(
|
||||
previousContract: Contract,
|
||||
contract: Contract,
|
||||
|
|
|
@ -9,7 +9,15 @@ import {
|
|||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
|
||||
import {
|
||||
getContractPath,
|
||||
getUser,
|
||||
getValues,
|
||||
isProd,
|
||||
log,
|
||||
payUser,
|
||||
revalidateStaticProps,
|
||||
} from './utils'
|
||||
import {
|
||||
getLoanPayouts,
|
||||
getPayouts,
|
||||
|
@ -145,6 +153,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
resolutions,
|
||||
collectedFees,
|
||||
}),
|
||||
subsidyPool: 0,
|
||||
}
|
||||
|
||||
await contractDoc.update(updatedContract)
|
||||
|
|
|
@ -11,15 +11,18 @@ async function main() {
|
|||
await Promise.all(
|
||||
privateUsers.map((privateUser) => {
|
||||
if (!privateUser.id) return Promise.resolve()
|
||||
return firestore
|
||||
.collection('private-users')
|
||||
.doc(privateUser.id)
|
||||
.update({
|
||||
notificationPreferences: {
|
||||
...privateUser.notificationPreferences,
|
||||
opt_out_all: [],
|
||||
},
|
||||
})
|
||||
if (privateUser.notificationPreferences.badges_awarded === undefined) {
|
||||
return firestore
|
||||
.collection('private-users')
|
||||
.doc(privateUser.id)
|
||||
.update({
|
||||
notificationPreferences: {
|
||||
...privateUser.notificationPreferences,
|
||||
badges_awarded: ['browser'],
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
136
functions/src/scripts/backfill-badges.ts
Normal file
136
functions/src/scripts/backfill-badges.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
import { getAllUsers, getValues } from '../utils'
|
||||
import { Contract } from 'common/contract'
|
||||
import {
|
||||
MarketCreatorBadge,
|
||||
marketCreatorBadgeRarityThresholds,
|
||||
StreakerBadge,
|
||||
streakerBadgeRarityThresholds,
|
||||
} from 'common/badge'
|
||||
import { User } from 'common/user'
|
||||
initAdmin()
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function main() {
|
||||
const users = await getAllUsers()
|
||||
// const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian
|
||||
// const users = filterDefined([await getUser('uglwf3YKOZNGjjEXKc5HampOFRE2')]) // prod David
|
||||
// const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (!user.id) return
|
||||
// Only backfill users without achievements
|
||||
if (user.achievements === undefined) {
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements: {},
|
||||
})
|
||||
user.achievements = {}
|
||||
user.achievements = await awardMarketCreatorBadges(user)
|
||||
user.achievements = await awardBettingStreakBadges(user)
|
||||
console.log('Added achievements to user', user.id)
|
||||
// going to ignore backfilling the proven correct badges for now
|
||||
} else {
|
||||
// Make corrections to existing achievements
|
||||
await awardMarketCreatorBadges(user)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) main().then(() => process.exit())
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function removeErrorBadges(user: User) {
|
||||
if (
|
||||
user.achievements.streaker?.badges.some(
|
||||
(b) => b.data.totalBettingStreak > 1
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
`User ${
|
||||
user.id
|
||||
} has a streaker badge with streaks ${user.achievements.streaker?.badges.map(
|
||||
(b) => b.data.totalBettingStreak
|
||||
)}`
|
||||
)
|
||||
// delete non 1,50 streaks
|
||||
user.achievements.streaker.badges =
|
||||
user.achievements.streaker.badges.filter((b) =>
|
||||
streakerBadgeRarityThresholds.includes(b.data.totalBettingStreak)
|
||||
)
|
||||
// update user
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements: user.achievements,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function awardMarketCreatorBadges(user: User) {
|
||||
// Award market maker badges
|
||||
const contracts = (
|
||||
await getValues<Contract>(
|
||||
firestore.collection(`contracts`).where('creatorId', '==', user.id)
|
||||
)
|
||||
).filter((c) => !c.resolution || c.resolution != 'CANCEL')
|
||||
|
||||
const achievements = {
|
||||
...user.achievements,
|
||||
marketCreator: {
|
||||
badges: [...(user.achievements.marketCreator?.badges ?? [])],
|
||||
},
|
||||
}
|
||||
for (const threshold of marketCreatorBadgeRarityThresholds) {
|
||||
const alreadyHasBadge = user.achievements.marketCreator?.badges.some(
|
||||
(b) => b.data.totalContractsCreated === threshold
|
||||
)
|
||||
if (alreadyHasBadge) continue
|
||||
if (contracts.length >= threshold) {
|
||||
console.log(`User ${user.id} has at least ${threshold} contracts`)
|
||||
const badge = {
|
||||
type: 'MARKET_CREATOR',
|
||||
name: 'Market Creator',
|
||||
data: {
|
||||
totalContractsCreated: threshold,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as MarketCreatorBadge
|
||||
achievements.marketCreator.badges.push(badge)
|
||||
}
|
||||
}
|
||||
// update user
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements,
|
||||
})
|
||||
return achievements
|
||||
}
|
||||
|
||||
async function awardBettingStreakBadges(user: User) {
|
||||
const streak = user.currentBettingStreak ?? 0
|
||||
const achievements = {
|
||||
...user.achievements,
|
||||
streaker: {
|
||||
badges: [...(user.achievements?.streaker?.badges ?? [])],
|
||||
},
|
||||
}
|
||||
for (const threshold of streakerBadgeRarityThresholds) {
|
||||
if (streak >= threshold) {
|
||||
const badge = {
|
||||
type: 'STREAKER',
|
||||
name: 'Streaker',
|
||||
data: {
|
||||
totalBettingStreak: threshold,
|
||||
},
|
||||
createdTime: Date.now(),
|
||||
} as StreakerBadge
|
||||
achievements.streaker.badges.push(badge)
|
||||
}
|
||||
}
|
||||
// update user
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
achievements,
|
||||
})
|
||||
return achievements
|
||||
}
|
8
functions/src/scripts/drizzle.ts
Normal file
8
functions/src/scripts/drizzle.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { drizzleLiquidity } from '../drizzle-liquidity'
|
||||
|
||||
if (require.main === module) {
|
||||
drizzleLiquidity().then(() => process.exit())
|
||||
}
|
|
@ -63,10 +63,8 @@ addJsonEndpointRoute('/sellbet', sellbet)
|
|||
addJsonEndpointRoute('/sellshares', sellshares)
|
||||
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
||||
addJsonEndpointRoute('/createmarket', createmarket)
|
||||
addJsonEndpointRoute('/addliquidity', addliquidity)
|
||||
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
|
||||
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
|
||||
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
||||
addJsonEndpointRoute('/creategroup', creategroup)
|
||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { isProd } from './utils'
|
||||
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails'
|
||||
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
|
||||
|
||||
// Function for testing scheduled functions locally
|
||||
export const testscheduledfunction = newEndpoint(
|
||||
|
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
|
|||
throw new APIError(400, 'This function is only available in dev mode')
|
||||
|
||||
// Replace your function here
|
||||
await sendTrendingMarketsEmailsToAllUsers()
|
||||
await sendMarketCloseEmails()
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||
import { groupBy, keyBy, sortBy } from 'lodash'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
|
@ -15,6 +15,7 @@ import {
|
|||
calculateNewPortfolioMetrics,
|
||||
calculateNewProfit,
|
||||
calculateProbChanges,
|
||||
calculateMetricsByContract,
|
||||
computeElasticity,
|
||||
computeVolume,
|
||||
} from '../../common/calculate-metrics'
|
||||
|
@ -23,13 +24,15 @@ import { Group } from '../../common/group'
|
|||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { newEndpointNoAuth } from './api'
|
||||
import { getFunctionUrl } from '../../common/api'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const updateMetrics = functions.pubsub
|
||||
export const scheduleUpdateMetrics = functions.pubsub
|
||||
.schedule('every 15 minutes')
|
||||
.onRun(async () => {
|
||||
const response = await fetch(getFunctionUrl('updatemetrics'), {
|
||||
const url = getFunctionUrl('updatemetrics')
|
||||
console.log('Scheduling update metrics', url)
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
@ -59,11 +62,7 @@ export async function updateMetricsCore() {
|
|||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||
|
||||
console.log('Loading portfolio history')
|
||||
const allPortfolioHistories = await getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
.collectionGroup('portfolioHistory')
|
||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||
)
|
||||
const userPortfolioHistory = await loadPortfolioHistory(users)
|
||||
|
||||
console.log('Loading groups')
|
||||
const groups = await getValues<Group>(firestore.collection('groups'))
|
||||
|
@ -140,11 +139,10 @@ export async function updateMetricsCore() {
|
|||
)
|
||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||
|
||||
const userMetrics = users.map((user) => {
|
||||
const currentBets = betsByUser[user.id] ?? []
|
||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||
const portfolioHistory = userPortfolioHistory[user.id] ?? []
|
||||
const userContracts = contractsByUser[user.id] ?? []
|
||||
const newCreatorVolume = calculateCreatorVolume(userContracts)
|
||||
const newPortfolio = calculateNewPortfolioMetrics(
|
||||
|
@ -152,14 +150,20 @@ export async function updateMetricsCore() {
|
|||
contractsById,
|
||||
currentBets
|
||||
)
|
||||
const lastPortfolio = last(portfolioHistory)
|
||||
const currPortfolio = portfolioHistory.current
|
||||
const didPortfolioChange =
|
||||
lastPortfolio === undefined ||
|
||||
lastPortfolio.balance !== newPortfolio.balance ||
|
||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
currPortfolio === undefined ||
|
||||
currPortfolio.balance !== newPortfolio.balance ||
|
||||
currPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
currPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
|
||||
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
||||
|
||||
const metricsByContract = calculateMetricsByContract(
|
||||
currentBets,
|
||||
contractsById
|
||||
)
|
||||
|
||||
const contractRatios = userContracts
|
||||
.map((contract) => {
|
||||
if (
|
||||
|
@ -169,7 +173,7 @@ export async function updateMetricsCore() {
|
|||
return 0
|
||||
}
|
||||
const contractRatio =
|
||||
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
|
||||
contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1)
|
||||
|
||||
return contractRatio
|
||||
})
|
||||
|
@ -177,7 +181,7 @@ export async function updateMetricsCore() {
|
|||
const badResolutions = contractRatios.filter(
|
||||
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
|
||||
)
|
||||
let newFractionResolvedCorrectly = 0
|
||||
let newFractionResolvedCorrectly = 1
|
||||
if (userContracts.length > 0) {
|
||||
newFractionResolvedCorrectly =
|
||||
(userContracts.length - badResolutions.length) / userContracts.length
|
||||
|
@ -190,6 +194,7 @@ export async function updateMetricsCore() {
|
|||
newProfit,
|
||||
didPortfolioChange,
|
||||
newFractionResolvedCorrectly,
|
||||
metricsByContract,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -205,63 +210,61 @@ export async function updateMetricsCore() {
|
|||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||
|
||||
const userUpdates = userMetrics.map(
|
||||
({
|
||||
user,
|
||||
newCreatorVolume,
|
||||
newPortfolio,
|
||||
newProfit,
|
||||
didPortfolioChange,
|
||||
newFractionResolvedCorrectly,
|
||||
}) => {
|
||||
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => {
|
||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||
return {
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
profitCached: newProfit,
|
||||
nextLoanCached,
|
||||
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
||||
},
|
||||
},
|
||||
|
||||
subcollectionUpdates: {
|
||||
doc: firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: didPortfolioChange ? newPortfolio : {},
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
profitCached: newProfit,
|
||||
nextLoanCached,
|
||||
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates.map((u) => u.fieldUpdates)
|
||||
await writeAsync(firestore, userUpdates)
|
||||
|
||||
const portfolioHistoryUpdates = filterDefined(
|
||||
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
|
||||
return didPortfolioChange
|
||||
? {
|
||||
doc: firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: newPortfolio,
|
||||
}
|
||||
: null
|
||||
})
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates
|
||||
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
|
||||
.map((u) => u.subcollectionUpdates),
|
||||
'set'
|
||||
await writeAsync(firestore, portfolioHistoryUpdates, 'set')
|
||||
|
||||
const contractMetricsUpdates = userMetrics.flatMap(
|
||||
({ user, metricsByContract }) => {
|
||||
const collection = firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('contract-metrics')
|
||||
return metricsByContract.map((metrics) => ({
|
||||
doc: collection.doc(metrics.contractId),
|
||||
fields: metrics,
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
await writeAsync(firestore, contractMetricsUpdates, 'set')
|
||||
|
||||
log(`Updated metrics for ${users.length} users.`)
|
||||
|
||||
try {
|
||||
const groupUpdates = groups.map((group, index) => {
|
||||
const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
|
||||
const groupContracts = groupContractIds
|
||||
.map((e) => contractsById[e.contractId])
|
||||
.filter((e) => e !== undefined) as Contract[]
|
||||
const bets = groupContracts.map((e) => {
|
||||
if (e != null && e.id in betsByContract) {
|
||||
return betsByContract[e.id] ?? []
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const groupContracts = filterDefined(
|
||||
groupContractIds.map((e) => contractsById[e.contractId])
|
||||
)
|
||||
const bets = groupContracts.map((e) => betsByContract[e.id] ?? [])
|
||||
|
||||
const creatorScores = scoreCreators(groupContracts)
|
||||
const traderScores = scoreTraders(groupContracts, bets)
|
||||
|
@ -295,3 +298,44 @@ const topUserScores = (scores: { [userId: string]: number }) => {
|
|||
type GroupContractDoc = { contractId: string; createdTime: number }
|
||||
|
||||
const BAD_RESOLUTION_THRESHOLD = 0.1
|
||||
|
||||
const loadPortfolioHistory = async (users: User[]) => {
|
||||
const now = Date.now()
|
||||
const userPortfolioHistory = await batchedWaitAll(
|
||||
users.map((user) => async () => {
|
||||
const query = firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.orderBy('timestamp', 'desc')
|
||||
.limit(1)
|
||||
|
||||
const portfolioMetrics = await Promise.all([
|
||||
getValues<PortfolioMetrics>(query),
|
||||
getValues<PortfolioMetrics>(
|
||||
query.where('timestamp', '<', now - DAY_MS)
|
||||
),
|
||||
getValues<PortfolioMetrics>(
|
||||
query.where('timestamp', '<', now - 7 * DAY_MS)
|
||||
),
|
||||
getValues<PortfolioMetrics>(
|
||||
query.where('timestamp', '<', now - 30 * DAY_MS)
|
||||
),
|
||||
])
|
||||
const [current, day, week, month] = portfolioMetrics.map(
|
||||
(p) => p[0] as PortfolioMetrics | undefined
|
||||
)
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
current,
|
||||
day,
|
||||
week,
|
||||
month,
|
||||
}
|
||||
}),
|
||||
100
|
||||
)
|
||||
|
||||
return keyBy(userPortfolioHistory, (p) => p.userId)
|
||||
}
|
||||
|
|
|
@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => {
|
|||
return users.docs.map((doc) => doc.data() as PrivateUser)
|
||||
}
|
||||
|
||||
export const getAllUsers = async () => {
|
||||
const firestore = admin.firestore()
|
||||
const users = await firestore.collection('users').get()
|
||||
return users.docs.map((doc) => doc.data() as User)
|
||||
}
|
||||
|
||||
export const getUserByUsername = async (username: string) => {
|
||||
const firestore = admin.firestore()
|
||||
const snap = await firestore
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { subtractObjects } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { noFees } from '../../common/fees'
|
||||
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
})
|
||||
|
||||
export const withdrawliquidity = newEndpoint({}, async (req, auth) => {
|
||||
const { contractId } = validate(bodySchema, req.body)
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const lpSnap = await trans.get(lpDoc)
|
||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const lp = lpSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as CPMMContract
|
||||
|
||||
const liquidityCollection = firestore.collection(
|
||||
`contracts/${contractId}/liquidity`
|
||||
)
|
||||
|
||||
const liquiditiesSnap = await trans.get(liquidityCollection)
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const userShares = getUserLiquidityShares(
|
||||
auth.uid,
|
||||
contract,
|
||||
liquidities,
|
||||
true
|
||||
)
|
||||
|
||||
// zero all added amounts for now
|
||||
// can add support for partial withdrawals in the future
|
||||
liquiditiesSnap.docs
|
||||
.filter(
|
||||
(_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid
|
||||
)
|
||||
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
||||
|
||||
const payout = Math.min(...Object.values(userShares))
|
||||
if (payout <= 0) return {}
|
||||
|
||||
const newBalance = lp.balance + payout
|
||||
const newTotalDeposits = lp.totalDeposits + payout
|
||||
trans.update(lpDoc, {
|
||||
balance: newBalance,
|
||||
totalDeposits: newTotalDeposits,
|
||||
} as Partial<User>)
|
||||
|
||||
const newPool = subtractObjects(contract.pool, userShares)
|
||||
|
||||
const minPoolShares = Math.min(...Object.values(newPool))
|
||||
const adjustedTotal = contract.totalLiquidity - payout
|
||||
|
||||
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
||||
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
||||
|
||||
trans.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
|
||||
const prob = getProbability(contract)
|
||||
|
||||
// surplus shares become user's bets
|
||||
const bets = Object.entries(userShares)
|
||||
.map(([outcome, shares]) =>
|
||||
shares - payout < 1 // don't create bet if less than 1 share
|
||||
? undefined
|
||||
: ({
|
||||
userId: auth.uid,
|
||||
contractId: contract.id,
|
||||
amount:
|
||||
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
|
||||
shares: shares - payout,
|
||||
outcome,
|
||||
probBefore: prob,
|
||||
probAfter: prob,
|
||||
createdTime: Date.now(),
|
||||
isLiquidityProvision: true,
|
||||
fees: noFees,
|
||||
} as Omit<Bet, 'id'>)
|
||||
)
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
for (const bet of bets) {
|
||||
const doc = firestore.collection(`contracts/${contract.id}/bets`).doc()
|
||||
trans.create(doc, { id: doc.id, ...bet })
|
||||
}
|
||||
|
||||
return userShares
|
||||
})
|
||||
.then(async (result) => {
|
||||
// redeem surplus bet with pre-existing bets
|
||||
await redeemShares(auth.uid, contractId)
|
||||
console.log('userid', auth.uid, 'withdraws', result)
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -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}
|
||||
|
@ -92,7 +93,7 @@ export function AnswerItem(props: {
|
|||
<div
|
||||
className={clsx(
|
||||
'text-2xl',
|
||||
tradingAllowed(contract) ? 'text-green-500' : 'text-gray-500'
|
||||
tradingAllowed(contract) ? 'text-teal-500' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{probPercent}
|
||||
|
@ -143,7 +144,7 @@ export function AnswerItem(props: {
|
|||
<div
|
||||
className={clsx(
|
||||
'text-xl',
|
||||
resolution === 'MKT' ? 'text-blue-700' : 'text-green-700'
|
||||
resolution === 'MKT' ? 'text-blue-700' : 'text-teal-600'
|
||||
)}
|
||||
>
|
||||
Chosen{' '}
|
||||
|
|
|
@ -23,7 +23,7 @@ import { Linkify } from 'web/components/linkify'
|
|||
import { Button } from 'web/components/button'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
||||
import { CATEGORY_COLORS } from '../charts/contract/choice'
|
||||
import { CHOICE_ANSWER_COLORS } from '../charts/contract/choice'
|
||||
import { useChartAnswers } from '../charts/contract/choice'
|
||||
|
||||
export function AnswersPanel(props: {
|
||||
|
@ -190,7 +190,10 @@ function OpenAnswer(props: {
|
|||
const probPercent = formatPercent(prob)
|
||||
const [open, setOpen] = useState(false)
|
||||
const color =
|
||||
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
|
||||
colorIndex != undefined && colorIndex < CHOICE_ANSWER_COLORS.length
|
||||
? CHOICE_ANSWER_COLORS[colorIndex] + '55' // semi-transparent
|
||||
: '#B1B1C755'
|
||||
const colorWidth = 100 * Math.max(prob, 0.01)
|
||||
|
||||
return (
|
||||
<Col className="my-1 px-2">
|
||||
|
@ -206,9 +209,12 @@ function OpenAnswer(props: {
|
|||
|
||||
<Col
|
||||
className={clsx(
|
||||
'bg-greyscale-1 relative w-full rounded-lg transition-all',
|
||||
'relative w-full rounded-lg transition-all',
|
||||
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${color} ${colorWidth}%, #FBFBFF ${colorWidth}%)`,
|
||||
}}
|
||||
>
|
||||
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
|
||||
<Row>
|
||||
|
@ -236,11 +242,6 @@ function OpenAnswer(props: {
|
|||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
<hr
|
||||
color={color}
|
||||
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
|
||||
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
|
||||
/>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
|
|
62
web/components/badge-display.tsx
Normal file
62
web/components/badge-display.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getBadgesByRarity } from 'common/badge'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import clsx from 'clsx'
|
||||
import { BadgesModal } from 'web/components/profile/badges-modal'
|
||||
import { ParsedUrlQuery } from 'querystring'
|
||||
|
||||
export const goldClassName = 'text-amber-400'
|
||||
export const silverClassName = 'text-gray-500'
|
||||
export const bronzeClassName = 'text-amber-900'
|
||||
|
||||
export function BadgeDisplay(props: {
|
||||
user: User | undefined | null
|
||||
query: ParsedUrlQuery
|
||||
}) {
|
||||
const { user, query } = props
|
||||
const [showBadgesModal, setShowBadgesModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const showBadgesModal = query['show'] == 'badges'
|
||||
setShowBadgesModal(showBadgesModal)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
// get number of badges of each rarity type
|
||||
const badgesByRarity = getBadgesByRarity(user)
|
||||
const badgesByRarityItems = Object.entries(badgesByRarity).map(
|
||||
([rarity, numBadges]) => {
|
||||
return (
|
||||
<Row
|
||||
key={rarity}
|
||||
className={clsx(
|
||||
'items-center gap-2',
|
||||
rarity === 'bronze'
|
||||
? bronzeClassName
|
||||
: rarity === 'silver'
|
||||
? silverClassName
|
||||
: goldClassName
|
||||
)}
|
||||
>
|
||||
<span className={clsx('-m-0.5 text-lg')}>•</span>
|
||||
<span className="text-xs">{numBadges}</span>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
)
|
||||
return (
|
||||
<Row
|
||||
className={'cursor-pointer gap-2'}
|
||||
onClick={() => setShowBadgesModal(true)}
|
||||
>
|
||||
{badgesByRarityItems}
|
||||
{user && (
|
||||
<BadgesModal
|
||||
isOpen={showBadgesModal}
|
||||
setOpen={setShowBadgesModal}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -47,6 +47,7 @@ import { Modal } from './layout/modal'
|
|||
import { Title } from './title'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckIcon } from '@heroicons/react/solid'
|
||||
import { Button } from './button'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
|
@ -469,7 +470,6 @@ function LimitOrderPanel(props: {
|
|||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||
const [lowLimitProb, setLowLimitProb] = useState<number | undefined>()
|
||||
const [highLimitProb, setHighLimitProb] = useState<number | undefined>()
|
||||
const betChoice = 'YES'
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
|
@ -783,22 +783,18 @@ function LimitOrderPanel(props: {
|
|||
{(hasYesLimitBet || hasNoLimitBet) && <Spacer h={8} />}
|
||||
|
||||
{user && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn flex-1',
|
||||
betDisabled
|
||||
? 'btn-disabled'
|
||||
: betChoice === 'YES'
|
||||
? 'btn-primary'
|
||||
: 'border-none bg-red-400 hover:bg-red-500',
|
||||
isSubmitting ? 'loading' : ''
|
||||
)}
|
||||
<Button
|
||||
size="xl"
|
||||
disabled={betDisabled ? true : false}
|
||||
color={'indigo'}
|
||||
loading={isSubmitting}
|
||||
className="flex-1"
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
: `Submit order${hasTwoBets ? 's' : ''}`}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -20,11 +20,10 @@ 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
|
||||
|
@ -43,7 +42,6 @@ export function CreateChallengeModal(props: {
|
|||
const { user, contract, isOpen, setOpen } = props
|
||||
const [challengeSlug, setChallengeSlug] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { editor } = useTextEditor({ placeholder: '' })
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
|
@ -64,7 +62,6 @@ export function CreateChallengeModal(props: {
|
|||
question: newChallenge.question,
|
||||
outcomeType: 'BINARY',
|
||||
initialProb: 50,
|
||||
description: editor?.getJSON(),
|
||||
ante: FIXED_ANTE,
|
||||
closeTime: dayjs().add(30, 'day').valueOf(),
|
||||
})
|
||||
|
@ -153,9 +150,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}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo } from 'react'
|
||||
import { last, sum, sortBy, groupBy } from 'lodash'
|
||||
import { last, range, sum, sortBy, groupBy } from 'lodash'
|
||||
import { scaleTime, scaleLinear } from 'd3-scale'
|
||||
import { curveStepAfter } from 'd3-shape'
|
||||
|
||||
|
@ -19,83 +19,36 @@ import { MultiPoint, MultiValueHistoryChart } from '../generic-charts'
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
|
||||
export const CATEGORY_COLORS = [
|
||||
'#7eb0d5',
|
||||
'#fd7f6f',
|
||||
'#b2e061',
|
||||
'#bd7ebe',
|
||||
'#ffb55a',
|
||||
'#ffee65',
|
||||
'#beb9db',
|
||||
'#fdcce5',
|
||||
'#8bd3c7',
|
||||
'#bddfb7',
|
||||
'#e2e3f3',
|
||||
'#fafafa',
|
||||
'#9fcdeb',
|
||||
'#d3d3d3',
|
||||
'#b1a296',
|
||||
'#e1bdb6',
|
||||
'#f2dbc0',
|
||||
'#fae5d3',
|
||||
'#c5e0ec',
|
||||
'#e0f0ff',
|
||||
'#ffddcd',
|
||||
'#fbd5e2',
|
||||
'#f2e7e5',
|
||||
'#ffe7ba',
|
||||
'#eed9c4',
|
||||
'#ea9999',
|
||||
'#f9cb9c',
|
||||
'#ffe599',
|
||||
'#b6d7a8',
|
||||
'#a2c4c9',
|
||||
'#9fc5e8',
|
||||
'#b4a7d6',
|
||||
'#d5a6bd',
|
||||
'#e06666',
|
||||
'#f6b26b',
|
||||
'#ffd966',
|
||||
'#93c47d',
|
||||
'#76a5af',
|
||||
'#6fa8dc',
|
||||
'#8e7cc3',
|
||||
'#c27ba0',
|
||||
'#cc0000',
|
||||
'#e69138',
|
||||
'#f1c232',
|
||||
'#6aa84f',
|
||||
'#45818e',
|
||||
'#3d85c6',
|
||||
'#674ea7',
|
||||
'#a64d79',
|
||||
'#990000',
|
||||
'#b45f06',
|
||||
'#bf9000',
|
||||
type ChoiceContract = FreeResponseContract | MultipleChoiceContract
|
||||
|
||||
export const CHOICE_ANSWER_COLORS = [
|
||||
'#97C1EB',
|
||||
'#F39F83',
|
||||
'#F9EBA5',
|
||||
'#FFC7D2',
|
||||
'#C7ECFF',
|
||||
'#8CDEC7',
|
||||
'#DBE96F',
|
||||
]
|
||||
export const CHOICE_OTHER_COLOR = '#CCC'
|
||||
export const CHOICE_ALL_COLORS = [...CHOICE_ANSWER_COLORS, CHOICE_OTHER_COLOR]
|
||||
|
||||
const MARGIN = { top: 20, right: 10, bottom: 20, left: 40 }
|
||||
const MARGIN_X = MARGIN.left + MARGIN.right
|
||||
const MARGIN_Y = MARGIN.top + MARGIN.bottom
|
||||
|
||||
const getTrackedAnswers = (
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
topN: number
|
||||
) => {
|
||||
const { answers, outcomeType, totalBets } = contract
|
||||
const validAnswers = answers.filter((answer) => {
|
||||
return (
|
||||
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
totalBets[answer.id] > 0.000000001
|
||||
)
|
||||
})
|
||||
const getAnswers = (contract: ChoiceContract) => {
|
||||
const { answers, outcomeType } = contract
|
||||
const validAnswers = answers.filter(
|
||||
(answer) => answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE'
|
||||
)
|
||||
return sortBy(
|
||||
validAnswers,
|
||||
(answer) => -1 * getOutcomeProbability(contract, answer.id)
|
||||
).slice(0, topN)
|
||||
)
|
||||
}
|
||||
|
||||
const getBetPoints = (answers: Answer[], bets: Bet[]) => {
|
||||
const getBetPoints = (answers: Answer[], bets: Bet[], topN?: number) => {
|
||||
const sortedBets = sortBy(bets, (b) => b.createdTime)
|
||||
const betsByOutcome = groupBy(sortedBets, (bet) => bet.outcome)
|
||||
const sharesByOutcome = Object.fromEntries(
|
||||
|
@ -109,11 +62,14 @@ const getBetPoints = (answers: Answer[], bets: Bet[]) => {
|
|||
const sharesSquared = sum(
|
||||
Object.values(sharesByOutcome).map((shares) => shares ** 2)
|
||||
)
|
||||
points.push({
|
||||
x: new Date(bet.createdTime),
|
||||
y: answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared),
|
||||
obj: bet,
|
||||
})
|
||||
const probs = answers.map((a) => sharesByOutcome[a.id] ** 2 / sharesSquared)
|
||||
|
||||
if (topN != null && answers.length > topN) {
|
||||
const y = [...probs.slice(0, topN), sum(probs.slice(topN))]
|
||||
points.push({ x: new Date(bet.createdTime), y, obj: bet })
|
||||
} else {
|
||||
points.push({ x: new Date(bet.createdTime), y: probs, obj: bet })
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
@ -141,17 +97,12 @@ const Legend = (props: { className?: string; items: LegendItem[] }) => {
|
|||
)
|
||||
}
|
||||
|
||||
export function useChartAnswers(
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
) {
|
||||
return useMemo(
|
||||
() => getTrackedAnswers(contract, CATEGORY_COLORS.length),
|
||||
[contract]
|
||||
)
|
||||
export function useChartAnswers(contract: ChoiceContract) {
|
||||
return useMemo(() => getAnswers(contract), [contract])
|
||||
}
|
||||
|
||||
export const ChoiceContractChart = (props: {
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
contract: ChoiceContract
|
||||
bets: Bet[]
|
||||
width: number
|
||||
height: number
|
||||
|
@ -160,18 +111,33 @@ export const ChoiceContractChart = (props: {
|
|||
const { contract, bets, width, height, onMouseOver } = props
|
||||
const [start, end] = getDateRange(contract)
|
||||
const answers = useChartAnswers(contract)
|
||||
const betPoints = useMemo(() => getBetPoints(answers, bets), [answers, bets])
|
||||
const data = useMemo(
|
||||
() => [
|
||||
{ x: new Date(start), y: answers.map((_) => 0) },
|
||||
const topN = Math.min(CHOICE_ANSWER_COLORS.length, answers.length)
|
||||
const betPoints = useMemo(
|
||||
() => getBetPoints(answers, bets, topN),
|
||||
[answers, bets, topN]
|
||||
)
|
||||
const endProbs = useMemo(
|
||||
() => answers.map((a) => getOutcomeProbability(contract, a.id)),
|
||||
[answers, contract]
|
||||
)
|
||||
|
||||
const data = useMemo(() => {
|
||||
const yCount = answers.length > topN ? topN + 1 : topN
|
||||
const startY = range(0, yCount).map((_) => 0)
|
||||
const endY =
|
||||
answers.length > topN
|
||||
? [...endProbs.slice(0, topN), sum(endProbs.slice(topN))]
|
||||
: endProbs
|
||||
return [
|
||||
{ x: new Date(start), y: startY },
|
||||
...betPoints,
|
||||
{
|
||||
x: new Date(end ?? Date.now() + DAY_MS),
|
||||
y: answers.map((a) => getOutcomeProbability(contract, a.id)),
|
||||
y: endY,
|
||||
},
|
||||
],
|
||||
[answers, contract, betPoints, start, end]
|
||||
)
|
||||
]
|
||||
}, [answers.length, topN, betPoints, endProbs, start, end])
|
||||
|
||||
const rightmostDate = getRightmostVisibleDate(
|
||||
end,
|
||||
last(betPoints)?.x?.getTime(),
|
||||
|
@ -188,8 +154,8 @@ export const ChoiceContractChart = (props: {
|
|||
const d = xScale.invert(x)
|
||||
const legendItems = sortBy(
|
||||
data.y.map((p, i) => ({
|
||||
color: CATEGORY_COLORS[i],
|
||||
label: answers[i].text,
|
||||
color: CHOICE_ALL_COLORS[i],
|
||||
label: i === CHOICE_ANSWER_COLORS.length ? 'Other' : answers[i].text,
|
||||
value: formatPct(p),
|
||||
p,
|
||||
})),
|
||||
|
@ -221,7 +187,7 @@ export const ChoiceContractChart = (props: {
|
|||
yScale={yScale}
|
||||
yKind="percent"
|
||||
data={data}
|
||||
colors={CATEGORY_COLORS}
|
||||
colors={CHOICE_ALL_COLORS}
|
||||
curve={curveStepAfter}
|
||||
onMouseOver={onMouseOver}
|
||||
Tooltip={ChoiceTooltip}
|
||||
|
|
|
@ -17,13 +17,21 @@ export function CommentInput(props: {
|
|||
// Reply to another comment
|
||||
parentCommentId?: string
|
||||
onSubmitComment?: (editor: Editor) => void
|
||||
// unique id for autosave
|
||||
pageId: string
|
||||
className?: string
|
||||
}) {
|
||||
const { parentAnswerOutcome, parentCommentId, replyTo, onSubmitComment } =
|
||||
props
|
||||
const {
|
||||
parentAnswerOutcome,
|
||||
parentCommentId,
|
||||
replyTo,
|
||||
onSubmitComment,
|
||||
pageId,
|
||||
} = props
|
||||
const user = useUser()
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
key: `comment ${pageId} ${parentCommentId ?? parentAnswerOutcome ?? ''}`,
|
||||
simple: true,
|
||||
max: MAX_COMMENT_LENGTH,
|
||||
placeholder:
|
||||
|
@ -80,7 +88,7 @@ export function CommentInputTextArea(props: {
|
|||
|
||||
const submit = () => {
|
||||
submitComment()
|
||||
editor?.commands?.clearContent()
|
||||
editor?.commands?.clearContent(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -107,7 +115,7 @@ export function CommentInputTextArea(props: {
|
|||
},
|
||||
})
|
||||
// insert at mention and focus
|
||||
if (replyTo) {
|
||||
if (replyTo && editor.isEmpty) {
|
||||
editor
|
||||
.chain()
|
||||
.setContent({
|
||||
|
|
|
@ -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' },
|
||||
|
@ -438,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 && (
|
||||
|
|
36
web/components/contract/add-liquidity-button.tsx
Normal file
36
web/components/contract/add-liquidity-button.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { buttonClass } from 'web/components/button'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { LiquidityModal } from './liquidity-modal'
|
||||
|
||||
export function AddLiquidityButton(props: {
|
||||
contract: CPMMContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const disabled =
|
||||
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
|
||||
|
||||
if (disabled) return <></>
|
||||
|
||||
return (
|
||||
<a
|
||||
className={clsx(
|
||||
buttonClass('2xs', 'override'),
|
||||
'cursor-pointer',
|
||||
'gap-1 border-2 border-blue-400 text-blue-400 hover:bg-blue-400 hover:text-white',
|
||||
className
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
target="_blank"
|
||||
>
|
||||
<div>💧 Add liquidity</div>
|
||||
<LiquidityModal contract={contract} isOpen={open} setOpen={setOpen} />
|
||||
</a>
|
||||
)
|
||||
}
|
|
@ -3,9 +3,9 @@ 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() {
|
||||
|
@ -30,13 +30,20 @@ export function BountiedContractSmallBadge(props: {
|
|||
const modal = (
|
||||
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
|
||||
)
|
||||
if (!openCommentBounties)
|
||||
|
||||
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
|
||||
|
@ -49,7 +56,7 @@ export function BountiedContractSmallBadge(props: {
|
|||
{modal}
|
||||
<SmallBadge
|
||||
text={`${formatMoney(openCommentBounties)} bounty`}
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={bountiesClosed ? undefined : () => setOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
@ -57,11 +64,13 @@ export function BountiedContractSmallBadge(props: {
|
|||
|
||||
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'
|
||||
'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'} />
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { Row } from '../layout/row'
|
||||
import { formatLargeNumber, formatPercent } from 'common/util/format'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
} from 'common/util/format'
|
||||
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import {
|
||||
|
@ -29,7 +33,7 @@ import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
|||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||
import { getColor, ProbBar, QuickBet } from './quick-bet'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUser, useUserContractMetrics } from 'web/hooks/use-user'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
|
@ -37,6 +41,7 @@ import { Tooltip } from '../tooltip'
|
|||
import { SiteLink } from '../site-link'
|
||||
import { ProbChange } from './prob-change-table'
|
||||
import { Card } from '../card'
|
||||
import { ProfitBadgeMana } from '../profit-badge'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -390,11 +395,18 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
|||
export function ContractCardProbChange(props: {
|
||||
contract: CPMMContract
|
||||
noLinkAvatar?: boolean
|
||||
showPosition?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { noLinkAvatar, className } = props
|
||||
const { noLinkAvatar, showPosition, className } = props
|
||||
const contract = useContractWithPreload(props.contract) as CPMMBinaryContract
|
||||
|
||||
const user = useUser()
|
||||
const metrics = useUserContractMetrics(user?.id, contract.id)
|
||||
const dayMetrics = metrics && metrics.from && metrics.from.day
|
||||
const outcome =
|
||||
metrics && metrics.hasShares && metrics.totalShares.YES ? 'YES' : 'NO'
|
||||
|
||||
return (
|
||||
<Card className={clsx(className, 'mb-4')}>
|
||||
<AvatarDetails
|
||||
|
@ -411,6 +423,27 @@ export function ContractCardProbChange(props: {
|
|||
</SiteLink>
|
||||
<ProbChange className="py-2 pr-4" contract={contract} />
|
||||
</Row>
|
||||
{showPosition && metrics && (
|
||||
<Row
|
||||
className={clsx(
|
||||
'items-center justify-between gap-4 pl-6 pr-4 pb-2 text-sm'
|
||||
)}
|
||||
>
|
||||
<Row className="gap-1 text-gray-700">
|
||||
<div className="text-gray-500">Position</div>
|
||||
{formatMoney(metrics.payout)} {outcome}
|
||||
</Row>
|
||||
|
||||
{dayMetrics && (
|
||||
<>
|
||||
<Row className="items-center">
|
||||
<div className="mr-1 text-gray-500">Daily profit</div>
|
||||
<ProfitBadgeMana amount={dayMetrics.profit} gray />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
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'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { updateContract } from 'web/lib/firebase/contracts'
|
||||
import { Row } from '../layout/row'
|
||||
import { Content } from '../editor'
|
||||
import { TextEditor, useTextEditor } from 'web/components/editor'
|
||||
import {
|
||||
TextEditor,
|
||||
editorExtensions,
|
||||
useTextEditor,
|
||||
} from 'web/components/editor'
|
||||
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
|
||||
|
@ -46,6 +48,7 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {
|
|||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
// key: `description ${contract.id}`,
|
||||
max: MAX_DESCRIPTION_LENGTH,
|
||||
defaultValue: contract.description,
|
||||
disabled: isSubmitting,
|
||||
|
@ -119,7 +122,10 @@ function EditQuestion(props: {
|
|||
}
|
||||
|
||||
function joinContent(oldContent: ContentType, newContent: string) {
|
||||
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
||||
const editor = new Editor({
|
||||
content: oldContent,
|
||||
extensions: editorExtensions(),
|
||||
})
|
||||
editor.commands.focus('end')
|
||||
insertContent(editor, newContent)
|
||||
return editor.getJSON()
|
||||
|
@ -138,8 +144,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 || '')}
|
||||
|
|
|
@ -8,7 +8,6 @@ import clsx from 'clsx'
|
|||
import { Editor } from '@tiptap/react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||
|
@ -20,7 +19,6 @@ import NewContractBadge from '../new-contract-badge'
|
|||
import { MiniUserFollowButton } from '../follow-button'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
@ -40,6 +38,8 @@ import {
|
|||
BountiedContractBadge,
|
||||
BountiedContractSmallBadge,
|
||||
} from 'web/components/contract/bountied-contract-badge'
|
||||
import { Input } from '../input'
|
||||
import { editorExtensions } from '../editor'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -153,8 +153,8 @@ export function MarketSubheader(props: {
|
|||
const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract
|
||||
const { resolvedDate } = contractMetrics(contract)
|
||||
const user = useUser()
|
||||
const correctResolutionPercentage =
|
||||
useUserById(creatorId)?.fractionResolvedCorrectly
|
||||
const creator = useUserById(creatorId)
|
||||
const correctResolutionPercentage = creator?.fractionResolvedCorrectly
|
||||
const isCreator = user?.id === creatorId
|
||||
const isMobile = useIsMobile()
|
||||
return (
|
||||
|
@ -177,12 +177,14 @@ export function MarketSubheader(props: {
|
|||
{disabled ? (
|
||||
creatorName
|
||||
) : (
|
||||
<UserLink
|
||||
className="my-auto whitespace-nowrap"
|
||||
name={creatorName}
|
||||
username={creatorUsername}
|
||||
short={isMobile}
|
||||
/>
|
||||
<Row className={'gap-2'}>
|
||||
<UserLink
|
||||
className="my-auto whitespace-nowrap"
|
||||
name={creatorName}
|
||||
username={creatorUsername}
|
||||
/>
|
||||
{/*<BadgeDisplay user={creator} />*/}
|
||||
</Row>
|
||||
)}
|
||||
{correctResolutionPercentage != null &&
|
||||
correctResolutionPercentage < BAD_CREATOR_THRESHOLD && (
|
||||
|
@ -418,7 +420,7 @@ function EditableCloseDate(props: {
|
|||
const content = contract.description
|
||||
const formattedCloseDate = dayjs(newCloseTime).format('YYYY-MM-DD h:mm a')
|
||||
|
||||
const editor = new Editor({ content, extensions: exhibitExts })
|
||||
const editor = new Editor({ content, extensions: editorExtensions() })
|
||||
editor.commands.focus('end')
|
||||
insertContent(
|
||||
editor,
|
||||
|
@ -445,17 +447,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"
|
||||
|
|
|
@ -7,7 +7,6 @@ import { capitalize } from 'lodash'
|
|||
import { Contract } from 'common/contract'
|
||||
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'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Title } from '../title'
|
||||
|
@ -21,6 +20,7 @@ import { DuplicateContractButton } from '../duplicate-contract-button'
|
|||
import { Row } from '../layout/row'
|
||||
import { BETTORS, User } from 'common/user'
|
||||
import { Button } from '../button'
|
||||
import { AddLiquidityButton } from './add-liquidity-button'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -55,6 +55,7 @@ export function ContractInfoDialog(props: {
|
|||
outcomeType,
|
||||
id,
|
||||
elasticity,
|
||||
pool,
|
||||
} = contract
|
||||
|
||||
const typeDisplay =
|
||||
|
@ -172,10 +173,25 @@ export function ContractInfoDialog(props: {
|
|||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Liquidity subsidies</td>
|
||||
<td>
|
||||
{mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'}
|
||||
{mechanism === 'cpmm-1'
|
||||
? formatMoney(contract.totalLiquidity)
|
||||
: formatMoney(100)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Pool</td>
|
||||
<td>
|
||||
{mechanism === 'cpmm-1' && outcomeType === 'BINARY'
|
||||
? `${Math.round(pool.YES)} YES, ${Math.round(pool.NO)} NO`
|
||||
: mechanism === 'cpmm-1' && outcomeType === 'PSEUDO_NUMERIC'
|
||||
? `${Math.round(pool.YES)} HIGHER, ${Math.round(
|
||||
pool.NO
|
||||
)} LOWER`
|
||||
: contractPool(contract)}
|
||||
</td>
|
||||
<td>{contractPool(contract)}</td>
|
||||
</tr>
|
||||
|
||||
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
|
||||
|
@ -226,9 +242,11 @@ export function ContractInfoDialog(props: {
|
|||
</table>
|
||||
|
||||
<Row className="flex-wrap">
|
||||
{mechanism === 'cpmm-1' && (
|
||||
<AddLiquidityButton contract={contract} className="mr-2" />
|
||||
)}
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
{!contract.resolution && <LiquidityBountyPanel contract={contract} />}
|
||||
</Col>
|
||||
</Modal>
|
||||
</>
|
||||
|
|
|
@ -2,15 +2,17 @@ import { Bet } from 'common/bet'
|
|||
import { resolvedPayout } from 'common/calculate'
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
|
||||
import { groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { FeedBet } from '../feed/feed-bets'
|
||||
import { FeedComment } from '../feed/feed-comments'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Leaderboard } from '../leaderboard'
|
||||
import { Title } from '../title'
|
||||
import { BETTORS } from 'common/user'
|
||||
import { scoreCommentorsAndBettors } from 'common/scoring'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { memo } from 'react'
|
||||
|
||||
export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
|
||||
contract: Contract
|
||||
|
@ -50,47 +52,38 @@ export const ContractLeaderboard = memo(function ContractLeaderboard(props: {
|
|||
) : null
|
||||
})
|
||||
|
||||
export function ContractTopTrades(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
// todo: this stuff should be calced in DB at resolve time
|
||||
const comments = useComments(contract.id)
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
// Otherwise, we record the profit at resolution time
|
||||
const profitById: Record<string, number> = {}
|
||||
for (const bet of bets) {
|
||||
if (bet.sale) {
|
||||
const originalBet = betsById[bet.sale.betId]
|
||||
const profit = bet.sale.amount - originalBet.amount
|
||||
profitById[bet.id] = profit
|
||||
profitById[originalBet.id] = profit
|
||||
} else {
|
||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the betId with the highest profit
|
||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = betsById[topBetId]?.userName
|
||||
|
||||
// And also the comment with the highest profit
|
||||
const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0]
|
||||
|
||||
export function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
}) {
|
||||
const { contract, bets, comments } = props
|
||||
const {
|
||||
topBetId,
|
||||
topBettor,
|
||||
profitById,
|
||||
betsById,
|
||||
topCommentId,
|
||||
commentsById,
|
||||
topCommentBetId,
|
||||
} = scoreCommentorsAndBettors(contract, bets, comments)
|
||||
return (
|
||||
<div className="mt-12 max-w-sm">
|
||||
{topComment && profitById[topComment.id] > 0 && (
|
||||
{topCommentBetId && profitById[topCommentBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💬 Proven correct" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedComment contract={contract} comment={topComment} />
|
||||
<FeedComment
|
||||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
/>
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && (
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Best bet" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
|
|
|
@ -81,6 +81,7 @@ export function ContractsGrid(props: {
|
|||
<ContractCardProbChange
|
||||
key={contract.id}
|
||||
contract={contract as CPMMBinaryContract}
|
||||
showPosition
|
||||
/>
|
||||
) : (
|
||||
<ContractCard
|
||||
|
|
|
@ -18,7 +18,9 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
return (
|
||||
<Row>
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
|
||||
<LikeMarketButton contract={contract} user={user} />
|
||||
|
||||
<Tooltip text="Share" placement="bottom" noTap noFade>
|
||||
<Button
|
||||
size="sm"
|
||||
|
@ -35,6 +37,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
|
|||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<ContractInfoDialog contract={contract} user={user} />
|
||||
</Row>
|
||||
)
|
||||
|
|
|
@ -1,240 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Contract, CPMMContract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api'
|
||||
import { AmountInput } from 'web/components/amount-input'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { useUserLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { NoLabel, YesLabel } from 'web/components/outcome-label'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
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'
|
||||
|
||||
export function LiquidityBountyPanel(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
|
||||
const isCPMM = contract.mechanism === 'cpmm-1'
|
||||
const user = useUser()
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const lpShares = isCPMM && useUserLiquidity(contract, user?.id ?? '')
|
||||
|
||||
const [showWithdrawal, setShowWithdrawal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWithdrawal && lpShares && lpShares.YES && lpShares.NO)
|
||||
setShowWithdrawal(true)
|
||||
}, [showWithdrawal, lpShares])
|
||||
|
||||
const isCreator = user?.id === contract.creatorId
|
||||
const isAdmin = useAdmin()
|
||||
|
||||
if (!isCreator && !isAdmin && !showWithdrawal) return <></>
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
tabs={buildArray(
|
||||
(isCreator || isAdmin) &&
|
||||
isCPMM && {
|
||||
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
|
||||
content: <AddLiquidityPanel contract={contract} />,
|
||||
},
|
||||
showWithdrawal &&
|
||||
isCPMM && {
|
||||
title: 'Withdraw',
|
||||
content: (
|
||||
<WithdrawLiquidityPanel
|
||||
contract={contract}
|
||||
lpShares={lpShares as { YES: number; NO: number }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
(isCreator || isAdmin) &&
|
||||
isCPMM && {
|
||||
title: 'Pool',
|
||||
content: <ViewLiquidityPanel contract={contract} />,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
const { id: contractId, slug } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [amount, setAmount] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const onAmountChange = (amount: number | undefined) => {
|
||||
setIsSuccess(false)
|
||||
setAmount(amount)
|
||||
|
||||
// Check for errors.
|
||||
if (amount !== undefined) {
|
||||
if (user && user.balance < amount) {
|
||||
setError('Insufficient balance')
|
||||
} else if (amount < 1) {
|
||||
setError('Minimum amount: ' + formatMoney(1))
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!amount) return
|
||||
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
addLiquidity({ amount, contractId })
|
||||
.then((_) => {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((_) => setError('Server error'))
|
||||
|
||||
track('add liquidity', { amount, contractId, slug })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Contribute your M$ to make this market more accurate.{' '}
|
||||
<InfoTooltip
|
||||
text={`More liquidity stabilizes the market, encouraging ${BETTORS} to ${PRESENT_BET}. You can withdraw your subsidy at any time.`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label="M$"
|
||||
error={error}
|
||||
disabled={isLoading}
|
||||
inputClassName="w-28"
|
||||
/>
|
||||
<button
|
||||
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{isSuccess && amount && (
|
||||
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewLiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
const { pool } = contract
|
||||
const { YES: yesShares, NO: noShares } = pool
|
||||
|
||||
return (
|
||||
<Col className="mb-4">
|
||||
<div className="mb-4 text-gray-500">
|
||||
The liquidity pool for this market currently contains:
|
||||
</div>
|
||||
<span>
|
||||
{yesShares.toFixed(2)} <YesLabel /> shares
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{noShares.toFixed(2)} <NoLabel /> shares
|
||||
</span>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function WithdrawLiquidityPanel(props: {
|
||||
contract: CPMMContract
|
||||
lpShares: { YES: number; NO: number }
|
||||
}) {
|
||||
const { contract, lpShares } = props
|
||||
const { YES: yesShares, NO: noShares } = lpShares
|
||||
|
||||
const [_error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const submit = () => {
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
withdrawLiquidity({ contractId: contract.id })
|
||||
.then((_) => {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((_) => setError('Server error'))
|
||||
|
||||
track('withdraw liquidity')
|
||||
}
|
||||
|
||||
if (isSuccess)
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
Success! Your liquidity was withdrawn.
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!yesShares && !noShares)
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
You do not have any liquidity positions to withdraw.
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Your liquidity position is currently:
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{yesShares.toFixed(2)} <YesLabel /> shares
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{noShares.toFixed(2)} <NoLabel /> shares
|
||||
</span>
|
||||
|
||||
<Row className="mt-4 mb-2">
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-outline btn-sm ml-2',
|
||||
isLoading && 'btn-disabled'
|
||||
)}
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
92
web/components/contract/liquidity-button.tsx
Normal file
92
web/components/contract/liquidity-button.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Button } from 'web/components/button'
|
||||
import { formatMoney, shortFormatNumber } from 'common/util/format'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Tooltip } from '../tooltip'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { LiquidityModal } from './liquidity-modal'
|
||||
|
||||
export function LiquidityButton(props: {
|
||||
contract: CPMMContract
|
||||
user: User | undefined | null
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const { totalLiquidity: total } = contract
|
||||
|
||||
const lp = useLiquidity(contract.id)
|
||||
const userActive = lp?.find((l) => l.userId === user?.id) !== undefined
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const disabled =
|
||||
contract.isResolved || (contract.closeTime ?? Infinity) < Date.now()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={`${formatMoney(total)} in liquidity subsidies`}
|
||||
placement="bottom"
|
||||
noTap
|
||||
noFade
|
||||
>
|
||||
<LiquidityIconButton
|
||||
total={total}
|
||||
userActive={userActive}
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<LiquidityModal contract={contract} isOpen={open} setOpen={setOpen} />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function LiquidityIconButton(props: {
|
||||
total: number
|
||||
onClick: () => void
|
||||
userActive: boolean
|
||||
isCompact?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { total, userActive, isCompact, onClick, disabled } = props
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
className={clsx(
|
||||
'max-w-xs self-center pt-1',
|
||||
isCompact && 'px-0 py-0',
|
||||
disabled && 'hover:bg-inherit'
|
||||
)}
|
||||
color={'gray-white'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Col className={'relative items-center sm:flex-row'}>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-xl sm:text-2xl',
|
||||
total > 0 ? 'mr-2' : '',
|
||||
userActive ? '' : 'grayscale'
|
||||
)}
|
||||
>
|
||||
💧
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
|
||||
total > 99
|
||||
? 'text-[0.4rem] sm:text-[0.5rem]'
|
||||
: 'sm:text-2xs text-[0.5rem]'
|
||||
)}
|
||||
>
|
||||
{shortFormatNumber(total)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Button>
|
||||
)
|
||||
}
|
108
web/components/contract/liquidity-modal.tsx
Normal file
108
web/components/contract/liquidity-modal.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { CPMMContract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { addSubsidy } from 'web/lib/firebase/api'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { AmountInput } from '../amount-input'
|
||||
import { Button } from '../button'
|
||||
import { InfoTooltip } from '../info-tooltip'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { Title } from '../title'
|
||||
|
||||
export function LiquidityModal(props: {
|
||||
contract: CPMMContract
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { contract, isOpen, setOpen } = props
|
||||
const { totalLiquidity } = contract
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen} size="sm">
|
||||
<Col className="gap-2.5 rounded bg-white p-4 pb-8 sm:gap-4">
|
||||
<Title className="!mt-0 !mb-2" text="💧 Add liquidity" />
|
||||
|
||||
<div>Total liquidity subsidies: {formatMoney(totalLiquidity)}</div>
|
||||
<AddLiquidityPanel contract={contract as CPMMContract} />
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
const { id: contractId, slug } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [amount, setAmount] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const onAmountChange = (amount: number | undefined) => {
|
||||
setIsSuccess(false)
|
||||
setAmount(amount)
|
||||
|
||||
// Check for errors.
|
||||
if (amount !== undefined) {
|
||||
if (user && user.balance < amount) {
|
||||
setError('Insufficient balance')
|
||||
} else if (amount < 1) {
|
||||
setError('Minimum amount: ' + formatMoney(1))
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!amount) return
|
||||
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
addSubsidy({ amount, contractId })
|
||||
.then((_) => {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((_) => setError('Server error'))
|
||||
|
||||
track('add liquidity', { amount, contractId, slug })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Contribute your M$ to make this market more accurate by subsidizing
|
||||
trading.{' '}
|
||||
<InfoTooltip text="Liquidity is how much money traders can make if they're right. The more traders can earn, the greater the incentive to find the correct probability." />
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label="M$"
|
||||
error={error}
|
||||
disabled={isLoading}
|
||||
inputClassName="w-28 mr-4"
|
||||
/>
|
||||
<Button size="md" color="blue" onClick={submit} disabled={isLoading}>
|
||||
Add
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
{isSuccess && amount && (
|
||||
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,11 +1,70 @@
|
|||
import clsx from 'clsx'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
import { sortBy } from 'lodash'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { ContractMetrics } from 'common/calculate-metrics'
|
||||
import { CPMMBinaryContract, CPMMContract } from 'common/contract'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
import { Col } from '../layout/col'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { ContractCardProbChange } from './contract-card'
|
||||
|
||||
export function ProfitChangeTable(props: {
|
||||
contracts: CPMMBinaryContract[]
|
||||
metrics: ContractMetrics[]
|
||||
}) {
|
||||
const { contracts, metrics } = props
|
||||
|
||||
const contractProfit = metrics.map(
|
||||
(m) => [m.contractId, m.from?.day.profit ?? 0] as const
|
||||
)
|
||||
|
||||
const positiveProfit = sortBy(
|
||||
contractProfit.filter(([, profit]) => profit > 0),
|
||||
([, profit]) => profit
|
||||
).reverse()
|
||||
const positive = filterDefined(
|
||||
positiveProfit.map(([contractId]) =>
|
||||
contracts.find((c) => c.id === contractId)
|
||||
)
|
||||
)
|
||||
|
||||
const negativeProfit = sortBy(
|
||||
contractProfit.filter(([, profit]) => profit < 0),
|
||||
([, profit]) => profit
|
||||
)
|
||||
const negative = filterDefined(
|
||||
negativeProfit.map(([contractId]) =>
|
||||
contracts.find((c) => c.id === contractId)
|
||||
)
|
||||
)
|
||||
|
||||
if (positive.length === 0 && negative.length === 0)
|
||||
return <div className="px-4 text-gray-500">None</div>
|
||||
|
||||
return (
|
||||
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
|
||||
<Col className="flex-1">
|
||||
{positive.map((contract) => (
|
||||
<ContractCardProbChange
|
||||
key={contract.id}
|
||||
contract={contract}
|
||||
showPosition
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
<Col className="flex-1">
|
||||
{negative.map((contract) => (
|
||||
<ContractCardProbChange
|
||||
key={contract.id}
|
||||
contract={contract}
|
||||
showPosition
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProbChangeTable(props: {
|
||||
changes: CPMMContract[] | undefined
|
||||
full?: boolean
|
||||
|
@ -39,12 +98,20 @@ export function ProbChangeTable(props: {
|
|||
<Col className="mb-4 w-full gap-4 rounded-lg md:flex-row">
|
||||
<Col className="flex-1">
|
||||
{filteredPositiveChanges.map((contract) => (
|
||||
<ContractCardProbChange key={contract.id} contract={contract} />
|
||||
<ContractCardProbChange
|
||||
key={contract.id}
|
||||
contract={contract}
|
||||
showPosition
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
<Col className="flex-1">
|
||||
{filteredNegativeChanges.map((contract) => (
|
||||
<ContractCardProbChange key={contract.id} contract={contract} />
|
||||
<ContractCardProbChange
|
||||
key={contract.id}
|
||||
contract={contract}
|
||||
showPosition
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
|
@ -61,7 +128,7 @@ export function ProbChange(props: {
|
|||
probChanges: { day: change },
|
||||
} = contract
|
||||
|
||||
const color = change >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
const color = change >= 0 ? 'text-teal-500' : 'text-red-400'
|
||||
|
||||
return (
|
||||
<Col className={clsx('flex flex-col items-end', className)}>
|
||||
|
|
|
@ -166,14 +166,14 @@ export function QuickBet(props: {
|
|||
<TriangleFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
upHover ? 'text-green-500' : 'text-gray-400'
|
||||
upHover ? 'text-teal-500' : 'text-gray-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TriangleFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
upHover ? 'text-green-500' : 'text-gray-200'
|
||||
upHover ? 'text-teal-500' : 'text-gray-200'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
@ -201,14 +201,14 @@ export function QuickBet(props: {
|
|||
<TriangleDownFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
downHover ? 'text-red-500' : 'text-gray-400'
|
||||
downHover ? 'text-red-400' : 'text-gray-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TriangleDownFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
downHover ? 'text-red-500' : 'text-gray-200'
|
||||
downHover ? 'text-red-400' : 'text-gray-200'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { HeartIcon } from '@heroicons/react/outline'
|
||||
import { Button } from 'web/components/button'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import clsx from 'clsx'
|
||||
import { HeartIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Button } from 'web/components/button'
|
||||
import { formatMoney, shortFormatNumber } from 'common/util/format'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Tooltip } from '../tooltip'
|
||||
|
||||
|
@ -16,9 +17,15 @@ export function TipButton(props: {
|
|||
const { tipAmount, totalTipped, userTipped, isCompact, onClick, disabled } =
|
||||
props
|
||||
|
||||
const tipDisplay = shortFormatNumber(Math.ceil(totalTipped / 10))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={disabled ? 'Tips' : `Tip ${formatMoney(tipAmount)}`}
|
||||
text={
|
||||
disabled
|
||||
? `Tips (${formatMoney(totalTipped)})`
|
||||
: `Tip ${formatMoney(tipAmount)}`
|
||||
}
|
||||
placement="bottom"
|
||||
noTap
|
||||
noFade
|
||||
|
@ -39,19 +46,19 @@ export function TipButton(props: {
|
|||
className={clsx(
|
||||
'h-5 w-5 sm:h-6 sm:w-6',
|
||||
totalTipped > 0 ? 'mr-2' : '',
|
||||
userTipped ? 'fill-green-700 text-green-700' : ''
|
||||
userTipped ? 'fill-teal-500 text-teal-500' : ''
|
||||
)}
|
||||
/>
|
||||
{totalTipped > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-greyscale-5 absolute ml-3.5 mt-2 h-4 w-4 rounded-full align-middle text-white sm:mt-3 sm:h-5 sm:w-5 sm:px-1',
|
||||
totalTipped > 99
|
||||
tipDisplay.length > 2
|
||||
? 'text-[0.4rem] sm:text-[0.5rem]'
|
||||
: 'sm:text-2xs text-[0.5rem]'
|
||||
)}
|
||||
>
|
||||
{totalTipped}
|
||||
{tipDisplay}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
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'
|
||||
import clsx from 'clsx'
|
||||
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'
|
||||
import { Button } from './button'
|
||||
|
||||
export function CreatePost(props: { group?: Group }) {
|
||||
const [title, setTitle] = useState('')
|
||||
|
@ -21,6 +21,7 @@ export function CreatePost(props: { group?: Group }) {
|
|||
const { group } = props
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
key: `post ${group?.id || ''}`,
|
||||
disabled: isSubmitting,
|
||||
})
|
||||
|
||||
|
@ -45,6 +46,7 @@ export function CreatePost(props: { group?: Group }) {
|
|||
return e
|
||||
})
|
||||
if (result.post) {
|
||||
editor.commands.clearContent(true)
|
||||
await Router.push(postPath(result.post.slug))
|
||||
}
|
||||
}
|
||||
|
@ -60,9 +62,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 +75,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}
|
||||
|
@ -91,12 +91,10 @@ export function CreatePost(props: { group?: Group }) {
|
|||
<TextEditor editor={editor} upload={upload} />
|
||||
<Spacer h={6} />
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
'btn btn-primary normal-case',
|
||||
isSubmitting && 'loading disabled'
|
||||
)}
|
||||
loading={isSubmitting}
|
||||
size="xl"
|
||||
disabled={isSubmitting || !isValid || upload.isLoading}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true)
|
||||
|
@ -105,7 +103,7 @@ export function CreatePost(props: { group?: Group }) {
|
|||
}}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create a post'}
|
||||
</button>
|
||||
</Button>
|
||||
{error !== '' && <div className="text-red-700">{error}</div>}
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -8,20 +8,21 @@ import {
|
|||
Content,
|
||||
Editor,
|
||||
mergeAttributes,
|
||||
Extensions,
|
||||
} from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Linkify } from './linkify'
|
||||
import { uploadImage } from 'web/lib/firebase/storage'
|
||||
import { useMutation } from 'react-query'
|
||||
import { linkClass } from './site-link'
|
||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||
import { DisplayMention } from './editor/mention'
|
||||
import { contractMentionSuggestion } from './editor/contract-mention-suggestion'
|
||||
import { DisplayContractMention } from './editor/contract-mention'
|
||||
import GridComponent from './editor/tiptap-grid-cards'
|
||||
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
import TiptapTweet from './editor/tiptap-tweet'
|
||||
import { EmbedModal } from './editor/embed-modal'
|
||||
|
@ -42,6 +43,12 @@ import LinkIcon from 'web/lib/icons/link-icon'
|
|||
import { getUrl } from 'common/util/parse'
|
||||
import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
|
||||
import { ImageModal } from './editor/image-modal'
|
||||
import {
|
||||
storageStore,
|
||||
usePersistentState,
|
||||
} from 'web/hooks/use-persistent-state'
|
||||
import { safeLocalStorage } from 'web/lib/util/local'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
const DisplayImage = Image.configure({
|
||||
HTMLAttributes: {
|
||||
|
@ -64,6 +71,23 @@ const DisplayLink = Link.extend({
|
|||
},
|
||||
})
|
||||
|
||||
export const editorExtensions = (simple = false): Extensions => [
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
simple ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayMention,
|
||||
DisplayContractMention,
|
||||
GridComponent,
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
TiptapSpoiler.configure({
|
||||
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
|
||||
}),
|
||||
]
|
||||
|
||||
const proseClass = clsx(
|
||||
'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
||||
'font-light prose-a:font-light prose-blockquote:font-light'
|
||||
|
@ -75,45 +99,44 @@ export function useTextEditor(props: {
|
|||
defaultValue?: Content
|
||||
disabled?: boolean
|
||||
simple?: boolean
|
||||
key?: string // unique key for autosave. If set, plz call `clearContent(true)` on submit to clear autosave
|
||||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
||||
const { placeholder, max, defaultValue, disabled, simple, key } = props
|
||||
|
||||
const [content, saveContent] = usePersistentState<JSONContent | undefined>(
|
||||
undefined,
|
||||
{
|
||||
key: `text ${key}`,
|
||||
store: storageStore(safeLocalStorage()),
|
||||
}
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const save = useCallback(debounce(saveContent, 500), [])
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
!simple && 'min-h-[6em]',
|
||||
'outline-none pt-2 px-4',
|
||||
'prose-img:select-auto',
|
||||
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
|
||||
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, embeds
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
editorProps: {
|
||||
attributes: { class: editorClass, spellcheck: simple ? 'true' : 'false' },
|
||||
},
|
||||
onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
...editorExtensions(simple),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
simple ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayMention.configure({
|
||||
suggestion: mentionSuggestion,
|
||||
}),
|
||||
DisplayContractMention.configure({
|
||||
suggestion: contractMentionSuggestion,
|
||||
}),
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
TiptapSpoiler.configure({
|
||||
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
|
||||
}),
|
||||
],
|
||||
content: defaultValue,
|
||||
content: defaultValue ?? (key && content ? content : ''),
|
||||
})
|
||||
|
||||
const upload = useUploadMutation(editor)
|
||||
|
@ -342,10 +365,8 @@ export function RichContent(props: {
|
|||
smallImage ? DisplayImage : Image,
|
||||
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
||||
DisplayMention,
|
||||
DisplayContractMention.configure({
|
||||
// Needed to set a different PluginKey for Prosemirror
|
||||
suggestion: contractMentionSuggestion,
|
||||
}),
|
||||
DisplayContractMention,
|
||||
GridComponent,
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
TiptapSpoiler.configure({
|
||||
|
|
|
@ -6,16 +6,29 @@ 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'
|
||||
import { contractMentionSuggestion } from './contract-mention-suggestion'
|
||||
|
||||
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 +42,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),
|
||||
}).configure({ suggestion: contractMentionSuggestion })
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Editor } from '@tiptap/react'
|
||||
import { Contract } from 'common/contract'
|
||||
import { SelectMarketsModal } from '../contract-select-modal'
|
||||
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
|
||||
import { embedContractCode } from '../share-embed-button'
|
||||
import { insertContent } from './utils'
|
||||
|
||||
export function MarketModal(props: {
|
||||
|
@ -15,7 +15,10 @@ export function MarketModal(props: {
|
|||
if (contracts.length == 1) {
|
||||
insertContent(editor, embedContractCode(contracts[0]))
|
||||
} else if (contracts.length > 1) {
|
||||
insertContent(editor, embedContractGridCode(contracts))
|
||||
insertContent(
|
||||
editor,
|
||||
`<grid-cards-component contractIds="${contracts.map((c) => c.id)}" />`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import { Linkify } from '../linkify'
|
||||
import { mentionSuggestion } from './mention-suggestion'
|
||||
|
||||
const name = 'mention-component'
|
||||
|
||||
|
@ -27,4 +28,4 @@ export const DisplayMention = Mention.extend({
|
|||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () =>
|
||||
ReactNodeViewRenderer(MentionComponent, { className: 'inline-block' }),
|
||||
})
|
||||
}).configure({ suggestion: mentionSuggestion })
|
||||
|
|
55
web/components/editor/tiptap-grid-cards.tsx
Normal file
55
web/components/editor/tiptap-grid-cards.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import React from 'react'
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { ContractsGrid } from '../contract/contracts-grid'
|
||||
|
||||
import { useContractsFromIds } from 'web/hooks/use-contract'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
|
||||
export default Node.create({
|
||||
name: 'gridCardsComponent',
|
||||
|
||||
group: 'block',
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
contractIds: [],
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'grid-cards-component',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['grid-cards-component', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(GridComponent)
|
||||
},
|
||||
})
|
||||
|
||||
export function GridComponent(props: any) {
|
||||
const contractIds = props.node.attrs.contractIds
|
||||
const contracts = useContractsFromIds(contractIds.split(','))
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="grid-cards-component">
|
||||
{contracts ? (
|
||||
<ContractsGrid
|
||||
contracts={contracts}
|
||||
breakpointColumns={{ default: 2, 650: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -268,6 +268,7 @@ export function ContractCommentInput(props: {
|
|||
parentAnswerOutcome={parentAnswerOutcome}
|
||||
parentCommentId={parentCommentId}
|
||||
onSubmitComment={onSubmitComment}
|
||||
pageId={contract.id}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -5,57 +5,52 @@ import { useFollows } from 'web/hooks/use-follows'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { follow, unfollow } from 'web/lib/firebase/users'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Button } from './button'
|
||||
|
||||
export function FollowButton(props: {
|
||||
isFollowing: boolean | undefined
|
||||
onFollow: () => void
|
||||
onUnfollow: () => void
|
||||
small?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { isFollowing, onFollow, onUnfollow, small, className } = props
|
||||
const { isFollowing, onFollow, onUnfollow, className } = props
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const smallStyle =
|
||||
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
||||
|
||||
if (!user || isFollowing === undefined)
|
||||
return (
|
||||
<button
|
||||
className={clsx('btn btn-sm invisible', small && smallStyle, className)}
|
||||
>
|
||||
<Button size="sm" color="gray" className={clsx(className, 'invisible')}>
|
||||
Follow
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isFollowing) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-outline btn-sm',
|
||||
small && smallStyle,
|
||||
className
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="gray-outline"
|
||||
className={clsx('my-auto', className)}
|
||||
onClick={withTracking(onUnfollow, 'unfollow')}
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="indigo"
|
||||
className={clsx(className, 'my-auto')}
|
||||
onClick={withTracking(onFollow, 'follow')}
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserFollowButton(props: { userId: string; small?: boolean }) {
|
||||
const { userId, small } = props
|
||||
export function UserFollowButton(props: { userId: string }) {
|
||||
const { userId } = props
|
||||
const user = useUser()
|
||||
const following = useFollows(user?.id)
|
||||
const isFollowing = following?.includes(userId)
|
||||
|
@ -67,7 +62,6 @@ export function UserFollowButton(props: { userId: string; small?: boolean }) {
|
|||
isFollowing={isFollowing}
|
||||
onFollow={() => follow(user.id, userId)}
|
||||
onUnfollow={() => unfollow(user.id, userId)}
|
||||
small={small}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 || '')}
|
||||
|
|
|
@ -231,7 +231,7 @@ export function PinnedItems(props: {
|
|||
return pinned.length > 0 || isEditable ? (
|
||||
<div>
|
||||
<Row className="mb-3 items-center justify-between">
|
||||
<SectionHeader label={'Pinned'} />
|
||||
<SectionHeader label={'Featured'} href={`#`} />
|
||||
{isEditable && (
|
||||
<Button
|
||||
color="gray"
|
||||
|
|
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
|
||||
*/
|
|
@ -140,12 +140,9 @@ function LimitBet(props: {
|
|||
{isCancelling ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-xs btn-outline my-auto normal-case"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Button size="2xs" color="gray-outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -139,6 +139,7 @@ export function NotificationSettings(props: {
|
|||
'loan_income',
|
||||
'limit_order_fills',
|
||||
'tips_on_your_comments',
|
||||
'badges_awarded',
|
||||
],
|
||||
}
|
||||
const userInteractions: SectionData = {
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
|
|
|
@ -69,9 +69,9 @@ export function PinnedSelectModal(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
|
||||
<Col className="h-[85vh] w-full gap-4 rounded-md bg-white">
|
||||
<div className="p-8 pb-0">
|
||||
<Modal open={open} setOpen={setOpen} className={' sm:p-0'} size={'lg'}>
|
||||
<Col className=" h-[85vh] w-full gap-4 overflow-scroll rounded-md bg-white">
|
||||
<div className=" p-8 pb-0">
|
||||
<Row>
|
||||
<div className={'text-xl text-indigo-700'}>{title}</div>
|
||||
|
||||
|
|
|
@ -3,12 +3,12 @@ import { DocumentIcon } from '@heroicons/react/solid'
|
|||
import clsx from 'clsx'
|
||||
import { Post } from 'common/post'
|
||||
import Link from 'next/link'
|
||||
import { useUserById } from 'web/hooks/use-user'
|
||||
import { postPath } from 'web/lib/firebase/posts'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Avatar } from './avatar'
|
||||
import { Card } from './card'
|
||||
import { CardHighlightOptions } from './contract/contracts-grid'
|
||||
import { Row } from './layout/row'
|
||||
import { UserLink } from './user-link'
|
||||
|
||||
export function PostCard(props: {
|
||||
|
@ -17,38 +17,31 @@ export function PostCard(props: {
|
|||
highlightOptions?: CardHighlightOptions
|
||||
}) {
|
||||
const { post, onPostClick, highlightOptions } = props
|
||||
const creatorId = post.creatorId
|
||||
|
||||
const user = useUserById(creatorId)
|
||||
const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
|
||||
|
||||
if (!user) return <> </>
|
||||
|
||||
return (
|
||||
<div className="relative py-1">
|
||||
<Card
|
||||
className={clsx(
|
||||
'relative flex gap-3 py-2 px-3',
|
||||
itemIds?.includes(post.id) && highlightClassName
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar className="h-12 w-12" username={user?.username} />
|
||||
</div>
|
||||
<Card
|
||||
className={clsx(
|
||||
'group relative flex gap-3 py-2 px-3',
|
||||
itemIds?.includes(post.id) && highlightClassName
|
||||
)}
|
||||
>
|
||||
<Row className="flex grow justify-between">
|
||||
<div className="">
|
||||
<div className="text-sm text-gray-500">
|
||||
<Row className="items-center text-sm ">
|
||||
<Avatar className="mx-1 h-7 w-7" username={post.creatorUsername} />
|
||||
<UserLink
|
||||
className="text-neutral"
|
||||
name={user?.name}
|
||||
username={user?.username}
|
||||
className=" text-gray-400"
|
||||
name={post.creatorName}
|
||||
username={post.creatorUsername}
|
||||
/>
|
||||
<span className="mx-1">•</span>
|
||||
<span className="text-gray-500">{fromNow(post.createdTime)}</span>
|
||||
</div>
|
||||
<div className=" break-words text-lg font-medium text-gray-900">
|
||||
<span className="mx-1 text-gray-400">•</span>
|
||||
<span className="text-gray-400">{fromNow(post.createdTime)}</span>
|
||||
</Row>
|
||||
<div className=" break-words text-lg font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2">
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="font-small text-md break-words text-gray-500">
|
||||
<div className="font-small text-md break-words text-indigo-400">
|
||||
{post.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -58,7 +51,7 @@ export function PostCard(props: {
|
|||
Post
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Row>
|
||||
{onPostClick ? (
|
||||
<a
|
||||
className="absolute top-0 left-0 right-0 bottom-0"
|
||||
|
@ -89,7 +82,7 @@ export function PostCard(props: {
|
|||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
226
web/components/profile/badges-modal.tsx
Normal file
226
web/components/profile/badges-modal.tsx
Normal file
|
@ -0,0 +1,226 @@
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { PAST_BETS, User } from 'common/user'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Badge,
|
||||
calculateBadgeRarity,
|
||||
MarketCreatorBadge,
|
||||
ProvenCorrectBadge,
|
||||
rarities,
|
||||
StreakerBadge,
|
||||
} from 'common/badge'
|
||||
import { groupBy } from 'lodash'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { contractPathWithoutContract } from 'web/lib/firebase/contracts'
|
||||
import { Tooltip } from 'web/components/tooltip'
|
||||
import {
|
||||
bronzeClassName,
|
||||
goldClassName,
|
||||
silverClassName,
|
||||
} from 'web/components/badge-display'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export function BadgesModal(props: {
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
user: User
|
||||
}) {
|
||||
const { isOpen, setOpen, user } = props
|
||||
const { provenCorrect, marketCreator, streaker } = user.achievements ?? {}
|
||||
const badges = [
|
||||
...(provenCorrect?.badges ?? []),
|
||||
...(streaker?.badges ?? []),
|
||||
...(marketCreator?.badges ?? []),
|
||||
]
|
||||
|
||||
// group badges by their rarities
|
||||
const badgesByRarity = groupBy(badges, (badge) => calculateBadgeRarity(badge))
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={clsx('text-8xl')}>🏅</span>
|
||||
<span className="text-xl">{user.name + "'s"} badges</span>
|
||||
|
||||
<Row className={'flex-wrap gap-2'}>
|
||||
<Col
|
||||
className={clsx(
|
||||
'min-w-full gap-2 rounded-md border-2 border-amber-900 border-opacity-40 p-2 text-center'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(' ', bronzeClassName)}>Bronze</span>
|
||||
<Row className={'flex-wrap justify-center gap-4'}>
|
||||
{badgesByRarity['bronze'] ? (
|
||||
badgesByRarity['bronze'].map((badge, i) => (
|
||||
<BadgeToItem badge={badge} key={i} rarity={'bronze'} />
|
||||
))
|
||||
) : (
|
||||
<span className={'text-gray-500'}>None yet</span>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx(
|
||||
'min-w-full gap-2 rounded-md border-2 border-gray-500 border-opacity-40 p-2 text-center '
|
||||
)}
|
||||
>
|
||||
<span className={clsx(' ', silverClassName)}>Silver</span>
|
||||
<Row className={'flex-wrap justify-center gap-4'}>
|
||||
{badgesByRarity['silver'] ? (
|
||||
badgesByRarity['silver'].map((badge, i) => (
|
||||
<BadgeToItem badge={badge} key={i} rarity={'silver'} />
|
||||
))
|
||||
) : (
|
||||
<span className={'text-gray-500'}>None yet</span>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
<Col
|
||||
className={clsx(
|
||||
'min-w-full gap-2 rounded-md border-2 border-amber-400 p-2 text-center '
|
||||
)}
|
||||
>
|
||||
<span className={clsx('', goldClassName)}>Gold</span>
|
||||
<Row className={'flex-wrap justify-center gap-4'}>
|
||||
{badgesByRarity['gold'] ? (
|
||||
badgesByRarity['gold'].map((badge, i) => (
|
||||
<BadgeToItem badge={badge} key={i} rarity={'gold'} />
|
||||
))
|
||||
) : (
|
||||
<span className={'text-gray-500'}>None yet</span>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function BadgeToItem(props: { badge: Badge; rarity: rarities }) {
|
||||
const { badge, rarity } = props
|
||||
if (badge.type === 'PROVEN_CORRECT')
|
||||
return (
|
||||
<ProvenCorrectBadgeItem
|
||||
badge={badge as ProvenCorrectBadge}
|
||||
rarity={rarity}
|
||||
/>
|
||||
)
|
||||
else if (badge.type === 'STREAKER')
|
||||
return <StreakerBadgeItem badge={badge as StreakerBadge} rarity={rarity} />
|
||||
else if (badge.type === 'MARKET_CREATOR')
|
||||
return (
|
||||
<MarketCreatorBadgeItem
|
||||
badge={badge as MarketCreatorBadge}
|
||||
rarity={rarity}
|
||||
/>
|
||||
)
|
||||
else return null
|
||||
}
|
||||
|
||||
function ProvenCorrectBadgeItem(props: {
|
||||
badge: ProvenCorrectBadge
|
||||
rarity: rarities
|
||||
}) {
|
||||
const { badge, rarity } = props
|
||||
const { betAmount, contractSlug, contractCreatorUsername } = badge.data
|
||||
return (
|
||||
<SiteLink
|
||||
href={contractPathWithoutContract(contractCreatorUsername, contractSlug)}
|
||||
>
|
||||
<Col className={'text-center'}>
|
||||
<Medal rarity={rarity} />
|
||||
<Tooltip
|
||||
text={`Make a comment attached to a winning bet worth ${formatMoney(
|
||||
betAmount
|
||||
)}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
rarity === 'gold'
|
||||
? goldClassName
|
||||
: rarity === 'silver'
|
||||
? silverClassName
|
||||
: bronzeClassName
|
||||
}
|
||||
>
|
||||
Proven Correct
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) {
|
||||
const { badge, rarity } = props
|
||||
const { totalBettingStreak } = badge.data
|
||||
return (
|
||||
<Col className={'cursor-default text-center'}>
|
||||
<Medal rarity={rarity} />
|
||||
<Tooltip
|
||||
text={`Make ${PAST_BETS} ${totalBettingStreak} day${
|
||||
totalBettingStreak > 1 ? 's' : ''
|
||||
} in a row`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
rarity === 'gold'
|
||||
? goldClassName
|
||||
: rarity === 'silver'
|
||||
? silverClassName
|
||||
: bronzeClassName
|
||||
}
|
||||
>
|
||||
Prediction Streak
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
function MarketCreatorBadgeItem(props: {
|
||||
badge: MarketCreatorBadge
|
||||
rarity: rarities
|
||||
}) {
|
||||
const { badge, rarity } = props
|
||||
const { totalContractsCreated } = badge.data
|
||||
return (
|
||||
<Col className={'cursor-default text-center'}>
|
||||
<Medal rarity={rarity} />
|
||||
<Tooltip
|
||||
text={`Make ${totalContractsCreated} market${
|
||||
totalContractsCreated > 1 ? 's' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
rarity === 'gold'
|
||||
? goldClassName
|
||||
: rarity === 'silver'
|
||||
? silverClassName
|
||||
: bronzeClassName
|
||||
}
|
||||
>
|
||||
Market Creator
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
function Medal(props: { rarity: rarities }) {
|
||||
const { rarity } = props
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
rarity === 'gold'
|
||||
? goldClassName
|
||||
: rarity === 'silver'
|
||||
? silverClassName
|
||||
: bronzeClassName
|
||||
}
|
||||
>
|
||||
{rarity === 'gold' ? '🥇' : rarity === 'silver' ? '🥈' : '🥉'}
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -13,7 +13,7 @@ import clsx from 'clsx'
|
|||
export function BettingStreakModal(props: {
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
currentUser?: User | null
|
||||
currentUser: User | null | undefined
|
||||
}) {
|
||||
const { isOpen, setOpen, currentUser } = props
|
||||
const missingStreak = currentUser && !hasCompletedStreakToday(currentUser)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export function ProfitBadge(props: {
|
||||
profitPercent: number
|
||||
|
@ -26,3 +27,31 @@ export function ProfitBadge(props: {
|
|||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfitBadgeMana(props: {
|
||||
amount: number
|
||||
gray?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { amount, gray, className } = props
|
||||
const colors = gray
|
||||
? 'bg-gray-100 text-gray-700'
|
||||
: amount > 0
|
||||
? 'bg-gray-100 text-green-800'
|
||||
: 'bg-gray-100 text-red-800'
|
||||
|
||||
const formatted =
|
||||
ENV_CONFIG.moneyMoniker + (amount > 0 ? '+' : '') + amount.toFixed(0)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
|
||||
colors,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { OutcomeLabel } from './outcome-label'
|
|||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { SellSharesModal } from './sell-modal'
|
||||
import { Button } from './button'
|
||||
|
||||
export function SellRow(props: {
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
|
@ -37,17 +38,14 @@ export function SellRow(props: {
|
|||
shares
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid',
|
||||
color: '#3D4451',
|
||||
}}
|
||||
<Button
|
||||
className="my-auto"
|
||||
size="xs"
|
||||
color="gray-outline"
|
||||
onClick={() => setShowSellModal(true)}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</Button>
|
||||
</Row>
|
||||
</Col>
|
||||
{showSellModal && (
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user