Merge branch 'main' into rich-content
This commit is contained in:
commit
903b7f1db0
|
@ -1,6 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: ['lodash'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['lib'],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
|
@ -31,6 +32,7 @@ module.exports = {
|
|||
rules: {
|
||||
'no-extra-semi': 'off',
|
||||
'no-constant-condition': ['error', { checkLoops: false }],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
},
|
||||
}
|
||||
|
|
|
@ -10,12 +10,9 @@ import {
|
|||
import { User } from './user'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { noFees } from './fees'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
|
||||
export const FIXED_ANTE = 100
|
||||
|
||||
// deprecated
|
||||
export const PHANTOM_ANTE = 0.001
|
||||
export const MINIMUM_ANTE = 50
|
||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||
|
||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||
|
||||
|
|
|
@ -18,15 +18,24 @@ import {
|
|||
getDpmProbabilityAfterSale,
|
||||
} from './calculate-dpm'
|
||||
import { calculateFixedPayout } from './calculate-fixed-payouts'
|
||||
import { Contract, BinaryContract, FreeResponseContract } from './contract'
|
||||
import {
|
||||
Contract,
|
||||
BinaryContract,
|
||||
FreeResponseContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
|
||||
export function getProbability(contract: BinaryContract) {
|
||||
export function getProbability(
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
) {
|
||||
return contract.mechanism === 'cpmm-1'
|
||||
? getCpmmProbability(contract.pool, contract.p)
|
||||
: getDpmProbability(contract.totalShares)
|
||||
}
|
||||
|
||||
export function getInitialProbability(contract: BinaryContract) {
|
||||
export function getInitialProbability(
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
) {
|
||||
if (contract.initialProbability) return contract.initialProbability
|
||||
|
||||
if (contract.mechanism === 'dpm-2' || (contract as any).totalShares)
|
||||
|
@ -65,7 +74,9 @@ export function calculateShares(
|
|||
}
|
||||
|
||||
export function calculateSaleAmount(contract: Contract, bet: Bet) {
|
||||
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
|
||||
return contract.mechanism === 'cpmm-1' &&
|
||||
(contract.outcomeType === 'BINARY' ||
|
||||
contract.outcomeType === 'PSEUDO_NUMERIC')
|
||||
? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue
|
||||
: calculateDpmSaleAmount(contract, bet)
|
||||
}
|
||||
|
@ -87,7 +98,9 @@ export function getProbabilityAfterSale(
|
|||
}
|
||||
|
||||
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
|
||||
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
|
||||
return contract.mechanism === 'cpmm-1' &&
|
||||
(contract.outcomeType === 'BINARY' ||
|
||||
contract.outcomeType === 'PSEUDO_NUMERIC')
|
||||
? calculateFixedPayout(contract, bet, outcome)
|
||||
: calculateDpmPayout(contract, bet, outcome)
|
||||
}
|
||||
|
@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
|
|||
const outcome = contract.resolution
|
||||
if (!outcome) throw new Error('Contract not resolved')
|
||||
|
||||
return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY'
|
||||
return contract.mechanism === 'cpmm-1' &&
|
||||
(contract.outcomeType === 'BINARY' ||
|
||||
contract.outcomeType === 'PSEUDO_NUMERIC')
|
||||
? calculateFixedPayout(contract, bet, outcome)
|
||||
: calculateDpmPayout(contract, bet, outcome)
|
||||
}
|
||||
|
@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
|||
const profit = payout + saleValue + redeemed - totalInvested
|
||||
const profitPercent = (profit / totalInvested) * 100
|
||||
|
||||
const hasShares = Object.values(totalShares).some(
|
||||
(shares) => shares > 0
|
||||
)
|
||||
const hasShares = Object.values(totalShares).some((shares) => shares > 0)
|
||||
|
||||
return {
|
||||
invested: Math.max(0, currentInvested),
|
||||
|
|
|
@ -3,9 +3,10 @@ import { Fees } from './fees'
|
|||
import { JSONContent } from '@tiptap/core'
|
||||
|
||||
export type AnyMechanism = DPM | CPMM
|
||||
export type AnyOutcomeType = Binary | FreeResponse | Numeric
|
||||
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
||||
export type AnyContractType =
|
||||
| (CPMM & Binary)
|
||||
| (CPMM & PseudoNumeric)
|
||||
| (DPM & Binary)
|
||||
| (DPM & FreeResponse)
|
||||
| (DPM & Numeric)
|
||||
|
@ -45,7 +46,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
collectedFees: Fees
|
||||
} & T
|
||||
|
||||
export type BinaryContract = Contract & Binary
|
||||
export type BinaryContract = Contract & Binary
|
||||
export type PseudoNumericContract = Contract & PseudoNumeric
|
||||
export type NumericContract = Contract & Numeric
|
||||
export type FreeResponseContract = Contract & FreeResponse
|
||||
export type DPMContract = Contract & DPM
|
||||
|
@ -76,6 +78,18 @@ export type Binary = {
|
|||
resolution?: resolution
|
||||
}
|
||||
|
||||
export type PseudoNumeric = {
|
||||
outcomeType: 'PSEUDO_NUMERIC'
|
||||
min: number
|
||||
max: number
|
||||
isLogScale: boolean
|
||||
resolutionValue?: number
|
||||
|
||||
// same as binary market; map everything to probability
|
||||
initialProbability: number
|
||||
resolutionProbability?: number
|
||||
}
|
||||
|
||||
export type FreeResponse = {
|
||||
outcomeType: 'FREE_RESPONSE'
|
||||
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
|
||||
|
@ -95,7 +109,7 @@ export type Numeric = {
|
|||
export type outcomeType = AnyOutcomeType['outcomeType']
|
||||
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
||||
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
||||
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const
|
||||
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const
|
||||
|
||||
export const MAX_QUESTION_LENGTH = 480
|
||||
export const MAX_DESCRIPTION_LENGTH = 10000
|
||||
|
|
|
@ -18,13 +18,17 @@ export type EnvConfig = {
|
|||
faviconPath?: string // Should be a file in /public
|
||||
navbarLogoPath?: string
|
||||
newQuestionPlaceholders: string[]
|
||||
|
||||
// Currency controls
|
||||
fixedAnte?: number
|
||||
startingBalance?: number
|
||||
}
|
||||
|
||||
type FirebaseConfig = {
|
||||
apiKey: string
|
||||
authDomain: string
|
||||
projectId: string
|
||||
region: string
|
||||
region?: string
|
||||
storageBucket: string
|
||||
messagingSenderId: string
|
||||
appId: string
|
||||
|
|
|
@ -14,14 +14,15 @@ import {
|
|||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
import { noFees } from './fees'
|
||||
import { addObjects } from './util/object'
|
||||
import { NUMERIC_FIXED_VAR } from './numeric-constants'
|
||||
|
||||
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
||||
export type CandidateBet<T extends Bet = Bet> = Omit<T, 'id' | 'userId'>
|
||||
export type BetInfo = {
|
||||
newBet: CandidateBet<Bet>
|
||||
newBet: CandidateBet
|
||||
newPool?: { [outcome: string]: number }
|
||||
newTotalShares?: { [outcome: string]: number }
|
||||
newTotalBets?: { [outcome: string]: number }
|
||||
|
@ -32,7 +33,7 @@ export type BetInfo = {
|
|||
export const getNewBinaryCpmmBetInfo = (
|
||||
outcome: 'YES' | 'NO',
|
||||
amount: number,
|
||||
contract: CPMMBinaryContract,
|
||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||
loanAmount: number
|
||||
) => {
|
||||
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
|
||||
|
@ -45,7 +46,7 @@ export const getNewBinaryCpmmBetInfo = (
|
|||
const probBefore = getCpmmProbability(pool, p)
|
||||
const probAfter = getCpmmProbability(newPool, newP)
|
||||
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
|
@ -95,7 +96,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
const probBefore = getDpmProbability(contract.totalShares)
|
||||
const probAfter = getDpmProbability(newTotalShares)
|
||||
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
|
@ -132,7 +133,7 @@ export const getNewMultiBetInfo = (
|
|||
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
|
||||
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
|
||||
|
||||
const newBet: CandidateBet<Bet> = {
|
||||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
FreeResponse,
|
||||
Numeric,
|
||||
outcomeType,
|
||||
PseudoNumeric,
|
||||
} from './contract'
|
||||
import { User } from './user'
|
||||
import { parseTags, richTextToString } from './util/parse'
|
||||
|
@ -28,7 +29,8 @@ export function getNewContract(
|
|||
// used for numeric markets
|
||||
bucketCount: number,
|
||||
min: number,
|
||||
max: number
|
||||
max: number,
|
||||
isLogScale: boolean
|
||||
) {
|
||||
const tags = parseTags(
|
||||
[
|
||||
|
@ -42,6 +44,8 @@ export function getNewContract(
|
|||
const propsByOutcomeType =
|
||||
outcomeType === 'BINARY'
|
||||
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
||||
: outcomeType === 'PSEUDO_NUMERIC'
|
||||
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
|
||||
: outcomeType === 'NUMERIC'
|
||||
? getNumericProps(ante, bucketCount, min, max)
|
||||
: getFreeAnswerProps(ante)
|
||||
|
@ -116,6 +120,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
|||
return system
|
||||
}
|
||||
|
||||
const getPseudoNumericCpmmProps = (
|
||||
initialProb: number,
|
||||
ante: number,
|
||||
min: number,
|
||||
max: number,
|
||||
isLogScale: boolean
|
||||
) => {
|
||||
const system: CPMM & PseudoNumeric = {
|
||||
...getBinaryCpmmProps(initialProb, ante),
|
||||
outcomeType: 'PSEUDO_NUMERIC',
|
||||
min,
|
||||
max,
|
||||
isLogScale,
|
||||
}
|
||||
|
||||
return system
|
||||
}
|
||||
|
||||
const getFreeAnswerProps = (ante: number) => {
|
||||
const system: DPM & FreeResponse = {
|
||||
mechanism: 'dpm-2',
|
||||
|
|
|
@ -22,6 +22,8 @@ export type Notification = {
|
|||
|
||||
sourceSlug?: string
|
||||
sourceTitle?: string
|
||||
|
||||
isSeenOnHref?: string
|
||||
}
|
||||
export type notification_source_types =
|
||||
| 'contract'
|
||||
|
@ -33,6 +35,8 @@ export type notification_source_types =
|
|||
| 'tip'
|
||||
| 'admin_message'
|
||||
| 'group'
|
||||
| 'user'
|
||||
| 'bonus'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -53,3 +57,7 @@ export type notification_reason_types =
|
|||
| 'on_new_follow'
|
||||
| 'you_follow_user'
|
||||
| 'added_you_to_group'
|
||||
| 'you_referred_user'
|
||||
| 'user_joined_to_bet_on_your_market'
|
||||
| 'unique_bettors_on_your_contract'
|
||||
| 'on_group_you_are_member_of'
|
||||
|
|
|
@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
|
|||
|
||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { sumBy, groupBy, mapValues } from 'lodash'
|
||||
|
||||
import { Bet, NumericBet } from './bet'
|
||||
import { Contract, CPMMBinaryContract, DPMContract } from './contract'
|
||||
import {
|
||||
Contract,
|
||||
CPMMBinaryContract,
|
||||
DPMContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
import { Fees } from './fees'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import {
|
||||
|
@ -48,15 +53,19 @@ export type PayoutInfo = {
|
|||
|
||||
export const getPayouts = (
|
||||
outcome: string | undefined,
|
||||
resolutions: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
liquidities: LiquidityProvision[],
|
||||
resolutions?: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
resolutionProbability?: number
|
||||
): PayoutInfo => {
|
||||
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') {
|
||||
if (
|
||||
contract.mechanism === 'cpmm-1' &&
|
||||
(contract.outcomeType === 'BINARY' ||
|
||||
contract.outcomeType === 'PSEUDO_NUMERIC')
|
||||
) {
|
||||
return getFixedPayouts(
|
||||
outcome,
|
||||
contract,
|
||||
|
@ -67,16 +76,16 @@ export const getPayouts = (
|
|||
}
|
||||
return getDpmPayouts(
|
||||
outcome,
|
||||
resolutions,
|
||||
contract,
|
||||
bets,
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
}
|
||||
|
||||
export const getFixedPayouts = (
|
||||
outcome: string | undefined,
|
||||
contract: CPMMBinaryContract,
|
||||
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||
bets: Bet[],
|
||||
liquidities: LiquidityProvision[],
|
||||
resolutionProbability?: number
|
||||
|
@ -100,11 +109,11 @@ export const getFixedPayouts = (
|
|||
|
||||
export const getDpmPayouts = (
|
||||
outcome: string | undefined,
|
||||
resolutions: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
contract: DPMContract,
|
||||
bets: Bet[],
|
||||
resolutions?: {
|
||||
[outcome: string]: number
|
||||
},
|
||||
resolutionProbability?: number
|
||||
): PayoutInfo => {
|
||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
|
@ -115,8 +124,8 @@ export const getDpmPayouts = (
|
|||
return getDpmStandardPayouts(outcome, contract, openBets)
|
||||
|
||||
case 'MKT':
|
||||
return contract.outcomeType === 'FREE_RESPONSE'
|
||||
? getPayoutsMultiOutcome(resolutions, contract, openBets)
|
||||
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
||||
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
||||
case 'CANCEL':
|
||||
case undefined:
|
||||
|
|
45
common/pseudo-numeric.ts
Normal file
45
common/pseudo-numeric.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { BinaryContract, PseudoNumericContract } from './contract'
|
||||
import { formatLargeNumber, formatPercent } from './util/format'
|
||||
|
||||
export function formatNumericProbability(
|
||||
p: number,
|
||||
contract: PseudoNumericContract
|
||||
) {
|
||||
const value = getMappedValue(contract)(p)
|
||||
return formatLargeNumber(value)
|
||||
}
|
||||
|
||||
export const getMappedValue =
|
||||
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
|
||||
if (contract.outcomeType === 'BINARY') return p
|
||||
|
||||
const { min, max, isLogScale } = contract
|
||||
|
||||
if (isLogScale) {
|
||||
const logValue = p * Math.log10(max - min)
|
||||
return 10 ** logValue + min
|
||||
}
|
||||
|
||||
return p * (max - min) + min
|
||||
}
|
||||
|
||||
export const getFormattedMappedValue =
|
||||
(contract: PseudoNumericContract | BinaryContract) => (p: number) => {
|
||||
if (contract.outcomeType === 'BINARY') return formatPercent(p)
|
||||
|
||||
const value = getMappedValue(contract)(p)
|
||||
return formatLargeNumber(value)
|
||||
}
|
||||
|
||||
export const getPseudoProbability = (
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
isLogScale = false
|
||||
) => {
|
||||
if (isLogScale) {
|
||||
return Math.log10(value - min) / Math.log10(max - min)
|
||||
}
|
||||
|
||||
return (value - min) / (max - min)
|
||||
}
|
54
common/redeem.ts
Normal file
54
common/redeem.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { partition, sumBy } from 'lodash'
|
||||
|
||||
import { Bet } from './bet'
|
||||
import { getProbability } from './calculate'
|
||||
import { CPMMContract } from './contract'
|
||||
import { noFees } from './fees'
|
||||
import { CandidateBet } from './new-bet'
|
||||
|
||||
type RedeemableBet = Pick<Bet, 'outcome' | 'shares' | 'loanAmount'>
|
||||
|
||||
export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
||||
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
|
||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPayment = Math.min(loanAmount, shares)
|
||||
const netAmount = shares - loanPayment
|
||||
return { shares, loanPayment, netAmount }
|
||||
}
|
||||
|
||||
export const getRedemptionBets = (
|
||||
shares: number,
|
||||
loanPayment: number,
|
||||
contract: CPMMContract
|
||||
) => {
|
||||
const p = getProbability(contract)
|
||||
const createdTime = Date.now()
|
||||
const yesBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount: p * -shares,
|
||||
shares: -shares,
|
||||
loanAmount: loanPayment ? -loanPayment / 2 : 0,
|
||||
outcome: 'YES',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
}
|
||||
const noBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount: (1 - p) * -shares,
|
||||
shares: -shares,
|
||||
loanAmount: loanPayment ? -loanPayment / 2 : 0,
|
||||
outcome: 'NO',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
}
|
||||
return [yesBet, noBet]
|
||||
}
|
|
@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
|||
)
|
||||
const { payouts: resolvePayouts } = getPayouts(
|
||||
resolution as string,
|
||||
{},
|
||||
contract,
|
||||
openBets,
|
||||
[],
|
||||
{},
|
||||
resolutionProb
|
||||
)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||
type AnyTxnType = Donation | Tip | Manalink
|
||||
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
|
||||
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
||||
|
@ -46,6 +47,19 @@ type Manalink = {
|
|||
category: 'MANALINK'
|
||||
}
|
||||
|
||||
type Referral = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'REFERRAL'
|
||||
}
|
||||
|
||||
type Bonus = {
|
||||
fromType: 'BANK'
|
||||
toType: 'USER'
|
||||
category: 'UNIQUE_BETTOR_BONUS'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
export type ReferralTxn = Txn & Referral
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ENV_CONFIG } from './envs/constants'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
@ -33,11 +35,15 @@ export type User = {
|
|||
followerCountCached: number
|
||||
|
||||
followedCategories?: string[]
|
||||
|
||||
referredByUserId?: string
|
||||
referredByContractId?: string
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = 1000
|
||||
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
|
||||
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||
export const REFERRAL_AMOUNT = 500
|
||||
export type PrivateUser = {
|
||||
id: string // same as User.id
|
||||
username: string // denormalized from User
|
||||
|
@ -51,6 +57,7 @@ export type PrivateUser = {
|
|||
initialIpAddress?: string
|
||||
apiKey?: string
|
||||
notificationPreferences?: notification_subscribe_types
|
||||
lastTimeCheckedBonuses?: number
|
||||
}
|
||||
|
||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||
|
|
|
@ -456,7 +456,6 @@ Requires no authorization.
|
|||
}
|
||||
```
|
||||
|
||||
|
||||
### `POST /v0/bet`
|
||||
|
||||
Places a new bet on behalf of the authorized user.
|
||||
|
@ -514,6 +513,60 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
|||
"initialProb":25}'
|
||||
```
|
||||
|
||||
### `POST /v0/market/[marketId]/resolve`
|
||||
|
||||
Resolves a market on behalf of the authorized user.
|
||||
|
||||
Parameters:
|
||||
|
||||
For binary markets:
|
||||
|
||||
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
|
||||
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
|
||||
|
||||
For free response markets:
|
||||
|
||||
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
|
||||
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
|
||||
|
||||
For numeric markets:
|
||||
|
||||
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
|
||||
- `value`: The value that the market may resolves to.
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
# Resolve a binary market
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": "YES"}'
|
||||
|
||||
# Resolve a binary market with a specified probability
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": "MKT", \
|
||||
"probabilityInt": 75}'
|
||||
|
||||
# Resolve a free response market with a single answer chosen
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": 2}'
|
||||
|
||||
# Resolve a free response market with multiple answers chosen
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": "MKT", \
|
||||
"resolutions": [ \
|
||||
{"answer": 0, "pct": 50}, \
|
||||
{"answer": 2, "pct": 50} \
|
||||
]}'
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2022-06-08: Add paging to markets endpoint
|
||||
|
|
|
@ -19,7 +19,6 @@ for the pool to be sorted into.
|
|||
- Users can create a market on any question they want.
|
||||
- When a user creates a market, they must choose a close date, after which trading will halt.
|
||||
- They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market.
|
||||
- The creation fee for the first market created each day is provided by Manifold.
|
||||
- The market creator will earn a commission on all bets placed in the market.
|
||||
- The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution.
|
||||
- Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares.
|
||||
|
|
|
@ -337,6 +337,20 @@
|
|||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "portfolioHistory",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
|
|
|
@ -20,7 +20,16 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['referredByUserId'])
|
||||
// only one referral allowed per user
|
||||
&& !("referredByUserId" in resource.data)
|
||||
// user can't refer themselves
|
||||
&& !(resource.data.id == request.resource.data.referredByUserId);
|
||||
// quid pro quos enabled (only once though so nbd) - bc I can't make this work:
|
||||
// && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id);
|
||||
}
|
||||
|
||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||
|
|
3
functions/.env
Normal file
3
functions/.env
Normal file
|
@ -0,0 +1,3 @@
|
|||
# This sets which EnvConfig is deployed to Firebase Cloud Functions
|
||||
|
||||
NEXT_PUBLIC_FIREBASE_ENV=PROD
|
|
@ -1,7 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: ['lodash'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['lib'],
|
||||
ignorePatterns: ['dist', 'lib'],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
|
@ -30,6 +30,7 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
rules: {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
},
|
||||
}
|
||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -1,5 +1,4 @@
|
|||
# Secrets
|
||||
.env*
|
||||
.runtimeconfig.json
|
||||
|
||||
# GCP deployment artifact
|
||||
|
|
|
@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
### 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.`): 0. `$ brew install java`
|
||||
1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
|
||||
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.`):
|
||||
|
||||
1. `$ brew install java`
|
||||
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
|
||||
2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
|
||||
3. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
|
||||
4. `$ mkdir firestore_export` to create a folder to store the exported database
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
"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",
|
||||
"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",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
"start": "yarn shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log",
|
||||
"serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||
|
@ -23,9 +23,9 @@
|
|||
"main": "functions/src/index.js",
|
||||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"@google-cloud/functions-framework": "3.1.2",
|
||||
"@tiptap/core": "^2.0.0-beta.181",
|
||||
"@tiptap/starter-kit": "^2.0.0-beta.190",
|
||||
"fetch": "1.1.0",
|
||||
"firebase-admin": "10.0.0",
|
||||
"firebase-functions": "3.21.2",
|
||||
"lodash": "4.17.21",
|
||||
|
|
|
@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
|
|||
const contract = contractSnap.data() as Contract
|
||||
if (
|
||||
contract.mechanism !== 'cpmm-1' ||
|
||||
contract.outcomeType !== 'BINARY'
|
||||
(contract.outcomeType !== 'BINARY' &&
|
||||
contract.outcomeType !== 'PSEUDO_NUMERIC')
|
||||
)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
|
||||
|
|
|
@ -108,7 +108,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS: HttpsOptions = {
|
||||
interface EndpointOptions extends HttpsOptions {
|
||||
methods?: string[]
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS = {
|
||||
methods: ['POST'],
|
||||
minInstances: 1,
|
||||
concurrency: 100,
|
||||
memory: '2GiB',
|
||||
|
@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = {
|
|||
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
}
|
||||
|
||||
export const newEndpoint = (methods: [string], fn: Handler) =>
|
||||
onRequest(DEFAULT_OPTS, async (req, res) => {
|
||||
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
|
||||
return onRequest(opts, async (req, res) => {
|
||||
log('Request processing started.')
|
||||
try {
|
||||
if (!methods.includes(req.method)) {
|
||||
const allowed = methods.join(', ')
|
||||
if (!opts.methods.includes(req.method)) {
|
||||
const allowed = opts.methods.join(', ')
|
||||
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
||||
}
|
||||
const authedUser = await lookupUser(await parseCredentials(req))
|
||||
|
@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
|
|||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -18,46 +18,63 @@
|
|||
|
||||
import * as functions from 'firebase-functions'
|
||||
import * as firestore from '@google-cloud/firestore'
|
||||
const client = new firestore.v1.FirestoreAdminClient()
|
||||
import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client'
|
||||
|
||||
const bucket = 'gs://manifold-firestore-backup'
|
||||
export const backupDbCore = async (
|
||||
client: FirestoreAdminClient,
|
||||
project: string,
|
||||
bucket: string
|
||||
) => {
|
||||
const name = client.databasePath(project, '(default)')
|
||||
const outputUriPrefix = `gs://${bucket}`
|
||||
// Leave collectionIds empty to export all collections
|
||||
// or set to a list of collection IDs to export,
|
||||
// collectionIds: ['users', 'posts']
|
||||
// NOTE: Subcollections are not backed up by default
|
||||
const collectionIds = [
|
||||
'contracts',
|
||||
'groups',
|
||||
'private-users',
|
||||
'stripe-transactions',
|
||||
'transactions',
|
||||
'users',
|
||||
'bets',
|
||||
'comments',
|
||||
'follows',
|
||||
'followers',
|
||||
'answers',
|
||||
'txns',
|
||||
'manalinks',
|
||||
'liquidity',
|
||||
'stats',
|
||||
'cache',
|
||||
'latency',
|
||||
'views',
|
||||
'notifications',
|
||||
'portfolioHistory',
|
||||
'folds',
|
||||
]
|
||||
return await client.exportDocuments({ name, outputUriPrefix, collectionIds })
|
||||
}
|
||||
|
||||
export const backupDb = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun((_context) => {
|
||||
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
|
||||
if (projectId == null) {
|
||||
throw new Error('No project ID environment variable set.')
|
||||
.onRun(async (_context) => {
|
||||
try {
|
||||
const client = new firestore.v1.FirestoreAdminClient()
|
||||
const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
|
||||
if (project == null) {
|
||||
throw new Error('No project ID environment variable set.')
|
||||
}
|
||||
const responses = await backupDbCore(
|
||||
client,
|
||||
project,
|
||||
'manifold-firestore-backup'
|
||||
)
|
||||
const response = responses[0]
|
||||
console.log(`Operation Name: ${response['name']}`)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw new Error('Export operation failed')
|
||||
}
|
||||
const databaseName = client.databasePath(projectId, '(default)')
|
||||
|
||||
return client
|
||||
.exportDocuments({
|
||||
name: databaseName,
|
||||
outputUriPrefix: bucket,
|
||||
// Leave collectionIds empty to export all collections
|
||||
// or set to a list of collection IDs to export,
|
||||
// collectionIds: ['users', 'posts']
|
||||
// NOTE: Subcollections are not backed up by default
|
||||
collectionIds: [
|
||||
'contracts',
|
||||
'groups',
|
||||
'private-users',
|
||||
'stripe-transactions',
|
||||
'users',
|
||||
'bets',
|
||||
'comments',
|
||||
'followers',
|
||||
'answers',
|
||||
'txns',
|
||||
],
|
||||
})
|
||||
.then((responses) => {
|
||||
const response = responses[0]
|
||||
console.log(`Operation Name: ${response['name']}`)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
throw new Error('Export operation failed')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import fetch from './fetch'
|
||||
|
||||
export const callCloudFunction = (functionName: string, data: unknown = {}) => {
|
||||
const projectId = admin.instanceId().app.options.projectId
|
||||
|
||||
const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}`
|
||||
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ data }),
|
||||
}).then((response) => response.json())
|
||||
}
|
|
@ -27,6 +27,7 @@ import { getNewContract } from '../../common/new-contract'
|
|||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||
import { User } from '../../common/user'
|
||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
|
||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
|
@ -68,19 +69,31 @@ const binarySchema = z.object({
|
|||
initialProb: z.number().min(1).max(99),
|
||||
})
|
||||
|
||||
const finite = () => z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER)
|
||||
|
||||
const numericSchema = z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
min: finite(),
|
||||
max: finite(),
|
||||
initialValue: finite(),
|
||||
isLogScale: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||
validate(bodySchema, req.body)
|
||||
|
||||
let min, max, initialProb
|
||||
if (outcomeType === 'NUMERIC') {
|
||||
;({ min, max } = validate(numericSchema, req.body))
|
||||
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.')
|
||||
let min, max, initialProb, isLogScale
|
||||
|
||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||
let initialValue
|
||||
;({ min, max, initialValue, isLogScale } = validate(
|
||||
numericSchema,
|
||||
req.body
|
||||
))
|
||||
if (max - min <= 0.01 || initialValue < min || initialValue > max)
|
||||
throw new APIError(400, 'Invalid range.')
|
||||
|
||||
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||
}
|
||||
if (outcomeType === 'BINARY') {
|
||||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
|
@ -144,7 +157,8 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
|||
tags ?? [],
|
||||
NUMERIC_BUCKET_COUNT,
|
||||
min ?? 0,
|
||||
max ?? 0
|
||||
max ?? 0,
|
||||
isLogScale ?? false
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
@ -153,7 +167,7 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
|||
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const liquidityDoc = firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.doc()
|
||||
|
|
|
@ -20,7 +20,7 @@ const bodySchema = z.object({
|
|||
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
|
||||
})
|
||||
|
||||
export const creategroup = newEndpoint(['POST'], async (req, auth) => {
|
||||
export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||
const { name, about, memberIds, anyoneCanJoin } = validate(
|
||||
bodySchema,
|
||||
req.body
|
||||
|
|
|
@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
|||
const firestore = admin.firestore()
|
||||
|
||||
type user_to_reason_texts = {
|
||||
[userId: string]: { reason: notification_reason_types }
|
||||
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
|
||||
}
|
||||
|
||||
export const createNotification = async (
|
||||
|
@ -68,9 +68,11 @@ export const createNotification = async (
|
|||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||
// TODO: move away from sourceContractTitle to sourceTitle
|
||||
sourceContractTitle: sourceContract?.question,
|
||||
// TODO: move away from sourceContractSlug to sourceSlug
|
||||
sourceContractSlug: sourceContract?.slug,
|
||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
||||
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
|
@ -252,44 +254,90 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyUserReceivedReferralBonus = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
) => {
|
||||
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
// If the referrer is the market creator, just tell them they joined to bet on their market
|
||||
reason:
|
||||
sourceContract?.creatorId === relatedUserId
|
||||
? 'user_joined_to_bet_on_your_market'
|
||||
: 'you_referred_user',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userId: string
|
||||
) => {
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'unique_bettors_on_your_contract',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOtherGroupMembersOfComment = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userId: string
|
||||
) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_group_you_are_member_of',
|
||||
isSeeOnHref: sourceSlug,
|
||||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
if (sourceContract) {
|
||||
if (
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
(sourceType === 'contract' &&
|
||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (relatedUserId && relatedSourceType)
|
||||
await notifyRepliedUsers(
|
||||
userToReasonTexts,
|
||||
relatedUserId,
|
||||
relatedSourceType
|
||||
)
|
||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
}
|
||||
} else if (sourceType === 'follow' && relatedUserId) {
|
||||
if (sourceType === 'follow' && relatedUserId) {
|
||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'group' && relatedUserId) {
|
||||
if (sourceUpdateType === 'created')
|
||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'user' && relatedUserId) {
|
||||
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
|
||||
} else if (sourceType === 'comment' && !sourceContract && relatedUserId) {
|
||||
await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId)
|
||||
}
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
if (!sourceContract) return userToReasonTexts
|
||||
if (
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
(sourceType === 'contract' &&
|
||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (relatedUserId && relatedSourceType)
|
||||
await notifyRepliedUsers(
|
||||
userToReasonTexts,
|
||||
relatedUserId,
|
||||
relatedSourceType
|
||||
)
|
||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
}
|
||||
return userToReasonTexts
|
||||
}
|
||||
|
|
|
@ -6,8 +6,13 @@ import { Comment } from '../../common/comment'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { DPM_CREATOR_FEE } from '../../common/fees'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { formatMoney, formatPercent } from '../../common/util/format'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
} from '../../common/util/format'
|
||||
import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||
|
||||
import { sendTemplateEmail } from './send-email'
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
|
@ -101,6 +106,17 @@ const toDisplayResolution = (
|
|||
return display || resolution
|
||||
}
|
||||
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const { resolutionValue } = contract
|
||||
|
||||
return resolutionValue
|
||||
? formatLargeNumber(resolutionValue)
|
||||
: formatNumericProbability(
|
||||
resolutionProbability ?? getProbability(contract),
|
||||
contract
|
||||
)
|
||||
}
|
||||
|
||||
if (resolution === 'MKT' && resolutions) return 'MULTI'
|
||||
if (resolution === 'CANCEL') return 'N/A'
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
let fetchRequest: typeof fetch
|
||||
|
||||
try {
|
||||
fetchRequest = fetch
|
||||
} catch {
|
||||
fetchRequest = require('node-fetch')
|
||||
}
|
||||
|
||||
export default fetchRequest
|
139
functions/src/get-daily-bonuses.ts
Normal file
139
functions/src/get-daily-bonuses.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { APIError, newEndpoint } from './api'
|
||||
import { log } from './utils'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { PrivateUser } from '../../common/lib/user'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet } from '../../common/lib/bet'
|
||||
const firestore = admin.firestore()
|
||||
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
import { createNotification } from './create-notification'
|
||||
import { User } from '../../common/lib/user'
|
||||
import { Contract } from '../../common/lib/contract'
|
||||
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
|
||||
|
||||
const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime()
|
||||
const QUERY_LIMIT_SECONDS = 60
|
||||
|
||||
export const getdailybonuses = newEndpoint({}, async (req, auth) => {
|
||||
const { user, lastTimeCheckedBonuses } = await firestore.runTransaction(
|
||||
async (trans) => {
|
||||
const userSnap = await trans.get(
|
||||
firestore.doc(`private-users/${auth.uid}`)
|
||||
)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const user = userSnap.data() as PrivateUser
|
||||
const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0
|
||||
if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000)
|
||||
throw new APIError(
|
||||
400,
|
||||
`Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.`
|
||||
)
|
||||
await trans.update(userSnap.ref, {
|
||||
lastTimeCheckedBonuses: Date.now(),
|
||||
})
|
||||
return {
|
||||
user,
|
||||
lastTimeCheckedBonuses,
|
||||
}
|
||||
}
|
||||
)
|
||||
// TODO: switch to prod id
|
||||
// const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account
|
||||
const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account
|
||||
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
|
||||
// Get all users contracts made since implementation time
|
||||
const userContractsSnap = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', user.id)
|
||||
.where('createdTime', '>=', BONUS_START_DATE)
|
||||
.get()
|
||||
const userContracts = userContractsSnap.docs.map(
|
||||
(doc) => doc.data() as Contract
|
||||
)
|
||||
const nullReturn = { status: 'no bets', txn: null }
|
||||
for (const contract of userContracts) {
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const contractId = contract.id
|
||||
// Get all bets made on user's contracts
|
||||
const bets = (
|
||||
await firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.where('userId', '!=', user.id)
|
||||
.get()
|
||||
).docs.map((bet) => bet.ref)
|
||||
if (bets.length === 0) {
|
||||
return nullReturn
|
||||
}
|
||||
const contractBetsSnap = await trans.getAll(...bets)
|
||||
const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet)
|
||||
|
||||
const uniqueBettorIdsBeforeLastResetTime = uniq(
|
||||
contractBets
|
||||
.filter((bet) => bet.createdTime < lastTimeCheckedBonuses)
|
||||
.map((bet) => bet.userId)
|
||||
)
|
||||
|
||||
// Filter users for ONLY those that have made bets since the last daily bonus received time
|
||||
const uniqueBettorIdsWithBetsAfterLastResetTime = uniq(
|
||||
contractBets
|
||||
.filter((bet) => bet.createdTime > lastTimeCheckedBonuses)
|
||||
.map((bet) => bet.userId)
|
||||
)
|
||||
|
||||
// Filter for users only present in the above list
|
||||
const newUniqueBettorIds =
|
||||
uniqueBettorIdsWithBetsAfterLastResetTime.filter(
|
||||
(userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId)
|
||||
)
|
||||
newUniqueBettorIds.length > 0 &&
|
||||
log(
|
||||
`Got ${newUniqueBettorIds.length} new unique bettors since last bonus`
|
||||
)
|
||||
if (newUniqueBettorIds.length === 0) {
|
||||
return nullReturn
|
||||
}
|
||||
// Create combined txn for all unique bettors
|
||||
const bonusTxnDetails = {
|
||||
contractId: contractId,
|
||||
uniqueBettors: newUniqueBettorIds.length,
|
||||
}
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
fromType: 'BANK',
|
||||
toId: user.id,
|
||||
toType: 'USER',
|
||||
amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length,
|
||||
token: 'M$',
|
||||
category: 'UNIQUE_BETTOR_BONUS',
|
||||
description: JSON.stringify(bonusTxnDetails),
|
||||
}
|
||||
return await runTxn(trans, bonusTxn)
|
||||
})
|
||||
|
||||
if (result.status != 'success' || !result.txn) {
|
||||
result.status != nullReturn.status &&
|
||||
log(`No bonus for user: ${user.id} - reason:`, result.status)
|
||||
} else {
|
||||
log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id)
|
||||
await createNotification(
|
||||
result.txn.id,
|
||||
'bonus',
|
||||
'created',
|
||||
fromUser,
|
||||
result.txn.id,
|
||||
result.txn.amount + '',
|
||||
contract,
|
||||
undefined,
|
||||
// No need to set the user id, we'll use the contract creator id
|
||||
undefined,
|
||||
contract.slug,
|
||||
contract.question
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { userId: user.id, message: 'success' }
|
||||
})
|
|
@ -1,6 +1,6 @@
|
|||
import { newEndpoint } from './api'
|
||||
|
||||
export const health = newEndpoint(['GET'], async (_req, auth) => {
|
||||
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
|
||||
return {
|
||||
message: 'Server is working.',
|
||||
uid: auth.uid,
|
||||
|
|
|
@ -6,12 +6,11 @@ admin.initializeApp()
|
|||
// export * from './keep-awake'
|
||||
export * from './claim-manalink'
|
||||
export * from './transact'
|
||||
export * from './resolve-market'
|
||||
export * from './stripe'
|
||||
export * from './create-user'
|
||||
export * from './create-answer'
|
||||
export * from './on-create-bet'
|
||||
export * from './on-create-comment'
|
||||
export * from './on-create-comment-on-contract'
|
||||
export * from './on-view'
|
||||
export * from './unsubscribe'
|
||||
export * from './update-metrics'
|
||||
|
@ -28,6 +27,8 @@ export * from './on-unfollow-user'
|
|||
export * from './on-create-liquidity-provision'
|
||||
export * from './on-update-group'
|
||||
export * from './on-create-group'
|
||||
export * from './on-update-user'
|
||||
export * from './on-create-comment-on-group'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
@ -37,3 +38,5 @@ export * from './sell-shares'
|
|||
export * from './create-contract'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './get-daily-bonuses'
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
|
||||
import { callCloudFunction } from './call-cloud-function'
|
||||
|
||||
export const keepAwake = functions.pubsub
|
||||
.schedule('every 1 minutes')
|
||||
.onRun(async () => {
|
||||
await Promise.all([
|
||||
callCloudFunction('placeBet'),
|
||||
callCloudFunction('resolveMarket'),
|
||||
callCloudFunction('sellBet'),
|
||||
])
|
||||
|
||||
await sleep(30)
|
||||
|
||||
await Promise.all([
|
||||
callCloudFunction('placeBet'),
|
||||
callCloudFunction('resolveMarket'),
|
||||
callCloudFunction('sellBet'),
|
||||
])
|
||||
})
|
||||
|
||||
const sleep = (seconds: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||
}
|
|
@ -11,7 +11,7 @@ import { createNotification } from './create-notification'
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onCreateComment = functions
|
||||
export const onCreateCommentOnContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.firestore.document('contracts/{contractId}/comments/{commentId}')
|
||||
.onCreate(async (change, context) => {
|
52
functions/src/on-create-comment-on-group.ts
Normal file
52
functions/src/on-create-comment-on-group.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { Comment } from '../../common/comment'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { Group } from '../../common/group'
|
||||
import { User } from '../../common/user'
|
||||
import { createNotification } from './create-notification'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onCreateCommentOnGroup = functions.firestore
|
||||
.document('groups/{groupId}/comments/{commentId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const { eventId } = context
|
||||
const { groupId } = context.params as {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const comment = change.data() as Comment
|
||||
const creatorSnapshot = await firestore
|
||||
.collection('users')
|
||||
.doc(comment.userId)
|
||||
.get()
|
||||
if (!creatorSnapshot.exists) throw new Error('Could not find user')
|
||||
|
||||
const groupSnapshot = await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.get()
|
||||
if (!groupSnapshot.exists) throw new Error('Could not find group')
|
||||
|
||||
const group = groupSnapshot.data() as Group
|
||||
await firestore.collection('groups').doc(groupId).update({
|
||||
mostRecentActivityTime: comment.createdTime,
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
group.memberIds.map(async (memberId) => {
|
||||
return await createNotification(
|
||||
comment.id,
|
||||
'comment',
|
||||
'created',
|
||||
creatorSnapshot.data() as User,
|
||||
eventId,
|
||||
comment.text,
|
||||
undefined,
|
||||
undefined,
|
||||
memberId,
|
||||
`/group/${group.slug}`,
|
||||
`${group.name}`
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
|
@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore
|
|||
// ignore the update we just made
|
||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||
return
|
||||
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
|
||||
|
||||
await firestore
|
||||
.collection('groups')
|
||||
|
|
111
functions/src/on-update-user.ts
Normal file
111
functions/src/on-update-user.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
||||
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||
import { createNotification } from './create-notification'
|
||||
import { ReferralTxn } from '../../common/txn'
|
||||
import { Contract } from '../../common/contract'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onUpdateUser = functions.firestore
|
||||
.document('users/{userId}')
|
||||
.onUpdate(async (change, context) => {
|
||||
const prevUser = change.before.data() as User
|
||||
const user = change.after.data() as User
|
||||
const { eventId } = context
|
||||
|
||||
if (prevUser.referredByUserId !== user.referredByUserId) {
|
||||
await handleUserUpdatedReferral(user, eventId)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||
// Only create a referral txn if the user has a referredByUserId
|
||||
if (!user.referredByUserId) {
|
||||
console.log(`Not set: referredByUserId ${user.referredByUserId}`)
|
||||
return
|
||||
}
|
||||
const referredByUserId = user.referredByUserId
|
||||
|
||||
await firestore.runTransaction(async (transaction) => {
|
||||
// get user that referred this user
|
||||
const referredByUserDoc = firestore.doc(`users/${referredByUserId}`)
|
||||
const referredByUserSnap = await transaction.get(referredByUserDoc)
|
||||
if (!referredByUserSnap.exists) {
|
||||
console.log(`User ${referredByUserId} not found`)
|
||||
return
|
||||
}
|
||||
const referredByUser = referredByUserSnap.data() as User
|
||||
|
||||
let referredByContract: Contract | undefined = undefined
|
||||
if (user.referredByContractId) {
|
||||
const referredByContractDoc = firestore.doc(
|
||||
`contracts/${user.referredByContractId}`
|
||||
)
|
||||
referredByContract = await transaction
|
||||
.get(referredByContractDoc)
|
||||
.then((snap) => snap.data() as Contract)
|
||||
}
|
||||
console.log(`referredByContract: ${referredByContract}`)
|
||||
|
||||
const txns = (
|
||||
await firestore
|
||||
.collection('txns')
|
||||
.where('toId', '==', referredByUserId)
|
||||
.where('category', '==', 'REFERRAL')
|
||||
.get()
|
||||
).docs.map((txn) => txn.ref)
|
||||
if (txns.length > 0) {
|
||||
const referralTxns = await transaction.getAll(...txns).catch((err) => {
|
||||
console.error('error getting txns:', err)
|
||||
throw err
|
||||
})
|
||||
// If the referring user already has a referral txn due to referring this user, halt
|
||||
if (
|
||||
referralTxns.map((txn) => txn.data()?.description).includes(user.id)
|
||||
) {
|
||||
console.log('found referral txn with the same details, aborting')
|
||||
return
|
||||
}
|
||||
}
|
||||
console.log('creating referral txns')
|
||||
const fromId = HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
// if they're updating their referredId, create a txn for both
|
||||
const txn: ReferralTxn = {
|
||||
id: eventId,
|
||||
createdTime: Date.now(),
|
||||
fromId,
|
||||
fromType: 'BANK',
|
||||
toId: referredByUserId,
|
||||
toType: 'USER',
|
||||
amount: REFERRAL_AMOUNT,
|
||||
token: 'M$',
|
||||
category: 'REFERRAL',
|
||||
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
|
||||
}
|
||||
|
||||
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
|
||||
await transaction.set(txnDoc, txn)
|
||||
console.log('created referral with txn id:', txn.id)
|
||||
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
|
||||
transaction.update(referredByUserDoc, {
|
||||
balance: referredByUser.balance + REFERRAL_AMOUNT,
|
||||
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
|
||||
})
|
||||
|
||||
await createNotification(
|
||||
user.id,
|
||||
'user',
|
||||
'updated',
|
||||
user,
|
||||
eventId,
|
||||
txn.amount.toString(),
|
||||
referredByContract,
|
||||
'user',
|
||||
referredByUser.id,
|
||||
referredByContract?.slug,
|
||||
referredByContract?.question
|
||||
)
|
||||
})
|
||||
}
|
|
@ -33,7 +33,7 @@ const numericSchema = z.object({
|
|||
value: z.number(),
|
||||
})
|
||||
|
||||
export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
||||
export const placebet = newEndpoint({}, async (req, auth) => {
|
||||
log('Inside endpoint handler.')
|
||||
const { amount, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
|
@ -41,10 +41,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
|||
log('Inside main transaction.')
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const [contractSnap, userSnap] = await Promise.all([
|
||||
trans.get(contractDoc),
|
||||
trans.get(userDoc),
|
||||
])
|
||||
const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
log('Loaded user and contract snapshots.')
|
||||
|
@ -70,7 +67,10 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
|||
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
|
||||
const { outcome } = validate(binarySchema, req.body)
|
||||
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
|
||||
} else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') {
|
||||
} else if (
|
||||
(outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') &&
|
||||
mechanism == 'cpmm-1'
|
||||
) {
|
||||
const { outcome } = validate(binarySchema, req.body)
|
||||
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
|
||||
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
|
||||
|
|
|
@ -1,92 +1,46 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { partition, sumBy } from 'lodash'
|
||||
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { noFees } from '../../common/fees'
|
||||
import { User } from '../../common/user'
|
||||
|
||||
export const redeemShares = async (userId: string, contractId: string) => {
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
return await firestore.runTransaction(async (trans) => {
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1')
|
||||
return { status: 'success' }
|
||||
const { mechanism } = contract
|
||||
if (mechanism !== 'cpmm-1') return { status: 'success' }
|
||||
|
||||
const betsSnap = await transaction.get(
|
||||
firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.where('userId', '==', userId)
|
||||
)
|
||||
const betsColl = firestore.collection(`contracts/${contract.id}/bets`)
|
||||
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
|
||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
|
||||
const amount = Math.min(yesShares, noShares)
|
||||
if (amount <= 0) return
|
||||
|
||||
const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPaid = Math.min(prevLoanAmount, amount)
|
||||
const netAmount = amount - loanPaid
|
||||
|
||||
const p = getProbability(contract)
|
||||
const createdTime = Date.now()
|
||||
|
||||
const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
|
||||
const yesBet: Bet = {
|
||||
id: yesDoc.id,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount: p * -amount,
|
||||
shares: -amount,
|
||||
loanAmount: loanPaid ? -loanPaid / 2 : 0,
|
||||
outcome: 'YES',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
}
|
||||
|
||||
const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
|
||||
const noBet: Bet = {
|
||||
id: noDoc.id,
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount: (1 - p) * -amount,
|
||||
shares: -amount,
|
||||
loanAmount: loanPaid ? -loanPaid / 2 : 0,
|
||||
outcome: 'NO',
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isRedemption: true,
|
||||
fees: noFees,
|
||||
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
|
||||
if (netAmount === 0) {
|
||||
return { status: 'success' }
|
||||
}
|
||||
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
|
||||
|
||||
const userDoc = firestore.doc(`users/${userId}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
const userSnap = await trans.get(userDoc)
|
||||
if (!userSnap.exists) return { status: 'error', message: 'User not found' }
|
||||
|
||||
const user = userSnap.data() as User
|
||||
|
||||
const newBalance = user.balance + netAmount
|
||||
|
||||
if (!isFinite(newBalance)) {
|
||||
throw new Error('Invalid user balance for ' + user.username)
|
||||
}
|
||||
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
|
||||
transaction.create(yesDoc, yesBet)
|
||||
transaction.create(noDoc, noBet)
|
||||
const yesDoc = betsColl.doc()
|
||||
const noDoc = betsColl.doc()
|
||||
trans.update(userDoc, { balance: newBalance })
|
||||
trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet })
|
||||
trans.create(noDoc, { id: noDoc.id, userId, ...noBet })
|
||||
|
||||
return { status: 'success' }
|
||||
})
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
||||
|
||||
import { Contract, resolution, RESOLUTIONS } from '../../common/contract'
|
||||
import {
|
||||
Contract,
|
||||
FreeResponseContract,
|
||||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getUser, isProd, payUser } from './utils'
|
||||
|
@ -15,156 +19,162 @@ import {
|
|||
} from '../../common/payouts'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
|
||||
export const resolveMarket = functions
|
||||
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
outcome: resolution
|
||||
value?: number
|
||||
contractId: string
|
||||
probabilityInt?: number
|
||||
resolutions?: { [outcome: string]: number }
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
})
|
||||
|
||||
const { outcome, contractId, probabilityInt, resolutions, value } = data
|
||||
const binarySchema = z.object({
|
||||
outcome: z.enum(RESOLUTIONS),
|
||||
probabilityInt: z.number().gte(0).lte(100).optional(),
|
||||
})
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId, outcomeType, closeTime } = contract
|
||||
const freeResponseSchema = z.union([
|
||||
z.object({
|
||||
outcome: z.literal('CANCEL'),
|
||||
}),
|
||||
z.object({
|
||||
outcome: z.literal('MKT'),
|
||||
resolutions: z.array(
|
||||
z.object({
|
||||
answer: z.number().int().nonnegative(),
|
||||
pct: z.number().gte(0).lte(100),
|
||||
})
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
outcome: z.number().int().nonnegative(),
|
||||
}),
|
||||
])
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
if (!RESOLUTIONS.includes(outcome))
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
if (
|
||||
isNaN(+outcome) &&
|
||||
!(outcome === 'MKT' && resolutions) &&
|
||||
outcome !== 'CANCEL'
|
||||
)
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else if (outcomeType === 'NUMERIC') {
|
||||
if (isNaN(+outcome) && outcome !== 'CANCEL')
|
||||
return { status: 'error', message: 'Invalid outcome' }
|
||||
} else {
|
||||
return { status: 'error', message: 'Invalid contract outcomeType' }
|
||||
}
|
||||
const numericSchema = z.object({
|
||||
outcome: z.union([z.literal('CANCEL'), z.string()]),
|
||||
value: z.number().optional(),
|
||||
})
|
||||
|
||||
if (value !== undefined && !isFinite(value))
|
||||
return { status: 'error', message: 'Invalid value' }
|
||||
const pseudoNumericSchema = z.union([
|
||||
z.object({
|
||||
outcome: z.literal('CANCEL'),
|
||||
}),
|
||||
z.object({
|
||||
outcome: z.literal('MKT'),
|
||||
value: z.number(),
|
||||
probabilityInt: z.number().gte(0).lte(100),
|
||||
}),
|
||||
])
|
||||
|
||||
if (
|
||||
outcomeType === 'BINARY' &&
|
||||
probabilityInt !== undefined &&
|
||||
(probabilityInt < 0 ||
|
||||
probabilityInt > 100 ||
|
||||
!isFinite(probabilityInt))
|
||||
)
|
||||
return { status: 'error', message: 'Invalid probability' }
|
||||
const opts = { secrets: ['MAILGUN_KEY'] }
|
||||
|
||||
if (creatorId !== userId)
|
||||
return { status: 'error', message: 'User not creator of contract' }
|
||||
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||
const { contractId } = validate(bodySchema, req.body)
|
||||
const userId = auth.uid
|
||||
|
||||
if (contract.resolution)
|
||||
return { status: 'error', message: 'Contract already resolved' }
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await contractDoc.get()
|
||||
if (!contractSnap.exists)
|
||||
throw new APIError(404, 'No contract exists with the provided ID')
|
||||
const contract = contractSnap.data() as Contract
|
||||
const { creatorId, closeTime } = contract
|
||||
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) return { status: 'error', message: 'Creator not found' }
|
||||
|
||||
const resolutionProbability =
|
||||
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
||||
|
||||
const resolutionTime = Date.now()
|
||||
const newCloseTime = closeTime
|
||||
? Math.min(closeTime, resolutionTime)
|
||||
: closeTime
|
||||
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.get()
|
||||
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
|
||||
const liquiditiesSnap = await firestore
|
||||
.collection(`contracts/${contractId}/liquidity`)
|
||||
.get()
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
||||
getPayouts(
|
||||
outcome,
|
||||
resolutions ?? {},
|
||||
contract,
|
||||
bets,
|
||||
liquidities,
|
||||
resolutionProbability
|
||||
)
|
||||
|
||||
await contractDoc.update(
|
||||
removeUndefinedProps({
|
||||
isResolved: true,
|
||||
resolution: outcome,
|
||||
resolutionValue: value,
|
||||
resolutionTime,
|
||||
closeTime: newCloseTime,
|
||||
resolutionProbability,
|
||||
resolutions,
|
||||
collectedFees,
|
||||
})
|
||||
)
|
||||
|
||||
console.log('contract ', contractId, 'resolved to:', outcome)
|
||||
|
||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const loanPayouts = getLoanPayouts(openBets)
|
||||
|
||||
if (!isProd())
|
||||
console.log(
|
||||
'payouts:',
|
||||
payouts,
|
||||
'creator payout:',
|
||||
creatorPayout,
|
||||
'liquidity payout:'
|
||||
)
|
||||
|
||||
if (creatorPayout)
|
||||
await processPayouts(
|
||||
[{ userId: creatorId, payout: creatorPayout }],
|
||||
true
|
||||
)
|
||||
|
||||
await processPayouts(liquidityPayouts, true)
|
||||
|
||||
const result = await processPayouts([...payouts, ...loanPayouts])
|
||||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
|
||||
await sendResolutionEmails(
|
||||
openBets,
|
||||
userPayoutsWithoutLoans,
|
||||
creator,
|
||||
creatorPayout,
|
||||
contract,
|
||||
outcome,
|
||||
resolutionProbability,
|
||||
resolutions
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||
contract,
|
||||
req.body
|
||||
)
|
||||
|
||||
if (creatorId !== userId)
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) throw new APIError(500, 'Creator not found')
|
||||
|
||||
const resolutionProbability =
|
||||
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
||||
|
||||
const resolutionTime = Date.now()
|
||||
const newCloseTime = closeTime
|
||||
? Math.min(closeTime, resolutionTime)
|
||||
: closeTime
|
||||
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.get()
|
||||
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
|
||||
const liquiditiesSnap = await firestore
|
||||
.collection(`contracts/${contractId}/liquidity`)
|
||||
.get()
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
||||
getPayouts(
|
||||
outcome,
|
||||
contract,
|
||||
bets,
|
||||
liquidities,
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
|
||||
const updatedContract = {
|
||||
...contract,
|
||||
...removeUndefinedProps({
|
||||
isResolved: true,
|
||||
resolution: outcome,
|
||||
resolutionValue: value,
|
||||
resolutionTime,
|
||||
closeTime: newCloseTime,
|
||||
resolutionProbability,
|
||||
resolutions,
|
||||
collectedFees,
|
||||
}),
|
||||
}
|
||||
|
||||
await contractDoc.update(updatedContract)
|
||||
|
||||
console.log('contract ', contractId, 'resolved to:', outcome)
|
||||
|
||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const loanPayouts = getLoanPayouts(openBets)
|
||||
|
||||
if (!isProd())
|
||||
console.log(
|
||||
'payouts:',
|
||||
payouts,
|
||||
'creator payout:',
|
||||
creatorPayout,
|
||||
'liquidity payout:'
|
||||
)
|
||||
|
||||
if (creatorPayout)
|
||||
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
|
||||
|
||||
await processPayouts(liquidityPayouts, true)
|
||||
|
||||
await processPayouts([...payouts, ...loanPayouts])
|
||||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
|
||||
await sendResolutionEmails(
|
||||
openBets,
|
||||
userPayoutsWithoutLoans,
|
||||
creator,
|
||||
creatorPayout,
|
||||
contract,
|
||||
outcome,
|
||||
resolutionProbability,
|
||||
resolutions
|
||||
)
|
||||
|
||||
return updatedContract
|
||||
})
|
||||
|
||||
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||
const userPayouts = groupPayoutsByUser(payouts)
|
||||
|
||||
|
@ -221,4 +231,72 @@ const sendResolutionEmails = async (
|
|||
)
|
||||
}
|
||||
|
||||
function getResolutionParams(contract: Contract, body: string) {
|
||||
const { outcomeType } = contract
|
||||
|
||||
if (outcomeType === 'NUMERIC') {
|
||||
return {
|
||||
...validate(numericSchema, body),
|
||||
resolutions: undefined,
|
||||
probabilityInt: undefined,
|
||||
}
|
||||
} else if (outcomeType === 'PSEUDO_NUMERIC') {
|
||||
return {
|
||||
...validate(pseudoNumericSchema, body),
|
||||
resolutions: undefined,
|
||||
}
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
const freeResponseParams = validate(freeResponseSchema, body)
|
||||
const { outcome } = freeResponseParams
|
||||
switch (outcome) {
|
||||
case 'CANCEL':
|
||||
return {
|
||||
outcome: outcome.toString(),
|
||||
resolutions: undefined,
|
||||
value: undefined,
|
||||
probabilityInt: undefined,
|
||||
}
|
||||
case 'MKT': {
|
||||
const { resolutions } = freeResponseParams
|
||||
resolutions.forEach(({ answer }) => validateAnswer(contract, answer))
|
||||
const pctSum = sumBy(resolutions, ({ pct }) => pct)
|
||||
if (Math.abs(pctSum - 100) > 0.1) {
|
||||
throw new APIError(400, 'Resolution percentages must sum to 100')
|
||||
}
|
||||
return {
|
||||
outcome: outcome.toString(),
|
||||
resolutions: Object.fromEntries(
|
||||
resolutions.map((r) => [r.answer, r.pct])
|
||||
),
|
||||
value: undefined,
|
||||
probabilityInt: undefined,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
validateAnswer(contract, outcome)
|
||||
return {
|
||||
outcome: outcome.toString(),
|
||||
resolutions: undefined,
|
||||
value: undefined,
|
||||
probabilityInt: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (outcomeType === 'BINARY') {
|
||||
return {
|
||||
...validate(binarySchema, body),
|
||||
value: undefined,
|
||||
resolutions: undefined,
|
||||
}
|
||||
}
|
||||
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
||||
}
|
||||
|
||||
function validateAnswer(contract: FreeResponseContract, answer: number) {
|
||||
const validIds = contract.answers.map((a) => a.id)
|
||||
if (!validIds.includes(answer.toString())) {
|
||||
throw new APIError(400, `${answer} is not a valid answer ID`)
|
||||
}
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
16
functions/src/scripts/backup-db.ts
Normal file
16
functions/src/scripts/backup-db.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as firestore from '@google-cloud/firestore'
|
||||
import { getServiceAccountCredentials } from './script-init'
|
||||
import { backupDbCore } from '../backup-db'
|
||||
|
||||
async function backupDb() {
|
||||
const credentials = getServiceAccountCredentials()
|
||||
const projectId = credentials.project_id
|
||||
const client = new firestore.v1.FirestoreAdminClient({ credentials })
|
||||
const bucket = 'manifold-firestore-backup'
|
||||
const resp = await backupDbCore(client, projectId, bucket)
|
||||
console.log(`Operation: ${resp[0]['name']}`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
backupDb().then(() => process.exit())
|
||||
}
|
|
@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
|
|||
|
||||
const { payouts } = getPayouts(
|
||||
resolution,
|
||||
resolutions,
|
||||
contract,
|
||||
openBets,
|
||||
[],
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
|
||||
|
|
|
@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const initAdmin = (env?: string) => {
|
||||
export const getServiceAccountCredentials = (env?: string) => {
|
||||
env = env || getFirebaseActiveProject(process.cwd())
|
||||
if (env == null) {
|
||||
console.error(
|
||||
throw new Error(
|
||||
"Couldn't find active Firebase project; did you do `firebase use <alias>?`"
|
||||
)
|
||||
return
|
||||
}
|
||||
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
|
||||
const keyPath = process.env[envVar]
|
||||
if (keyPath == null) {
|
||||
console.error(
|
||||
throw new Error(
|
||||
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.`
|
||||
)
|
||||
return
|
||||
}
|
||||
console.log(`Initializing connection to ${env} Firebase...`)
|
||||
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
|
||||
const serviceAccount = require(keyPath)
|
||||
admin.initializeApp({
|
||||
return require(keyPath)
|
||||
}
|
||||
|
||||
export const initAdmin = (env?: string) => {
|
||||
const serviceAccount = getServiceAccountCredentials(env)
|
||||
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { getValues } from '../utils'
|
||||
import { User } from '../../../common/user'
|
||||
import { batchedWaitAll } from '../../../common/util/promise'
|
||||
import { Contract } from '../../../common/contract'
|
||||
import { updateWordScores } from '../update-recommendations'
|
||||
import { computeFeed } from '../update-feed'
|
||||
import { getFeedContracts, getTaggedContracts } from '../get-feed-data'
|
||||
import { CATEGORY_LIST } from '../../../common/categories'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function updateFeed() {
|
||||
console.log('Updating feed')
|
||||
|
||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||
const feedContracts = await getFeedContracts()
|
||||
const users = await getValues<User>(
|
||||
firestore.collection('users').where('username', '==', 'JamesGrugett')
|
||||
)
|
||||
|
||||
await batchedWaitAll(
|
||||
users.map((user) => async () => {
|
||||
console.log('Updating recs for', user.username)
|
||||
await updateWordScores(user, contracts)
|
||||
console.log('Updating feed for', user.username)
|
||||
await computeFeed(user, feedContracts)
|
||||
})
|
||||
)
|
||||
|
||||
console.log('Updating feed categories!')
|
||||
|
||||
await batchedWaitAll(
|
||||
users.map((user) => async () => {
|
||||
for (const category of CATEGORY_LIST) {
|
||||
const contracts = await getTaggedContracts(category)
|
||||
const feed = await computeFeed(user, contracts)
|
||||
await firestore
|
||||
.collection(`private-users/${user.id}/cache`)
|
||||
.doc(`feed-${category}`)
|
||||
.set({ feed })
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
updateFeed().then(() => process.exit())
|
||||
}
|
|
@ -13,7 +13,7 @@ const bodySchema = z.object({
|
|||
betId: z.string(),
|
||||
})
|
||||
|
||||
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
|
||||
export const sellbet = newEndpoint({}, async (req, auth) => {
|
||||
const { contractId, betId } = validate(bodySchema, req.body)
|
||||
|
||||
// run as transaction to prevent race conditions
|
||||
|
@ -21,11 +21,11 @@ export const sellbet = newEndpoint(['POST'], async (req, auth) => {
|
|||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
|
||||
const [contractSnap, userSnap, betSnap] = await Promise.all([
|
||||
transaction.get(contractDoc),
|
||||
transaction.get(userDoc),
|
||||
transaction.get(betDoc),
|
||||
])
|
||||
const [contractSnap, userSnap, betSnap] = await transaction.getAll(
|
||||
contractDoc,
|
||||
userDoc,
|
||||
betDoc
|
||||
)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')
|
||||
|
|
|
@ -16,7 +16,7 @@ const bodySchema = z.object({
|
|||
outcome: z.enum(['YES', 'NO']),
|
||||
})
|
||||
|
||||
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
||||
export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
|
@ -24,9 +24,8 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
|||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||
const [contractSnap, userSnap, userBets] = await Promise.all([
|
||||
transaction.get(contractDoc),
|
||||
transaction.get(userDoc),
|
||||
const [[contractSnap, userSnap], userBets] = await Promise.all([
|
||||
transaction.getAll(contractDoc, userDoc),
|
||||
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
|
@ -47,7 +46,7 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
|||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||
|
||||
if (shares > maxShares + 0.000000000001)
|
||||
if (shares > maxShares)
|
||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||
|
||||
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(
|
||||
|
|
|
@ -1,220 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash'
|
||||
|
||||
import { getValue, getValues } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { logInterpolation } from '../../common/util/math'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import {
|
||||
getProbability,
|
||||
getOutcomeProbability,
|
||||
getTopAnswer,
|
||||
} from '../../common/calculate'
|
||||
import { User } from '../../common/user'
|
||||
import {
|
||||
getContractScore,
|
||||
MAX_FEED_CONTRACTS,
|
||||
} from '../../common/recommended-contracts'
|
||||
import { callCloudFunction } from './call-cloud-function'
|
||||
import {
|
||||
getFeedContracts,
|
||||
getRecentBetsAndComments,
|
||||
getTaggedContracts,
|
||||
} from './get-feed-data'
|
||||
import { CATEGORY_LIST } from '../../common/categories'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const BATCH_SIZE = 30
|
||||
const MAX_BATCHES = 50
|
||||
|
||||
const getUserBatches = async () => {
|
||||
const users = shuffle(await getValues<User>(firestore.collection('users')))
|
||||
const userBatches: User[][] = []
|
||||
for (let i = 0; i < users.length; i += BATCH_SIZE) {
|
||||
userBatches.push(users.slice(i, i + BATCH_SIZE))
|
||||
}
|
||||
|
||||
console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length)
|
||||
|
||||
return userBatches.slice(0, MAX_BATCHES)
|
||||
}
|
||||
|
||||
export const updateFeed = functions.pubsub
|
||||
.schedule('every 60 minutes')
|
||||
.onRun(async () => {
|
||||
const userBatches = await getUserBatches()
|
||||
|
||||
await Promise.all(
|
||||
userBatches.map((users) =>
|
||||
callCloudFunction('updateFeedBatch', { users })
|
||||
)
|
||||
)
|
||||
|
||||
console.log('updating category feed')
|
||||
|
||||
await Promise.all(
|
||||
CATEGORY_LIST.map((category) =>
|
||||
callCloudFunction('updateCategoryFeed', {
|
||||
category,
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
export const updateFeedBatch = functions.https.onCall(
|
||||
async (data: { users: User[] }) => {
|
||||
const { users } = data
|
||||
const contracts = await getFeedContracts()
|
||||
const feeds = await getNewFeeds(users, contracts)
|
||||
await Promise.all(
|
||||
zip(users, feeds).map(([user, feed]) =>
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
getUserCacheCollection(user!).doc('feed').set({ feed })
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const updateCategoryFeed = functions.https.onCall(
|
||||
async (data: { category: string }) => {
|
||||
const { category } = data
|
||||
const userBatches = await getUserBatches()
|
||||
|
||||
await Promise.all(
|
||||
userBatches.map(async (users) => {
|
||||
await callCloudFunction('updateCategoryFeedBatch', {
|
||||
users,
|
||||
category,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const updateCategoryFeedBatch = functions.https.onCall(
|
||||
async (data: { users: User[]; category: string }) => {
|
||||
const { users, category } = data
|
||||
const contracts = await getTaggedContracts(category)
|
||||
const feeds = await getNewFeeds(users, contracts)
|
||||
await Promise.all(
|
||||
zip(users, feeds).map(([user, feed]) =>
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed })
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const getNewFeeds = async (users: User[], contracts: Contract[]) => {
|
||||
const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts)))
|
||||
const contractIds = uniq(flatten(feeds).map((c) => c.id))
|
||||
const data = await Promise.all(contractIds.map(getRecentBetsAndComments))
|
||||
const dataByContractId = zipObject(contractIds, data)
|
||||
return feeds.map((feed) =>
|
||||
feed.map((contract) => {
|
||||
return { contract, ...dataByContractId[contract.id] }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const getUserCacheCollection = (user: User) =>
|
||||
firestore.collection(`private-users/${user.id}/cache`)
|
||||
|
||||
export const computeFeed = async (user: User, contracts: Contract[]) => {
|
||||
const userCacheCollection = getUserCacheCollection(user)
|
||||
|
||||
const [wordScores, lastViewedTime] = await Promise.all([
|
||||
getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')),
|
||||
getValue<{ [contractId: string]: number }>(
|
||||
userCacheCollection.doc('lastViewTime')
|
||||
),
|
||||
]).then((dicts) => dicts.map((dict) => dict ?? {}))
|
||||
|
||||
const scoredContracts = contracts.map((contract) => {
|
||||
const score = scoreContract(
|
||||
contract,
|
||||
wordScores,
|
||||
lastViewedTime[contract.id]
|
||||
)
|
||||
return [contract, score] as [Contract, number]
|
||||
})
|
||||
|
||||
const sortedContracts = sortBy(
|
||||
scoredContracts,
|
||||
([_, score]) => score
|
||||
).reverse()
|
||||
|
||||
// console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score))
|
||||
|
||||
return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c)
|
||||
}
|
||||
|
||||
function scoreContract(
|
||||
contract: Contract,
|
||||
wordScores: { [word: string]: number },
|
||||
viewTime: number | undefined
|
||||
) {
|
||||
const recommendationScore = getContractScore(contract, wordScores)
|
||||
const activityScore = getActivityScore(contract, viewTime)
|
||||
// const lastViewedScore = getLastViewedScore(viewTime)
|
||||
return recommendationScore * activityScore
|
||||
}
|
||||
|
||||
function getActivityScore(contract: Contract, viewTime: number | undefined) {
|
||||
const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract
|
||||
const hasNewComments =
|
||||
lastCommentTime && (!viewTime || lastCommentTime > viewTime)
|
||||
const newCommentScore = hasNewComments ? 1 : 0.5
|
||||
|
||||
const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime)
|
||||
const commentDaysAgo = timeSinceLastComment / DAY_MS
|
||||
const commentTimeScore =
|
||||
0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo))
|
||||
|
||||
const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime)
|
||||
const betDaysAgo = timeSinceLastBet / DAY_MS
|
||||
const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo))
|
||||
|
||||
let prob = 0.5
|
||||
if (outcomeType === 'BINARY') {
|
||||
prob = getProbability(contract)
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
const topAnswer = getTopAnswer(contract)
|
||||
if (topAnswer)
|
||||
prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id))
|
||||
}
|
||||
const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25
|
||||
const probScore = 0.5 + frac * 0.5
|
||||
|
||||
const { volume24Hours, volume7Days } = contract
|
||||
const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1)
|
||||
const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume)
|
||||
|
||||
const score =
|
||||
newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore
|
||||
|
||||
// Map score to [0.5, 1] since no recent activty is not a deal breaker.
|
||||
const mappedScore = 0.5 + 0.5 * score
|
||||
const newMappedScore = 0.7 + 0.3 * score
|
||||
|
||||
const isNew = Date.now() < contract.createdTime + DAY_MS
|
||||
return isNew ? newMappedScore : mappedScore
|
||||
}
|
||||
|
||||
// function getLastViewedScore(viewTime: number | undefined) {
|
||||
// if (viewTime === undefined) {
|
||||
// return 1
|
||||
// }
|
||||
|
||||
// const daysAgo = (Date.now() - viewTime) / DAY_MS
|
||||
|
||||
// if (daysAgo < 0.5) {
|
||||
// const frac = logInterpolation(0, 0.5, daysAgo)
|
||||
// return 0.5 + 0.25 * frac
|
||||
// }
|
||||
|
||||
// const frac = logInterpolation(0.5, 14, daysAgo)
|
||||
// return 0.75 + 0.25 * frac
|
||||
// }
|
|
@ -1,70 +0,0 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getValue, getValues } from './utils'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { User } from '../../common/user'
|
||||
import { ClickEvent } from '../../common/tracking'
|
||||
import { getWordScores } from '../../common/recommended-contracts'
|
||||
import { batchedWaitAll } from '../../common/util/promise'
|
||||
import { callCloudFunction } from './call-cloud-function'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const updateRecommendations = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
const users = await getValues<User>(firestore.collection('users'))
|
||||
|
||||
const batchSize = 100
|
||||
const userBatches: User[][] = []
|
||||
for (let i = 0; i < users.length; i += batchSize) {
|
||||
userBatches.push(users.slice(i, i + batchSize))
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
userBatches.map((batch) =>
|
||||
callCloudFunction('updateRecommendationsBatch', { users: batch })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
export const updateRecommendationsBatch = functions.https.onCall(
|
||||
async (data: { users: User[] }) => {
|
||||
const { users } = data
|
||||
|
||||
const contracts = await getValues<Contract>(
|
||||
firestore.collection('contracts')
|
||||
)
|
||||
|
||||
await batchedWaitAll(
|
||||
users.map((user) => () => updateWordScores(user, contracts))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const updateWordScores = async (user: User, contracts: Contract[]) => {
|
||||
const [bets, viewCounts, clicks] = await Promise.all([
|
||||
getValues<Bet>(
|
||||
firestore.collectionGroup('bets').where('userId', '==', user.id)
|
||||
),
|
||||
|
||||
getValue<{ [contractId: string]: number }>(
|
||||
firestore.doc(`private-users/${user.id}/cache/viewCounts`)
|
||||
),
|
||||
|
||||
getValues<ClickEvent>(
|
||||
firestore
|
||||
.collection(`private-users/${user.id}/events`)
|
||||
.where('type', '==', 'click')
|
||||
),
|
||||
])
|
||||
|
||||
const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets)
|
||||
|
||||
const cachedCollection = firestore.collection(
|
||||
`private-users/${user.id}/cache`
|
||||
)
|
||||
await cachedCollection.doc('wordScores').set(wordScores)
|
||||
}
|
|
@ -1,138 +1,138 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
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 } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
export const withdrawLiquidity = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId } = data
|
||||
if (!contractId)
|
||||
return { status: 'error', message: 'Missing contract id' }
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${userId}`)
|
||||
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(
|
||||
userId,
|
||||
contract,
|
||||
liquidities
|
||||
)
|
||||
|
||||
// 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 === userId
|
||||
)
|
||||
.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: userId,
|
||||
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(userId, contractId)
|
||||
|
||||
console.log('userid', userId, 'withdraws', result)
|
||||
return { status: 'success', userShares: result }
|
||||
})
|
||||
.catch((e) => {
|
||||
return { status: 'error', message: e.message }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
||||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
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 } from './api'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
|
||||
export const withdrawLiquidity = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId } = data
|
||||
if (!contractId)
|
||||
return { status: 'error', message: 'Missing contract id' }
|
||||
|
||||
return await firestore
|
||||
.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${userId}`)
|
||||
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(
|
||||
userId,
|
||||
contract,
|
||||
liquidities
|
||||
)
|
||||
|
||||
// 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 === userId
|
||||
)
|
||||
.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: userId,
|
||||
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(userId, contractId)
|
||||
|
||||
console.log('userid', userId, 'withdraws', result)
|
||||
return { status: 'success', userShares: result }
|
||||
})
|
||||
.catch((e) => {
|
||||
return { status: 'error', message: e.message }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -19,6 +19,7 @@ module.exports = {
|
|||
],
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@next/next/no-typos': 'off',
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
},
|
||||
env: {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import clsx from 'clsx'
|
||||
import { sum, mapValues } from 'lodash'
|
||||
import { sum } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Contract, FreeResponse } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||
import { Row } from '../layout/row'
|
||||
import { ChooseCancelSelector } from '../yes-no-selector'
|
||||
import { ResolveConfirmationButton } from '../confirmation-button'
|
||||
|
@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: {
|
|||
setIsSubmitting(true)
|
||||
|
||||
const totalProb = sum(Object.values(chosenAnswers))
|
||||
const normalizedProbs = mapValues(
|
||||
chosenAnswers,
|
||||
(prob) => (100 * prob) / totalProb
|
||||
)
|
||||
const resolutions = Object.entries(chosenAnswers).map(([i, p]) => {
|
||||
return { answer: parseInt(i), pct: (100 * p) / totalProb }
|
||||
})
|
||||
|
||||
const resolutionProps = removeUndefinedProps({
|
||||
outcome:
|
||||
resolveOption === 'CHOOSE'
|
||||
? answers[0]
|
||||
? parseInt(answers[0])
|
||||
: resolveOption === 'CHOOSE_MULTIPLE'
|
||||
? 'MKT'
|
||||
: 'CANCEL',
|
||||
resolutions:
|
||||
resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined,
|
||||
resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined,
|
||||
contractId: contract.id,
|
||||
})
|
||||
|
||||
const result = await resolveMarket(resolutionProps).then((r) => r.data)
|
||||
|
||||
console.log('resolved', resolutionProps, 'result:', result)
|
||||
|
||||
if (result?.status !== 'success') {
|
||||
setError(result?.message || 'Error resolving market')
|
||||
try {
|
||||
const result = await resolveMarket(resolutionProps)
|
||||
console.log('resolved', resolutionProps, 'result:', result)
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
setError(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setError('Error resolving market')
|
||||
}
|
||||
}
|
||||
|
||||
setResolveOption(undefined)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
|
@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
setText('')
|
||||
setBetAmount(10)
|
||||
setAmountError(undefined)
|
||||
setPossibleDuplicateAnswer(undefined)
|
||||
} else setAmountError(result.message)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react'
|
|||
import { partition, sumBy } from 'lodash'
|
||||
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { BinaryContract, CPMMBinaryContract } from 'common/contract'
|
||||
import {
|
||||
BinaryContract,
|
||||
CPMMBinaryContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call'
|
|||
import { sellShares } from 'web/lib/firebase/api-call'
|
||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import { BinaryOutcomeLabel } from './outcome-label'
|
||||
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
|
||||
import {
|
||||
calculatePayoutAfterCorrectBet,
|
||||
calculateShares,
|
||||
|
@ -35,6 +39,7 @@ import {
|
|||
getCpmmProbability,
|
||||
getCpmmLiquidityFee,
|
||||
} from 'common/calculate-cpmm'
|
||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||
import { SellRow } from './sell-row'
|
||||
import { useSaveShares } from './use-save-shares'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
|
@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device'
|
|||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
|
@ -81,7 +86,7 @@ export function BetPanel(props: {
|
|||
}
|
||||
|
||||
export function BetPanelSwitcher(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
title?: string // Set if BetPanel is on a feed modal
|
||||
selected?: 'YES' | 'NO'
|
||||
|
@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: {
|
|||
}) {
|
||||
const { contract, className, title, selected, onBetSuccess } = props
|
||||
|
||||
const { mechanism } = contract
|
||||
const { mechanism, outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const user = useUser()
|
||||
const userBets = useUserContractBets(user?.id, contract.id)
|
||||
|
@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: {
|
|||
<Row className="items-center justify-between gap-2">
|
||||
<div>
|
||||
You have {formatWithCommas(floorShares)}{' '}
|
||||
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
|
||||
{isPseudoNumeric ? (
|
||||
<PseudoNumericOutcomeLabel outcome={sharesOutcome} />
|
||||
) : (
|
||||
<BinaryOutcomeLabel outcome={sharesOutcome} />
|
||||
)}{' '}
|
||||
shares
|
||||
</div>
|
||||
|
||||
{tradeType === 'BUY' && (
|
||||
|
@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: {
|
|||
}
|
||||
|
||||
function BuyPanel(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
user: User | null | undefined
|
||||
selected?: 'YES' | 'NO'
|
||||
onBuySuccess?: () => void
|
||||
}) {
|
||||
const { contract, user, selected, onBuySuccess } = props
|
||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||
|
@ -302,6 +314,9 @@ function BuyPanel(props: {
|
|||
: 0)
|
||||
)} ${betChoice ?? 'YES'} shares`
|
||||
: undefined
|
||||
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
return (
|
||||
<>
|
||||
<YesNoSelector
|
||||
|
@ -309,6 +324,7 @@ function BuyPanel(props: {
|
|||
btnClassName="flex-1"
|
||||
selected={betChoice}
|
||||
onSelect={(choice) => onBetChoice(choice)}
|
||||
isPseudoNumeric={isPseudoNumeric}
|
||||
/>
|
||||
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
||||
<BuyAmountInput
|
||||
|
@ -323,11 +339,13 @@ function BuyPanel(props: {
|
|||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">Probability</div>
|
||||
<div className="text-gray-500">
|
||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||
</div>
|
||||
<div>
|
||||
{formatPercent(initialProb)}
|
||||
{format(initialProb)}
|
||||
<span className="mx-2">→</span>
|
||||
{formatPercent(resultProb)}
|
||||
{format(resultProb)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
|
@ -340,6 +358,8 @@ function BuyPanel(props: {
|
|||
<br /> payout if{' '}
|
||||
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
||||
</>
|
||||
) : isPseudoNumeric ? (
|
||||
'Max payout'
|
||||
) : (
|
||||
<>
|
||||
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
||||
|
@ -389,7 +409,7 @@ function BuyPanel(props: {
|
|||
}
|
||||
|
||||
export function SellPanel(props: {
|
||||
contract: CPMMBinaryContract
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
userBets: Bet[]
|
||||
shares: number
|
||||
sharesOutcome: 'YES' | 'NO'
|
||||
|
@ -488,6 +508,10 @@ export function SellPanel(props: {
|
|||
}
|
||||
}
|
||||
|
||||
const { outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AmountInput
|
||||
|
@ -511,11 +535,13 @@ export function SellPanel(props: {
|
|||
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
||||
</Row>
|
||||
<Row className="items-center justify-between">
|
||||
<div className="text-gray-500">Probability</div>
|
||||
<div className="text-gray-500">
|
||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||
</div>
|
||||
<div>
|
||||
{formatPercent(initialProb)}
|
||||
{format(initialProb)}
|
||||
<span className="mx-2">→</span>
|
||||
{formatPercent(resultProb)}
|
||||
{format(resultProb)}
|
||||
</div>
|
||||
</Row>
|
||||
</Col>
|
||||
|
|
|
@ -3,7 +3,7 @@ import clsx from 'clsx'
|
|||
|
||||
import { BetPanelSwitcher } from './bet-panel'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { BinaryContract } from 'common/contract'
|
||||
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { Modal } from './layout/modal'
|
||||
import { SellButton } from './sell-button'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares'
|
|||
|
||||
// Inline version of a bet panel. Opens BetPanel in a new modal.
|
||||
export default function BetRow(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
btnClassName?: string
|
||||
betPanelClassName?: string
|
||||
|
@ -32,6 +32,7 @@ export default function BetRow(props: {
|
|||
return (
|
||||
<>
|
||||
<YesNoSelector
|
||||
isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'}
|
||||
className={clsx('justify-end', className)}
|
||||
btnClassName={clsx('btn-sm w-24', btnClassName)}
|
||||
onSelect={(choice) => {
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets'
|
|||
import { Bet } from 'web/lib/firebase/bets'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
|
@ -40,6 +41,7 @@ import {
|
|||
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
|
||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
||||
import { NumericContract } from 'common/contract'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { SellSharesModal } from './sell-modal'
|
||||
|
||||
|
@ -366,6 +368,7 @@ export function BetsSummary(props: {
|
|||
const { contract, isYourBets, className } = props
|
||||
const { resolution, closeTime, outcomeType, mechanism } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const isCpmm = mechanism === 'cpmm-1'
|
||||
const isClosed = closeTime && Date.now() > closeTime
|
||||
|
||||
|
@ -427,6 +430,25 @@ export function BetsSummary(props: {
|
|||
</div>
|
||||
</Col>
|
||||
</>
|
||||
) : isPseudoNumeric ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(yesWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(noWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
|
@ -507,13 +529,15 @@ export function ContractBetsTable(props: {
|
|||
const { isResolved, mechanism, outcomeType } = contract
|
||||
const isCPMM = mechanism === 'cpmm-1'
|
||||
const isNumeric = outcomeType === 'NUMERIC'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto', className)}>
|
||||
{amountRedeemed > 0 && (
|
||||
<>
|
||||
<div className="pl-2 text-sm text-gray-500">
|
||||
{amountRedeemed} YES shares and {amountRedeemed} NO shares
|
||||
{amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '}
|
||||
{amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares
|
||||
automatically redeemed for {formatMoney(amountRedeemed)}.
|
||||
</div>
|
||||
<Spacer h={4} />
|
||||
|
@ -541,7 +565,7 @@ export function ContractBetsTable(props: {
|
|||
)}
|
||||
{!isCPMM && !isResolved && <th>Payout if chosen</th>}
|
||||
<th>Shares</th>
|
||||
<th>Probability</th>
|
||||
{!isPseudoNumeric && <th>Probability</th>}
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -585,6 +609,7 @@ function BetRow(props: {
|
|||
|
||||
const isCPMM = mechanism === 'cpmm-1'
|
||||
const isNumeric = outcomeType === 'NUMERIC'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
const saleAmount = saleBet?.sale?.amount
|
||||
|
||||
|
@ -628,14 +653,18 @@ function BetRow(props: {
|
|||
truncate="short"
|
||||
/>
|
||||
)}
|
||||
{isPseudoNumeric &&
|
||||
' than ' + formatNumericProbability(bet.probAfter, contract)}
|
||||
</td>
|
||||
<td>{formatMoney(Math.abs(amount))}</td>
|
||||
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
|
||||
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
|
||||
<td>{formatWithCommas(Math.abs(shares))}</td>
|
||||
<td>
|
||||
{formatPercent(probBefore)} → {formatPercent(probAfter)}
|
||||
</td>
|
||||
{!isPseudoNumeric && (
|
||||
<td>
|
||||
{formatPercent(probBefore)} → {formatPercent(probAfter)}
|
||||
</td>
|
||||
)}
|
||||
<td>{dayjs(createdTime).format('MMM D, h:mma')}</td>
|
||||
</tr>
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
useSortBy,
|
||||
} from 'react-instantsearch-hooks-web'
|
||||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract } from 'common/contract'
|
||||
import {
|
||||
Sort,
|
||||
useInitialQueryAndSort,
|
||||
|
@ -58,15 +58,24 @@ export function ContractSearch(props: {
|
|||
additionalFilter?: {
|
||||
creatorId?: string
|
||||
tag?: string
|
||||
excludeContractIds?: string[]
|
||||
}
|
||||
showCategorySelector: boolean
|
||||
onContractClick?: (contract: Contract) => void
|
||||
showPlaceHolder?: boolean
|
||||
hideOrderSelector?: boolean
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
}) {
|
||||
const {
|
||||
querySortOptions,
|
||||
additionalFilter,
|
||||
showCategorySelector,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
hideOrderSelector,
|
||||
showPlaceHolder,
|
||||
hideQuickBet,
|
||||
} = props
|
||||
|
||||
const user = useUser()
|
||||
|
@ -122,7 +131,7 @@ export function ContractSearch(props: {
|
|||
|
||||
const indexName = `${indexPrefix}contracts-${sort}`
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD) {
|
||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
return (
|
||||
<ContractSearchFirestore
|
||||
querySortOptions={querySortOptions}
|
||||
|
@ -136,6 +145,7 @@ export function ContractSearch(props: {
|
|||
<Row className="gap-1 sm:gap-2">
|
||||
<SearchBox
|
||||
className="flex-1"
|
||||
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
|
||||
classNames={{
|
||||
form: 'before:top-6',
|
||||
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
|
||||
|
@ -153,13 +163,15 @@ export function ContractSearch(props: {
|
|||
<option value="resolved">Resolved</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
<SortBy
|
||||
items={sortIndexes}
|
||||
classNames={{
|
||||
select: '!select !select-bordered',
|
||||
}}
|
||||
onBlur={trackCallback('select search sort')}
|
||||
/>
|
||||
{!hideOrderSelector && (
|
||||
<SortBy
|
||||
items={sortIndexes}
|
||||
classNames={{
|
||||
select: '!select !select-bordered',
|
||||
}}
|
||||
onBlur={trackCallback('select search sort')}
|
||||
/>
|
||||
)}
|
||||
<Configure
|
||||
facetFilters={filters}
|
||||
numericFilters={numericFilters}
|
||||
|
@ -187,6 +199,9 @@ export function ContractSearch(props: {
|
|||
<ContractSearchInner
|
||||
querySortOptions={querySortOptions}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
hideQuickBet={hideQuickBet}
|
||||
excludeContractIds={additionalFilter?.excludeContractIds}
|
||||
/>
|
||||
)}
|
||||
</InstantSearch>
|
||||
|
@ -199,8 +214,17 @@ export function ContractSearchInner(props: {
|
|||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
excludeContractIds?: string[]
|
||||
}) {
|
||||
const { querySortOptions, onContractClick } = props
|
||||
const {
|
||||
querySortOptions,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
hideQuickBet,
|
||||
excludeContractIds,
|
||||
} = props
|
||||
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||
|
||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
||||
|
@ -239,7 +263,7 @@ export function ContractSearchInner(props: {
|
|||
}, [])
|
||||
|
||||
const { showMore, hits, isLastPage } = useInfiniteHits()
|
||||
const contracts = hits as any as Contract[]
|
||||
let contracts = hits as any as Contract[]
|
||||
|
||||
if (isInitialLoad && contracts.length === 0) return <></>
|
||||
|
||||
|
@ -249,6 +273,9 @@ export function ContractSearchInner(props: {
|
|||
? 'resolve-date'
|
||||
: undefined
|
||||
|
||||
if (excludeContractIds)
|
||||
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
|
||||
|
||||
return (
|
||||
<ContractsGrid
|
||||
contracts={contracts}
|
||||
|
@ -256,6 +283,8 @@ export function ContractSearchInner(props: {
|
|||
hasMore={!isLastPage}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
hideQuickBet={hideQuickBet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
BinaryContract,
|
||||
FreeResponseContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
import {
|
||||
AnswerLabel,
|
||||
|
@ -16,7 +17,11 @@ import {
|
|||
CancelLabel,
|
||||
FreeResponseOutcomeLabel,
|
||||
} from '../outcome-label'
|
||||
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
|
||||
import {
|
||||
getOutcomeProbability,
|
||||
getProbability,
|
||||
getTopAnswer,
|
||||
} from 'common/calculate'
|
||||
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
||||
|
@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -131,6 +137,13 @@ export function ContractCard(props: {
|
|||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'PSEUDO_NUMERIC' && (
|
||||
<PseudoNumericResolutionOrExpectation
|
||||
className="items-center"
|
||||
contract={contract}
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
className="items-center"
|
||||
|
@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: {
|
|||
{resolution === 'CANCEL' ? (
|
||||
<CancelLabel />
|
||||
) : (
|
||||
<div className="text-blue-400">{resolutionValue}</div>
|
||||
<div className="text-blue-400">
|
||||
{formatLargeNumber(resolutionValue)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: {
|
|||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export function PseudoNumericResolutionOrExpectation(props: {
|
||||
contract: PseudoNumericContract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
const { resolution, resolutionValue, resolutionProbability } = contract
|
||||
const textColor = `text-blue-400`
|
||||
|
||||
return (
|
||||
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
||||
{resolution ? (
|
||||
<>
|
||||
<div className={clsx('text-base text-gray-500')}>Resolved</div>
|
||||
|
||||
{resolution === 'CANCEL' ? (
|
||||
<CancelLabel />
|
||||
) : (
|
||||
<div className="text-blue-400">
|
||||
{resolutionValue
|
||||
? formatLargeNumber(resolutionValue)
|
||||
: formatNumericProbability(
|
||||
resolutionProbability ?? 0,
|
||||
contract
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={clsx('text-3xl', textColor)}>
|
||||
{formatNumericProbability(getProbability(contract), contract)}
|
||||
</div>
|
||||
<div className={clsx('text-base', textColor)}>expected</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ import { groupPath } from 'web/lib/firebase/groups'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -128,8 +130,32 @@ export function ContractDetails(props: {
|
|||
const { contract, bets, isCreator, disabled } = props
|
||||
const { closeTime, creatorName, creatorUsername, creatorId } = contract
|
||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||
// Find a group that this contract id is in
|
||||
const groups = useGroupsWithContract(contract.id)
|
||||
|
||||
const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => {
|
||||
return g2.createdTime - g1.createdTime
|
||||
})
|
||||
const user = useUser()
|
||||
|
||||
const groupsUserIsMemberOf = groups
|
||||
? groups.filter((g) => g.memberIds.includes(contract.creatorId))
|
||||
: []
|
||||
const groupsUserIsCreatorOf = groups
|
||||
? groups.filter((g) => g.creatorId === contract.creatorId)
|
||||
: []
|
||||
|
||||
// Priorities for which group the contract belongs to:
|
||||
// In order of created most recently
|
||||
// Group that the contract owner created
|
||||
// Group the contract owner is a member of
|
||||
// Any group the contract is in
|
||||
const groupToDisplay =
|
||||
groupsUserIsCreatorOf.length > 0
|
||||
? groupsUserIsCreatorOf[0]
|
||||
: groupsUserIsMemberOf.length > 0
|
||||
? groupsUserIsMemberOf[0]
|
||||
: groups
|
||||
? groups[0]
|
||||
: undefined
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
<Row className="items-center gap-2">
|
||||
|
@ -150,14 +176,15 @@ export function ContractDetails(props: {
|
|||
)}
|
||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||
</Row>
|
||||
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/}
|
||||
{groups && groups.length > 0 && (
|
||||
{groupToDisplay ? (
|
||||
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
|
||||
<SiteLink href={`${groupPath(groups[0].slug)}`}>
|
||||
<SiteLink href={`${groupPath(groupToDisplay.slug)}`}>
|
||||
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
|
||||
<span>{groups[0].name}</span>
|
||||
<span>{groupToDisplay.name}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{(!!closeTime || !!resolvedDate) && (
|
||||
|
@ -192,6 +219,11 @@ export function ContractDetails(props: {
|
|||
|
||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||
</Row>
|
||||
<ShareIconButton
|
||||
contract={contract}
|
||||
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||
username={user?.username}
|
||||
/>
|
||||
|
||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||
</Row>
|
||||
|
|
|
@ -13,7 +13,6 @@ import {
|
|||
getBinaryProbPercent,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { CopyLinkButton } from '../copy-link-button'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
|
@ -22,6 +21,10 @@ import { Title } from '../title'
|
|||
import { TweetButton } from '../tweet-button'
|
||||
import { InfoTooltip } from '../info-tooltip'
|
||||
import { TagsInput } from 'web/components/tags-input'
|
||||
import { DuplicateContractButton } from '../copy-contract-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'
|
||||
|
||||
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
|
@ -48,13 +51,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
return (
|
||||
<>
|
||||
<button
|
||||
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"
|
||||
className={contractDetailsButtonClassName}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<DotsHorizontalIcon
|
||||
className={clsx(
|
||||
'h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500'
|
||||
)}
|
||||
className={clsx('h-6 w-6 flex-shrink-0')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
@ -66,15 +67,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<div>Share</div>
|
||||
|
||||
<Row className="justify-start gap-4">
|
||||
<CopyLinkButton
|
||||
contract={contract}
|
||||
toastClassName={'sm:-left-10 -left-4 min-w-[250%]'}
|
||||
/>
|
||||
<TweetButton
|
||||
className="self-start"
|
||||
tweetText={getTweetText(contract, false)}
|
||||
/>
|
||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||
<DuplicateContractButton contract={contract} />
|
||||
</Row>
|
||||
<div />
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
FreeResponseResolutionOrChance,
|
||||
BinaryResolutionOrChance,
|
||||
NumericResolutionOrExpectation,
|
||||
PseudoNumericResolutionOrExpectation,
|
||||
} from './contract-card'
|
||||
import { Bet } from 'common/bet'
|
||||
import BetRow from '../bet-row'
|
||||
|
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
|
|||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
return (
|
||||
<Col className={clsx('mb-6', className)}>
|
||||
|
@ -49,6 +51,13 @@ export const ContractOverview = (props: {
|
|||
/>
|
||||
)}
|
||||
|
||||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
|
@ -61,6 +70,11 @@ export const ContractOverview = (props: {
|
|||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
</Row>
|
||||
) : (
|
||||
|
@ -86,7 +100,9 @@ export const ContractOverview = (props: {
|
|||
/>
|
||||
</Col>
|
||||
<Spacer h={4} />
|
||||
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '}
|
||||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
)}{' '}
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<AnswersGraph contract={contract} bets={bets} />
|
||||
)}
|
||||
|
|
|
@ -5,16 +5,20 @@ import dayjs from 'dayjs'
|
|||
import { memo } from 'react'
|
||||
import { Bet } from 'common/bet'
|
||||
import { getInitialProbability } from 'common/calculate'
|
||||
import { BinaryContract } from 'common/contract'
|
||||
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
import { formatLargeNumber } from 'common/util/format'
|
||||
|
||||
export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
bets: Bet[]
|
||||
height?: number
|
||||
}) {
|
||||
const { contract, height } = props
|
||||
const { resolutionTime, closeTime } = contract
|
||||
const { resolutionTime, closeTime, outcomeType } = contract
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
|
||||
|
||||
const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
|
||||
|
||||
|
@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
contract.createdTime,
|
||||
...bets.map((bet) => bet.createdTime),
|
||||
].map((time) => new Date(time))
|
||||
const probs = [startProb, ...bets.map((bet) => bet.probAfter)]
|
||||
|
||||
const f = getMappedValue(contract)
|
||||
|
||||
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
|
||||
|
||||
const isClosed = !!closeTime && Date.now() > closeTime
|
||||
const latestTime = dayjs(
|
||||
|
@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
times.push(latestTime.toDate())
|
||||
probs.push(probs[probs.length - 1])
|
||||
|
||||
const yTickValues = [0, 25, 50, 75, 100]
|
||||
const quartiles = [0, 25, 50, 75, 100]
|
||||
|
||||
const yTickValues = isBinary
|
||||
? quartiles
|
||||
: quartiles.map((x) => x / 100).map(f)
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
|
@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
const totalPoints = width ? (width > 800 ? 300 : 50) : 1
|
||||
|
||||
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
|
||||
|
||||
const points: { x: Date; y: number }[] = []
|
||||
const s = isBinary ? 100 : 1
|
||||
const c = isLogScale && contract.min === 0 ? 1 : 0
|
||||
|
||||
for (let i = 0; i < times.length - 1; i++) {
|
||||
points[points.length] = { x: times[i], y: probs[i] * 100 }
|
||||
points[points.length] = { x: times[i], y: s * probs[i] + c }
|
||||
const numPoints: number = Math.floor(
|
||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
||||
)
|
||||
|
@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
x: dayjs(times[i])
|
||||
.add(thisTimeStep * n, 'ms')
|
||||
.toDate(),
|
||||
y: probs[i] * 100,
|
||||
y: s * probs[i] + c,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = [{ id: 'Yes', data: points, color: '#11b981' }]
|
||||
const data = [
|
||||
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
|
||||
]
|
||||
|
||||
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
||||
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
|
||||
|
||||
const formatter = isBinary
|
||||
? formatPercent
|
||||
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-visible"
|
||||
|
@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
>
|
||||
<ResponsiveLine
|
||||
data={data}
|
||||
yScale={{ min: 0, max: 100, type: 'linear' }}
|
||||
yFormat={formatPercent}
|
||||
yScale={
|
||||
isBinary
|
||||
? { min: 0, max: 100, type: 'linear' }
|
||||
: {
|
||||
min: contract.min + c,
|
||||
max: contract.max + c,
|
||||
type: contract.isLogScale ? 'log' : 'linear',
|
||||
}
|
||||
}
|
||||
yFormat={formatter}
|
||||
gridYValues={yTickValues}
|
||||
axisLeft={{
|
||||
tickValues: yTickValues,
|
||||
format: formatPercent,
|
||||
format: formatter,
|
||||
}}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
|||
import {
|
||||
getOutcomeProbability,
|
||||
getOutcomeProbabilityAfterBet,
|
||||
getProbability,
|
||||
getTopAnswer,
|
||||
} from 'common/calculate'
|
||||
import { getExpectedValue } from 'common/calculate-dpm'
|
||||
|
@ -25,18 +26,18 @@ import { useSaveShares } from '../use-save-shares'
|
|||
import { sellShares } from 'web/lib/firebase/api-call'
|
||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
|
||||
const BET_SIZE = 10
|
||||
|
||||
export function QuickBet(props: { contract: Contract; user: User }) {
|
||||
const { contract, user } = props
|
||||
const isCpmm = contract.mechanism === 'cpmm-1'
|
||||
const { mechanism, outcomeType } = contract
|
||||
const isCpmm = mechanism === 'cpmm-1'
|
||||
|
||||
const userBets = useUserContractBets(user.id, contract.id)
|
||||
const topAnswer =
|
||||
contract.outcomeType === 'FREE_RESPONSE'
|
||||
? getTopAnswer(contract)
|
||||
: undefined
|
||||
outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
|
||||
|
||||
// TODO: yes/no from useSaveShares doesn't work on numeric contracts
|
||||
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
|
||||
|
@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
|||
topAnswer?.number.toString() || undefined
|
||||
)
|
||||
const hasUpShares =
|
||||
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC')
|
||||
yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC')
|
||||
const hasDownShares =
|
||||
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC'
|
||||
noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC'
|
||||
|
||||
const [upHover, setUpHover] = useState(false)
|
||||
const [downHover, setDownHover] = useState(false)
|
||||
|
@ -130,25 +131,6 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
|||
})
|
||||
}
|
||||
|
||||
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
|
||||
if (contract.outcomeType === 'BINARY') {
|
||||
return direction === 'UP' ? 'YES' : 'NO'
|
||||
}
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
// TODO: Implement shorting of free response answers
|
||||
if (direction === 'DOWN') {
|
||||
throw new Error("Can't bet against free response answers")
|
||||
}
|
||||
return getTopAnswer(contract)?.id
|
||||
}
|
||||
if (contract.outcomeType === 'NUMERIC') {
|
||||
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
|
||||
throw new Error("Can't quick bet on numeric markets")
|
||||
}
|
||||
}
|
||||
|
||||
const textColor = `text-${getColor(contract)}`
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
|
@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
|||
<TriangleFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
upHover ? textColor : 'text-gray-400'
|
||||
upHover ? 'text-green-500' : 'text-gray-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TriangleFillIcon
|
||||
className={clsx(
|
||||
'mx-auto h-5 w-5',
|
||||
upHover ? textColor : 'text-gray-200'
|
||||
upHover ? 'text-green-500' : 'text-gray-200'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
|||
<QuickOutcomeView contract={contract} previewProb={previewProb} />
|
||||
|
||||
{/* Down bet triangle */}
|
||||
{contract.outcomeType !== 'BINARY' ? (
|
||||
{outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
|
||||
<div>
|
||||
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
|
||||
<TriangleDownFillIcon
|
||||
|
@ -254,6 +236,25 @@ export function ProbBar(props: { contract: Contract; previewProb?: number }) {
|
|||
)
|
||||
}
|
||||
|
||||
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
|
||||
const { outcomeType } = contract
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
return direction === 'UP' ? 'YES' : 'NO'
|
||||
}
|
||||
if (outcomeType === 'FREE_RESPONSE') {
|
||||
// TODO: Implement shorting of free response answers
|
||||
if (direction === 'DOWN') {
|
||||
throw new Error("Can't bet against free response answers")
|
||||
}
|
||||
return getTopAnswer(contract)?.id
|
||||
}
|
||||
if (outcomeType === 'NUMERIC') {
|
||||
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
|
||||
throw new Error("Can't quick bet on numeric markets")
|
||||
}
|
||||
}
|
||||
|
||||
function QuickOutcomeView(props: {
|
||||
contract: Contract
|
||||
previewProb?: number
|
||||
|
@ -261,9 +262,16 @@ function QuickOutcomeView(props: {
|
|||
}) {
|
||||
const { contract, previewProb, caption } = props
|
||||
const { outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
// If there's a preview prob, display that instead of the current prob
|
||||
const override =
|
||||
previewProb === undefined ? undefined : formatPercent(previewProb)
|
||||
previewProb === undefined
|
||||
? undefined
|
||||
: isPseudoNumeric
|
||||
? formatNumericProbability(previewProb, contract)
|
||||
: formatPercent(previewProb)
|
||||
|
||||
const textColor = `text-${getColor(contract)}`
|
||||
|
||||
let display: string | undefined
|
||||
|
@ -271,6 +279,9 @@ function QuickOutcomeView(props: {
|
|||
case 'BINARY':
|
||||
display = getBinaryProbPercent(contract)
|
||||
break
|
||||
case 'PSEUDO_NUMERIC':
|
||||
display = formatNumericProbability(getProbability(contract), contract)
|
||||
break
|
||||
case 'NUMERIC':
|
||||
display = formatLargeNumber(getExpectedValue(contract))
|
||||
break
|
||||
|
@ -295,11 +306,15 @@ function QuickOutcomeView(props: {
|
|||
// Return a number from 0 to 1 for this contract
|
||||
// Resolved contracts are set to 1, for coloring purposes (even if NO)
|
||||
function getProb(contract: Contract) {
|
||||
const { outcomeType, resolution } = contract
|
||||
return resolution
|
||||
const { outcomeType, resolution, resolutionProbability } = contract
|
||||
return resolutionProbability
|
||||
? resolutionProbability
|
||||
: resolution
|
||||
? 1
|
||||
: outcomeType === 'BINARY'
|
||||
? getBinaryProb(contract)
|
||||
: outcomeType === 'PSEUDO_NUMERIC'
|
||||
? getProbability(contract)
|
||||
: outcomeType === 'FREE_RESPONSE'
|
||||
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
||||
: outcomeType === 'NUMERIC'
|
||||
|
@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) {
|
|||
export function getColor(contract: Contract) {
|
||||
// TODO: Try injecting a gradient here
|
||||
// return 'primary'
|
||||
const { resolution } = contract
|
||||
const { resolution, outcomeType } = contract
|
||||
|
||||
if (resolution) {
|
||||
return (
|
||||
OUTCOME_TO_COLOR[resolution as resolution] ??
|
||||
|
@ -325,6 +341,8 @@ export function getColor(contract: Contract) {
|
|||
)
|
||||
}
|
||||
|
||||
if (outcomeType === 'PSEUDO_NUMERIC') return 'blue-400'
|
||||
|
||||
if ((contract.closeTime ?? Infinity) < Date.now()) {
|
||||
return 'gray-400'
|
||||
}
|
||||
|
|
54
web/components/copy-contract-button.tsx
Normal file
54
web/components/copy-contract-button.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Contract } from 'common/contract'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export function DuplicateContractButton(props: {
|
||||
contract: Contract
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, className } = props
|
||||
|
||||
return (
|
||||
<a
|
||||
className={clsx('btn btn-xs flex-nowrap normal-case', className)}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #a78bfa',
|
||||
// violet-400
|
||||
color: '#a78bfa',
|
||||
}}
|
||||
href={duplicateContractHref(contract)}
|
||||
onClick={trackCallback('duplicate market')}
|
||||
target="_blank"
|
||||
>
|
||||
<DuplicateIcon className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
<div>Duplicate</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// Pass along the Uri to create a new contract
|
||||
function duplicateContractHref(contract: Contract) {
|
||||
const params = {
|
||||
q: contract.question,
|
||||
closeTime: contract.closeTime || 0,
|
||||
description: contract.description,
|
||||
outcomeType: contract.outcomeType,
|
||||
} as Record<string, any>
|
||||
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
params.min = contract.min
|
||||
params.max = contract.max
|
||||
params.isLogScale = contract.isLogScale
|
||||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||
}
|
||||
|
||||
return (
|
||||
`/create?` +
|
||||
Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
)
|
||||
}
|
|
@ -7,13 +7,14 @@ import { Row } from 'web/components/layout/row'
|
|||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import clsx from 'clsx'
|
||||
import { UsersIcon } from '@heroicons/react/solid'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import React, { Fragment } from 'react'
|
||||
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
|
||||
import { JoinSpans } from 'web/components/join-spans'
|
||||
import { UserLink } from '../user-page'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
|
||||
export function FeedBet(props: {
|
||||
contract: Contract
|
||||
|
@ -75,6 +76,8 @@ export function BetStatusText(props: {
|
|||
hideOutcome?: boolean
|
||||
}) {
|
||||
const { bet, contract, bettor, isSelf, hideOutcome } = props
|
||||
const { outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
const { amount, outcome, createdTime } = bet
|
||||
|
||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||
|
@ -97,7 +100,10 @@ export function BetStatusText(props: {
|
|||
value={(bet as any).value}
|
||||
contract={contract}
|
||||
truncate="short"
|
||||
/>
|
||||
/>{' '}
|
||||
{isPseudoNumeric
|
||||
? ' than ' + formatNumericProbability(bet.probAfter, contract)
|
||||
: ' at ' + formatPercent(bet.probAfter)}
|
||||
</>
|
||||
)}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserIcon } from '@heroicons/react/outline'
|
||||
import { UserIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { User } from 'common/user'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
|
@ -6,13 +6,24 @@ import clsx from 'clsx'
|
|||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
|
||||
export function FilterSelectUsers(props: {
|
||||
setSelectedUsers: (users: User[]) => void
|
||||
selectedUsers: User[]
|
||||
ignoreUserIds: string[]
|
||||
showSelectedUsersTitle?: boolean
|
||||
selectedUsersClassName?: string
|
||||
maxUsers?: number
|
||||
}) {
|
||||
const { ignoreUserIds, selectedUsers, setSelectedUsers } = props
|
||||
const {
|
||||
ignoreUserIds,
|
||||
selectedUsers,
|
||||
setSelectedUsers,
|
||||
showSelectedUsersTitle,
|
||||
selectedUsersClassName,
|
||||
maxUsers,
|
||||
} = props
|
||||
const users = useUsers()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
|
||||
|
@ -24,94 +35,124 @@ export function FilterSelectUsers(props: {
|
|||
return (
|
||||
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
||||
!ignoreUserIds.includes(user.id) &&
|
||||
user.name.toLowerCase().includes(query.toLowerCase())
|
||||
(user.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
user.username.toLowerCase().includes(query.toLowerCase()))
|
||||
)
|
||||
})
|
||||
)
|
||||
}, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
|
||||
|
||||
const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mt-1 rounded-md">
|
||||
<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
|
||||
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 "
|
||||
placeholder="Austin Chen"
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
as="div"
|
||||
className={clsx(
|
||||
'relative inline-block w-full overflow-y-scroll text-right',
|
||||
beginQuerying && 'h-36'
|
||||
)}
|
||||
>
|
||||
{({}) => (
|
||||
<Transition
|
||||
show={beginQuerying}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
{shouldShow && (
|
||||
<>
|
||||
<div className="relative mt-1 rounded-md">
|
||||
<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
|
||||
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 "
|
||||
placeholder="Austin Chen"
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
as="div"
|
||||
className={clsx(
|
||||
'relative inline-block w-full overflow-y-scroll text-right',
|
||||
beginQuerying && 'h-36'
|
||||
)}
|
||||
>
|
||||
<Menu.Items
|
||||
static={true}
|
||||
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div className="py-1">
|
||||
{filteredUsers.map((user: User) => (
|
||||
<Menu.Item key={user.id}>
|
||||
{({ active }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700',
|
||||
'group flex items-center px-4 py-2 text-sm'
|
||||
{({}) => (
|
||||
<Transition
|
||||
show={beginQuerying}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static={true}
|
||||
className="absolute right-0 mt-2 w-full origin-top-right cursor-pointer divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div className="py-1">
|
||||
{filteredUsers.map((user: User) => (
|
||||
<Menu.Item key={user.id}>
|
||||
{({ active }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700',
|
||||
'group flex items-center px-4 py-2 text-sm'
|
||||
)}
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedUsers([...selectedUsers, user])
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'xs'}
|
||||
className={'mr-2'}
|
||||
/>
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedUsers([...selectedUsers, user])
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'xs'}
|
||||
className={'mr-2'}
|
||||
/>
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
)}
|
||||
</Menu>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
{selectedUsers.length > 0 && (
|
||||
<>
|
||||
<div className={'mb-2'}>Added members:</div>
|
||||
<Row className="mt-0 grid grid-cols-6 gap-2">
|
||||
<div className={'mb-2'}>
|
||||
{showSelectedUsersTitle && 'Added members:'}
|
||||
</div>
|
||||
<Row
|
||||
className={clsx(
|
||||
'mt-0 grid grid-cols-6 gap-2',
|
||||
selectedUsersClassName
|
||||
)}
|
||||
>
|
||||
{selectedUsers.map((user: User) => (
|
||||
<div key={user.id} className="col-span-2 flex items-center">
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'sm'}
|
||||
<div
|
||||
key={user.id}
|
||||
className="col-span-2 flex flex-row items-center justify-between"
|
||||
>
|
||||
<Row className={'items-center'}>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={'sm'}
|
||||
/>
|
||||
<UserLink
|
||||
username={user.username}
|
||||
className="ml-2"
|
||||
name={user.name}
|
||||
/>
|
||||
</Row>
|
||||
<XIcon
|
||||
onClick={() =>
|
||||
setSelectedUsers([
|
||||
...selectedUsers.filter((u) => u.id != user.id),
|
||||
])
|
||||
}
|
||||
className=" h-5 w-5 cursor-pointer text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-2">{user.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { User } from 'common/user'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||
const { group, className } = props
|
||||
|
@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
await updateGroup(group, {
|
||||
name,
|
||||
about,
|
||||
memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)],
|
||||
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
|
||||
})
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
@ -46,7 +47,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
<div className={clsx('flex p-1', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'btn-ghost cursor-pointer whitespace-nowrap rounded-full text-sm text-white'
|
||||
'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700'
|
||||
)}
|
||||
onClick={() => updateOpen(!open)}
|
||||
>
|
||||
|
|
|
@ -91,6 +91,9 @@ export function GroupChat(props: {
|
|||
setReplyToUsername('')
|
||||
inputRef?.focus()
|
||||
}
|
||||
function focusInput() {
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className={'flex-1'}>
|
||||
|
@ -117,7 +120,13 @@ export function GroupChat(props: {
|
|||
))}
|
||||
{messages.length === 0 && (
|
||||
<div className="p-2 text-gray-500">
|
||||
No messages yet. 🦗... Why not say something?
|
||||
No messages yet. Why not{' '}
|
||||
<button
|
||||
className={'cursor-pointer font-bold text-gray-700'}
|
||||
onClick={() => focusInput()}
|
||||
>
|
||||
add one?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
|
|
|
@ -22,7 +22,7 @@ export function GroupSelector(props: {
|
|||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const memberGroups = useMemberGroups(creator)
|
||||
const memberGroups = useMemberGroups(creator?.id)
|
||||
const filteredGroups = memberGroups
|
||||
? query === ''
|
||||
? memberGroups
|
||||
|
|
144
web/components/groups/groups-button.tsx
Normal file
144
web/components/groups/groups-button.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { TextButton } from 'web/components/text-button'
|
||||
import { Group } from 'common/group'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { addUserToGroup, leaveGroup } from 'web/lib/firebase/groups'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { GroupLink } from 'web/pages/groups'
|
||||
|
||||
export function GroupsButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const groups = useMemberGroups(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<span className="font-semibold">{groups?.length ?? ''}</span> Groups
|
||||
</TextButton>
|
||||
|
||||
<GroupsDialog
|
||||
user={user}
|
||||
groups={groups ?? []}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsDialog(props: {
|
||||
user: User
|
||||
groups: Group[]
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
}) {
|
||||
const { user, groups, isOpen, setIsOpen } = props
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
<Col className="rounded bg-white p-6">
|
||||
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||
<GroupsList groups={groups} />
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsList(props: { groups: Group[] }) {
|
||||
const { groups } = props
|
||||
return (
|
||||
<Col className="gap-2">
|
||||
{groups.length === 0 && (
|
||||
<div className="text-gray-500">No groups yet...</div>
|
||||
)}
|
||||
{groups
|
||||
.sort((group1, group2) => group2.createdTime - group1.createdTime)
|
||||
.map((group) => (
|
||||
<GroupItem key={group.id} group={group} />
|
||||
))}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupItem(props: { group: Group; className?: string }) {
|
||||
const { group, className } = props
|
||||
return (
|
||||
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||
<Row className="line-clamp-1 items-center gap-2">
|
||||
<GroupLink group={group} />
|
||||
</Row>
|
||||
<JoinOrLeaveGroupButton group={group} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function JoinOrLeaveGroupButton(props: {
|
||||
group: Group
|
||||
small?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { group, small, className } = props
|
||||
const currentUser = useUser()
|
||||
const isFollowing = currentUser
|
||||
? group.memberIds.includes(currentUser.id)
|
||||
: false
|
||||
const onJoinGroup = () => {
|
||||
if (!currentUser) return
|
||||
addUserToGroup(group, currentUser.id)
|
||||
}
|
||||
const onLeaveGroup = () => {
|
||||
if (!currentUser) return
|
||||
leaveGroup(group, currentUser.id)
|
||||
}
|
||||
|
||||
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 (!currentUser || isFollowing === undefined) {
|
||||
if (!group.anyoneCanJoin)
|
||||
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||
return (
|
||||
<button
|
||||
onClick={firebaseLogin}
|
||||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
>
|
||||
Login to Join
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFollowing) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-outline btn-sm',
|
||||
small && smallStyle,
|
||||
className
|
||||
)}
|
||||
onClick={withTracking(onLeaveGroup, 'leave group')}
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (!group.anyoneCanJoin)
|
||||
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||
return (
|
||||
<button
|
||||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
onClick={withTracking(onJoinGroup, 'join group')}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
import { Fragment, ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// From https://tailwindui.com/components/application-ui/overlays/modals
|
||||
export function Modal(props: {
|
||||
children: ReactNode
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { children, open, setOpen } = props
|
||||
const { children, open, setOpen, className } = props
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
|
@ -45,7 +47,12 @@ export function Modal(props: {
|
|||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle">
|
||||
<div
|
||||
className={clsx(
|
||||
'inline-block transform overflow-hidden text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-md sm:p-6 sm:align-middle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Transition.Child>
|
||||
|
|
|
@ -14,16 +14,16 @@ type Tab = {
|
|||
export function Tabs(props: {
|
||||
tabs: Tab[]
|
||||
defaultIndex?: number
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
onClick?: (tabTitle: string, index: number) => void
|
||||
}) {
|
||||
const { tabs, defaultIndex, className, onClick } = props
|
||||
const { tabs, defaultIndex, labelClassName, onClick } = props
|
||||
const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
|
||||
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-gray-200">
|
||||
<>
|
||||
<div className="mb-4 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab, i) => (
|
||||
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
|
||||
|
@ -42,7 +42,7 @@ export function Tabs(props: {
|
|||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
||||
'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium',
|
||||
className
|
||||
labelClassName
|
||||
)}
|
||||
aria-current={activeIndex === i ? 'page' : undefined}
|
||||
>
|
||||
|
@ -56,7 +56,7 @@ export function Tabs(props: {
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">{activeTab?.content}</div>
|
||||
</div>
|
||||
{activeTab?.content}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import Link from 'next/link'
|
|||
import {
|
||||
HomeIcon,
|
||||
MenuAlt3Icon,
|
||||
PresentationChartLineIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
|
@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon'
|
|||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
function getNavigation(username: string) {
|
||||
function getNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{
|
||||
name: 'Portfolio',
|
||||
href: `/${username}?tab=bets`,
|
||||
icon: PresentationChartLineIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
href: `/notifications`,
|
||||
|
@ -55,38 +49,40 @@ export function BottomNavBar() {
|
|||
}
|
||||
|
||||
const navigationOptions =
|
||||
user === null
|
||||
? signedOutNavigation
|
||||
: getNavigation(user?.username || 'error')
|
||||
user === null ? signedOutNavigation : getNavigation()
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
||||
{navigationOptions.map((item) => (
|
||||
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
|
||||
))}
|
||||
|
||||
{user && (
|
||||
<NavBarItem
|
||||
key={'profile'}
|
||||
currentPage={currentPage}
|
||||
item={{
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=bets`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
size="xs"
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
noLink
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
{user === null ? (
|
||||
<>
|
||||
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||
More
|
||||
</>
|
||||
) : user ? (
|
||||
<>
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
size="xs"
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
noLink
|
||||
/>
|
||||
{formatMoney(user.balance)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||
More
|
||||
</div>
|
||||
|
||||
<MobileSidebar
|
||||
|
@ -99,6 +95,7 @@ export function BottomNavBar() {
|
|||
|
||||
function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||
const { item, currentPage } = props
|
||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||
|
||||
return (
|
||||
<Link href={item.href}>
|
||||
|
@ -107,9 +104,9 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
|||
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
||||
currentPage === item.href && 'bg-gray-200 text-indigo-700'
|
||||
)}
|
||||
onClick={trackCallback('navbar: ' + item.name)}
|
||||
onClick={track}
|
||||
>
|
||||
<item.icon className="my-1 mx-auto h-6 w-6" />
|
||||
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
|
||||
{item.name}
|
||||
</a>
|
||||
</Link>
|
||||
|
|
|
@ -6,8 +6,8 @@ import {
|
|||
CashIcon,
|
||||
HeartIcon,
|
||||
UserGroupIcon,
|
||||
ChevronDownIcon,
|
||||
TrendingUpIcon,
|
||||
ChatIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
@ -18,13 +18,16 @@ import { ManifoldLogo } from './manifold-logo'
|
|||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group } from 'common/group'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { usePreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
|
@ -82,8 +85,20 @@ const signedOutNavigation = [
|
|||
]
|
||||
|
||||
const signedOutMobileNavigation = [
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
||||
]
|
||||
|
||||
const signedInMobileNavigation = [
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
|
@ -91,17 +106,12 @@ const signedOutMobileNavigation = [
|
|||
},
|
||||
]
|
||||
|
||||
const signedInMobileNavigation = [
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
...signedOutMobileNavigation,
|
||||
]
|
||||
|
||||
function getMoreMobileNav() {
|
||||
return [
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Statistics', href: '/stats' },
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
|
@ -112,8 +122,9 @@ function getMoreMobileNav() {
|
|||
|
||||
export type Item = {
|
||||
name: string
|
||||
trackingEventName?: string
|
||||
href: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||
|
@ -130,15 +141,17 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
|||
)}
|
||||
aria-current={item.href == currentPage ? 'page' : undefined}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.href == currentPage
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-400 group-hover:text-gray-500',
|
||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className={clsx(
|
||||
item.href == currentPage
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-400 group-hover:text-gray-500',
|
||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
|
@ -167,14 +180,6 @@ function MoreButton() {
|
|||
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
|
||||
}
|
||||
|
||||
function GroupsButton() {
|
||||
return (
|
||||
<SidebarButton icon={UserGroupIcon} text={'Groups'}>
|
||||
<ChevronDownIcon className=" mt-0.5 ml-2 h-5 w-5" aria-hidden="true" />
|
||||
</SidebarButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar(props: { className?: string }) {
|
||||
const { className } = props
|
||||
const router = useRouter()
|
||||
|
@ -185,7 +190,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
const mobileNavigationOptions = !user
|
||||
? signedOutMobileNavigation
|
||||
: signedInMobileNavigation
|
||||
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
|
||||
const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
|
||||
name: group.name,
|
||||
href: groupPath(group.slug),
|
||||
}))
|
||||
|
@ -193,31 +198,20 @@ export default function Sidebar(props: { className?: string }) {
|
|||
return (
|
||||
<nav aria-label="Sidebar" className={className}>
|
||||
<ManifoldLogo className="pb-6" twoLine />
|
||||
|
||||
<CreateQuestionButton user={user} />
|
||||
<Spacer h={4} />
|
||||
{user && (
|
||||
<div className="mb-2" style={{ minHeight: 80 }}>
|
||||
<div className="w-full" style={{ minHeight: 80 }}>
|
||||
<ProfileSummary user={user} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<div className="space-y-1 lg:hidden">
|
||||
{user && (
|
||||
<MenuButton
|
||||
buttonContent={<GroupsButton />}
|
||||
menuItems={[{ name: 'Explore', href: '/groups' }, ...memberItems]}
|
||||
className={'relative z-50 flex-shrink-0'}
|
||||
/>
|
||||
)}
|
||||
{mobileNavigationOptions.map((item) => (
|
||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||
))}
|
||||
{!user && (
|
||||
<SidebarItem
|
||||
key={'Groups'}
|
||||
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<MenuButton
|
||||
|
@ -225,41 +219,83 @@ export default function Sidebar(props: { className?: string }) {
|
|||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
<div className="hidden space-y-1 lg:block">
|
||||
{navigationOptions.map((item) =>
|
||||
item.name === 'Notifications' ? (
|
||||
<div key={item.href}>
|
||||
<SidebarItem item={item} currentPage={currentPage} />
|
||||
{user && (
|
||||
<MenuButton
|
||||
key={'groupsdropdown'}
|
||||
buttonContent={<GroupsButton />}
|
||||
menuItems={[
|
||||
{ name: 'Explore', href: '/groups' },
|
||||
...memberItems,
|
||||
]}
|
||||
className={'relative z-50 flex-shrink-0'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SidebarItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{navigationOptions.map((item) => (
|
||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||
))}
|
||||
<MenuButton
|
||||
menuItems={getMoreNavigation(user)}
|
||||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
|
||||
{/* Spacer if there are any groups */}
|
||||
{memberItems.length > 0 && (
|
||||
<div className="py-3">
|
||||
<div className="h-[1px] bg-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
<CreateQuestionButton user={user} />
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsList(props: {
|
||||
currentPage: string
|
||||
memberItems: Item[]
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { currentPage, memberItems, user } = props
|
||||
const preferredNotifications = usePreferredNotifications(user?.id, {
|
||||
unseenOnly: true,
|
||||
customHref: '/group/',
|
||||
})
|
||||
|
||||
// Set notification as seen if our current page is equal to the isSeenOnHref property
|
||||
useEffect(() => {
|
||||
preferredNotifications.forEach((notification) => {
|
||||
if (notification.isSeenOnHref === currentPage) {
|
||||
setNotificationsAsSeen([notification])
|
||||
}
|
||||
})
|
||||
}, [currentPage, preferredNotifications])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarItem
|
||||
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{memberItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||
preferredNotifications.some(
|
||||
(n) => !n.isSeen && n.isSeenOnHref === item.href
|
||||
) && 'font-bold'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline'
|
|||
import clsx from 'clsx'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useRouter } from 'next/router'
|
||||
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
import { requestBonuses } from 'web/lib/firebase/api-call'
|
||||
|
||||
export default function NotificationsIcon(props: { className?: string }) {
|
||||
const user = useUser()
|
||||
const notifications = usePreferredGroupedNotifications(user?.id, {
|
||||
const privateUser = usePrivateUser(user?.id)
|
||||
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
|
||||
unseenOnly: true,
|
||||
})
|
||||
const [seen, setSeen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!privateUser) return
|
||||
|
||||
if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000)
|
||||
requestBonuses({}).catch((error) => {
|
||||
console.log("couldn't get bonuses:", error.message)
|
||||
})
|
||||
}, [privateUser])
|
||||
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
if (router.pathname.endsWith('notifications')) return setSeen(true)
|
||||
|
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
|
|||
<div className={'relative'}>
|
||||
{!seen && notifications && notifications.length > 0 && (
|
||||
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
|
||||
{notifications.length}
|
||||
{notifications.length > NOTIFICATIONS_PER_PAGE
|
||||
? `${NOTIFICATIONS_PER_PAGE}+`
|
||||
: notifications.length}
|
||||
</div>
|
||||
)}
|
||||
<BellIcon className={clsx(props.className)} />
|
||||
|
|
|
@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users'
|
|||
import { NumberCancelSelector } from './yes-no-selector'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
||||
import { NumericContract } from 'common/contract'
|
||||
import { NumericContract, PseudoNumericContract } from 'common/contract'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||
import { BucketInput } from './bucket-input'
|
||||
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||
|
||||
export function NumericResolutionPanel(props: {
|
||||
creator: User
|
||||
contract: NumericContract
|
||||
contract: NumericContract | PseudoNumericContract
|
||||
className?: string
|
||||
}) {
|
||||
useEffect(() => {
|
||||
|
@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: {
|
|||
}, [])
|
||||
|
||||
const { contract, className } = props
|
||||
const { min, max, outcomeType } = contract
|
||||
|
||||
const [outcomeMode, setOutcomeMode] = useState<
|
||||
'NUMBER' | 'CANCEL' | undefined
|
||||
|
@ -32,22 +34,44 @@ export function NumericResolutionPanel(props: {
|
|||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
const resolve = async () => {
|
||||
const finalOutcome = outcomeMode === 'NUMBER' ? outcome : 'CANCEL'
|
||||
const finalOutcome =
|
||||
outcomeMode === 'CANCEL'
|
||||
? 'CANCEL'
|
||||
: outcomeType === 'PSEUDO_NUMERIC'
|
||||
? 'MKT'
|
||||
: 'NUMBER'
|
||||
if (outcomeMode === undefined || finalOutcome === undefined) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await resolveMarket({
|
||||
outcome: finalOutcome,
|
||||
value,
|
||||
contractId: contract.id,
|
||||
}).then((r) => r.data)
|
||||
const boundedValue = Math.max(Math.min(max, value ?? 0), min)
|
||||
|
||||
console.log('resolved', outcome, 'result:', result)
|
||||
const probabilityInt =
|
||||
100 *
|
||||
getPseudoProbability(
|
||||
boundedValue,
|
||||
min,
|
||||
max,
|
||||
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
|
||||
)
|
||||
|
||||
if (result?.status !== 'success') {
|
||||
setError(result?.message || 'Error resolving market')
|
||||
try {
|
||||
const result = await resolveMarket({
|
||||
outcome: finalOutcome,
|
||||
value,
|
||||
probabilityInt,
|
||||
contractId: contract.id,
|
||||
})
|
||||
console.log('resolved', outcome, 'result:', result)
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
setError(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setError('Error resolving market')
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
|
@ -72,7 +96,7 @@ export function NumericResolutionPanel(props: {
|
|||
|
||||
{outcomeMode === 'NUMBER' && (
|
||||
<BucketInput
|
||||
contract={contract}
|
||||
contract={contract as any}
|
||||
isSubmitting={isSubmitting}
|
||||
onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
|
||||
/>
|
||||
|
|
|
@ -19,11 +19,15 @@ export function OutcomeLabel(props: {
|
|||
value?: number
|
||||
}) {
|
||||
const { outcome, contract, truncate, value } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
if (contract.outcomeType === 'BINARY')
|
||||
if (outcomeType === 'PSEUDO_NUMERIC')
|
||||
return <PseudoNumericOutcomeLabel outcome={outcome as any} />
|
||||
|
||||
if (outcomeType === 'BINARY')
|
||||
return <BinaryOutcomeLabel outcome={outcome as any} />
|
||||
|
||||
if (contract.outcomeType === 'NUMERIC')
|
||||
if (outcomeType === 'NUMERIC')
|
||||
return (
|
||||
<span className="text-blue-500">
|
||||
{value ?? getValueFromBucket(outcome, contract)}
|
||||
|
@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) {
|
|||
return <CancelLabel />
|
||||
}
|
||||
|
||||
export function PseudoNumericOutcomeLabel(props: { outcome: resolution }) {
|
||||
const { outcome } = props
|
||||
|
||||
if (outcome === 'YES') return <HigherLabel />
|
||||
if (outcome === 'NO') return <LowerLabel />
|
||||
if (outcome === 'MKT') return <ProbLabel />
|
||||
return <CancelLabel />
|
||||
}
|
||||
|
||||
export function BinaryContractOutcomeLabel(props: {
|
||||
contract: BinaryContract
|
||||
resolution: resolution
|
||||
|
@ -74,7 +87,7 @@ export function FreeResponseOutcomeLabel(props: {
|
|||
if (resolution === 'CANCEL') return <CancelLabel />
|
||||
if (resolution === 'MKT') return <MultiLabel />
|
||||
|
||||
const chosen = contract.answers.find((answer) => answer.id === resolution)
|
||||
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
||||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||
return (
|
||||
<FreeResponseAnswerToolTip text={chosen.text}>
|
||||
|
@ -98,6 +111,14 @@ export function YesLabel() {
|
|||
return <span className="text-primary">YES</span>
|
||||
}
|
||||
|
||||
export function HigherLabel() {
|
||||
return <span className="text-primary">HIGHER</span>
|
||||
}
|
||||
|
||||
export function LowerLabel() {
|
||||
return <span className="text-red-400">LOWER</span>
|
||||
}
|
||||
|
||||
export function NoLabel() {
|
||||
return <span className="text-red-400">NO</span>
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
|||
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: points[0].x,
|
||||
min: points[0]?.x,
|
||||
max: endDate,
|
||||
}}
|
||||
yScale={{
|
||||
|
@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
|||
enableGridY={true}
|
||||
enableSlices="x"
|
||||
animate={false}
|
||||
yFormat={(value) => formatMoney(+value)}
|
||||
></ResponsiveLine>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -13,7 +13,7 @@ export const PortfolioValueSection = memo(
|
|||
}) {
|
||||
const { portfolioHistory } = props
|
||||
const lastPortfolioMetrics = last(portfolioHistory)
|
||||
const [portfolioPeriod] = useState<Period>('allTime')
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||
|
||||
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
||||
return <div> No portfolio history data yet </div>
|
||||
|
@ -33,9 +33,16 @@ export const PortfolioValueSection = memo(
|
|||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{
|
||||
//TODO: enable day/week/monthly as data becomes available
|
||||
}
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="daily">Daily</option>
|
||||
</select>
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={portfolioHistory}
|
||||
|
|
178
web/components/referrals-button.tsx
Normal file
178
web/components/referrals-button.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { prefetchUsers, useUserById } from 'web/hooks/use-user'
|
||||
import { Col } from './layout/col'
|
||||
import { Modal } from './layout/modal'
|
||||
import { Tabs } from './layout/tabs'
|
||||
import { TextButton } from './text-button'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
import { useReferrals } from 'web/hooks/use-referrals'
|
||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { getUser, updateUser } from 'web/lib/firebase/users'
|
||||
|
||||
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const referralIds = useReferrals(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextButton onClick={() => setIsOpen(true)}>
|
||||
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
|
||||
Referrals
|
||||
</TextButton>
|
||||
|
||||
<ReferralsDialog
|
||||
user={user}
|
||||
referralIds={referralIds ?? []}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ReferralsDialog(props: {
|
||||
user: User
|
||||
referralIds: string[]
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
currentUser?: User
|
||||
}) {
|
||||
const { user, referralIds, isOpen, setIsOpen, currentUser } = props
|
||||
const [referredBy, setReferredBy] = useState<User[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
const [referredByUser, setReferredByUser] = useState<User | null>()
|
||||
useEffect(() => {
|
||||
if (isOpen && !referredByUser && user?.referredByUserId) {
|
||||
getUser(user.referredByUserId).then((user) => {
|
||||
setReferredByUser(user)
|
||||
})
|
||||
}
|
||||
}, [isOpen, referredByUser, user.referredByUserId])
|
||||
|
||||
useEffect(() => {
|
||||
prefetchUsers(referralIds)
|
||||
}, [referralIds])
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
<Col className="rounded bg-white p-6">
|
||||
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
title: 'Referrals',
|
||||
content: <ReferralsList userIds={referralIds} />,
|
||||
},
|
||||
{
|
||||
title: 'Referred by',
|
||||
content: (
|
||||
<>
|
||||
{user.id === currentUser?.id && !referredByUser ? (
|
||||
<>
|
||||
<FilterSelectUsers
|
||||
setSelectedUsers={setReferredBy}
|
||||
selectedUsers={referredBy}
|
||||
ignoreUserIds={[currentUser.id]}
|
||||
showSelectedUsersTitle={false}
|
||||
selectedUsersClassName={'grid-cols-2 '}
|
||||
maxUsers={1}
|
||||
/>
|
||||
<Row className={'mt-0 justify-end'}>
|
||||
<button
|
||||
className={
|
||||
referredBy.length === 0
|
||||
? 'hidden'
|
||||
: 'btn btn-primary btn-md my-2 w-24 normal-case'
|
||||
}
|
||||
disabled={referredBy.length === 0 || isSubmitting}
|
||||
onClick={() => {
|
||||
setIsSubmitting(true)
|
||||
updateUser(currentUser.id, {
|
||||
referredByUserId: referredBy[0].id,
|
||||
})
|
||||
.then(async () => {
|
||||
setErrorText('')
|
||||
setIsSubmitting(false)
|
||||
setReferredBy([])
|
||||
setIsOpen(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsSubmitting(false)
|
||||
setErrorText(error.message)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Row>
|
||||
<span className={'text-warning'}>
|
||||
{referredBy.length > 0 &&
|
||||
'Careful: you can only set who referred you once!'}
|
||||
</span>
|
||||
<span className={'text-error'}>{errorText}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="justify-center text-gray-700">
|
||||
{referredByUser ? (
|
||||
<Row className={'items-center gap-2 p-2'}>
|
||||
<Avatar
|
||||
username={referredByUser.username}
|
||||
avatarUrl={referredByUser.avatarUrl}
|
||||
/>
|
||||
<UserLink
|
||||
username={referredByUser.username}
|
||||
name={referredByUser.name}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<span className={'text-gray-500'}>No one...</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ReferralsList(props: { userIds: string[] }) {
|
||||
const { userIds } = props
|
||||
|
||||
return (
|
||||
<Col className="gap-2">
|
||||
{userIds.length === 0 && (
|
||||
<div className="text-gray-500">No users yet...</div>
|
||||
)}
|
||||
{userIds.map((userId) => (
|
||||
<UserReferralItem key={userId} userId={userId} />
|
||||
))}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function UserReferralItem(props: { userId: string; className?: string }) {
|
||||
const { userId, className } = props
|
||||
const user = useUserById(userId)
|
||||
|
||||
return (
|
||||
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} />
|
||||
{user && <UserLink name={user.name} username={user.username} />}
|
||||
</Row>
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users'
|
|||
import { YesNoCancelSelector } from './yes-no-selector'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||
import { ProbabilitySelector } from './probability-selector'
|
||||
import { DPM_CREATOR_FEE } from 'common/fees'
|
||||
import { getProbability } from 'common/calculate'
|
||||
|
@ -42,17 +42,22 @@ export function ResolutionPanel(props: {
|
|||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const result = await resolveMarket({
|
||||
outcome,
|
||||
contractId: contract.id,
|
||||
probabilityInt: prob,
|
||||
}).then((r) => r.data)
|
||||
|
||||
console.log('resolved', outcome, 'result:', result)
|
||||
|
||||
if (result?.status !== 'success') {
|
||||
setError(result?.message || 'Error resolving market')
|
||||
try {
|
||||
const result = await resolveMarket({
|
||||
outcome,
|
||||
contractId: contract.id,
|
||||
probabilityInt: prob,
|
||||
})
|
||||
console.log('resolved', outcome, 'result:', result)
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
setError(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setError('Error resolving market')
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { BinaryContract } from 'common/contract'
|
||||
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
import { useState } from 'react'
|
||||
|
@ -7,7 +7,7 @@ import clsx from 'clsx'
|
|||
import { SellSharesModal } from './sell-modal'
|
||||
|
||||
export function SellButton(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
user: User | null | undefined
|
||||
sharesOutcome: 'YES' | 'NO' | undefined
|
||||
shares: number
|
||||
|
@ -16,7 +16,8 @@ export function SellButton(props: {
|
|||
const { contract, user, sharesOutcome, shares, panelClassName } = props
|
||||
const userBets = useUserContractBets(user?.id, contract.id)
|
||||
const [showSellModal, setShowSellModal] = useState(false)
|
||||
const { mechanism } = contract
|
||||
const { mechanism, outcomeType } = contract
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
if (sharesOutcome && user && mechanism === 'cpmm-1') {
|
||||
return (
|
||||
|
@ -32,7 +33,10 @@ export function SellButton(props: {
|
|||
)}
|
||||
onClick={() => setShowSellModal(true)}
|
||||
>
|
||||
{'Sell ' + sharesOutcome}
|
||||
Sell{' '}
|
||||
{isPseudoNumeric
|
||||
? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome]
|
||||
: sharesOutcome}
|
||||
</button>
|
||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||
{'(' + Math.floor(shares) + ' shares)'}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { Bet } from 'common/bet'
|
||||
import { User } from 'common/user'
|
||||
import { Modal } from './layout/modal'
|
||||
|
@ -11,7 +11,7 @@ import clsx from 'clsx'
|
|||
|
||||
export function SellSharesModal(props: {
|
||||
className?: string
|
||||
contract: CPMMBinaryContract
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
userBets: Bet[]
|
||||
shares: number
|
||||
sharesOutcome: 'YES' | 'NO'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { BinaryContract } from 'common/contract'
|
||||
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { useState } from 'react'
|
||||
import { Col } from './layout/col'
|
||||
|
@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares'
|
|||
import { SellSharesModal } from './sell-modal'
|
||||
|
||||
export function SellRow(props: {
|
||||
contract: BinaryContract
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
user: User | null | undefined
|
||||
className?: string
|
||||
}) {
|
||||
|
|
70
web/components/share-icon-button.tsx
Normal file
70
web/components/share-icon-button.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import React, { useState } from 'react'
|
||||
import { ShareIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
|
||||
import { Group } from 'common/group'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
|
||||
function copyContractWithReferral(contract: Contract, username?: string) {
|
||||
const postFix =
|
||||
username && contract.creatorUsername !== username
|
||||
? '?referrer=' + username
|
||||
: ''
|
||||
copyToClipboard(
|
||||
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
|
||||
)
|
||||
}
|
||||
|
||||
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
|
||||
function copyGroupWithReferral(group: Group, username?: string) {
|
||||
const postFix = username ? '?referrer=' + username : ''
|
||||
copyToClipboard(
|
||||
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
|
||||
)
|
||||
}
|
||||
|
||||
export function ShareIconButton(props: {
|
||||
contract?: Contract
|
||||
group?: Group
|
||||
buttonClassName?: string
|
||||
toastClassName?: string
|
||||
username?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
buttonClassName,
|
||||
toastClassName,
|
||||
username,
|
||||
group,
|
||||
children,
|
||||
} = props
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex-shrink-0">
|
||||
<button
|
||||
className={clsx(contractDetailsButtonClassName, buttonClassName)}
|
||||
onClick={() => {
|
||||
if (contract) copyContractWithReferral(contract, username)
|
||||
if (group) copyGroupWithReferral(group, username)
|
||||
track('copy share link')
|
||||
setShowToast(true)
|
||||
setTimeout(() => setShowToast(false), 2000)
|
||||
}}
|
||||
>
|
||||
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
|
||||
{children}
|
||||
</button>
|
||||
|
||||
{showToast && <ToastClipboard className={toastClassName} />}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -36,6 +36,9 @@ import { FollowersButton, FollowingButton } from './following-button'
|
|||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { FollowButton } from './follow-button'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { ReferralsButton } from 'web/components/referrals-button'
|
||||
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||
import { PortfolioValueSection } from './portfolio/portfolio-value-section'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -73,7 +76,9 @@ export function UserPage(props: {
|
|||
'loading'
|
||||
)
|
||||
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
||||
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
|
||||
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
||||
PortfolioMetrics[]
|
||||
>([])
|
||||
const [commentsByContract, setCommentsByContract] = useState<
|
||||
Map<Contract, Comment[]> | 'loading'
|
||||
>('loading')
|
||||
|
@ -154,7 +159,7 @@ export function UserPage(props: {
|
|||
<Avatar
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
size={20}
|
||||
size={24}
|
||||
className="bg-white ring-4 ring-white"
|
||||
/>
|
||||
</div>
|
||||
|
@ -193,10 +198,12 @@ export function UserPage(props: {
|
|||
</>
|
||||
)}
|
||||
|
||||
<Col className="gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<Row className="gap-4">
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
<ReferralsButton user={user} currentUser={currentUser} />
|
||||
<GroupsButton user={user} />
|
||||
</Row>
|
||||
|
||||
{user.website && (
|
||||
|
@ -254,7 +261,7 @@ export function UserPage(props: {
|
|||
|
||||
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
||||
<Tabs
|
||||
className={'pb-2 pt-1 '}
|
||||
labelClassName={'pb-2 pt-1 '}
|
||||
defaultIndex={
|
||||
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
|
||||
}
|
||||
|
@ -293,9 +300,9 @@ export function UserPage(props: {
|
|||
title: 'Bets',
|
||||
content: (
|
||||
<div>
|
||||
{
|
||||
// TODO: add portfolio-value-section here
|
||||
}
|
||||
<PortfolioValueSection
|
||||
portfolioHistory={portfolioHistory}
|
||||
/>
|
||||
<BetsList
|
||||
user={user}
|
||||
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
||||
|
|
|
@ -12,6 +12,7 @@ export function YesNoSelector(props: {
|
|||
btnClassName?: string
|
||||
replaceYesButton?: React.ReactNode
|
||||
replaceNoButton?: React.ReactNode
|
||||
isPseudoNumeric?: boolean
|
||||
}) {
|
||||
const {
|
||||
selected,
|
||||
|
@ -20,6 +21,7 @@ export function YesNoSelector(props: {
|
|||
btnClassName,
|
||||
replaceNoButton,
|
||||
replaceYesButton,
|
||||
isPseudoNumeric,
|
||||
} = props
|
||||
|
||||
const commonClassNames =
|
||||
|
@ -41,7 +43,7 @@ export function YesNoSelector(props: {
|
|||
)}
|
||||
onClick={() => onSelect('YES')}
|
||||
>
|
||||
Bet YES
|
||||
{isPseudoNumeric ? 'HIGHER' : 'Bet YES'}
|
||||
</button>
|
||||
)}
|
||||
{replaceNoButton ? (
|
||||
|
@ -58,7 +60,7 @@ export function YesNoSelector(props: {
|
|||
)}
|
||||
onClick={() => onSelect('NO')}
|
||||
>
|
||||
Bet NO
|
||||
{isPseudoNumeric ? 'LOWER' : 'Bet NO'}
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
|
|
|
@ -2,16 +2,16 @@ import { useEffect } from 'react'
|
|||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||
import {
|
||||
Contract,
|
||||
contractDocRef,
|
||||
contracts,
|
||||
listenForContract,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { useStateCheckEquality } from './use-state-check-equality'
|
||||
import { DocumentData } from 'firebase/firestore'
|
||||
import { doc, DocumentData } from 'firebase/firestore'
|
||||
|
||||
export const useContract = (contractId: string) => {
|
||||
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
||||
['contracts', contractId],
|
||||
contractDocRef(contractId),
|
||||
doc(contracts, contractId),
|
||||
{ subscribe: true, includeMetadataChanges: true }
|
||||
)
|
||||
|
||||
|
|
|
@ -29,11 +29,11 @@ export const useGroups = () => {
|
|||
return groups
|
||||
}
|
||||
|
||||
export const useMemberGroups = (user: User | null | undefined) => {
|
||||
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||
useEffect(() => {
|
||||
if (user) return listenForMemberGroups(user.id, setMemberGroups)
|
||||
}, [user])
|
||||
if (userId) return listenForMemberGroups(userId, setMemberGroups)
|
||||
}, [userId])
|
||||
return memberGroups
|
||||
}
|
||||
|
||||
|
|
|
@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
|
|||
|
||||
export type NotificationGroup = {
|
||||
notifications: Notification[]
|
||||
sourceContractId: string
|
||||
groupedById: string
|
||||
isSeen: boolean
|
||||
timePeriod: string
|
||||
type: 'income' | 'normal'
|
||||
}
|
||||
|
||||
export function usePreferredGroupedNotifications(
|
||||
|
@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
new Date(notification.createdTime).toDateString()
|
||||
)
|
||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||
// Group notifications by contract:
|
||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const bonusNotifications = notificationsGroupedByDay.filter(
|
||||
(notification) => notification.sourceType === 'bonus'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) => notification.sourceType !== 'bonus'
|
||||
)
|
||||
if (bonusNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
notifications: bonusNotifications,
|
||||
groupedById: 'income' + day,
|
||||
isSeen: bonusNotifications[0].isSeen,
|
||||
timePeriod: day,
|
||||
type: 'income',
|
||||
})
|
||||
}
|
||||
// Group notifications by contract, filtering out bonuses:
|
||||
const groupedNotificationsByContractId = groupBy(
|
||||
notificationGroupsByDay[day],
|
||||
normalNotificationsGroupedByDay,
|
||||
(notification) => {
|
||||
return notification.sourceContractId
|
||||
}
|
||||
)
|
||||
notificationGroups = notificationGroups.concat(
|
||||
map(groupedNotificationsByContractId, (notifications, contractId) => {
|
||||
const notificationsForContractId = groupedNotificationsByContractId[
|
||||
contractId
|
||||
].sort((a, b) => {
|
||||
return b.createdTime - a.createdTime
|
||||
})
|
||||
// Create a notification group for each contract within each day
|
||||
const notificationGroup: NotificationGroup = {
|
||||
notifications: groupedNotificationsByContractId[contractId].sort(
|
||||
(a, b) => {
|
||||
return b.createdTime - a.createdTime
|
||||
}
|
||||
),
|
||||
sourceContractId: contractId,
|
||||
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
|
||||
notifications: notificationsForContractId,
|
||||
groupedById: contractId,
|
||||
isSeen: notificationsForContractId[0].isSeen,
|
||||
timePeriod: day,
|
||||
type: 'normal',
|
||||
}
|
||||
return notificationGroup
|
||||
})
|
||||
|
@ -64,11 +83,11 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
return notificationGroups
|
||||
}
|
||||
|
||||
function usePreferredNotifications(
|
||||
export function usePreferredNotifications(
|
||||
userId: string | undefined,
|
||||
options: { unseenOnly: boolean }
|
||||
options: { unseenOnly: boolean; customHref?: string }
|
||||
) {
|
||||
const { unseenOnly } = options
|
||||
const { unseenOnly, customHref } = options
|
||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [userAppropriateNotifications, setUserAppropriateNotifications] =
|
||||
|
@ -93,9 +112,11 @@ function usePreferredNotifications(
|
|||
const notificationsToShow = getAppropriateNotifications(
|
||||
notifications,
|
||||
privateUser.notificationPreferences
|
||||
).filter((n) =>
|
||||
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
|
||||
)
|
||||
setUserAppropriateNotifications(notificationsToShow)
|
||||
}, [privateUser, notifications])
|
||||
}, [privateUser, notifications, customHref])
|
||||
|
||||
return userAppropriateNotifications
|
||||
}
|
||||
|
@ -117,7 +138,7 @@ function getAppropriateNotifications(
|
|||
return notifications.filter(
|
||||
(n) =>
|
||||
n.reason &&
|
||||
// Show all contract notifications
|
||||
// Show all contract notifications and any that aren't in the above list:
|
||||
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
||||
)
|
||||
if (notificationPreferences === 'none') return []
|
||||
|
|
12
web/hooks/use-referrals.ts
Normal file
12
web/hooks/use-referrals.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { listenForReferrals } from 'web/lib/firebase/users'
|
||||
|
||||
export const useReferrals = (userId: string | null | undefined) => {
|
||||
const [referralIds, setReferralIds] = useState<string[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) return listenForReferrals(userId, setReferralIds)
|
||||
}, [userId])
|
||||
|
||||
return referralIds
|
||||
}
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||
import { QueryClient } from 'react-query'
|
||||
|
||||
import { DocumentData } from 'firebase/firestore'
|
||||
import { doc, DocumentData } from 'firebase/firestore'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import {
|
||||
getUser,
|
||||
|
@ -10,7 +10,7 @@ import {
|
|||
listenForPrivateUser,
|
||||
listenForUser,
|
||||
User,
|
||||
userDocRef,
|
||||
users,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { useStateCheckEquality } from './use-state-check-equality'
|
||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||
|
@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => {
|
|||
export const useUserById = (userId: string) => {
|
||||
const result = useFirestoreDocumentData<DocumentData, User>(
|
||||
['users', userId],
|
||||
userDocRef(userId),
|
||||
doc(users, userId),
|
||||
{ subscribe: true, includeMetadataChanges: true }
|
||||
)
|
||||
|
||||
|
|
|
@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => {
|
|||
'Origin',
|
||||
])
|
||||
const hasBody = req.method != 'HEAD' && req.method != 'GET'
|
||||
const opts = { headers, method: req.method, body: hasBody ? req : undefined }
|
||||
const body = req.body ? JSON.stringify(req.body) : req
|
||||
const opts = {
|
||||
headers,
|
||||
method: req.method,
|
||||
body: hasBody ? body : undefined,
|
||||
}
|
||||
return fetch(url, opts)
|
||||
}
|
||||
|
||||
|
|
|
@ -41,14 +41,23 @@ export async function call(url: string, method: string, params: any) {
|
|||
// one less hop
|
||||
|
||||
export function getFunctionUrl(name: string) {
|
||||
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
|
||||
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
|
||||
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
const { projectId, region } = ENV_CONFIG.firebaseConfig
|
||||
return `http://localhost:5001/${projectId}/${region}/${name}`
|
||||
} else {
|
||||
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
|
||||
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
|
||||
}
|
||||
}
|
||||
|
||||
export function createMarket(params: any) {
|
||||
return call(getFunctionUrl('createmarket'), 'POST', params)
|
||||
}
|
||||
|
||||
export function resolveMarket(params: any) {
|
||||
return call(getFunctionUrl('resolvemarket'), 'POST', params)
|
||||
}
|
||||
|
||||
export function placeBet(params: any) {
|
||||
return call(getFunctionUrl('placebet'), 'POST', params)
|
||||
}
|
||||
|
@ -64,3 +73,7 @@ export function sellBet(params: any) {
|
|||
export function createGroup(params: any) {
|
||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
||||
}
|
||||
|
||||
export function requestBonuses(params: any) {
|
||||
return call(getFunctionUrl('getdailybonuses'), 'POST', params)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import dayjs from 'dayjs'
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
setDoc,
|
||||
deleteDoc,
|
||||
|
@ -16,8 +15,7 @@ import {
|
|||
} from 'firebase/firestore'
|
||||
import { sortBy, sum } from 'lodash'
|
||||
|
||||
import { app } from './init'
|
||||
import { getValues, listenForValue, listenForValues } from './utils'
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { BinaryContract, Contract } from 'common/contract'
|
||||
import { getDpmProbability } from 'common/calculate-dpm'
|
||||
import { createRNG, shuffle } from 'common/util/random'
|
||||
|
@ -28,6 +26,9 @@ import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
|
|||
import { Bet } from 'common/bet'
|
||||
import { Comment } from 'common/comment'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export const contracts = coll<Contract>('contracts')
|
||||
|
||||
export type { Contract }
|
||||
|
||||
export function contractPath(contract: Contract) {
|
||||
|
@ -86,83 +87,72 @@ export function tradingAllowed(contract: Contract) {
|
|||
)
|
||||
}
|
||||
|
||||
const db = getFirestore(app)
|
||||
export const contractCollection = collection(db, 'contracts')
|
||||
export const contractDocRef = (contractId: string) =>
|
||||
doc(db, 'contracts', contractId)
|
||||
|
||||
// Push contract to Firestore
|
||||
export async function setContract(contract: Contract) {
|
||||
const docRef = doc(db, 'contracts', contract.id)
|
||||
await setDoc(docRef, contract)
|
||||
await setDoc(doc(contracts, contract.id), contract)
|
||||
}
|
||||
|
||||
export async function updateContract(
|
||||
contractId: string,
|
||||
update: Partial<Contract>
|
||||
) {
|
||||
const docRef = doc(db, 'contracts', contractId)
|
||||
await updateDoc(docRef, update)
|
||||
await updateDoc(doc(contracts, contractId), update)
|
||||
}
|
||||
|
||||
export async function getContractFromId(contractId: string) {
|
||||
const docRef = doc(db, 'contracts', contractId)
|
||||
const result = await getDoc(docRef)
|
||||
|
||||
return result.exists() ? (result.data() as Contract) : undefined
|
||||
const result = await getDoc(doc(contracts, contractId))
|
||||
return result.exists() ? result.data() : undefined
|
||||
}
|
||||
|
||||
export async function getContractFromSlug(slug: string) {
|
||||
const q = query(contractCollection, where('slug', '==', slug))
|
||||
const q = query(contracts, where('slug', '==', slug))
|
||||
const snapshot = await getDocs(q)
|
||||
|
||||
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
|
||||
return snapshot.empty ? undefined : snapshot.docs[0].data()
|
||||
}
|
||||
|
||||
export async function deleteContract(contractId: string) {
|
||||
const docRef = doc(db, 'contracts', contractId)
|
||||
await deleteDoc(docRef)
|
||||
await deleteDoc(doc(contracts, contractId))
|
||||
}
|
||||
|
||||
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
||||
const q = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('creatorId', '==', creatorId),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
const snapshot = await getDocs(q)
|
||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
||||
return snapshot.docs.map((doc) => doc.data())
|
||||
}
|
||||
|
||||
export async function listTaggedContractsCaseInsensitive(
|
||||
tag: string
|
||||
): Promise<Contract[]> {
|
||||
const q = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
const snapshot = await getDocs(q)
|
||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
||||
return snapshot.docs.map((doc) => doc.data())
|
||||
}
|
||||
|
||||
export async function listAllContracts(
|
||||
n: number,
|
||||
before?: string
|
||||
): Promise<Contract[]> {
|
||||
let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n))
|
||||
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
|
||||
if (before != null) {
|
||||
const snap = await getDoc(doc(db, 'contracts', before))
|
||||
const snap = await getDoc(doc(contracts, before))
|
||||
q = query(q, startAfter(snap))
|
||||
}
|
||||
const snapshot = await getDocs(q)
|
||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
||||
return snapshot.docs.map((doc) => doc.data())
|
||||
}
|
||||
|
||||
export function listenForContracts(
|
||||
setContracts: (contracts: Contract[]) => void
|
||||
) {
|
||||
const q = query(contractCollection, orderBy('createdTime', 'desc'))
|
||||
const q = query(contracts, orderBy('createdTime', 'desc'))
|
||||
return listenForValues<Contract>(q, setContracts)
|
||||
}
|
||||
|
||||
|
@ -171,7 +161,7 @@ export function listenForUserContracts(
|
|||
setContracts: (contracts: Contract[]) => void
|
||||
) {
|
||||
const q = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('creatorId', '==', creatorId),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
|
@ -179,7 +169,7 @@ export function listenForUserContracts(
|
|||
}
|
||||
|
||||
const activeContractsQuery = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('visibility', '==', 'public'),
|
||||
where('volume7Days', '>', 0)
|
||||
|
@ -196,7 +186,7 @@ export function listenForActiveContracts(
|
|||
}
|
||||
|
||||
const inactiveContractsQuery = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('closeTime', '>', Date.now()),
|
||||
where('visibility', '==', 'public'),
|
||||
|
@ -214,7 +204,7 @@ export function listenForInactiveContracts(
|
|||
}
|
||||
|
||||
const newContractsQuery = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('volume7Days', '==', 0),
|
||||
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||
|
@ -230,7 +220,7 @@ export function listenForContract(
|
|||
contractId: string,
|
||||
setContract: (contract: Contract | null) => void
|
||||
) {
|
||||
const contractRef = doc(contractCollection, contractId)
|
||||
const contractRef = doc(contracts, contractId)
|
||||
return listenForValue<Contract>(contractRef, setContract)
|
||||
}
|
||||
|
||||
|
@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
|||
}
|
||||
|
||||
const hotContractsQuery = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('visibility', '==', 'public'),
|
||||
orderBy('volume24Hours', 'desc'),
|
||||
|
@ -262,22 +252,22 @@ export function listenForHotContracts(
|
|||
}
|
||||
|
||||
export async function getHotContracts() {
|
||||
const contracts = await getValues<Contract>(hotContractsQuery)
|
||||
const data = await getValues<Contract>(hotContractsQuery)
|
||||
return sortBy(
|
||||
chooseRandomSubset(contracts, 10),
|
||||
chooseRandomSubset(data, 10),
|
||||
(contract) => -1 * contract.volume24Hours
|
||||
)
|
||||
}
|
||||
|
||||
export async function getContractsBySlugs(slugs: string[]) {
|
||||
const q = query(contractCollection, where('slug', 'in', slugs))
|
||||
const q = query(contracts, where('slug', 'in', slugs))
|
||||
const snapshot = await getDocs(q)
|
||||
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
|
||||
return sortBy(contracts, (contract) => -1 * contract.volume24Hours)
|
||||
const data = snapshot.docs.map((doc) => doc.data())
|
||||
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
||||
}
|
||||
|
||||
const topWeeklyQuery = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
orderBy('volume7Days', 'desc'),
|
||||
limit(MAX_FEED_CONTRACTS)
|
||||
|
@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() {
|
|||
}
|
||||
|
||||
const closingSoonQuery = query(
|
||||
contractCollection,
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('visibility', '==', 'public'),
|
||||
where('closeTime', '>', Date.now()),
|
||||
|
@ -296,15 +286,12 @@ const closingSoonQuery = query(
|
|||
)
|
||||
|
||||
export async function getClosingSoonContracts() {
|
||||
const contracts = await getValues<Contract>(closingSoonQuery)
|
||||
return sortBy(
|
||||
chooseRandomSubset(contracts, 2),
|
||||
(contract) => contract.closeTime
|
||||
)
|
||||
const data = await getValues<Contract>(closingSoonQuery)
|
||||
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
||||
}
|
||||
|
||||
export async function getRecentBetsAndComments(contract: Contract) {
|
||||
const contractDoc = doc(db, 'contracts', contract.id)
|
||||
const contractDoc = doc(contracts, contract.id)
|
||||
|
||||
const [recentBets, recentComments] = await Promise.all([
|
||||
getValues<Bet>(
|
||||
|
|
|
@ -29,17 +29,6 @@ export const createAnswer = cloudFunction<
|
|||
}
|
||||
>('createAnswer')
|
||||
|
||||
export const resolveMarket = cloudFunction<
|
||||
{
|
||||
outcome: string
|
||||
value?: number
|
||||
contractId: string
|
||||
probabilityInt?: number
|
||||
resolutions?: { [outcome: string]: number }
|
||||
},
|
||||
{ status: 'error' | 'success'; message?: string }
|
||||
>('resolveMarket')
|
||||
|
||||
export const createUser: () => Promise<User | null> = () => {
|
||||
const local = safeLocalStorage()
|
||||
let deviceToken = local?.getItem('device-token')
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
import {
|
||||
collection,
|
||||
deleteDoc,
|
||||
doc,
|
||||
getDocs,
|
||||
query,
|
||||
updateDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { sortBy } from 'lodash'
|
||||
import { sortBy, uniq } from 'lodash'
|
||||
import { Group } from 'common/group'
|
||||
import { getContractFromId } from './contracts'
|
||||
import { db } from './init'
|
||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
||||
import {
|
||||
coll,
|
||||
getValue,
|
||||
getValues,
|
||||
listenForValue,
|
||||
listenForValues,
|
||||
} from './utils'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
|
||||
const groupCollection = collection(db, 'groups')
|
||||
export const groups = coll<Group>('groups')
|
||||
|
||||
export function groupPath(
|
||||
groupSlug: string,
|
||||
|
@ -23,30 +28,29 @@ export function groupPath(
|
|||
}
|
||||
|
||||
export function updateGroup(group: Group, updates: Partial<Group>) {
|
||||
return updateDoc(doc(groupCollection, group.id), updates)
|
||||
return updateDoc(doc(groups, group.id), updates)
|
||||
}
|
||||
|
||||
export function deleteGroup(group: Group) {
|
||||
return deleteDoc(doc(groupCollection, group.id))
|
||||
return deleteDoc(doc(groups, group.id))
|
||||
}
|
||||
|
||||
export async function listAllGroups() {
|
||||
return getValues<Group>(groupCollection)
|
||||
return getValues<Group>(groups)
|
||||
}
|
||||
|
||||
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||
return listenForValues(groupCollection, setGroups)
|
||||
return listenForValues(groups, setGroups)
|
||||
}
|
||||
|
||||
export function getGroup(groupId: string) {
|
||||
return getValue<Group>(doc(groupCollection, groupId))
|
||||
return getValue<Group>(doc(groups, groupId))
|
||||
}
|
||||
|
||||
export async function getGroupBySlug(slug: string) {
|
||||
const q = query(groupCollection, where('slug', '==', slug))
|
||||
const groups = await getValues<Group>(q)
|
||||
|
||||
return groups.length === 0 ? null : groups[0]
|
||||
const q = query(groups, where('slug', '==', slug))
|
||||
const docs = (await getDocs(q)).docs
|
||||
return docs.length === 0 ? null : docs[0].data()
|
||||
}
|
||||
|
||||
export async function getGroupContracts(group: Group) {
|
||||
|
@ -68,14 +72,14 @@ export function listenForGroup(
|
|||
groupId: string,
|
||||
setGroup: (group: Group | null) => void
|
||||
) {
|
||||
return listenForValue(doc(groupCollection, groupId), setGroup)
|
||||
return listenForValue(doc(groups, groupId), setGroup)
|
||||
}
|
||||
|
||||
export function listenForMemberGroups(
|
||||
userId: string,
|
||||
setGroups: (groups: Group[]) => void
|
||||
) {
|
||||
const q = query(groupCollection, where('memberIds', 'array-contains', userId))
|
||||
const q = query(groups, where('memberIds', 'array-contains', userId))
|
||||
|
||||
return listenForValues<Group>(q, (groups) => {
|
||||
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
||||
|
@ -87,10 +91,51 @@ export async function getGroupsWithContractId(
|
|||
contractId: string,
|
||||
setGroups: (groups: Group[]) => void
|
||||
) {
|
||||
const q = query(
|
||||
groupCollection,
|
||||
where('contractIds', 'array-contains', contractId)
|
||||
)
|
||||
const groups = await getValues<Group>(q)
|
||||
setGroups(groups)
|
||||
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
||||
setGroups(await getValues<Group>(q))
|
||||
}
|
||||
|
||||
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
|
||||
// get group to get the member ids
|
||||
const group = await getGroupBySlug(groupSlug)
|
||||
if (!group) {
|
||||
console.error(`Group not found: ${groupSlug}`)
|
||||
return
|
||||
}
|
||||
return await addUserToGroup(group, userId)
|
||||
}
|
||||
|
||||
export async function addUserToGroup(
|
||||
group: Group,
|
||||
userId: string
|
||||
): Promise<Group> {
|
||||
const { memberIds } = group
|
||||
if (memberIds.includes(userId)) {
|
||||
return group
|
||||
}
|
||||
const newMemberIds = [...memberIds, userId]
|
||||
const newGroup = { ...group, memberIds: newMemberIds }
|
||||
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
|
||||
return newGroup
|
||||
}
|
||||
export async function leaveGroup(group: Group, userId: string): Promise<Group> {
|
||||
const { memberIds } = group
|
||||
if (!memberIds.includes(userId)) {
|
||||
return group
|
||||
}
|
||||
const newMemberIds = memberIds.filter((id) => id !== userId)
|
||||
const newGroup = { ...group, memberIds: newMemberIds }
|
||||
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
|
||||
return newGroup
|
||||
}
|
||||
|
||||
export async function addContractToGroup(group: Group, contractId: string) {
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq([...group.contractIds, contractId]),
|
||||
})
|
||||
.then(() => group)
|
||||
.catch((err) => {
|
||||
console.error('error adding contract to group', err)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user