Merge branch 'main' into rich-content
This commit is contained in:
commit
903b7f1db0
|
@ -1,6 +1,7 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: ['lodash'],
|
plugins: ['lodash'],
|
||||||
extends: ['eslint:recommended'],
|
extends: ['eslint:recommended'],
|
||||||
|
ignorePatterns: ['lib'],
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
node: true,
|
node: true,
|
||||||
|
@ -31,6 +32,7 @@ module.exports = {
|
||||||
rules: {
|
rules: {
|
||||||
'no-extra-semi': 'off',
|
'no-extra-semi': 'off',
|
||||||
'no-constant-condition': ['error', { checkLoops: false }],
|
'no-constant-condition': ['error', { checkLoops: false }],
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
'lodash/import-scope': [2, 'member'],
|
'lodash/import-scope': [2, 'member'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,12 +10,9 @@ import {
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import { noFees } from './fees'
|
import { noFees } from './fees'
|
||||||
|
import { ENV_CONFIG } from './envs/constants'
|
||||||
|
|
||||||
export const FIXED_ANTE = 100
|
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||||
|
|
||||||
// deprecated
|
|
||||||
export const PHANTOM_ANTE = 0.001
|
|
||||||
export const MINIMUM_ANTE = 50
|
|
||||||
|
|
||||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||||
|
|
||||||
|
|
|
@ -18,15 +18,24 @@ import {
|
||||||
getDpmProbabilityAfterSale,
|
getDpmProbabilityAfterSale,
|
||||||
} from './calculate-dpm'
|
} from './calculate-dpm'
|
||||||
import { calculateFixedPayout } from './calculate-fixed-payouts'
|
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'
|
return contract.mechanism === 'cpmm-1'
|
||||||
? getCpmmProbability(contract.pool, contract.p)
|
? getCpmmProbability(contract.pool, contract.p)
|
||||||
: getDpmProbability(contract.totalShares)
|
: getDpmProbability(contract.totalShares)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialProbability(contract: BinaryContract) {
|
export function getInitialProbability(
|
||||||
|
contract: BinaryContract | PseudoNumericContract
|
||||||
|
) {
|
||||||
if (contract.initialProbability) return contract.initialProbability
|
if (contract.initialProbability) return contract.initialProbability
|
||||||
|
|
||||||
if (contract.mechanism === 'dpm-2' || (contract as any).totalShares)
|
if (contract.mechanism === 'dpm-2' || (contract as any).totalShares)
|
||||||
|
@ -65,7 +74,9 @@ export function calculateShares(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateSaleAmount(contract: Contract, bet: Bet) {
|
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
|
? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue
|
||||||
: calculateDpmSaleAmount(contract, bet)
|
: calculateDpmSaleAmount(contract, bet)
|
||||||
}
|
}
|
||||||
|
@ -87,7 +98,9 @@ export function getProbabilityAfterSale(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculatePayout(contract: Contract, bet: Bet, outcome: string) {
|
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)
|
? calculateFixedPayout(contract, bet, outcome)
|
||||||
: calculateDpmPayout(contract, bet, outcome)
|
: calculateDpmPayout(contract, bet, outcome)
|
||||||
}
|
}
|
||||||
|
@ -96,7 +109,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) {
|
||||||
const outcome = contract.resolution
|
const outcome = contract.resolution
|
||||||
if (!outcome) throw new Error('Contract not resolved')
|
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)
|
? calculateFixedPayout(contract, bet, outcome)
|
||||||
: calculateDpmPayout(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 profit = payout + saleValue + redeemed - totalInvested
|
||||||
const profitPercent = (profit / totalInvested) * 100
|
const profitPercent = (profit / totalInvested) * 100
|
||||||
|
|
||||||
const hasShares = Object.values(totalShares).some(
|
const hasShares = Object.values(totalShares).some((shares) => shares > 0)
|
||||||
(shares) => shares > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invested: Math.max(0, currentInvested),
|
invested: Math.max(0, currentInvested),
|
||||||
|
|
|
@ -3,9 +3,10 @@ import { Fees } from './fees'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
export type AnyMechanism = DPM | CPMM
|
export type AnyMechanism = DPM | CPMM
|
||||||
export type AnyOutcomeType = Binary | FreeResponse | Numeric
|
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
||||||
export type AnyContractType =
|
export type AnyContractType =
|
||||||
| (CPMM & Binary)
|
| (CPMM & Binary)
|
||||||
|
| (CPMM & PseudoNumeric)
|
||||||
| (DPM & Binary)
|
| (DPM & Binary)
|
||||||
| (DPM & FreeResponse)
|
| (DPM & FreeResponse)
|
||||||
| (DPM & Numeric)
|
| (DPM & Numeric)
|
||||||
|
@ -45,7 +46,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
collectedFees: Fees
|
collectedFees: Fees
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
export type PseudoNumericContract = Contract & PseudoNumeric
|
||||||
export type NumericContract = Contract & Numeric
|
export type NumericContract = Contract & Numeric
|
||||||
export type FreeResponseContract = Contract & FreeResponse
|
export type FreeResponseContract = Contract & FreeResponse
|
||||||
export type DPMContract = Contract & DPM
|
export type DPMContract = Contract & DPM
|
||||||
|
@ -76,6 +78,18 @@ export type Binary = {
|
||||||
resolution?: resolution
|
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 = {
|
export type FreeResponse = {
|
||||||
outcomeType: 'FREE_RESPONSE'
|
outcomeType: 'FREE_RESPONSE'
|
||||||
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
|
answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'.
|
||||||
|
@ -95,7 +109,7 @@ export type Numeric = {
|
||||||
export type outcomeType = AnyOutcomeType['outcomeType']
|
export type outcomeType = AnyOutcomeType['outcomeType']
|
||||||
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
||||||
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
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_QUESTION_LENGTH = 480
|
||||||
export const MAX_DESCRIPTION_LENGTH = 10000
|
export const MAX_DESCRIPTION_LENGTH = 10000
|
||||||
|
|
|
@ -18,13 +18,17 @@ export type EnvConfig = {
|
||||||
faviconPath?: string // Should be a file in /public
|
faviconPath?: string // Should be a file in /public
|
||||||
navbarLogoPath?: string
|
navbarLogoPath?: string
|
||||||
newQuestionPlaceholders: string[]
|
newQuestionPlaceholders: string[]
|
||||||
|
|
||||||
|
// Currency controls
|
||||||
|
fixedAnte?: number
|
||||||
|
startingBalance?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig = {
|
type FirebaseConfig = {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
authDomain: string
|
authDomain: string
|
||||||
projectId: string
|
projectId: string
|
||||||
region: string
|
region?: string
|
||||||
storageBucket: string
|
storageBucket: string
|
||||||
messagingSenderId: string
|
messagingSenderId: string
|
||||||
appId: string
|
appId: string
|
||||||
|
|
|
@ -14,14 +14,15 @@ import {
|
||||||
DPMBinaryContract,
|
DPMBinaryContract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
|
PseudoNumericContract,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { noFees } from './fees'
|
import { noFees } from './fees'
|
||||||
import { addObjects } from './util/object'
|
import { addObjects } from './util/object'
|
||||||
import { NUMERIC_FIXED_VAR } from './numeric-constants'
|
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 = {
|
export type BetInfo = {
|
||||||
newBet: CandidateBet<Bet>
|
newBet: CandidateBet
|
||||||
newPool?: { [outcome: string]: number }
|
newPool?: { [outcome: string]: number }
|
||||||
newTotalShares?: { [outcome: string]: number }
|
newTotalShares?: { [outcome: string]: number }
|
||||||
newTotalBets?: { [outcome: string]: number }
|
newTotalBets?: { [outcome: string]: number }
|
||||||
|
@ -32,7 +33,7 @@ export type BetInfo = {
|
||||||
export const getNewBinaryCpmmBetInfo = (
|
export const getNewBinaryCpmmBetInfo = (
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: CPMMBinaryContract,
|
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||||
loanAmount: number
|
loanAmount: number
|
||||||
) => {
|
) => {
|
||||||
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
|
const { shares, newPool, newP, fees } = calculateCpmmPurchase(
|
||||||
|
@ -45,7 +46,7 @@ export const getNewBinaryCpmmBetInfo = (
|
||||||
const probBefore = getCpmmProbability(pool, p)
|
const probBefore = getCpmmProbability(pool, p)
|
||||||
const probAfter = getCpmmProbability(newPool, newP)
|
const probAfter = getCpmmProbability(newPool, newP)
|
||||||
|
|
||||||
const newBet: CandidateBet<Bet> = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
shares,
|
shares,
|
||||||
|
@ -95,7 +96,7 @@ export const getNewBinaryDpmBetInfo = (
|
||||||
const probBefore = getDpmProbability(contract.totalShares)
|
const probBefore = getDpmProbability(contract.totalShares)
|
||||||
const probAfter = getDpmProbability(newTotalShares)
|
const probAfter = getDpmProbability(newTotalShares)
|
||||||
|
|
||||||
const newBet: CandidateBet<Bet> = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
loanAmount,
|
loanAmount,
|
||||||
|
@ -132,7 +133,7 @@ export const getNewMultiBetInfo = (
|
||||||
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
|
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
|
||||||
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
|
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
|
||||||
|
|
||||||
const newBet: CandidateBet<Bet> = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
loanAmount,
|
loanAmount,
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
FreeResponse,
|
FreeResponse,
|
||||||
Numeric,
|
Numeric,
|
||||||
outcomeType,
|
outcomeType,
|
||||||
|
PseudoNumeric,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { parseTags, richTextToString } from './util/parse'
|
import { parseTags, richTextToString } from './util/parse'
|
||||||
|
@ -28,7 +29,8 @@ export function getNewContract(
|
||||||
// used for numeric markets
|
// used for numeric markets
|
||||||
bucketCount: number,
|
bucketCount: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number
|
max: number,
|
||||||
|
isLogScale: boolean
|
||||||
) {
|
) {
|
||||||
const tags = parseTags(
|
const tags = parseTags(
|
||||||
[
|
[
|
||||||
|
@ -42,6 +44,8 @@ export function getNewContract(
|
||||||
const propsByOutcomeType =
|
const propsByOutcomeType =
|
||||||
outcomeType === 'BINARY'
|
outcomeType === 'BINARY'
|
||||||
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
||||||
|
: outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
|
||||||
: outcomeType === 'NUMERIC'
|
: outcomeType === 'NUMERIC'
|
||||||
? getNumericProps(ante, bucketCount, min, max)
|
? getNumericProps(ante, bucketCount, min, max)
|
||||||
: getFreeAnswerProps(ante)
|
: getFreeAnswerProps(ante)
|
||||||
|
@ -116,6 +120,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
||||||
return system
|
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 getFreeAnswerProps = (ante: number) => {
|
||||||
const system: DPM & FreeResponse = {
|
const system: DPM & FreeResponse = {
|
||||||
mechanism: 'dpm-2',
|
mechanism: 'dpm-2',
|
||||||
|
|
|
@ -22,6 +22,8 @@ export type Notification = {
|
||||||
|
|
||||||
sourceSlug?: string
|
sourceSlug?: string
|
||||||
sourceTitle?: string
|
sourceTitle?: string
|
||||||
|
|
||||||
|
isSeenOnHref?: string
|
||||||
}
|
}
|
||||||
export type notification_source_types =
|
export type notification_source_types =
|
||||||
| 'contract'
|
| 'contract'
|
||||||
|
@ -33,6 +35,8 @@ export type notification_source_types =
|
||||||
| 'tip'
|
| 'tip'
|
||||||
| 'admin_message'
|
| 'admin_message'
|
||||||
| 'group'
|
| 'group'
|
||||||
|
| 'user'
|
||||||
|
| 'bonus'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -53,3 +57,7 @@ export type notification_reason_types =
|
||||||
| 'on_new_follow'
|
| 'on_new_follow'
|
||||||
| 'you_follow_user'
|
| 'you_follow_user'
|
||||||
| 'added_you_to_group'
|
| '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_GRAPH_COLOR = '#5fa5f9'
|
||||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
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 { sumBy, groupBy, mapValues } from 'lodash'
|
||||||
|
|
||||||
import { Bet, NumericBet } from './bet'
|
import { Bet, NumericBet } from './bet'
|
||||||
import { Contract, CPMMBinaryContract, DPMContract } from './contract'
|
import {
|
||||||
|
Contract,
|
||||||
|
CPMMBinaryContract,
|
||||||
|
DPMContract,
|
||||||
|
PseudoNumericContract,
|
||||||
|
} from './contract'
|
||||||
import { Fees } from './fees'
|
import { Fees } from './fees'
|
||||||
import { LiquidityProvision } from './liquidity-provision'
|
import { LiquidityProvision } from './liquidity-provision'
|
||||||
import {
|
import {
|
||||||
|
@ -48,15 +53,19 @@ export type PayoutInfo = {
|
||||||
|
|
||||||
export const getPayouts = (
|
export const getPayouts = (
|
||||||
outcome: string | undefined,
|
outcome: string | undefined,
|
||||||
resolutions: {
|
|
||||||
[outcome: string]: number
|
|
||||||
},
|
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
liquidities: LiquidityProvision[],
|
liquidities: LiquidityProvision[],
|
||||||
|
resolutions?: {
|
||||||
|
[outcome: string]: number
|
||||||
|
},
|
||||||
resolutionProbability?: number
|
resolutionProbability?: number
|
||||||
): PayoutInfo => {
|
): PayoutInfo => {
|
||||||
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') {
|
if (
|
||||||
|
contract.mechanism === 'cpmm-1' &&
|
||||||
|
(contract.outcomeType === 'BINARY' ||
|
||||||
|
contract.outcomeType === 'PSEUDO_NUMERIC')
|
||||||
|
) {
|
||||||
return getFixedPayouts(
|
return getFixedPayouts(
|
||||||
outcome,
|
outcome,
|
||||||
contract,
|
contract,
|
||||||
|
@ -67,16 +76,16 @@ export const getPayouts = (
|
||||||
}
|
}
|
||||||
return getDpmPayouts(
|
return getDpmPayouts(
|
||||||
outcome,
|
outcome,
|
||||||
resolutions,
|
|
||||||
contract,
|
contract,
|
||||||
bets,
|
bets,
|
||||||
|
resolutions,
|
||||||
resolutionProbability
|
resolutionProbability
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFixedPayouts = (
|
export const getFixedPayouts = (
|
||||||
outcome: string | undefined,
|
outcome: string | undefined,
|
||||||
contract: CPMMBinaryContract,
|
contract: CPMMBinaryContract | PseudoNumericContract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
liquidities: LiquidityProvision[],
|
liquidities: LiquidityProvision[],
|
||||||
resolutionProbability?: number
|
resolutionProbability?: number
|
||||||
|
@ -100,11 +109,11 @@ export const getFixedPayouts = (
|
||||||
|
|
||||||
export const getDpmPayouts = (
|
export const getDpmPayouts = (
|
||||||
outcome: string | undefined,
|
outcome: string | undefined,
|
||||||
resolutions: {
|
|
||||||
[outcome: string]: number
|
|
||||||
},
|
|
||||||
contract: DPMContract,
|
contract: DPMContract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
|
resolutions?: {
|
||||||
|
[outcome: string]: number
|
||||||
|
},
|
||||||
resolutionProbability?: number
|
resolutionProbability?: number
|
||||||
): PayoutInfo => {
|
): PayoutInfo => {
|
||||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||||
|
@ -115,8 +124,8 @@ export const getDpmPayouts = (
|
||||||
return getDpmStandardPayouts(outcome, contract, openBets)
|
return getDpmStandardPayouts(outcome, contract, openBets)
|
||||||
|
|
||||||
case 'MKT':
|
case 'MKT':
|
||||||
return contract.outcomeType === 'FREE_RESPONSE'
|
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
? getPayoutsMultiOutcome(resolutions, contract, openBets)
|
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
||||||
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
||||||
case 'CANCEL':
|
case 'CANCEL':
|
||||||
case undefined:
|
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(
|
const { payouts: resolvePayouts } = getPayouts(
|
||||||
resolution as string,
|
resolution as string,
|
||||||
{},
|
|
||||||
contract,
|
contract,
|
||||||
openBets,
|
openBets,
|
||||||
[],
|
[],
|
||||||
|
{},
|
||||||
resolutionProb
|
resolutionProb
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
// 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'
|
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||||
|
|
||||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
|
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
amount: number
|
amount: number
|
||||||
token: 'M$' // | 'USD' | MarketOutcome
|
token: 'M$' // | 'USD' | MarketOutcome
|
||||||
|
|
||||||
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
|
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
|
||||||
|
|
||||||
// Any extra data
|
// Any extra data
|
||||||
data?: { [key: string]: any }
|
data?: { [key: string]: any }
|
||||||
|
|
||||||
|
@ -46,6 +47,19 @@ type Manalink = {
|
||||||
category: '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 DonationTxn = Txn & Donation
|
||||||
export type TipTxn = Txn & Tip
|
export type TipTxn = Txn & Tip
|
||||||
export type ManalinkTxn = Txn & Manalink
|
export type ManalinkTxn = Txn & Manalink
|
||||||
|
export type ReferralTxn = Txn & Referral
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ENV_CONFIG } from './envs/constants'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
createdTime: number
|
createdTime: number
|
||||||
|
@ -33,11 +35,15 @@ export type User = {
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
|
|
||||||
|
referredByUserId?: string
|
||||||
|
referredByContractId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STARTING_BALANCE = 1000
|
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||||
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
|
// 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 = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
@ -51,6 +57,7 @@ export type PrivateUser = {
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
notificationPreferences?: notification_subscribe_types
|
notificationPreferences?: notification_subscribe_types
|
||||||
|
lastTimeCheckedBonuses?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
||||||
|
|
|
@ -456,7 +456,6 @@ Requires no authorization.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### `POST /v0/bet`
|
### `POST /v0/bet`
|
||||||
|
|
||||||
Places a new bet on behalf of the authorized user.
|
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}'
|
"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
|
## Changelog
|
||||||
|
|
||||||
- 2022-06-08: Add paging to markets endpoint
|
- 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.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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"
|
"order": "DESCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "portfolioHistory",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timestamp",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": [
|
"fieldOverrides": [
|
||||||
|
|
|
@ -20,7 +20,16 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& 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} {
|
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 = {
|
module.exports = {
|
||||||
plugins: ['lodash'],
|
plugins: ['lodash'],
|
||||||
extends: ['eslint:recommended'],
|
extends: ['eslint:recommended'],
|
||||||
ignorePatterns: ['lib'],
|
ignorePatterns: ['dist', 'lib'],
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
},
|
},
|
||||||
|
@ -30,6 +30,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
'lodash/import-scope': [2, 'member'],
|
'lodash/import-scope': [2, 'member'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -1,5 +1,4 @@
|
||||||
# Secrets
|
# Secrets
|
||||||
.env*
|
|
||||||
.runtimeconfig.json
|
.runtimeconfig.json
|
||||||
|
|
||||||
# GCP deployment artifact
|
# GCP deployment artifact
|
||||||
|
|
|
@ -23,8 +23,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
||||||
### For local development
|
### For local development
|
||||||
|
|
||||||
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
|
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. 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. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
|
|
||||||
|
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
|
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)
|
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
|
4. `$ mkdir firestore_export` to create a folder to store the exported database
|
||||||
|
|
|
@ -5,14 +5,14 @@
|
||||||
"firestore": "dev-mantic-markets.appspot.com"
|
"firestore": "dev-mantic-markets.appspot.com"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"compile": "tsc -b",
|
||||||
"watch": "tsc -w",
|
"watch": "tsc -w",
|
||||||
"shell": "yarn build && firebase functions:shell",
|
"shell": "yarn build && firebase functions:shell",
|
||||||
"start": "yarn shell",
|
"start": "yarn shell",
|
||||||
"deploy": "firebase deploy --only functions",
|
"deploy": "firebase deploy --only functions",
|
||||||
"logs": "firebase functions:log",
|
"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: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: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)",
|
"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",
|
"main": "functions/src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/node": "1.10.0",
|
"@amplitude/node": "1.10.0",
|
||||||
|
"@google-cloud/functions-framework": "3.1.2",
|
||||||
"@tiptap/core": "^2.0.0-beta.181",
|
"@tiptap/core": "^2.0.0-beta.181",
|
||||||
"@tiptap/starter-kit": "^2.0.0-beta.190",
|
"@tiptap/starter-kit": "^2.0.0-beta.190",
|
||||||
"fetch": "1.1.0",
|
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
"firebase-functions": "3.21.2",
|
"firebase-functions": "3.21.2",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
|
|
@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall(
|
||||||
const contract = contractSnap.data() as Contract
|
const contract = contractSnap.data() as Contract
|
||||||
if (
|
if (
|
||||||
contract.mechanism !== 'cpmm-1' ||
|
contract.mechanism !== 'cpmm-1' ||
|
||||||
contract.outcomeType !== 'BINARY'
|
(contract.outcomeType !== 'BINARY' &&
|
||||||
|
contract.outcomeType !== 'PSEUDO_NUMERIC')
|
||||||
)
|
)
|
||||||
return { status: 'error', message: 'Invalid contract' }
|
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,
|
minInstances: 1,
|
||||||
concurrency: 100,
|
concurrency: 100,
|
||||||
memory: '2GiB',
|
memory: '2GiB',
|
||||||
|
@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = {
|
||||||
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newEndpoint = (methods: [string], fn: Handler) =>
|
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||||
onRequest(DEFAULT_OPTS, async (req, res) => {
|
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
|
||||||
|
return onRequest(opts, async (req, res) => {
|
||||||
log('Request processing started.')
|
log('Request processing started.')
|
||||||
try {
|
try {
|
||||||
if (!methods.includes(req.method)) {
|
if (!opts.methods.includes(req.method)) {
|
||||||
const allowed = methods.join(', ')
|
const allowed = opts.methods.join(', ')
|
||||||
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
||||||
}
|
}
|
||||||
const authedUser = await lookupUser(await parseCredentials(req))
|
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 functions from 'firebase-functions'
|
||||||
import * as firestore from '@google-cloud/firestore'
|
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
|
export const backupDb = functions.pubsub
|
||||||
.schedule('every 24 hours')
|
.schedule('every 24 hours')
|
||||||
.onRun((_context) => {
|
.onRun(async (_context) => {
|
||||||
const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT
|
try {
|
||||||
if (projectId == null) {
|
const client = new firestore.v1.FirestoreAdminClient()
|
||||||
throw new Error('No project ID environment variable set.')
|
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 { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||||
|
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
|
@ -68,19 +69,31 @@ const binarySchema = z.object({
|
||||||
initialProb: z.number().min(1).max(99),
|
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({
|
const numericSchema = z.object({
|
||||||
min: z.number(),
|
min: finite(),
|
||||||
max: z.number(),
|
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 } =
|
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||||
validate(bodySchema, req.body)
|
validate(bodySchema, req.body)
|
||||||
|
|
||||||
let min, max, initialProb
|
let min, max, initialProb, isLogScale
|
||||||
if (outcomeType === 'NUMERIC') {
|
|
||||||
;({ min, max } = validate(numericSchema, req.body))
|
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||||
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.')
|
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') {
|
if (outcomeType === 'BINARY') {
|
||||||
;({ initialProb } = validate(binarySchema, req.body))
|
;({ initialProb } = validate(binarySchema, req.body))
|
||||||
|
@ -144,7 +157,8 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||||
tags ?? [],
|
tags ?? [],
|
||||||
NUMERIC_BUCKET_COUNT,
|
NUMERIC_BUCKET_COUNT,
|
||||||
min ?? 0,
|
min ?? 0,
|
||||||
max ?? 0
|
max ?? 0,
|
||||||
|
isLogScale ?? false
|
||||||
)
|
)
|
||||||
|
|
||||||
if (ante) await chargeUser(user.id, ante, true)
|
if (ante) await chargeUser(user.id, ante, true)
|
||||||
|
@ -153,7 +167,7 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||||
|
|
||||||
const providerId = user.id
|
const providerId = user.id
|
||||||
|
|
||||||
if (outcomeType === 'BINARY') {
|
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
const liquidityDoc = firestore
|
const liquidityDoc = firestore
|
||||||
.collection(`contracts/${contract.id}/liquidity`)
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
.doc()
|
.doc()
|
||||||
|
|
|
@ -20,7 +20,7 @@ const bodySchema = z.object({
|
||||||
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
|
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(
|
const { name, about, memberIds, anyoneCanJoin } = validate(
|
||||||
bodySchema,
|
bodySchema,
|
||||||
req.body
|
req.body
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
[userId: string]: { reason: notification_reason_types }
|
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotification = async (
|
export const createNotification = async (
|
||||||
|
@ -68,9 +68,11 @@ export const createNotification = async (
|
||||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||||
// TODO: move away from sourceContractTitle to sourceTitle
|
// TODO: move away from sourceContractTitle to sourceTitle
|
||||||
sourceContractTitle: sourceContract?.question,
|
sourceContractTitle: sourceContract?.question,
|
||||||
|
// TODO: move away from sourceContractSlug to sourceSlug
|
||||||
sourceContractSlug: sourceContract?.slug,
|
sourceContractSlug: sourceContract?.slug,
|
||||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
||||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
||||||
|
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
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 getUsersToNotify = async () => {
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
if (sourceContract) {
|
if (sourceType === 'follow' && relatedUserId) {
|
||||||
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) {
|
|
||||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||||
} else if (sourceType === 'group' && relatedUserId) {
|
} else if (sourceType === 'group' && relatedUserId) {
|
||||||
if (sourceUpdateType === 'created')
|
if (sourceUpdateType === 'created')
|
||||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
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
|
return userToReasonTexts
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,13 @@ import { Comment } from '../../common/comment'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { DPM_CREATOR_FEE } from '../../common/fees'
|
import { DPM_CREATOR_FEE } from '../../common/fees'
|
||||||
import { PrivateUser, User } from '../../common/user'
|
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 { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail } from './send-email'
|
import { sendTemplateEmail } from './send-email'
|
||||||
import { getPrivateUser, getUser } from './utils'
|
import { getPrivateUser, getUser } from './utils'
|
||||||
|
@ -101,6 +106,17 @@ const toDisplayResolution = (
|
||||||
return display || resolution
|
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 === 'MKT' && resolutions) return 'MULTI'
|
||||||
if (resolution === 'CANCEL') return 'N/A'
|
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'
|
import { newEndpoint } from './api'
|
||||||
|
|
||||||
export const health = newEndpoint(['GET'], async (_req, auth) => {
|
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
|
||||||
return {
|
return {
|
||||||
message: 'Server is working.',
|
message: 'Server is working.',
|
||||||
uid: auth.uid,
|
uid: auth.uid,
|
||||||
|
|
|
@ -6,12 +6,11 @@ admin.initializeApp()
|
||||||
// export * from './keep-awake'
|
// export * from './keep-awake'
|
||||||
export * from './claim-manalink'
|
export * from './claim-manalink'
|
||||||
export * from './transact'
|
export * from './transact'
|
||||||
export * from './resolve-market'
|
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
export * from './create-user'
|
export * from './create-user'
|
||||||
export * from './create-answer'
|
export * from './create-answer'
|
||||||
export * from './on-create-bet'
|
export * from './on-create-bet'
|
||||||
export * from './on-create-comment'
|
export * from './on-create-comment-on-contract'
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
export * from './update-metrics'
|
export * from './update-metrics'
|
||||||
|
@ -28,6 +27,8 @@ export * from './on-unfollow-user'
|
||||||
export * from './on-create-liquidity-provision'
|
export * from './on-create-liquidity-provision'
|
||||||
export * from './on-update-group'
|
export * from './on-update-group'
|
||||||
export * from './on-create-group'
|
export * from './on-create-group'
|
||||||
|
export * from './on-update-user'
|
||||||
|
export * from './on-create-comment-on-group'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -37,3 +38,5 @@ export * from './sell-shares'
|
||||||
export * from './create-contract'
|
export * from './create-contract'
|
||||||
export * from './withdraw-liquidity'
|
export * from './withdraw-liquidity'
|
||||||
export * from './create-group'
|
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()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onCreateComment = functions
|
export const onCreateCommentOnContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
.firestore.document('contracts/{contractId}/comments/{commentId}')
|
.firestore.document('contracts/{contractId}/comments/{commentId}')
|
||||||
.onCreate(async (change, context) => {
|
.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
|
// ignore the update we just made
|
||||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||||
return
|
return
|
||||||
|
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('groups')
|
.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(),
|
value: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
log('Inside endpoint handler.')
|
log('Inside endpoint handler.')
|
||||||
const { amount, contractId } = validate(bodySchema, req.body)
|
const { amount, contractId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
|
@ -41,10 +41,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
||||||
log('Inside main transaction.')
|
log('Inside main transaction.')
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const [contractSnap, userSnap] = await Promise.all([
|
const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
|
||||||
trans.get(contractDoc),
|
|
||||||
trans.get(userDoc),
|
|
||||||
])
|
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
log('Loaded user and contract snapshots.')
|
log('Loaded user and contract snapshots.')
|
||||||
|
@ -70,7 +67,10 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
||||||
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
|
if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
|
||||||
const { outcome } = validate(binarySchema, req.body)
|
const { outcome } = validate(binarySchema, req.body)
|
||||||
return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
|
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)
|
const { outcome } = validate(binarySchema, req.body)
|
||||||
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
|
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
|
||||||
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
|
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
|
||||||
|
|
|
@ -1,92 +1,46 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { partition, sumBy } from 'lodash'
|
|
||||||
|
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { noFees } from '../../common/fees'
|
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
|
||||||
export const redeemShares = async (userId: string, contractId: string) => {
|
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 contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const contractSnap = await transaction.get(contractDoc)
|
const contractSnap = await trans.get(contractDoc)
|
||||||
if (!contractSnap.exists)
|
if (!contractSnap.exists)
|
||||||
return { status: 'error', message: 'Invalid contract' }
|
return { status: 'error', message: 'Invalid contract' }
|
||||||
|
|
||||||
const contract = contractSnap.data() as Contract
|
const contract = contractSnap.data() as Contract
|
||||||
if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1')
|
const { mechanism } = contract
|
||||||
return { status: 'success' }
|
if (mechanism !== 'cpmm-1') return { status: 'success' }
|
||||||
|
|
||||||
const betsSnap = await transaction.get(
|
const betsColl = firestore.collection(`contracts/${contract.id}/bets`)
|
||||||
firestore
|
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
|
||||||
.collection(`contracts/${contract.id}/bets`)
|
|
||||||
.where('userId', '==', userId)
|
|
||||||
)
|
|
||||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
|
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
|
||||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
if (netAmount === 0) {
|
||||||
const noShares = sumBy(noBets, (b) => b.shares)
|
return { status: 'success' }
|
||||||
|
|
||||||
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 [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
|
||||||
|
|
||||||
const userDoc = firestore.doc(`users/${userId}`)
|
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' }
|
if (!userSnap.exists) return { status: 'error', message: 'User not found' }
|
||||||
|
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
|
|
||||||
const newBalance = user.balance + netAmount
|
const newBalance = user.balance + netAmount
|
||||||
|
|
||||||
if (!isFinite(newBalance)) {
|
if (!isFinite(newBalance)) {
|
||||||
throw new Error('Invalid user balance for ' + user.username)
|
throw new Error('Invalid user balance for ' + user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction.update(userDoc, { balance: newBalance })
|
const yesDoc = betsColl.doc()
|
||||||
|
const noDoc = betsColl.doc()
|
||||||
transaction.create(yesDoc, yesBet)
|
trans.update(userDoc, { balance: newBalance })
|
||||||
transaction.create(noDoc, noBet)
|
trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet })
|
||||||
|
trans.create(noDoc, { id: noDoc.id, userId, ...noBet })
|
||||||
|
|
||||||
return { status: 'success' }
|
return { status: 'success' }
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { z } from 'zod'
|
||||||
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
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 { User } from '../../common/user'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, isProd, payUser } from './utils'
|
import { getUser, isProd, payUser } from './utils'
|
||||||
|
@ -15,156 +19,162 @@ import {
|
||||||
} from '../../common/payouts'
|
} from '../../common/payouts'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
|
||||||
export const resolveMarket = functions
|
const bodySchema = z.object({
|
||||||
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
|
contractId: z.string(),
|
||||||
.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 { 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 freeResponseSchema = z.union([
|
||||||
const contractSnap = await contractDoc.get()
|
z.object({
|
||||||
if (!contractSnap.exists)
|
outcome: z.literal('CANCEL'),
|
||||||
return { status: 'error', message: 'Invalid contract' }
|
}),
|
||||||
const contract = contractSnap.data() as Contract
|
z.object({
|
||||||
const { creatorId, outcomeType, closeTime } = contract
|
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') {
|
const numericSchema = z.object({
|
||||||
if (!RESOLUTIONS.includes(outcome))
|
outcome: z.union([z.literal('CANCEL'), z.string()]),
|
||||||
return { status: 'error', message: 'Invalid outcome' }
|
value: z.number().optional(),
|
||||||
} 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' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined && !isFinite(value))
|
const pseudoNumericSchema = z.union([
|
||||||
return { status: 'error', message: 'Invalid value' }
|
z.object({
|
||||||
|
outcome: z.literal('CANCEL'),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
outcome: z.literal('MKT'),
|
||||||
|
value: z.number(),
|
||||||
|
probabilityInt: z.number().gte(0).lte(100),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
if (
|
const opts = { secrets: ['MAILGUN_KEY'] }
|
||||||
outcomeType === 'BINARY' &&
|
|
||||||
probabilityInt !== undefined &&
|
|
||||||
(probabilityInt < 0 ||
|
|
||||||
probabilityInt > 100 ||
|
|
||||||
!isFinite(probabilityInt))
|
|
||||||
)
|
|
||||||
return { status: 'error', message: 'Invalid probability' }
|
|
||||||
|
|
||||||
if (creatorId !== userId)
|
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
return { status: 'error', message: 'User not creator of contract' }
|
const { contractId } = validate(bodySchema, req.body)
|
||||||
|
const userId = auth.uid
|
||||||
|
|
||||||
if (contract.resolution)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
return { status: 'error', message: 'Contract already resolved' }
|
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)
|
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||||
if (!creator) return { status: 'error', message: 'Creator not found' }
|
contract,
|
||||||
|
req.body
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||||
const userPayouts = groupPayoutsByUser(payouts)
|
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()
|
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(
|
const { payouts } = getPayouts(
|
||||||
resolution,
|
resolution,
|
||||||
resolutions,
|
|
||||||
contract,
|
contract,
|
||||||
openBets,
|
openBets,
|
||||||
[],
|
[],
|
||||||
|
resolutions,
|
||||||
resolutionProbability
|
resolutionProbability
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initAdmin = (env?: string) => {
|
export const getServiceAccountCredentials = (env?: string) => {
|
||||||
env = env || getFirebaseActiveProject(process.cwd())
|
env = env || getFirebaseActiveProject(process.cwd())
|
||||||
if (env == null) {
|
if (env == null) {
|
||||||
console.error(
|
throw new Error(
|
||||||
"Couldn't find active Firebase project; did you do `firebase use <alias>?`"
|
"Couldn't find active Firebase project; did you do `firebase use <alias>?`"
|
||||||
)
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
|
const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}`
|
||||||
const keyPath = process.env[envVar]
|
const keyPath = process.env[envVar]
|
||||||
if (keyPath == null) {
|
if (keyPath == null) {
|
||||||
console.error(
|
throw new Error(
|
||||||
`Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.`
|
`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 */
|
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
|
||||||
const serviceAccount = require(keyPath)
|
return require(keyPath)
|
||||||
admin.initializeApp({
|
}
|
||||||
|
|
||||||
|
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),
|
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(),
|
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)
|
const { contractId, betId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// run as transaction to prevent race conditions
|
// 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 contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
|
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
|
||||||
const [contractSnap, userSnap, betSnap] = await Promise.all([
|
const [contractSnap, userSnap, betSnap] = await transaction.getAll(
|
||||||
transaction.get(contractDoc),
|
contractDoc,
|
||||||
transaction.get(userDoc),
|
userDoc,
|
||||||
transaction.get(betDoc),
|
betDoc
|
||||||
])
|
)
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
if (!betSnap.exists) throw new APIError(400, 'Bet 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']),
|
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)
|
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// Run as transaction to prevent race conditions.
|
// 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 contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||||
const [contractSnap, userSnap, userBets] = await Promise.all([
|
const [[contractSnap, userSnap], userBets] = await Promise.all([
|
||||||
transaction.get(contractDoc),
|
transaction.getAll(contractDoc, userDoc),
|
||||||
transaction.get(userDoc),
|
|
||||||
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
||||||
])
|
])
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
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 outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
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.`)
|
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||||
|
|
||||||
const { newBet, newPool, newP, fees } = getCpmmSellBetInfo(
|
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 functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { CPMMContract } from '../../common/contract'
|
import { CPMMContract } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { subtractObjects } from '../../common/util/object'
|
import { subtractObjects } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { noFees } from '../../common/fees'
|
import { noFees } from '../../common/fees'
|
||||||
|
|
||||||
import { APIError } from './api'
|
import { APIError } from './api'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
|
|
||||||
export const withdrawLiquidity = functions
|
export const withdrawLiquidity = functions
|
||||||
.runWith({ minInstances: 1 })
|
.runWith({ minInstances: 1 })
|
||||||
.https.onCall(
|
.https.onCall(
|
||||||
async (
|
async (
|
||||||
data: {
|
data: {
|
||||||
contractId: string
|
contractId: string
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
const userId = context?.auth?.uid
|
const userId = context?.auth?.uid
|
||||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||||
|
|
||||||
const { contractId } = data
|
const { contractId } = data
|
||||||
if (!contractId)
|
if (!contractId)
|
||||||
return { status: 'error', message: 'Missing contract id' }
|
return { status: 'error', message: 'Missing contract id' }
|
||||||
|
|
||||||
return await firestore
|
return await firestore
|
||||||
.runTransaction(async (trans) => {
|
.runTransaction(async (trans) => {
|
||||||
const lpDoc = firestore.doc(`users/${userId}`)
|
const lpDoc = firestore.doc(`users/${userId}`)
|
||||||
const lpSnap = await trans.get(lpDoc)
|
const lpSnap = await trans.get(lpDoc)
|
||||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
const lp = lpSnap.data() as User
|
const lp = lpSnap.data() as User
|
||||||
|
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const contractSnap = await trans.get(contractDoc)
|
const contractSnap = await trans.get(contractDoc)
|
||||||
if (!contractSnap.exists)
|
if (!contractSnap.exists)
|
||||||
throw new APIError(400, 'Contract not found.')
|
throw new APIError(400, 'Contract not found.')
|
||||||
const contract = contractSnap.data() as CPMMContract
|
const contract = contractSnap.data() as CPMMContract
|
||||||
|
|
||||||
const liquidityCollection = firestore.collection(
|
const liquidityCollection = firestore.collection(
|
||||||
`contracts/${contractId}/liquidity`
|
`contracts/${contractId}/liquidity`
|
||||||
)
|
)
|
||||||
|
|
||||||
const liquiditiesSnap = await trans.get(liquidityCollection)
|
const liquiditiesSnap = await trans.get(liquidityCollection)
|
||||||
|
|
||||||
const liquidities = liquiditiesSnap.docs.map(
|
const liquidities = liquiditiesSnap.docs.map(
|
||||||
(doc) => doc.data() as LiquidityProvision
|
(doc) => doc.data() as LiquidityProvision
|
||||||
)
|
)
|
||||||
|
|
||||||
const userShares = getUserLiquidityShares(
|
const userShares = getUserLiquidityShares(
|
||||||
userId,
|
userId,
|
||||||
contract,
|
contract,
|
||||||
liquidities
|
liquidities
|
||||||
)
|
)
|
||||||
|
|
||||||
// zero all added amounts for now
|
// zero all added amounts for now
|
||||||
// can add support for partial withdrawals in the future
|
// can add support for partial withdrawals in the future
|
||||||
liquiditiesSnap.docs
|
liquiditiesSnap.docs
|
||||||
.filter(
|
.filter(
|
||||||
(_, i) =>
|
(_, i) =>
|
||||||
!liquidities[i].isAnte && liquidities[i].userId === userId
|
!liquidities[i].isAnte && liquidities[i].userId === userId
|
||||||
)
|
)
|
||||||
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
||||||
|
|
||||||
const payout = Math.min(...Object.values(userShares))
|
const payout = Math.min(...Object.values(userShares))
|
||||||
if (payout <= 0) return {}
|
if (payout <= 0) return {}
|
||||||
|
|
||||||
const newBalance = lp.balance + payout
|
const newBalance = lp.balance + payout
|
||||||
const newTotalDeposits = lp.totalDeposits + payout
|
const newTotalDeposits = lp.totalDeposits + payout
|
||||||
trans.update(lpDoc, {
|
trans.update(lpDoc, {
|
||||||
balance: newBalance,
|
balance: newBalance,
|
||||||
totalDeposits: newTotalDeposits,
|
totalDeposits: newTotalDeposits,
|
||||||
} as Partial<User>)
|
} as Partial<User>)
|
||||||
|
|
||||||
const newPool = subtractObjects(contract.pool, userShares)
|
const newPool = subtractObjects(contract.pool, userShares)
|
||||||
|
|
||||||
const minPoolShares = Math.min(...Object.values(newPool))
|
const minPoolShares = Math.min(...Object.values(newPool))
|
||||||
const adjustedTotal = contract.totalLiquidity - payout
|
const adjustedTotal = contract.totalLiquidity - payout
|
||||||
|
|
||||||
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
||||||
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
||||||
|
|
||||||
trans.update(contractDoc, {
|
trans.update(contractDoc, {
|
||||||
pool: newPool,
|
pool: newPool,
|
||||||
totalLiquidity: newTotalLiquidity,
|
totalLiquidity: newTotalLiquidity,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prob = getProbability(contract)
|
const prob = getProbability(contract)
|
||||||
|
|
||||||
// surplus shares become user's bets
|
// surplus shares become user's bets
|
||||||
const bets = Object.entries(userShares)
|
const bets = Object.entries(userShares)
|
||||||
.map(([outcome, shares]) =>
|
.map(([outcome, shares]) =>
|
||||||
shares - payout < 1 // don't create bet if less than 1 share
|
shares - payout < 1 // don't create bet if less than 1 share
|
||||||
? undefined
|
? undefined
|
||||||
: ({
|
: ({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount:
|
amount:
|
||||||
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
|
(outcome === 'YES' ? prob : 1 - prob) * (shares - payout),
|
||||||
shares: shares - payout,
|
shares: shares - payout,
|
||||||
outcome,
|
outcome,
|
||||||
probBefore: prob,
|
probBefore: prob,
|
||||||
probAfter: prob,
|
probAfter: prob,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isLiquidityProvision: true,
|
isLiquidityProvision: true,
|
||||||
fees: noFees,
|
fees: noFees,
|
||||||
} as Omit<Bet, 'id'>)
|
} as Omit<Bet, 'id'>)
|
||||||
)
|
)
|
||||||
.filter((x) => x !== undefined)
|
.filter((x) => x !== undefined)
|
||||||
|
|
||||||
for (const bet of bets) {
|
for (const bet of bets) {
|
||||||
const doc = firestore
|
const doc = firestore
|
||||||
.collection(`contracts/${contract.id}/bets`)
|
.collection(`contracts/${contract.id}/bets`)
|
||||||
.doc()
|
.doc()
|
||||||
trans.create(doc, { id: doc.id, ...bet })
|
trans.create(doc, { id: doc.id, ...bet })
|
||||||
}
|
}
|
||||||
|
|
||||||
return userShares
|
return userShares
|
||||||
})
|
})
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
// redeem surplus bet with pre-existing bets
|
// redeem surplus bet with pre-existing bets
|
||||||
await redeemShares(userId, contractId)
|
await redeemShares(userId, contractId)
|
||||||
|
|
||||||
console.log('userid', userId, 'withdraws', result)
|
console.log('userid', userId, 'withdraws', result)
|
||||||
return { status: 'success', userShares: result }
|
return { status: 'success', userShares: result }
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
return { status: 'error', message: e.message }
|
return { status: 'error', message: e.message }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -19,6 +19,7 @@ module.exports = {
|
||||||
],
|
],
|
||||||
'@next/next/no-img-element': 'off',
|
'@next/next/no-img-element': 'off',
|
||||||
'@next/next/no-typos': 'off',
|
'@next/next/no-typos': 'off',
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
'lodash/import-scope': [2, 'member'],
|
'lodash/import-scope': [2, 'member'],
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sum, mapValues } from 'lodash'
|
import { sum } from 'lodash'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Contract, FreeResponse } from 'common/contract'
|
import { Contract, FreeResponse } from 'common/contract'
|
||||||
import { Col } from '../layout/col'
|
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 { Row } from '../layout/row'
|
||||||
import { ChooseCancelSelector } from '../yes-no-selector'
|
import { ChooseCancelSelector } from '../yes-no-selector'
|
||||||
import { ResolveConfirmationButton } from '../confirmation-button'
|
import { ResolveConfirmationButton } from '../confirmation-button'
|
||||||
|
@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const totalProb = sum(Object.values(chosenAnswers))
|
const totalProb = sum(Object.values(chosenAnswers))
|
||||||
const normalizedProbs = mapValues(
|
const resolutions = Object.entries(chosenAnswers).map(([i, p]) => {
|
||||||
chosenAnswers,
|
return { answer: parseInt(i), pct: (100 * p) / totalProb }
|
||||||
(prob) => (100 * prob) / totalProb
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const resolutionProps = removeUndefinedProps({
|
const resolutionProps = removeUndefinedProps({
|
||||||
outcome:
|
outcome:
|
||||||
resolveOption === 'CHOOSE'
|
resolveOption === 'CHOOSE'
|
||||||
? answers[0]
|
? parseInt(answers[0])
|
||||||
: resolveOption === 'CHOOSE_MULTIPLE'
|
: resolveOption === 'CHOOSE_MULTIPLE'
|
||||||
? 'MKT'
|
? 'MKT'
|
||||||
: 'CANCEL',
|
: 'CANCEL',
|
||||||
resolutions:
|
resolutions:
|
||||||
resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined,
|
resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await resolveMarket(resolutionProps).then((r) => r.data)
|
try {
|
||||||
|
const result = await resolveMarket(resolutionProps)
|
||||||
console.log('resolved', resolutionProps, 'result:', result)
|
console.log('resolved', resolutionProps, 'result:', result)
|
||||||
|
} catch (e) {
|
||||||
if (result?.status !== 'success') {
|
if (e instanceof APIError) {
|
||||||
setError(result?.message || 'Error resolving market')
|
setError(e.toString())
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Error resolving market')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResolveOption(undefined)
|
setResolveOption(undefined)
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
setText('')
|
setText('')
|
||||||
setBetAmount(10)
|
setBetAmount(10)
|
||||||
setAmountError(undefined)
|
setAmountError(undefined)
|
||||||
|
setPossibleDuplicateAnswer(undefined)
|
||||||
} else setAmountError(result.message)
|
} else setAmountError(result.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { partition, sumBy } from 'lodash'
|
import { partition, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
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 { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { Spacer } from './layout/spacer'
|
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 { sellShares } from 'web/lib/firebase/api-call'
|
||||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||||
import { InfoTooltip } from './info-tooltip'
|
import { InfoTooltip } from './info-tooltip'
|
||||||
import { BinaryOutcomeLabel } from './outcome-label'
|
import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label'
|
||||||
import {
|
import {
|
||||||
calculatePayoutAfterCorrectBet,
|
calculatePayoutAfterCorrectBet,
|
||||||
calculateShares,
|
calculateShares,
|
||||||
|
@ -35,6 +39,7 @@ import {
|
||||||
getCpmmProbability,
|
getCpmmProbability,
|
||||||
getCpmmLiquidityFee,
|
getCpmmLiquidityFee,
|
||||||
} from 'common/calculate-cpmm'
|
} from 'common/calculate-cpmm'
|
||||||
|
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||||
import { SellRow } from './sell-row'
|
import { SellRow } from './sell-row'
|
||||||
import { useSaveShares } from './use-save-shares'
|
import { useSaveShares } from './use-save-shares'
|
||||||
import { SignUpPrompt } from './sign-up-prompt'
|
import { SignUpPrompt } from './sign-up-prompt'
|
||||||
|
@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
|
@ -81,7 +86,7 @@ export function BetPanel(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BetPanelSwitcher(props: {
|
export function BetPanelSwitcher(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
title?: string // Set if BetPanel is on a feed modal
|
title?: string // Set if BetPanel is on a feed modal
|
||||||
selected?: 'YES' | 'NO'
|
selected?: 'YES' | 'NO'
|
||||||
|
@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, className, title, selected, onBetSuccess } = props
|
const { contract, className, title, selected, onBetSuccess } = props
|
||||||
|
|
||||||
const { mechanism } = contract
|
const { mechanism, outcomeType } = contract
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const userBets = useUserContractBets(user?.id, contract.id)
|
const userBets = useUserContractBets(user?.id, contract.id)
|
||||||
|
@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: {
|
||||||
<Row className="items-center justify-between gap-2">
|
<Row className="items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
You have {formatWithCommas(floorShares)}{' '}
|
You have {formatWithCommas(floorShares)}{' '}
|
||||||
<BinaryOutcomeLabel outcome={sharesOutcome} /> shares
|
{isPseudoNumeric ? (
|
||||||
|
<PseudoNumericOutcomeLabel outcome={sharesOutcome} />
|
||||||
|
) : (
|
||||||
|
<BinaryOutcomeLabel outcome={sharesOutcome} />
|
||||||
|
)}{' '}
|
||||||
|
shares
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tradeType === 'BUY' && (
|
{tradeType === 'BUY' && (
|
||||||
|
@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function BuyPanel(props: {
|
function BuyPanel(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
selected?: 'YES' | 'NO'
|
selected?: 'YES' | 'NO'
|
||||||
onBuySuccess?: () => void
|
onBuySuccess?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, selected, onBuySuccess } = props
|
const { contract, user, selected, onBuySuccess } = props
|
||||||
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
|
const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected)
|
||||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
||||||
|
@ -302,6 +314,9 @@ function BuyPanel(props: {
|
||||||
: 0)
|
: 0)
|
||||||
)} ${betChoice ?? 'YES'} shares`
|
)} ${betChoice ?? 'YES'} shares`
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const format = getFormattedMappedValue(contract)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
|
@ -309,6 +324,7 @@ function BuyPanel(props: {
|
||||||
btnClassName="flex-1"
|
btnClassName="flex-1"
|
||||||
selected={betChoice}
|
selected={betChoice}
|
||||||
onSelect={(choice) => onBetChoice(choice)}
|
onSelect={(choice) => onBetChoice(choice)}
|
||||||
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
/>
|
/>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
|
@ -323,11 +339,13 @@ function BuyPanel(props: {
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
<Row className="items-center justify-between text-sm">
|
<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>
|
<div>
|
||||||
{formatPercent(initialProb)}
|
{format(initialProb)}
|
||||||
<span className="mx-2">→</span>
|
<span className="mx-2">→</span>
|
||||||
{formatPercent(resultProb)}
|
{format(resultProb)}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
@ -340,6 +358,8 @@ function BuyPanel(props: {
|
||||||
<br /> payout if{' '}
|
<br /> payout if{' '}
|
||||||
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
<BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
||||||
</>
|
</>
|
||||||
|
) : isPseudoNumeric ? (
|
||||||
|
'Max payout'
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
Payout if <BinaryOutcomeLabel outcome={betChoice ?? 'YES'} />
|
||||||
|
@ -389,7 +409,7 @@ function BuyPanel(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SellPanel(props: {
|
export function SellPanel(props: {
|
||||||
contract: CPMMBinaryContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
userBets: Bet[]
|
userBets: Bet[]
|
||||||
shares: number
|
shares: number
|
||||||
sharesOutcome: 'YES' | 'NO'
|
sharesOutcome: 'YES' | 'NO'
|
||||||
|
@ -488,6 +508,10 @@ export function SellPanel(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { outcomeType } = contract
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
const format = getFormattedMappedValue(contract)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
|
@ -511,11 +535,13 @@ export function SellPanel(props: {
|
||||||
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
||||||
</Row>
|
</Row>
|
||||||
<Row className="items-center justify-between">
|
<Row className="items-center justify-between">
|
||||||
<div className="text-gray-500">Probability</div>
|
<div className="text-gray-500">
|
||||||
|
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{formatPercent(initialProb)}
|
{format(initialProb)}
|
||||||
<span className="mx-2">→</span>
|
<span className="mx-2">→</span>
|
||||||
{formatPercent(resultProb)}
|
{format(resultProb)}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -3,7 +3,7 @@ import clsx from 'clsx'
|
||||||
|
|
||||||
import { BetPanelSwitcher } from './bet-panel'
|
import { BetPanelSwitcher } from './bet-panel'
|
||||||
import { YesNoSelector } from './yes-no-selector'
|
import { YesNoSelector } from './yes-no-selector'
|
||||||
import { BinaryContract } from 'common/contract'
|
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
import { SellButton } from './sell-button'
|
import { SellButton } from './sell-button'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
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.
|
// Inline version of a bet panel. Opens BetPanel in a new modal.
|
||||||
export default function BetRow(props: {
|
export default function BetRow(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
btnClassName?: string
|
btnClassName?: string
|
||||||
betPanelClassName?: string
|
betPanelClassName?: string
|
||||||
|
@ -32,6 +32,7 @@ export default function BetRow(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
|
isPseudoNumeric={contract.outcomeType === 'PSEUDO_NUMERIC'}
|
||||||
className={clsx('justify-end', className)}
|
className={clsx('justify-end', className)}
|
||||||
btnClassName={clsx('btn-sm w-24', btnClassName)}
|
btnClassName={clsx('btn-sm w-24', btnClassName)}
|
||||||
onSelect={(choice) => {
|
onSelect={(choice) => {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets'
|
||||||
import { Bet } from 'web/lib/firebase/bets'
|
import { Bet } from 'web/lib/firebase/bets'
|
||||||
import { User } from 'web/lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
import {
|
import {
|
||||||
|
formatLargeNumber,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
|
@ -40,6 +41,7 @@ import {
|
||||||
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
|
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
|
||||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
import { trackLatency } from 'web/lib/firebase/tracking'
|
||||||
import { NumericContract } from 'common/contract'
|
import { NumericContract } from 'common/contract'
|
||||||
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { SellSharesModal } from './sell-modal'
|
import { SellSharesModal } from './sell-modal'
|
||||||
|
|
||||||
|
@ -366,6 +368,7 @@ export function BetsSummary(props: {
|
||||||
const { contract, isYourBets, className } = props
|
const { contract, isYourBets, className } = props
|
||||||
const { resolution, closeTime, outcomeType, mechanism } = contract
|
const { resolution, closeTime, outcomeType, mechanism } = contract
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const isCpmm = mechanism === 'cpmm-1'
|
const isCpmm = mechanism === 'cpmm-1'
|
||||||
const isClosed = closeTime && Date.now() > closeTime
|
const isClosed = closeTime && Date.now() > closeTime
|
||||||
|
|
||||||
|
@ -427,6 +430,25 @@ export function BetsSummary(props: {
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</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>
|
<Col>
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
@ -507,13 +529,15 @@ export function ContractBetsTable(props: {
|
||||||
const { isResolved, mechanism, outcomeType } = contract
|
const { isResolved, mechanism, outcomeType } = contract
|
||||||
const isCPMM = mechanism === 'cpmm-1'
|
const isCPMM = mechanism === 'cpmm-1'
|
||||||
const isNumeric = outcomeType === 'NUMERIC'
|
const isNumeric = outcomeType === 'NUMERIC'
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('overflow-x-auto', className)}>
|
<div className={clsx('overflow-x-auto', className)}>
|
||||||
{amountRedeemed > 0 && (
|
{amountRedeemed > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="pl-2 text-sm text-gray-500">
|
<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)}.
|
automatically redeemed for {formatMoney(amountRedeemed)}.
|
||||||
</div>
|
</div>
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
@ -541,7 +565,7 @@ export function ContractBetsTable(props: {
|
||||||
)}
|
)}
|
||||||
{!isCPMM && !isResolved && <th>Payout if chosen</th>}
|
{!isCPMM && !isResolved && <th>Payout if chosen</th>}
|
||||||
<th>Shares</th>
|
<th>Shares</th>
|
||||||
<th>Probability</th>
|
{!isPseudoNumeric && <th>Probability</th>}
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -585,6 +609,7 @@ function BetRow(props: {
|
||||||
|
|
||||||
const isCPMM = mechanism === 'cpmm-1'
|
const isCPMM = mechanism === 'cpmm-1'
|
||||||
const isNumeric = outcomeType === 'NUMERIC'
|
const isNumeric = outcomeType === 'NUMERIC'
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const saleAmount = saleBet?.sale?.amount
|
const saleAmount = saleBet?.sale?.amount
|
||||||
|
|
||||||
|
@ -628,14 +653,18 @@ function BetRow(props: {
|
||||||
truncate="short"
|
truncate="short"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isPseudoNumeric &&
|
||||||
|
' than ' + formatNumericProbability(bet.probAfter, contract)}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatMoney(Math.abs(amount))}</td>
|
<td>{formatMoney(Math.abs(amount))}</td>
|
||||||
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
|
{!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
|
||||||
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
|
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
|
||||||
<td>{formatWithCommas(Math.abs(shares))}</td>
|
<td>{formatWithCommas(Math.abs(shares))}</td>
|
||||||
<td>
|
{!isPseudoNumeric && (
|
||||||
{formatPercent(probBefore)} → {formatPercent(probAfter)}
|
<td>
|
||||||
</td>
|
{formatPercent(probBefore)} → {formatPercent(probAfter)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td>{dayjs(createdTime).format('MMM D, h:mma')}</td>
|
<td>{dayjs(createdTime).format('MMM D, h:mma')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
useSortBy,
|
useSortBy,
|
||||||
} from 'react-instantsearch-hooks-web'
|
} from 'react-instantsearch-hooks-web'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import {
|
import {
|
||||||
Sort,
|
Sort,
|
||||||
useInitialQueryAndSort,
|
useInitialQueryAndSort,
|
||||||
|
@ -58,15 +58,24 @@ export function ContractSearch(props: {
|
||||||
additionalFilter?: {
|
additionalFilter?: {
|
||||||
creatorId?: string
|
creatorId?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
|
excludeContractIds?: string[]
|
||||||
}
|
}
|
||||||
showCategorySelector: boolean
|
showCategorySelector: boolean
|
||||||
onContractClick?: (contract: Contract) => void
|
onContractClick?: (contract: Contract) => void
|
||||||
|
showPlaceHolder?: boolean
|
||||||
|
hideOrderSelector?: boolean
|
||||||
|
overrideGridClassName?: string
|
||||||
|
hideQuickBet?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
querySortOptions,
|
querySortOptions,
|
||||||
additionalFilter,
|
additionalFilter,
|
||||||
showCategorySelector,
|
showCategorySelector,
|
||||||
onContractClick,
|
onContractClick,
|
||||||
|
overrideGridClassName,
|
||||||
|
hideOrderSelector,
|
||||||
|
showPlaceHolder,
|
||||||
|
hideQuickBet,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -122,7 +131,7 @@ export function ContractSearch(props: {
|
||||||
|
|
||||||
const indexName = `${indexPrefix}contracts-${sort}`
|
const indexName = `${indexPrefix}contracts-${sort}`
|
||||||
|
|
||||||
if (IS_PRIVATE_MANIFOLD) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return (
|
return (
|
||||||
<ContractSearchFirestore
|
<ContractSearchFirestore
|
||||||
querySortOptions={querySortOptions}
|
querySortOptions={querySortOptions}
|
||||||
|
@ -136,6 +145,7 @@ export function ContractSearch(props: {
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<SearchBox
|
<SearchBox
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
|
||||||
classNames={{
|
classNames={{
|
||||||
form: 'before:top-6',
|
form: 'before:top-6',
|
||||||
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
|
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="resolved">Resolved</option>
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
</select>
|
</select>
|
||||||
<SortBy
|
{!hideOrderSelector && (
|
||||||
items={sortIndexes}
|
<SortBy
|
||||||
classNames={{
|
items={sortIndexes}
|
||||||
select: '!select !select-bordered',
|
classNames={{
|
||||||
}}
|
select: '!select !select-bordered',
|
||||||
onBlur={trackCallback('select search sort')}
|
}}
|
||||||
/>
|
onBlur={trackCallback('select search sort')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Configure
|
<Configure
|
||||||
facetFilters={filters}
|
facetFilters={filters}
|
||||||
numericFilters={numericFilters}
|
numericFilters={numericFilters}
|
||||||
|
@ -187,6 +199,9 @@ export function ContractSearch(props: {
|
||||||
<ContractSearchInner
|
<ContractSearchInner
|
||||||
querySortOptions={querySortOptions}
|
querySortOptions={querySortOptions}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
|
overrideGridClassName={overrideGridClassName}
|
||||||
|
hideQuickBet={hideQuickBet}
|
||||||
|
excludeContractIds={additionalFilter?.excludeContractIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InstantSearch>
|
</InstantSearch>
|
||||||
|
@ -199,8 +214,17 @@ export function ContractSearchInner(props: {
|
||||||
shouldLoadFromStorage?: boolean
|
shouldLoadFromStorage?: boolean
|
||||||
}
|
}
|
||||||
onContractClick?: (contract: Contract) => void
|
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 { initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||||
|
|
||||||
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
const { query, setQuery, setSort } = useUpdateQueryAndSort({
|
||||||
|
@ -239,7 +263,7 @@ export function ContractSearchInner(props: {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { showMore, hits, isLastPage } = useInfiniteHits()
|
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 <></>
|
if (isInitialLoad && contracts.length === 0) return <></>
|
||||||
|
|
||||||
|
@ -249,6 +273,9 @@ export function ContractSearchInner(props: {
|
||||||
? 'resolve-date'
|
? 'resolve-date'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
if (excludeContractIds)
|
||||||
|
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContractsGrid
|
<ContractsGrid
|
||||||
contracts={contracts}
|
contracts={contracts}
|
||||||
|
@ -256,6 +283,8 @@ export function ContractSearchInner(props: {
|
||||||
hasMore={!isLastPage}
|
hasMore={!isLastPage}
|
||||||
showTime={showTime}
|
showTime={showTime}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
|
overrideGridClassName={overrideGridClassName}
|
||||||
|
hideQuickBet={hideQuickBet}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
BinaryContract,
|
BinaryContract,
|
||||||
FreeResponseContract,
|
FreeResponseContract,
|
||||||
NumericContract,
|
NumericContract,
|
||||||
|
PseudoNumericContract,
|
||||||
} from 'common/contract'
|
} from 'common/contract'
|
||||||
import {
|
import {
|
||||||
AnswerLabel,
|
AnswerLabel,
|
||||||
|
@ -16,7 +17,11 @@ import {
|
||||||
CancelLabel,
|
CancelLabel,
|
||||||
FreeResponseOutcomeLabel,
|
FreeResponseOutcomeLabel,
|
||||||
} from '../outcome-label'
|
} from '../outcome-label'
|
||||||
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
|
import {
|
||||||
|
getOutcomeProbability,
|
||||||
|
getProbability,
|
||||||
|
getTopAnswer,
|
||||||
|
} from 'common/calculate'
|
||||||
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
||||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
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 { useUser } from 'web/hooks/use-user'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
export function ContractCard(props: {
|
export function ContractCard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -131,6 +137,13 @@ export function ContractCard(props: {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{outcomeType === 'PSEUDO_NUMERIC' && (
|
||||||
|
<PseudoNumericResolutionOrExpectation
|
||||||
|
className="items-center"
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{outcomeType === 'NUMERIC' && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<NumericResolutionOrExpectation
|
<NumericResolutionOrExpectation
|
||||||
className="items-center"
|
className="items-center"
|
||||||
|
@ -270,7 +283,9 @@ export function NumericResolutionOrExpectation(props: {
|
||||||
{resolution === 'CANCEL' ? (
|
{resolution === 'CANCEL' ? (
|
||||||
<CancelLabel />
|
<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>
|
</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 { SiteLink } from 'web/components/site-link'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
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'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -128,8 +130,32 @@ export function ContractDetails(props: {
|
||||||
const { contract, bets, isCreator, disabled } = props
|
const { contract, bets, isCreator, disabled } = props
|
||||||
const { closeTime, creatorName, creatorUsername, creatorId } = contract
|
const { closeTime, creatorName, creatorUsername, creatorId } = contract
|
||||||
const { volumeLabel, resolvedDate } = contractMetrics(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 (
|
return (
|
||||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
<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">
|
<Row className="items-center gap-2">
|
||||||
|
@ -150,14 +176,15 @@ export function ContractDetails(props: {
|
||||||
)}
|
)}
|
||||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||||
</Row>
|
</Row>
|
||||||
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/}
|
{groupToDisplay ? (
|
||||||
{groups && groups.length > 0 && (
|
|
||||||
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
|
<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" />
|
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
|
||||||
<span>{groups[0].name}</span>
|
<span>{groupToDisplay.name}</span>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
</Row>
|
</Row>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(!!closeTime || !!resolvedDate) && (
|
{(!!closeTime || !!resolvedDate) && (
|
||||||
|
@ -192,6 +219,11 @@ export function ContractDetails(props: {
|
||||||
|
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
<ShareIconButton
|
||||||
|
contract={contract}
|
||||||
|
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||||
|
username={user?.username}
|
||||||
|
/>
|
||||||
|
|
||||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
getBinaryProbPercent,
|
getBinaryProbPercent,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { LiquidityPanel } from '../liquidity-panel'
|
import { LiquidityPanel } from '../liquidity-panel'
|
||||||
import { CopyLinkButton } from '../copy-link-button'
|
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Modal } from '../layout/modal'
|
import { Modal } from '../layout/modal'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
@ -22,6 +21,10 @@ import { Title } from '../title'
|
||||||
import { TweetButton } from '../tweet-button'
|
import { TweetButton } from '../tweet-button'
|
||||||
import { InfoTooltip } from '../info-tooltip'
|
import { InfoTooltip } from '../info-tooltip'
|
||||||
import { TagsInput } from 'web/components/tags-input'
|
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[] }) {
|
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const { contract, bets } = props
|
const { contract, bets } = props
|
||||||
|
@ -48,13 +51,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<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)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon
|
<DotsHorizontalIcon
|
||||||
className={clsx(
|
className={clsx('h-6 w-6 flex-shrink-0')}
|
||||||
'h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500'
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
@ -66,15 +67,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
<div>Share</div>
|
<div>Share</div>
|
||||||
|
|
||||||
<Row className="justify-start gap-4">
|
<Row className="justify-start gap-4">
|
||||||
<CopyLinkButton
|
|
||||||
contract={contract}
|
|
||||||
toastClassName={'sm:-left-10 -left-4 min-w-[250%]'}
|
|
||||||
/>
|
|
||||||
<TweetButton
|
<TweetButton
|
||||||
className="self-start"
|
className="self-start"
|
||||||
tweetText={getTweetText(contract, false)}
|
tweetText={getTweetText(contract, false)}
|
||||||
/>
|
/>
|
||||||
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
<ShareEmbedButton contract={contract} toastClassName={'-left-20'} />
|
||||||
|
<DuplicateContractButton contract={contract} />
|
||||||
</Row>
|
</Row>
|
||||||
<div />
|
<div />
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
FreeResponseResolutionOrChance,
|
FreeResponseResolutionOrChance,
|
||||||
BinaryResolutionOrChance,
|
BinaryResolutionOrChance,
|
||||||
NumericResolutionOrExpectation,
|
NumericResolutionOrExpectation,
|
||||||
|
PseudoNumericResolutionOrExpectation,
|
||||||
} from './contract-card'
|
} from './contract-card'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import BetRow from '../bet-row'
|
import BetRow from '../bet-row'
|
||||||
|
@ -32,6 +33,7 @@ export const ContractOverview = (props: {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isCreator = user?.id === creatorId
|
const isCreator = user?.id === creatorId
|
||||||
const isBinary = outcomeType === 'BINARY'
|
const isBinary = outcomeType === 'BINARY'
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx('mb-6', className)}>
|
<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' && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<NumericResolutionOrExpectation
|
<NumericResolutionOrExpectation
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -61,6 +70,11 @@ export const ContractOverview = (props: {
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
<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} />}
|
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
|
@ -86,7 +100,9 @@ export const ContractOverview = (props: {
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '}
|
{(isBinary || isPseudoNumeric) && (
|
||||||
|
<ContractProbGraph contract={contract} bets={bets} />
|
||||||
|
)}{' '}
|
||||||
{outcomeType === 'FREE_RESPONSE' && (
|
{outcomeType === 'FREE_RESPONSE' && (
|
||||||
<AnswersGraph contract={contract} bets={bets} />
|
<AnswersGraph contract={contract} bets={bets} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -5,16 +5,20 @@ import dayjs from 'dayjs'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { getInitialProbability } from 'common/calculate'
|
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 { 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: {
|
export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
height?: number
|
height?: number
|
||||||
}) {
|
}) {
|
||||||
const { contract, height } = props
|
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)
|
const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
|
||||||
|
|
||||||
|
@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
contract.createdTime,
|
contract.createdTime,
|
||||||
...bets.map((bet) => bet.createdTime),
|
...bets.map((bet) => bet.createdTime),
|
||||||
].map((time) => new Date(time))
|
].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 isClosed = !!closeTime && Date.now() > closeTime
|
||||||
const latestTime = dayjs(
|
const latestTime = dayjs(
|
||||||
|
@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
times.push(latestTime.toDate())
|
times.push(latestTime.toDate())
|
||||||
probs.push(probs[probs.length - 1])
|
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()
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
|
@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
const totalPoints = width ? (width > 800 ? 300 : 50) : 1
|
const totalPoints = width ? (width > 800 ? 300 : 50) : 1
|
||||||
|
|
||||||
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
|
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
|
||||||
|
|
||||||
const points: { x: Date; y: number }[] = []
|
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++) {
|
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(
|
const numPoints: number = Math.floor(
|
||||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
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])
|
x: dayjs(times[i])
|
||||||
.add(thisTimeStep * n, 'ms')
|
.add(thisTimeStep * n, 'ms')
|
||||||
.toDate(),
|
.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 multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
||||||
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
|
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
|
||||||
|
|
||||||
|
const formatter = isBinary
|
||||||
|
? formatPercent
|
||||||
|
: (x: DatumValue) => formatLargeNumber(+x.valueOf())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full overflow-visible"
|
className="w-full overflow-visible"
|
||||||
|
@ -87,12 +108,20 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
||||||
>
|
>
|
||||||
<ResponsiveLine
|
<ResponsiveLine
|
||||||
data={data}
|
data={data}
|
||||||
yScale={{ min: 0, max: 100, type: 'linear' }}
|
yScale={
|
||||||
yFormat={formatPercent}
|
isBinary
|
||||||
|
? { min: 0, max: 100, type: 'linear' }
|
||||||
|
: {
|
||||||
|
min: contract.min + c,
|
||||||
|
max: contract.max + c,
|
||||||
|
type: contract.isLogScale ? 'log' : 'linear',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yFormat={formatter}
|
||||||
gridYValues={yTickValues}
|
gridYValues={yTickValues}
|
||||||
axisLeft={{
|
axisLeft={{
|
||||||
tickValues: yTickValues,
|
tickValues: yTickValues,
|
||||||
format: formatPercent,
|
format: formatter,
|
||||||
}}
|
}}
|
||||||
xScale={{
|
xScale={{
|
||||||
type: 'time',
|
type: 'time',
|
||||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
getOutcomeProbability,
|
getOutcomeProbability,
|
||||||
getOutcomeProbabilityAfterBet,
|
getOutcomeProbabilityAfterBet,
|
||||||
|
getProbability,
|
||||||
getTopAnswer,
|
getTopAnswer,
|
||||||
} from 'common/calculate'
|
} from 'common/calculate'
|
||||||
import { getExpectedValue } from 'common/calculate-dpm'
|
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 { sellShares } from 'web/lib/firebase/api-call'
|
||||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
const BET_SIZE = 10
|
const BET_SIZE = 10
|
||||||
|
|
||||||
export function QuickBet(props: { contract: Contract; user: User }) {
|
export function QuickBet(props: { contract: Contract; user: User }) {
|
||||||
const { contract, user } = props
|
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 userBets = useUserContractBets(user.id, contract.id)
|
||||||
const topAnswer =
|
const topAnswer =
|
||||||
contract.outcomeType === 'FREE_RESPONSE'
|
outcomeType === 'FREE_RESPONSE' ? getTopAnswer(contract) : undefined
|
||||||
? getTopAnswer(contract)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// TODO: yes/no from useSaveShares doesn't work on numeric contracts
|
// TODO: yes/no from useSaveShares doesn't work on numeric contracts
|
||||||
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
|
const { yesFloorShares, noFloorShares, yesShares, noShares } = useSaveShares(
|
||||||
|
@ -45,9 +46,9 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
||||||
topAnswer?.number.toString() || undefined
|
topAnswer?.number.toString() || undefined
|
||||||
)
|
)
|
||||||
const hasUpShares =
|
const hasUpShares =
|
||||||
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC')
|
yesFloorShares || (noFloorShares && outcomeType === 'NUMERIC')
|
||||||
const hasDownShares =
|
const hasDownShares =
|
||||||
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC'
|
noFloorShares && yesFloorShares <= 0 && outcomeType !== 'NUMERIC'
|
||||||
|
|
||||||
const [upHover, setUpHover] = useState(false)
|
const [upHover, setUpHover] = useState(false)
|
||||||
const [downHover, setDownHover] = 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 (
|
return (
|
||||||
<Col
|
<Col
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -173,14 +155,14 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
||||||
<TriangleFillIcon
|
<TriangleFillIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mx-auto h-5 w-5',
|
'mx-auto h-5 w-5',
|
||||||
upHover ? textColor : 'text-gray-400'
|
upHover ? 'text-green-500' : 'text-gray-400'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TriangleFillIcon
|
<TriangleFillIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'mx-auto h-5 w-5',
|
'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} />
|
<QuickOutcomeView contract={contract} previewProb={previewProb} />
|
||||||
|
|
||||||
{/* Down bet triangle */}
|
{/* Down bet triangle */}
|
||||||
{contract.outcomeType !== 'BINARY' ? (
|
{outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
|
<div className="peer absolute bottom-0 left-0 right-0 h-[50%] cursor-default"></div>
|
||||||
<TriangleDownFillIcon
|
<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: {
|
function QuickOutcomeView(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
previewProb?: number
|
previewProb?: number
|
||||||
|
@ -261,9 +262,16 @@ function QuickOutcomeView(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, previewProb, caption } = props
|
const { contract, previewProb, caption } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
// If there's a preview prob, display that instead of the current prob
|
// If there's a preview prob, display that instead of the current prob
|
||||||
const override =
|
const override =
|
||||||
previewProb === undefined ? undefined : formatPercent(previewProb)
|
previewProb === undefined
|
||||||
|
? undefined
|
||||||
|
: isPseudoNumeric
|
||||||
|
? formatNumericProbability(previewProb, contract)
|
||||||
|
: formatPercent(previewProb)
|
||||||
|
|
||||||
const textColor = `text-${getColor(contract)}`
|
const textColor = `text-${getColor(contract)}`
|
||||||
|
|
||||||
let display: string | undefined
|
let display: string | undefined
|
||||||
|
@ -271,6 +279,9 @@ function QuickOutcomeView(props: {
|
||||||
case 'BINARY':
|
case 'BINARY':
|
||||||
display = getBinaryProbPercent(contract)
|
display = getBinaryProbPercent(contract)
|
||||||
break
|
break
|
||||||
|
case 'PSEUDO_NUMERIC':
|
||||||
|
display = formatNumericProbability(getProbability(contract), contract)
|
||||||
|
break
|
||||||
case 'NUMERIC':
|
case 'NUMERIC':
|
||||||
display = formatLargeNumber(getExpectedValue(contract))
|
display = formatLargeNumber(getExpectedValue(contract))
|
||||||
break
|
break
|
||||||
|
@ -295,11 +306,15 @@ function QuickOutcomeView(props: {
|
||||||
// Return a number from 0 to 1 for this contract
|
// Return a number from 0 to 1 for this contract
|
||||||
// Resolved contracts are set to 1, for coloring purposes (even if NO)
|
// Resolved contracts are set to 1, for coloring purposes (even if NO)
|
||||||
function getProb(contract: Contract) {
|
function getProb(contract: Contract) {
|
||||||
const { outcomeType, resolution } = contract
|
const { outcomeType, resolution, resolutionProbability } = contract
|
||||||
return resolution
|
return resolutionProbability
|
||||||
|
? resolutionProbability
|
||||||
|
: resolution
|
||||||
? 1
|
? 1
|
||||||
: outcomeType === 'BINARY'
|
: outcomeType === 'BINARY'
|
||||||
? getBinaryProb(contract)
|
? getBinaryProb(contract)
|
||||||
|
: outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
? getProbability(contract)
|
||||||
: outcomeType === 'FREE_RESPONSE'
|
: outcomeType === 'FREE_RESPONSE'
|
||||||
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
|
||||||
: outcomeType === 'NUMERIC'
|
: outcomeType === 'NUMERIC'
|
||||||
|
@ -316,7 +331,8 @@ function getNumericScale(contract: NumericContract) {
|
||||||
export function getColor(contract: Contract) {
|
export function getColor(contract: Contract) {
|
||||||
// TODO: Try injecting a gradient here
|
// TODO: Try injecting a gradient here
|
||||||
// return 'primary'
|
// return 'primary'
|
||||||
const { resolution } = contract
|
const { resolution, outcomeType } = contract
|
||||||
|
|
||||||
if (resolution) {
|
if (resolution) {
|
||||||
return (
|
return (
|
||||||
OUTCOME_TO_COLOR[resolution as resolution] ??
|
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()) {
|
if ((contract.closeTime ?? Infinity) < Date.now()) {
|
||||||
return 'gray-400'
|
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 { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { UsersIcon } from '@heroicons/react/solid'
|
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 { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||||
import React, { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
|
import { uniqBy, partition, sumBy, groupBy } from 'lodash'
|
||||||
import { JoinSpans } from 'web/components/join-spans'
|
import { JoinSpans } from 'web/components/join-spans'
|
||||||
import { UserLink } from '../user-page'
|
import { UserLink } from '../user-page'
|
||||||
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
export function FeedBet(props: {
|
export function FeedBet(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -75,6 +76,8 @@ export function BetStatusText(props: {
|
||||||
hideOutcome?: boolean
|
hideOutcome?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { bet, contract, bettor, isSelf, hideOutcome } = props
|
const { bet, contract, bettor, isSelf, hideOutcome } = props
|
||||||
|
const { outcomeType } = contract
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
const { amount, outcome, createdTime } = bet
|
const { amount, outcome, createdTime } = bet
|
||||||
|
|
||||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
const bought = amount >= 0 ? 'bought' : 'sold'
|
||||||
|
@ -97,7 +100,10 @@ export function BetStatusText(props: {
|
||||||
value={(bet as any).value}
|
value={(bet as any).value}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
truncate="short"
|
truncate="short"
|
||||||
/>
|
/>{' '}
|
||||||
|
{isPseudoNumeric
|
||||||
|
? ' than ' + formatNumericProbability(bet.probAfter, contract)
|
||||||
|
: ' at ' + formatPercent(bet.probAfter)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RelativeTimestamp time={createdTime} />
|
<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 { useUsers } from 'web/hooks/use-users'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Fragment, useMemo, useState } from 'react'
|
import { Fragment, useMemo, useState } from 'react'
|
||||||
|
@ -6,13 +6,24 @@ import clsx from 'clsx'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { UserLink } from 'web/components/user-page'
|
||||||
|
|
||||||
export function FilterSelectUsers(props: {
|
export function FilterSelectUsers(props: {
|
||||||
setSelectedUsers: (users: User[]) => void
|
setSelectedUsers: (users: User[]) => void
|
||||||
selectedUsers: User[]
|
selectedUsers: User[]
|
||||||
ignoreUserIds: string[]
|
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 users = useUsers()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
|
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
|
||||||
|
@ -24,94 +35,124 @@ export function FilterSelectUsers(props: {
|
||||||
return (
|
return (
|
||||||
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
||||||
!ignoreUserIds.includes(user.id) &&
|
!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])
|
}, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
|
||||||
|
const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative mt-1 rounded-md">
|
{shouldShow && (
|
||||||
<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 className="relative mt-1 rounded-md">
|
||||||
</div>
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<input
|
<UserIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||||
type="text"
|
</div>
|
||||||
name="user name"
|
<input
|
||||||
id="user name"
|
type="text"
|
||||||
value={query}
|
name="user name"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
id="user name"
|
||||||
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
|
value={query}
|
||||||
placeholder="Austin Chen"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
|
||||||
</div>
|
placeholder="Austin Chen"
|
||||||
<Menu
|
/>
|
||||||
as="div"
|
</div>
|
||||||
className={clsx(
|
<Menu
|
||||||
'relative inline-block w-full overflow-y-scroll text-right',
|
as="div"
|
||||||
beginQuerying && 'h-36'
|
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"
|
|
||||||
>
|
>
|
||||||
<Menu.Items
|
{({}) => (
|
||||||
static={true}
|
<Transition
|
||||||
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"
|
show={beginQuerying}
|
||||||
>
|
as={Fragment}
|
||||||
<div className="py-1">
|
enter="transition ease-out duration-100"
|
||||||
{filteredUsers.map((user: User) => (
|
enterFrom="transform opacity-0 scale-95"
|
||||||
<Menu.Item key={user.id}>
|
enterTo="transform opacity-100 scale-100"
|
||||||
{({ active }) => (
|
leave="transition ease-in duration-75"
|
||||||
<span
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
className={clsx(
|
leaveTo="transform opacity-0 scale-95"
|
||||||
active
|
>
|
||||||
? 'bg-gray-100 text-gray-900'
|
<Menu.Items
|
||||||
: 'text-gray-700',
|
static={true}
|
||||||
'group flex items-center px-4 py-2 text-sm'
|
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={() => {
|
</Menu.Item>
|
||||||
setQuery('')
|
))}
|
||||||
setSelectedUsers([...selectedUsers, user])
|
</div>
|
||||||
}}
|
</Menu.Items>
|
||||||
>
|
</Transition>
|
||||||
<Avatar
|
)}
|
||||||
username={user.username}
|
</Menu>
|
||||||
avatarUrl={user.avatarUrl}
|
</>
|
||||||
size={'xs'}
|
)}
|
||||||
className={'mr-2'}
|
|
||||||
/>
|
|
||||||
{user.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Menu.Items>
|
|
||||||
</Transition>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
{selectedUsers.length > 0 && (
|
{selectedUsers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className={'mb-2'}>Added members:</div>
|
<div className={'mb-2'}>
|
||||||
<Row className="mt-0 grid grid-cols-6 gap-2">
|
{showSelectedUsersTitle && 'Added members:'}
|
||||||
|
</div>
|
||||||
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'mt-0 grid grid-cols-6 gap-2',
|
||||||
|
selectedUsersClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
{selectedUsers.map((user: User) => (
|
{selectedUsers.map((user: User) => (
|
||||||
<div key={user.id} className="col-span-2 flex items-center">
|
<div
|
||||||
<Avatar
|
key={user.id}
|
||||||
username={user.username}
|
className="col-span-2 flex flex-row items-center justify-between"
|
||||||
avatarUrl={user.avatarUrl}
|
>
|
||||||
size={'sm'}
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const { group, className } = props
|
const { group, className } = props
|
||||||
|
@ -35,7 +36,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
await updateGroup(group, {
|
await updateGroup(group, {
|
||||||
name,
|
name,
|
||||||
about,
|
about,
|
||||||
memberIds: [...memberIds, ...addMemberUsers.map((user) => user.id)],
|
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
|
@ -46,7 +47,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
<div className={clsx('flex p-1', className)}>
|
<div className={clsx('flex p-1', className)}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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)}
|
onClick={() => updateOpen(!open)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -91,6 +91,9 @@ export function GroupChat(props: {
|
||||||
setReplyToUsername('')
|
setReplyToUsername('')
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
|
function focusInput() {
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'flex-1'}>
|
<Col className={'flex-1'}>
|
||||||
|
@ -117,7 +120,13 @@ export function GroupChat(props: {
|
||||||
))}
|
))}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="p-2 text-gray-500">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -22,7 +22,7 @@ export function GroupSelector(props: {
|
||||||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const memberGroups = useMemberGroups(creator)
|
const memberGroups = useMemberGroups(creator?.id)
|
||||||
const filteredGroups = memberGroups
|
const filteredGroups = memberGroups
|
||||||
? query === ''
|
? query === ''
|
||||||
? memberGroups
|
? 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 { Fragment, ReactNode } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
// From https://tailwindui.com/components/application-ui/overlays/modals
|
// From https://tailwindui.com/components/application-ui/overlays/modals
|
||||||
export function Modal(props: {
|
export function Modal(props: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { children, open, setOpen } = props
|
const { children, open, setOpen, className } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={open} as={Fragment}>
|
<Transition.Root show={open} as={Fragment}>
|
||||||
|
@ -45,7 +47,12 @@ export function Modal(props: {
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
|
@ -14,16 +14,16 @@ type Tab = {
|
||||||
export function Tabs(props: {
|
export function Tabs(props: {
|
||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
defaultIndex?: number
|
defaultIndex?: number
|
||||||
className?: string
|
labelClassName?: string
|
||||||
onClick?: (tabTitle: string, index: number) => void
|
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 [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
|
||||||
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
|
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
|
||||||
|
|
||||||
return (
|
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">
|
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||||
{tabs.map((tab, i) => (
|
{tabs.map((tab, i) => (
|
||||||
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
|
<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-indigo-500 text-indigo-600'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
: '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',
|
'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium',
|
||||||
className
|
labelClassName
|
||||||
)}
|
)}
|
||||||
aria-current={activeIndex === i ? 'page' : undefined}
|
aria-current={activeIndex === i ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
|
@ -56,7 +56,7 @@ export function Tabs(props: {
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">{activeTab?.content}</div>
|
{activeTab?.content}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@ import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
MenuAlt3Icon,
|
MenuAlt3Icon,
|
||||||
PresentationChartLineIcon,
|
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
|
@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
function getNavigation(username: string) {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{
|
|
||||||
name: 'Portfolio',
|
|
||||||
href: `/${username}?tab=bets`,
|
|
||||||
icon: PresentationChartLineIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
href: `/notifications`,
|
href: `/notifications`,
|
||||||
|
@ -55,38 +49,40 @@ export function BottomNavBar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationOptions =
|
const navigationOptions =
|
||||||
user === null
|
user === null ? signedOutNavigation : getNavigation()
|
||||||
? signedOutNavigation
|
|
||||||
: getNavigation(user?.username || 'error')
|
|
||||||
|
|
||||||
return (
|
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">
|
<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) => (
|
{navigationOptions.map((item) => (
|
||||||
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
|
<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
|
<div
|
||||||
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
|
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)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
{user === null ? (
|
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||||
<>
|
More
|
||||||
<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)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
|
@ -99,6 +95,7 @@ export function BottomNavBar() {
|
||||||
|
|
||||||
function NavBarItem(props: { item: Item; currentPage: string }) {
|
function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||||
const { item, currentPage } = props
|
const { item, currentPage } = props
|
||||||
|
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={item.href}>
|
<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',
|
'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'
|
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}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -6,8 +6,8 @@ import {
|
||||||
CashIcon,
|
CashIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
ChevronDownIcon,
|
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
|
ChatIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
@ -18,13 +18,16 @@ import { ManifoldLogo } from './manifold-logo'
|
||||||
import { MenuButton } from './menu'
|
import { MenuButton } from './menu'
|
||||||
import { ProfileSummary } from './profile-menu'
|
import { ProfileSummary } from './profile-menu'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
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 { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||||
import { Group } from 'common/group'
|
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() {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
|
@ -82,8 +85,20 @@ const signedOutNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedOutMobileNavigation = [
|
const signedOutMobileNavigation = [
|
||||||
|
{
|
||||||
|
name: 'About',
|
||||||
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
icon: BookOpenIcon,
|
||||||
|
},
|
||||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
{ 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',
|
name: 'About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
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() {
|
function getMoreMobileNav() {
|
||||||
return [
|
return [
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'Statistics', href: '/stats' },
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
{
|
{
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
href: '#',
|
href: '#',
|
||||||
|
@ -112,8 +122,9 @@ function getMoreMobileNav() {
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
name: string
|
name: string
|
||||||
|
trackingEventName?: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem(props: { item: Item; currentPage: 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}
|
aria-current={item.href == currentPage ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<item.icon
|
{item.icon && (
|
||||||
className={clsx(
|
<item.icon
|
||||||
item.href == currentPage
|
className={clsx(
|
||||||
? 'text-gray-500'
|
item.href == currentPage
|
||||||
: 'text-gray-400 group-hover:text-gray-500',
|
? 'text-gray-500'
|
||||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
: 'text-gray-400 group-hover:text-gray-500',
|
||||||
)}
|
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||||
aria-hidden="true"
|
)}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -167,14 +180,6 @@ function MoreButton() {
|
||||||
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
|
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 }) {
|
export default function Sidebar(props: { className?: string }) {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -185,7 +190,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
const mobileNavigationOptions = !user
|
const mobileNavigationOptions = !user
|
||||||
? signedOutMobileNavigation
|
? signedOutMobileNavigation
|
||||||
: signedInMobileNavigation
|
: signedInMobileNavigation
|
||||||
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
|
const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
href: groupPath(group.slug),
|
href: groupPath(group.slug),
|
||||||
}))
|
}))
|
||||||
|
@ -193,31 +198,20 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Sidebar" className={className}>
|
<nav aria-label="Sidebar" className={className}>
|
||||||
<ManifoldLogo className="pb-6" twoLine />
|
<ManifoldLogo className="pb-6" twoLine />
|
||||||
|
|
||||||
|
<CreateQuestionButton user={user} />
|
||||||
|
<Spacer h={4} />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="mb-2" style={{ minHeight: 80 }}>
|
<div className="w-full" style={{ minHeight: 80 }}>
|
||||||
<ProfileSummary user={user} />
|
<ProfileSummary user={user} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile navigation */}
|
{/* Mobile navigation */}
|
||||||
<div className="space-y-1 lg:hidden">
|
<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) => (
|
{mobileNavigationOptions.map((item) => (
|
||||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
))}
|
))}
|
||||||
{!user && (
|
|
||||||
<SidebarItem
|
|
||||||
key={'Groups'}
|
|
||||||
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
|
||||||
currentPage={currentPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MenuButton
|
<MenuButton
|
||||||
|
@ -225,41 +219,83 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GroupsList
|
||||||
|
currentPage={router.asPath}
|
||||||
|
memberItems={memberItems}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop navigation */}
|
{/* Desktop navigation */}
|
||||||
<div className="hidden space-y-1 lg:block">
|
<div className="hidden space-y-1 lg:block">
|
||||||
{navigationOptions.map((item) =>
|
{navigationOptions.map((item) => (
|
||||||
item.name === 'Notifications' ? (
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MenuButton
|
<MenuButton
|
||||||
menuItems={getMoreNavigation(user)}
|
menuItems={getMoreNavigation(user)}
|
||||||
buttonContent={<MoreButton />}
|
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>
|
</div>
|
||||||
<CreateQuestionButton user={user} />
|
|
||||||
</nav>
|
</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 clsx from 'clsx'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { useEffect, useState } from 'react'
|
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 { useRouter } from 'next/router'
|
||||||
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
|
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 }) {
|
export default function NotificationsIcon(props: { className?: string }) {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const notifications = usePreferredGroupedNotifications(user?.id, {
|
const privateUser = usePrivateUser(user?.id)
|
||||||
|
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
|
||||||
unseenOnly: true,
|
unseenOnly: true,
|
||||||
})
|
})
|
||||||
const [seen, setSeen] = useState(false)
|
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()
|
const router = useRouter()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.pathname.endsWith('notifications')) return setSeen(true)
|
if (router.pathname.endsWith('notifications')) return setSeen(true)
|
||||||
|
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
|
||||||
<div className={'relative'}>
|
<div className={'relative'}>
|
||||||
{!seen && notifications && notifications.length > 0 && (
|
{!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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BellIcon className={clsx(props.className)} />
|
<BellIcon className={clsx(props.className)} />
|
||||||
|
|
|
@ -6,13 +6,14 @@ import { User } from 'web/lib/firebase/users'
|
||||||
import { NumberCancelSelector } from './yes-no-selector'
|
import { NumberCancelSelector } from './yes-no-selector'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ResolveConfirmationButton } from './confirmation-button'
|
import { ResolveConfirmationButton } from './confirmation-button'
|
||||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
import { NumericContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { NumericContract } from 'common/contract'
|
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||||
import { BucketInput } from './bucket-input'
|
import { BucketInput } from './bucket-input'
|
||||||
|
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
export function NumericResolutionPanel(props: {
|
export function NumericResolutionPanel(props: {
|
||||||
creator: User
|
creator: User
|
||||||
contract: NumericContract
|
contract: NumericContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -21,6 +22,7 @@ export function NumericResolutionPanel(props: {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { contract, className } = props
|
const { contract, className } = props
|
||||||
|
const { min, max, outcomeType } = contract
|
||||||
|
|
||||||
const [outcomeMode, setOutcomeMode] = useState<
|
const [outcomeMode, setOutcomeMode] = useState<
|
||||||
'NUMBER' | 'CANCEL' | undefined
|
'NUMBER' | 'CANCEL' | undefined
|
||||||
|
@ -32,22 +34,44 @@ export function NumericResolutionPanel(props: {
|
||||||
const [error, setError] = useState<string | undefined>(undefined)
|
const [error, setError] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
const resolve = async () => {
|
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
|
if (outcomeMode === undefined || finalOutcome === undefined) return
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const result = await resolveMarket({
|
const boundedValue = Math.max(Math.min(max, value ?? 0), min)
|
||||||
outcome: finalOutcome,
|
|
||||||
value,
|
|
||||||
contractId: contract.id,
|
|
||||||
}).then((r) => r.data)
|
|
||||||
|
|
||||||
console.log('resolved', outcome, 'result:', result)
|
const probabilityInt =
|
||||||
|
100 *
|
||||||
|
getPseudoProbability(
|
||||||
|
boundedValue,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
|
||||||
|
)
|
||||||
|
|
||||||
if (result?.status !== 'success') {
|
try {
|
||||||
setError(result?.message || 'Error resolving market')
|
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)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,7 +96,7 @@ export function NumericResolutionPanel(props: {
|
||||||
|
|
||||||
{outcomeMode === 'NUMBER' && (
|
{outcomeMode === 'NUMBER' && (
|
||||||
<BucketInput
|
<BucketInput
|
||||||
contract={contract}
|
contract={contract as any}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
|
onBucketChange={(v, o) => (setValue(v), setOutcome(o))}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -19,11 +19,15 @@ export function OutcomeLabel(props: {
|
||||||
value?: number
|
value?: number
|
||||||
}) {
|
}) {
|
||||||
const { outcome, contract, truncate, value } = props
|
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} />
|
return <BinaryOutcomeLabel outcome={outcome as any} />
|
||||||
|
|
||||||
if (contract.outcomeType === 'NUMERIC')
|
if (outcomeType === 'NUMERIC')
|
||||||
return (
|
return (
|
||||||
<span className="text-blue-500">
|
<span className="text-blue-500">
|
||||||
{value ?? getValueFromBucket(outcome, contract)}
|
{value ?? getValueFromBucket(outcome, contract)}
|
||||||
|
@ -49,6 +53,15 @@ export function BinaryOutcomeLabel(props: { outcome: resolution }) {
|
||||||
return <CancelLabel />
|
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: {
|
export function BinaryContractOutcomeLabel(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract
|
||||||
resolution: resolution
|
resolution: resolution
|
||||||
|
@ -74,7 +87,7 @@ export function FreeResponseOutcomeLabel(props: {
|
||||||
if (resolution === 'CANCEL') return <CancelLabel />
|
if (resolution === 'CANCEL') return <CancelLabel />
|
||||||
if (resolution === 'MKT') return <MultiLabel />
|
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} />
|
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||||
return (
|
return (
|
||||||
<FreeResponseAnswerToolTip text={chosen.text}>
|
<FreeResponseAnswerToolTip text={chosen.text}>
|
||||||
|
@ -98,6 +111,14 @@ export function YesLabel() {
|
||||||
return <span className="text-primary">YES</span>
|
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() {
|
export function NoLabel() {
|
||||||
return <span className="text-red-400">NO</span>
|
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 }}
|
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
|
||||||
xScale={{
|
xScale={{
|
||||||
type: 'time',
|
type: 'time',
|
||||||
min: points[0].x,
|
min: points[0]?.x,
|
||||||
max: endDate,
|
max: endDate,
|
||||||
}}
|
}}
|
||||||
yScale={{
|
yScale={{
|
||||||
|
@ -77,6 +77,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||||
enableGridY={true}
|
enableGridY={true}
|
||||||
enableSlices="x"
|
enableSlices="x"
|
||||||
animate={false}
|
animate={false}
|
||||||
|
yFormat={(value) => formatMoney(+value)}
|
||||||
></ResponsiveLine>
|
></ResponsiveLine>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,7 +13,7 @@ export const PortfolioValueSection = memo(
|
||||||
}) {
|
}) {
|
||||||
const { portfolioHistory } = props
|
const { portfolioHistory } = props
|
||||||
const lastPortfolioMetrics = last(portfolioHistory)
|
const lastPortfolioMetrics = last(portfolioHistory)
|
||||||
const [portfolioPeriod] = useState<Period>('allTime')
|
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||||
|
|
||||||
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
||||||
return <div> No portfolio history data yet </div>
|
return <div> No portfolio history data yet </div>
|
||||||
|
@ -33,9 +33,16 @@ export const PortfolioValueSection = memo(
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
</div>
|
</div>
|
||||||
{
|
<select
|
||||||
//TODO: enable day/week/monthly as data becomes available
|
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>
|
</Row>
|
||||||
<PortfolioValueGraph
|
<PortfolioValueGraph
|
||||||
portfolioHistory={portfolioHistory}
|
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 { YesNoCancelSelector } from './yes-no-selector'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ResolveConfirmationButton } from './confirmation-button'
|
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 { ProbabilitySelector } from './probability-selector'
|
||||||
import { DPM_CREATOR_FEE } from 'common/fees'
|
import { DPM_CREATOR_FEE } from 'common/fees'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
|
@ -42,17 +42,22 @@ export function ResolutionPanel(props: {
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const result = await resolveMarket({
|
try {
|
||||||
outcome,
|
const result = await resolveMarket({
|
||||||
contractId: contract.id,
|
outcome,
|
||||||
probabilityInt: prob,
|
contractId: contract.id,
|
||||||
}).then((r) => r.data)
|
probabilityInt: prob,
|
||||||
|
})
|
||||||
console.log('resolved', outcome, 'result:', result)
|
console.log('resolved', outcome, 'result:', result)
|
||||||
|
} catch (e) {
|
||||||
if (result?.status !== 'success') {
|
if (e instanceof APIError) {
|
||||||
setError(result?.message || 'Error resolving market')
|
setError(e.toString())
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Error resolving market')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BinaryContract } from 'common/contract'
|
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
@ -7,7 +7,7 @@ import clsx from 'clsx'
|
||||||
import { SellSharesModal } from './sell-modal'
|
import { SellSharesModal } from './sell-modal'
|
||||||
|
|
||||||
export function SellButton(props: {
|
export function SellButton(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
sharesOutcome: 'YES' | 'NO' | undefined
|
sharesOutcome: 'YES' | 'NO' | undefined
|
||||||
shares: number
|
shares: number
|
||||||
|
@ -16,7 +16,8 @@ export function SellButton(props: {
|
||||||
const { contract, user, sharesOutcome, shares, panelClassName } = props
|
const { contract, user, sharesOutcome, shares, panelClassName } = props
|
||||||
const userBets = useUserContractBets(user?.id, contract.id)
|
const userBets = useUserContractBets(user?.id, contract.id)
|
||||||
const [showSellModal, setShowSellModal] = useState(false)
|
const [showSellModal, setShowSellModal] = useState(false)
|
||||||
const { mechanism } = contract
|
const { mechanism, outcomeType } = contract
|
||||||
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
if (sharesOutcome && user && mechanism === 'cpmm-1') {
|
if (sharesOutcome && user && mechanism === 'cpmm-1') {
|
||||||
return (
|
return (
|
||||||
|
@ -32,7 +33,10 @@ export function SellButton(props: {
|
||||||
)}
|
)}
|
||||||
onClick={() => setShowSellModal(true)}
|
onClick={() => setShowSellModal(true)}
|
||||||
>
|
>
|
||||||
{'Sell ' + sharesOutcome}
|
Sell{' '}
|
||||||
|
{isPseudoNumeric
|
||||||
|
? { YES: 'HIGH', NO: 'LOW' }[sharesOutcome]
|
||||||
|
: sharesOutcome}
|
||||||
</button>
|
</button>
|
||||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||||
{'(' + Math.floor(shares) + ' shares)'}
|
{'(' + 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 { Bet } from 'common/bet'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
|
@ -11,7 +11,7 @@ import clsx from 'clsx'
|
||||||
|
|
||||||
export function SellSharesModal(props: {
|
export function SellSharesModal(props: {
|
||||||
className?: string
|
className?: string
|
||||||
contract: CPMMBinaryContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
userBets: Bet[]
|
userBets: Bet[]
|
||||||
shares: number
|
shares: number
|
||||||
sharesOutcome: 'YES' | 'NO'
|
sharesOutcome: 'YES' | 'NO'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BinaryContract } from 'common/contract'
|
import { BinaryContract, PseudoNumericContract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
|
@ -10,7 +10,7 @@ import { useSaveShares } from './use-save-shares'
|
||||||
import { SellSharesModal } from './sell-modal'
|
import { SellSharesModal } from './sell-modal'
|
||||||
|
|
||||||
export function SellRow(props: {
|
export function SellRow(props: {
|
||||||
contract: BinaryContract
|
contract: BinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
className?: string
|
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 { useFollows } from 'web/hooks/use-follows'
|
||||||
import { FollowButton } from './follow-button'
|
import { FollowButton } from './follow-button'
|
||||||
import { PortfolioMetrics } from 'common/user'
|
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: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -73,7 +76,9 @@ export function UserPage(props: {
|
||||||
'loading'
|
'loading'
|
||||||
)
|
)
|
||||||
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
||||||
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
|
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
||||||
|
PortfolioMetrics[]
|
||||||
|
>([])
|
||||||
const [commentsByContract, setCommentsByContract] = useState<
|
const [commentsByContract, setCommentsByContract] = useState<
|
||||||
Map<Contract, Comment[]> | 'loading'
|
Map<Contract, Comment[]> | 'loading'
|
||||||
>('loading')
|
>('loading')
|
||||||
|
@ -154,7 +159,7 @@ export function UserPage(props: {
|
||||||
<Avatar
|
<Avatar
|
||||||
username={user.username}
|
username={user.username}
|
||||||
avatarUrl={user.avatarUrl}
|
avatarUrl={user.avatarUrl}
|
||||||
size={20}
|
size={24}
|
||||||
className="bg-white ring-4 ring-white"
|
className="bg-white ring-4 ring-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<Row className="gap-4">
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
|
<ReferralsButton user={user} currentUser={currentUser} />
|
||||||
|
<GroupsButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{user.website && (
|
{user.website && (
|
||||||
|
@ -254,7 +261,7 @@ export function UserPage(props: {
|
||||||
|
|
||||||
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
className={'pb-2 pt-1 '}
|
labelClassName={'pb-2 pt-1 '}
|
||||||
defaultIndex={
|
defaultIndex={
|
||||||
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
|
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
|
||||||
}
|
}
|
||||||
|
@ -293,9 +300,9 @@ export function UserPage(props: {
|
||||||
title: 'Bets',
|
title: 'Bets',
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
{
|
<PortfolioValueSection
|
||||||
// TODO: add portfolio-value-section here
|
portfolioHistory={portfolioHistory}
|
||||||
}
|
/>
|
||||||
<BetsList
|
<BetsList
|
||||||
user={user}
|
user={user}
|
||||||
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
||||||
|
|
|
@ -12,6 +12,7 @@ export function YesNoSelector(props: {
|
||||||
btnClassName?: string
|
btnClassName?: string
|
||||||
replaceYesButton?: React.ReactNode
|
replaceYesButton?: React.ReactNode
|
||||||
replaceNoButton?: React.ReactNode
|
replaceNoButton?: React.ReactNode
|
||||||
|
isPseudoNumeric?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
selected,
|
selected,
|
||||||
|
@ -20,6 +21,7 @@ export function YesNoSelector(props: {
|
||||||
btnClassName,
|
btnClassName,
|
||||||
replaceNoButton,
|
replaceNoButton,
|
||||||
replaceYesButton,
|
replaceYesButton,
|
||||||
|
isPseudoNumeric,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const commonClassNames =
|
const commonClassNames =
|
||||||
|
@ -41,7 +43,7 @@ export function YesNoSelector(props: {
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect('YES')}
|
onClick={() => onSelect('YES')}
|
||||||
>
|
>
|
||||||
Bet YES
|
{isPseudoNumeric ? 'HIGHER' : 'Bet YES'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{replaceNoButton ? (
|
{replaceNoButton ? (
|
||||||
|
@ -58,7 +60,7 @@ export function YesNoSelector(props: {
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect('NO')}
|
onClick={() => onSelect('NO')}
|
||||||
>
|
>
|
||||||
Bet NO
|
{isPseudoNumeric ? 'LOWER' : 'Bet NO'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -2,16 +2,16 @@ import { useEffect } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
contractDocRef,
|
contracts,
|
||||||
listenForContract,
|
listenForContract,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
|
|
||||||
export const useContract = (contractId: string) => {
|
export const useContract = (contractId: string) => {
|
||||||
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
||||||
['contracts', contractId],
|
['contracts', contractId],
|
||||||
contractDocRef(contractId),
|
doc(contracts, contractId),
|
||||||
{ subscribe: true, includeMetadataChanges: true }
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -29,11 +29,11 @@ export const useGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (user: User | null | undefined) => {
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) return listenForMemberGroups(user.id, setMemberGroups)
|
if (userId) return listenForMemberGroups(userId, setMemberGroups)
|
||||||
}, [user])
|
}, [userId])
|
||||||
return memberGroups
|
return memberGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
|
||||||
|
|
||||||
export type NotificationGroup = {
|
export type NotificationGroup = {
|
||||||
notifications: Notification[]
|
notifications: Notification[]
|
||||||
sourceContractId: string
|
groupedById: string
|
||||||
isSeen: boolean
|
isSeen: boolean
|
||||||
timePeriod: string
|
timePeriod: string
|
||||||
|
type: 'income' | 'normal'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePreferredGroupedNotifications(
|
export function usePreferredGroupedNotifications(
|
||||||
|
@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
new Date(notification.createdTime).toDateString()
|
new Date(notification.createdTime).toDateString()
|
||||||
)
|
)
|
||||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
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(
|
const groupedNotificationsByContractId = groupBy(
|
||||||
notificationGroupsByDay[day],
|
normalNotificationsGroupedByDay,
|
||||||
(notification) => {
|
(notification) => {
|
||||||
return notification.sourceContractId
|
return notification.sourceContractId
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
notificationGroups = notificationGroups.concat(
|
notificationGroups = notificationGroups.concat(
|
||||||
map(groupedNotificationsByContractId, (notifications, contractId) => {
|
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
|
// Create a notification group for each contract within each day
|
||||||
const notificationGroup: NotificationGroup = {
|
const notificationGroup: NotificationGroup = {
|
||||||
notifications: groupedNotificationsByContractId[contractId].sort(
|
notifications: notificationsForContractId,
|
||||||
(a, b) => {
|
groupedById: contractId,
|
||||||
return b.createdTime - a.createdTime
|
isSeen: notificationsForContractId[0].isSeen,
|
||||||
}
|
|
||||||
),
|
|
||||||
sourceContractId: contractId,
|
|
||||||
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
|
|
||||||
timePeriod: day,
|
timePeriod: day,
|
||||||
|
type: 'normal',
|
||||||
}
|
}
|
||||||
return notificationGroup
|
return notificationGroup
|
||||||
})
|
})
|
||||||
|
@ -64,11 +83,11 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
return notificationGroups
|
return notificationGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePreferredNotifications(
|
export function usePreferredNotifications(
|
||||||
userId: string | undefined,
|
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 [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
const [userAppropriateNotifications, setUserAppropriateNotifications] =
|
const [userAppropriateNotifications, setUserAppropriateNotifications] =
|
||||||
|
@ -93,9 +112,11 @@ function usePreferredNotifications(
|
||||||
const notificationsToShow = getAppropriateNotifications(
|
const notificationsToShow = getAppropriateNotifications(
|
||||||
notifications,
|
notifications,
|
||||||
privateUser.notificationPreferences
|
privateUser.notificationPreferences
|
||||||
|
).filter((n) =>
|
||||||
|
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
|
||||||
)
|
)
|
||||||
setUserAppropriateNotifications(notificationsToShow)
|
setUserAppropriateNotifications(notificationsToShow)
|
||||||
}, [privateUser, notifications])
|
}, [privateUser, notifications, customHref])
|
||||||
|
|
||||||
return userAppropriateNotifications
|
return userAppropriateNotifications
|
||||||
}
|
}
|
||||||
|
@ -117,7 +138,7 @@ function getAppropriateNotifications(
|
||||||
return notifications.filter(
|
return notifications.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
n.reason &&
|
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))
|
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
||||||
)
|
)
|
||||||
if (notificationPreferences === 'none') return []
|
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 { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
@ -10,7 +10,7 @@ import {
|
||||||
listenForPrivateUser,
|
listenForPrivateUser,
|
||||||
listenForUser,
|
listenForUser,
|
||||||
User,
|
User,
|
||||||
userDocRef,
|
users,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||||
|
@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => {
|
||||||
export const useUserById = (userId: string) => {
|
export const useUserById = (userId: string) => {
|
||||||
const result = useFirestoreDocumentData<DocumentData, User>(
|
const result = useFirestoreDocumentData<DocumentData, User>(
|
||||||
['users', userId],
|
['users', userId],
|
||||||
userDocRef(userId),
|
doc(users, userId),
|
||||||
{ subscribe: true, includeMetadataChanges: true }
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => {
|
||||||
'Origin',
|
'Origin',
|
||||||
])
|
])
|
||||||
const hasBody = req.method != 'HEAD' && req.method != 'GET'
|
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)
|
return fetch(url, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -41,14 +41,23 @@ export async function call(url: string, method: string, params: any) {
|
||||||
// one less hop
|
// one less hop
|
||||||
|
|
||||||
export function getFunctionUrl(name: string) {
|
export function getFunctionUrl(name: string) {
|
||||||
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
|
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
|
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) {
|
export function createMarket(params: any) {
|
||||||
return call(getFunctionUrl('createmarket'), 'POST', params)
|
return call(getFunctionUrl('createmarket'), 'POST', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMarket(params: any) {
|
||||||
|
return call(getFunctionUrl('resolvemarket'), 'POST', params)
|
||||||
|
}
|
||||||
|
|
||||||
export function placeBet(params: any) {
|
export function placeBet(params: any) {
|
||||||
return call(getFunctionUrl('placebet'), 'POST', params)
|
return call(getFunctionUrl('placebet'), 'POST', params)
|
||||||
}
|
}
|
||||||
|
@ -64,3 +73,7 @@ export function sellBet(params: any) {
|
||||||
export function createGroup(params: any) {
|
export function createGroup(params: any) {
|
||||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
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 dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
|
@ -16,8 +15,7 @@ import {
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, sum } from 'lodash'
|
import { sortBy, sum } from 'lodash'
|
||||||
|
|
||||||
import { app } from './init'
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { getValues, listenForValue, listenForValues } from './utils'
|
|
||||||
import { BinaryContract, Contract } from 'common/contract'
|
import { BinaryContract, Contract } from 'common/contract'
|
||||||
import { getDpmProbability } from 'common/calculate-dpm'
|
import { getDpmProbability } from 'common/calculate-dpm'
|
||||||
import { createRNG, shuffle } from 'common/util/random'
|
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 { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
|
||||||
|
export const contracts = coll<Contract>('contracts')
|
||||||
|
|
||||||
export type { Contract }
|
export type { Contract }
|
||||||
|
|
||||||
export function contractPath(contract: 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
|
// Push contract to Firestore
|
||||||
export async function setContract(contract: Contract) {
|
export async function setContract(contract: Contract) {
|
||||||
const docRef = doc(db, 'contracts', contract.id)
|
await setDoc(doc(contracts, contract.id), contract)
|
||||||
await setDoc(docRef, contract)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContract(
|
export async function updateContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
update: Partial<Contract>
|
update: Partial<Contract>
|
||||||
) {
|
) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
await updateDoc(doc(contracts, contractId), update)
|
||||||
await updateDoc(docRef, update)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractFromId(contractId: string) {
|
export async function getContractFromId(contractId: string) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
const result = await getDoc(doc(contracts, contractId))
|
||||||
const result = await getDoc(docRef)
|
return result.exists() ? result.data() : undefined
|
||||||
|
|
||||||
return result.exists() ? (result.data() as Contract) : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractFromSlug(slug: string) {
|
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)
|
const snapshot = await getDocs(q)
|
||||||
|
return snapshot.empty ? undefined : snapshot.docs[0].data()
|
||||||
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContract(contractId: string) {
|
export async function deleteContract(contractId: string) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
await deleteDoc(doc(contracts, contractId))
|
||||||
await deleteDoc(docRef)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('creatorId', '==', creatorId),
|
where('creatorId', '==', creatorId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const snapshot = await getDocs(q)
|
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(
|
export async function listTaggedContractsCaseInsensitive(
|
||||||
tag: string
|
tag: string
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const snapshot = await getDocs(q)
|
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(
|
export async function listAllContracts(
|
||||||
n: number,
|
n: number,
|
||||||
before?: string
|
before?: string
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n))
|
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
|
||||||
if (before != null) {
|
if (before != null) {
|
||||||
const snap = await getDoc(doc(db, 'contracts', before))
|
const snap = await getDoc(doc(contracts, before))
|
||||||
q = query(q, startAfter(snap))
|
q = query(q, startAfter(snap))
|
||||||
}
|
}
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForContracts(
|
export function listenForContracts(
|
||||||
setContracts: (contracts: Contract[]) => void
|
setContracts: (contracts: Contract[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(contractCollection, orderBy('createdTime', 'desc'))
|
const q = query(contracts, orderBy('createdTime', 'desc'))
|
||||||
return listenForValues<Contract>(q, setContracts)
|
return listenForValues<Contract>(q, setContracts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,7 +161,7 @@ export function listenForUserContracts(
|
||||||
setContracts: (contracts: Contract[]) => void
|
setContracts: (contracts: Contract[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('creatorId', '==', creatorId),
|
where('creatorId', '==', creatorId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
|
@ -179,7 +169,7 @@ export function listenForUserContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeContractsQuery = query(
|
const activeContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
where('volume7Days', '>', 0)
|
where('volume7Days', '>', 0)
|
||||||
|
@ -196,7 +186,7 @@ export function listenForActiveContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const inactiveContractsQuery = query(
|
const inactiveContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('closeTime', '>', Date.now()),
|
where('closeTime', '>', Date.now()),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
|
@ -214,7 +204,7 @@ export function listenForInactiveContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const newContractsQuery = query(
|
const newContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('volume7Days', '==', 0),
|
where('volume7Days', '==', 0),
|
||||||
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||||
|
@ -230,7 +220,7 @@ export function listenForContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setContract: (contract: Contract | null) => void
|
setContract: (contract: Contract | null) => void
|
||||||
) {
|
) {
|
||||||
const contractRef = doc(contractCollection, contractId)
|
const contractRef = doc(contracts, contractId)
|
||||||
return listenForValue<Contract>(contractRef, setContract)
|
return listenForValue<Contract>(contractRef, setContract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotContractsQuery = query(
|
const hotContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
orderBy('volume24Hours', 'desc'),
|
orderBy('volume24Hours', 'desc'),
|
||||||
|
@ -262,22 +252,22 @@ export function listenForHotContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHotContracts() {
|
export async function getHotContracts() {
|
||||||
const contracts = await getValues<Contract>(hotContractsQuery)
|
const data = await getValues<Contract>(hotContractsQuery)
|
||||||
return sortBy(
|
return sortBy(
|
||||||
chooseRandomSubset(contracts, 10),
|
chooseRandomSubset(data, 10),
|
||||||
(contract) => -1 * contract.volume24Hours
|
(contract) => -1 * contract.volume24Hours
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsBySlugs(slugs: string[]) {
|
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 snapshot = await getDocs(q)
|
||||||
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
|
const data = snapshot.docs.map((doc) => doc.data())
|
||||||
return sortBy(contracts, (contract) => -1 * contract.volume24Hours)
|
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
const topWeeklyQuery = query(
|
const topWeeklyQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
orderBy('volume7Days', 'desc'),
|
orderBy('volume7Days', 'desc'),
|
||||||
limit(MAX_FEED_CONTRACTS)
|
limit(MAX_FEED_CONTRACTS)
|
||||||
|
@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const closingSoonQuery = query(
|
const closingSoonQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
where('closeTime', '>', Date.now()),
|
where('closeTime', '>', Date.now()),
|
||||||
|
@ -296,15 +286,12 @@ const closingSoonQuery = query(
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function getClosingSoonContracts() {
|
export async function getClosingSoonContracts() {
|
||||||
const contracts = await getValues<Contract>(closingSoonQuery)
|
const data = await getValues<Contract>(closingSoonQuery)
|
||||||
return sortBy(
|
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
||||||
chooseRandomSubset(contracts, 2),
|
|
||||||
(contract) => contract.closeTime
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentBetsAndComments(contract: Contract) {
|
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([
|
const [recentBets, recentComments] = await Promise.all([
|
||||||
getValues<Bet>(
|
getValues<Bet>(
|
||||||
|
|
|
@ -29,17 +29,6 @@ export const createAnswer = cloudFunction<
|
||||||
}
|
}
|
||||||
>('createAnswer')
|
>('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> = () => {
|
export const createUser: () => Promise<User | null> = () => {
|
||||||
const local = safeLocalStorage()
|
const local = safeLocalStorage()
|
||||||
let deviceToken = local?.getItem('device-token')
|
let deviceToken = local?.getItem('device-token')
|
||||||
|
|
|
@ -1,19 +1,24 @@
|
||||||
import {
|
import {
|
||||||
collection,
|
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
doc,
|
doc,
|
||||||
|
getDocs,
|
||||||
query,
|
query,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
where,
|
where,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy } from 'lodash'
|
import { sortBy, uniq } from 'lodash'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { getContractFromId } from './contracts'
|
import { getContractFromId } from './contracts'
|
||||||
import { db } from './init'
|
import {
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
coll,
|
||||||
|
getValue,
|
||||||
|
getValues,
|
||||||
|
listenForValue,
|
||||||
|
listenForValues,
|
||||||
|
} from './utils'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
|
|
||||||
const groupCollection = collection(db, 'groups')
|
export const groups = coll<Group>('groups')
|
||||||
|
|
||||||
export function groupPath(
|
export function groupPath(
|
||||||
groupSlug: string,
|
groupSlug: string,
|
||||||
|
@ -23,30 +28,29 @@ export function groupPath(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateGroup(group: Group, updates: Partial<Group>) {
|
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) {
|
export function deleteGroup(group: Group) {
|
||||||
return deleteDoc(doc(groupCollection, group.id))
|
return deleteDoc(doc(groups, group.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllGroups() {
|
export async function listAllGroups() {
|
||||||
return getValues<Group>(groupCollection)
|
return getValues<Group>(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groupCollection, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGroup(groupId: string) {
|
export function getGroup(groupId: string) {
|
||||||
return getValue<Group>(doc(groupCollection, groupId))
|
return getValue<Group>(doc(groups, groupId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupBySlug(slug: string) {
|
export async function getGroupBySlug(slug: string) {
|
||||||
const q = query(groupCollection, where('slug', '==', slug))
|
const q = query(groups, where('slug', '==', slug))
|
||||||
const groups = await getValues<Group>(q)
|
const docs = (await getDocs(q)).docs
|
||||||
|
return docs.length === 0 ? null : docs[0].data()
|
||||||
return groups.length === 0 ? null : groups[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupContracts(group: Group) {
|
export async function getGroupContracts(group: Group) {
|
||||||
|
@ -68,14 +72,14 @@ export function listenForGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
setGroup: (group: Group | null) => void
|
setGroup: (group: Group | null) => void
|
||||||
) {
|
) {
|
||||||
return listenForValue(doc(groupCollection, groupId), setGroup)
|
return listenForValue(doc(groups, groupId), setGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForMemberGroups(
|
export function listenForMemberGroups(
|
||||||
userId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
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) => {
|
return listenForValues<Group>(q, (groups) => {
|
||||||
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
||||||
|
@ -87,10 +91,51 @@ export async function getGroupsWithContractId(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(
|
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
||||||
groupCollection,
|
setGroups(await getValues<Group>(q))
|
||||||
where('contractIds', 'array-contains', contractId)
|
}
|
||||||
)
|
|
||||||
const groups = await getValues<Group>(q)
|
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
|
||||||
setGroups(groups)
|
// 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