Merge branch 'main' into badges
This commit is contained in:
commit
4d94f24892
|
@ -21,6 +21,25 @@ const computeInvestmentValue = (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const computeInvestmentValueCustomProb = (
|
||||||
|
bets: Bet[],
|
||||||
|
contract: Contract,
|
||||||
|
p: number
|
||||||
|
) => {
|
||||||
|
return sumBy(bets, (bet) => {
|
||||||
|
if (!contract || contract.isResolved) return 0
|
||||||
|
if (bet.sale || bet.isSold) return 0
|
||||||
|
const { outcome, shares } = bet
|
||||||
|
|
||||||
|
const betP = outcome === 'YES' ? p : 1 - p
|
||||||
|
|
||||||
|
const payout = betP * shares
|
||||||
|
const value = payout - (bet.loanAmount ?? 0)
|
||||||
|
if (isNaN(value)) return 0
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||||
const periodFilteredContracts = userContracts.filter(
|
const periodFilteredContracts = userContracts.filter(
|
||||||
(contract) => contract.createdTime >= startTime
|
(contract) => contract.createdTime >= startTime
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { maxBy, sortBy, sum, sumBy } from 'lodash'
|
import { maxBy, partition, sortBy, sum, sumBy } from 'lodash'
|
||||||
import { Bet, LimitBet } from './bet'
|
import { Bet, LimitBet } from './bet'
|
||||||
import {
|
import {
|
||||||
calculateCpmmSale,
|
calculateCpmmSale,
|
||||||
|
@ -210,7 +210,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const netPayout = payout - loan
|
|
||||||
const profit = payout + saleValue + redeemed - totalInvested
|
const profit = payout + saleValue + redeemed - totalInvested
|
||||||
const profitPercent = (profit / totalInvested) * 100
|
const profitPercent = (profit / totalInvested) * 100
|
||||||
|
|
||||||
|
@ -221,8 +220,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invested,
|
invested,
|
||||||
|
loan,
|
||||||
payout,
|
payout,
|
||||||
netPayout,
|
|
||||||
profit,
|
profit,
|
||||||
profitPercent,
|
profitPercent,
|
||||||
totalShares,
|
totalShares,
|
||||||
|
@ -233,8 +232,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
|
||||||
export function getContractBetNullMetrics() {
|
export function getContractBetNullMetrics() {
|
||||||
return {
|
return {
|
||||||
invested: 0,
|
invested: 0,
|
||||||
|
loan: 0,
|
||||||
payout: 0,
|
payout: 0,
|
||||||
netPayout: 0,
|
|
||||||
profit: 0,
|
profit: 0,
|
||||||
profitPercent: 0,
|
profitPercent: 0,
|
||||||
totalShares: {} as { [outcome: string]: number },
|
totalShares: {} as { [outcome: string]: number },
|
||||||
|
@ -255,3 +254,43 @@ export function getTopAnswer(
|
||||||
)
|
)
|
||||||
return top?.answer
|
return top?.answer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLargestPosition(contract: Contract, userBets: Bet[]) {
|
||||||
|
let yesFloorShares = 0,
|
||||||
|
yesShares = 0,
|
||||||
|
noShares = 0,
|
||||||
|
noFloorShares = 0
|
||||||
|
|
||||||
|
if (userBets.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||||
|
const answerCounts: { [outcome: string]: number } = {}
|
||||||
|
for (const bet of userBets) {
|
||||||
|
if (bet.outcome) {
|
||||||
|
if (!answerCounts[bet.outcome]) {
|
||||||
|
answerCounts[bet.outcome] = bet.amount
|
||||||
|
} else {
|
||||||
|
answerCounts[bet.outcome] += bet.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const majorityAnswer =
|
||||||
|
maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? ''
|
||||||
|
return {
|
||||||
|
prob: undefined,
|
||||||
|
shares: answerCounts[majorityAnswer] || 0,
|
||||||
|
outcome: majorityAnswer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [yesBets, noBets] = partition(userBets, (bet) => bet.outcome === 'YES')
|
||||||
|
yesShares = sumBy(yesBets, (bet) => bet.shares)
|
||||||
|
noShares = sumBy(noBets, (bet) => bet.shares)
|
||||||
|
yesFloorShares = Math.floor(yesShares)
|
||||||
|
noFloorShares = Math.floor(noShares)
|
||||||
|
|
||||||
|
const shares = yesFloorShares || noFloorShares
|
||||||
|
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
|
||||||
|
return { shares, outcome }
|
||||||
|
}
|
||||||
|
|
|
@ -576,7 +576,7 @@ Work towards sustainable, systemic change.`,
|
||||||
|
|
||||||
If you would like to support our work, you can do so by getting involved or by donating.`,
|
If you would like to support our work, you can do so by getting involved or by donating.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CaRLA',
|
name: 'CaRLA',
|
||||||
website: 'https://carlaef.org/',
|
website: 'https://carlaef.org/',
|
||||||
photo: 'https://i.imgur.com/IsNVTOY.png',
|
photo: 'https://i.imgur.com/IsNVTOY.png',
|
||||||
|
@ -589,6 +589,14 @@ CaRLA uses legal advocacy and education to ensure all cities comply with their o
|
||||||
|
|
||||||
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
|
In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Mriya',
|
||||||
|
website: 'https://mriya-ua.org/',
|
||||||
|
photo:
|
||||||
|
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637',
|
||||||
|
preview: 'Donate supplies to soldiers in Ukraine',
|
||||||
|
description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.',
|
||||||
|
},
|
||||||
].map((charity) => {
|
].map((charity) => {
|
||||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
||||||
userName: string
|
userName: string
|
||||||
userUsername: string
|
userUsername: string
|
||||||
userAvatarUrl?: string
|
userAvatarUrl?: string
|
||||||
|
bountiesAwarded?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type OnContract = {
|
export type OnContract = {
|
||||||
|
@ -33,6 +34,11 @@ export type OnContract = {
|
||||||
// denormalized from bet
|
// denormalized from bet
|
||||||
betAmount?: number
|
betAmount?: number
|
||||||
betOutcome?: string
|
betOutcome?: string
|
||||||
|
|
||||||
|
// denormalized based on betting history
|
||||||
|
commenterPositionProb?: number // binary only
|
||||||
|
commenterPositionShares?: number
|
||||||
|
commenterPositionOutcome?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OnGroup = {
|
export type OnGroup = {
|
||||||
|
|
|
@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
|
||||||
const { closeTime, groupLinks } = contract
|
const { closeTime, groupLinks } = contract
|
||||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
||||||
|
|
||||||
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
|
const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||||
|
|
|
@ -57,10 +57,14 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
uniqueBettorIds?: string[]
|
uniqueBettorIds?: string[]
|
||||||
uniqueBettorCount?: number
|
uniqueBettorCount?: number
|
||||||
popularityScore?: number
|
popularityScore?: number
|
||||||
|
dailyScore?: number
|
||||||
followerCount?: number
|
followerCount?: number
|
||||||
featuredOnHomeRank?: number
|
featuredOnHomeRank?: number
|
||||||
likedByUserIds?: string[]
|
likedByUserIds?: string[]
|
||||||
likedByUserCount?: number
|
likedByUserCount?: number
|
||||||
|
flaggedByUsernames?: string[]
|
||||||
|
openCommentBounties?: number
|
||||||
|
unlistedById?: string
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
@ -148,7 +152,7 @@ export const OUTCOME_TYPES = [
|
||||||
'NUMERIC',
|
'NUMERIC',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const MAX_QUESTION_LENGTH = 480
|
export const MAX_QUESTION_LENGTH = 240
|
||||||
export const MAX_DESCRIPTION_LENGTH = 16000
|
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||||
export const MAX_TAG_LENGTH = 60
|
export const MAX_TAG_LENGTH = 60
|
||||||
|
|
||||||
|
|
|
@ -7,11 +7,12 @@ export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
|
||||||
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
|
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
|
||||||
// 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 = econ?.SUS_STARTING_BALANCE ?? 10
|
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
|
||||||
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
|
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
|
||||||
|
|
||||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
|
||||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
|
||||||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
|
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
|
||||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
||||||
|
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
|
||||||
|
|
|
@ -16,4 +16,7 @@ export const DEV_CONFIG: EnvConfig = {
|
||||||
cloudRunId: 'w3txbmd3ba',
|
cloudRunId: 'w3txbmd3ba',
|
||||||
cloudRunRegion: 'uc',
|
cloudRunRegion: 'uc',
|
||||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||||
|
// this is Phil's deployment
|
||||||
|
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
|
||||||
|
sprigEnvironmentId: 'Tu7kRZPm7daP',
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ export type EnvConfig = {
|
||||||
domain: string
|
domain: string
|
||||||
firebaseConfig: FirebaseConfig
|
firebaseConfig: FirebaseConfig
|
||||||
amplitudeApiKey?: string
|
amplitudeApiKey?: string
|
||||||
|
twitchBotEndpoint?: string
|
||||||
|
sprigEnvironmentId?: string
|
||||||
|
|
||||||
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
||||||
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
||||||
|
@ -39,6 +41,7 @@ export type Economy = {
|
||||||
BETTING_STREAK_BONUS_MAX?: number
|
BETTING_STREAK_BONUS_MAX?: number
|
||||||
BETTING_STREAK_RESET_HOUR?: number
|
BETTING_STREAK_RESET_HOUR?: number
|
||||||
FREE_MARKETS_PER_USER_MAX?: number
|
FREE_MARKETS_PER_USER_MAX?: number
|
||||||
|
COMMENT_BOUNTY_AMOUNT?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig = {
|
type FirebaseConfig = {
|
||||||
|
@ -55,6 +58,7 @@ type FirebaseConfig = {
|
||||||
export const PROD_CONFIG: EnvConfig = {
|
export const PROD_CONFIG: EnvConfig = {
|
||||||
domain: 'manifold.markets',
|
domain: 'manifold.markets',
|
||||||
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
|
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
|
||||||
|
sprigEnvironmentId: 'sQcrq9TDqkib',
|
||||||
|
|
||||||
firebaseConfig: {
|
firebaseConfig: {
|
||||||
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
|
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
|
||||||
|
@ -66,6 +70,7 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
|
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
|
||||||
measurementId: 'G-SSFK1Q138D',
|
measurementId: 'G-SSFK1Q138D',
|
||||||
},
|
},
|
||||||
|
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
|
||||||
cloudRunId: 'nggbo3neva',
|
cloudRunId: 'nggbo3neva',
|
||||||
cloudRunRegion: 'uc',
|
cloudRunRegion: 'uc',
|
||||||
adminEmails: [
|
adminEmails: [
|
||||||
|
@ -82,9 +87,9 @@ export const PROD_CONFIG: EnvConfig = {
|
||||||
visibility: 'PUBLIC',
|
visibility: 'PUBLIC',
|
||||||
|
|
||||||
moneyMoniker: 'M$',
|
moneyMoniker: 'M$',
|
||||||
bettor: 'predictor',
|
bettor: 'trader',
|
||||||
pastBet: 'prediction',
|
pastBet: 'trade',
|
||||||
presentBet: 'predict',
|
presentBet: 'trade',
|
||||||
navbarLogoPath: '',
|
navbarLogoPath: '',
|
||||||
faviconPath: '/favicon.ico',
|
faviconPath: '/favicon.ico',
|
||||||
newQuestionPlaceholders: [
|
newQuestionPlaceholders: [
|
||||||
|
|
|
@ -10,6 +10,7 @@ export type Group = {
|
||||||
totalContracts: number
|
totalContracts: number
|
||||||
totalMembers: number
|
totalMembers: number
|
||||||
aboutPostId?: string
|
aboutPostId?: string
|
||||||
|
postIds: string[]
|
||||||
chatDisabled?: boolean
|
chatDisabled?: boolean
|
||||||
mostRecentContractAddedTime?: number
|
mostRecentContractAddedTime?: number
|
||||||
cachedLeaderboard?: {
|
cachedLeaderboard?: {
|
||||||
|
@ -22,6 +23,7 @@ export type Group = {
|
||||||
score: number
|
score: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
|
@ -37,3 +39,4 @@ export type GroupLink = {
|
||||||
createdTime: number
|
createdTime: number
|
||||||
userId?: string
|
userId?: string
|
||||||
}
|
}
|
||||||
|
export type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
|
|
@ -5,4 +5,4 @@ export type Like = {
|
||||||
createdTime: number
|
createdTime: number
|
||||||
tipTxnId?: string // only holds most recent tip txn id
|
tipTxnId?: string // only holds most recent tip txn id
|
||||||
}
|
}
|
||||||
export const LIKE_TIP_AMOUNT = 5
|
export const LIKE_TIP_AMOUNT = 10
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
visibility,
|
visibility,
|
||||||
} from './contract'
|
} from './contract'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
import { parseTags, richTextToString } from './util/parse'
|
|
||||||
import { removeUndefinedProps } from './util/object'
|
import { removeUndefinedProps } from './util/object'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
@ -38,15 +37,6 @@ export function getNewContract(
|
||||||
answers: string[],
|
answers: string[],
|
||||||
visibility: visibility
|
visibility: visibility
|
||||||
) {
|
) {
|
||||||
const tags = parseTags(
|
|
||||||
[
|
|
||||||
question,
|
|
||||||
richTextToString(description),
|
|
||||||
...extraTags.map((tag) => `#${tag}`),
|
|
||||||
].join(' ')
|
|
||||||
)
|
|
||||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
|
||||||
|
|
||||||
const propsByOutcomeType =
|
const propsByOutcomeType =
|
||||||
outcomeType === 'BINARY'
|
outcomeType === 'BINARY'
|
||||||
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
|
||||||
|
@ -70,8 +60,8 @@ export function getNewContract(
|
||||||
|
|
||||||
question: question.trim(),
|
question: question.trim(),
|
||||||
description,
|
description,
|
||||||
tags,
|
tags: [],
|
||||||
lowercaseTags,
|
lowercaseTags: [],
|
||||||
visibility,
|
visibility,
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
|
|
|
@ -97,6 +97,7 @@ type notification_descriptions = {
|
||||||
[key in notification_preference]: {
|
[key in notification_preference]: {
|
||||||
simple: string
|
simple: string
|
||||||
detailed: string
|
detailed: string
|
||||||
|
necessary?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
|
@ -117,8 +118,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
detailed: "Only answers by market creator on markets you're watching",
|
detailed: "Only answers by market creator on markets you're watching",
|
||||||
},
|
},
|
||||||
betting_streaks: {
|
betting_streaks: {
|
||||||
simple: 'For predictions made over consecutive days',
|
simple: `For prediction streaks`,
|
||||||
detailed: 'Bonuses for predictions made over consecutive days',
|
detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
|
||||||
},
|
},
|
||||||
comments_by_followed_users_on_watched_markets: {
|
comments_by_followed_users_on_watched_markets: {
|
||||||
simple: 'Only comments by users you follow',
|
simple: 'Only comments by users you follow',
|
||||||
|
@ -160,8 +161,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
detailed: 'Large changes in probability on markets that you watch',
|
detailed: 'Large changes in probability on markets that you watch',
|
||||||
},
|
},
|
||||||
profit_loss_updates: {
|
profit_loss_updates: {
|
||||||
simple: 'Weekly profit and loss updates',
|
simple: 'Weekly portfolio updates',
|
||||||
detailed: 'Weekly profit and loss updates',
|
detailed: 'Weekly portfolio updates',
|
||||||
},
|
},
|
||||||
referral_bonuses: {
|
referral_bonuses: {
|
||||||
simple: 'For referring new users',
|
simple: 'For referring new users',
|
||||||
|
@ -209,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
detailed: 'Bonuses for unique predictors on your markets',
|
detailed: 'Bonuses for unique predictors on your markets',
|
||||||
},
|
},
|
||||||
your_contract_closed: {
|
your_contract_closed: {
|
||||||
simple: 'Your market has closed and you need to resolve it',
|
simple: 'Your market has closed and you need to resolve it (necessary)',
|
||||||
detailed: 'Your market has closed and you need to resolve it',
|
detailed: 'Your market has closed and you need to resolve it (necessary)',
|
||||||
|
necessary: true,
|
||||||
},
|
},
|
||||||
all_comments_on_watched_markets: {
|
all_comments_on_watched_markets: {
|
||||||
simple: 'All new comments',
|
simple: 'All new comments',
|
||||||
|
@ -240,6 +242,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
|
||||||
simple: 'New badges awarded',
|
simple: 'New badges awarded',
|
||||||
detailed: 'New badges you have earned',
|
detailed: 'New badges you have earned',
|
||||||
},
|
},
|
||||||
|
opt_out_all: {
|
||||||
|
simple: 'Opt out of all notifications (excludes when your markets close)',
|
||||||
|
detailed:
|
||||||
|
'Opt out of all notifications excluding your own market closure notifications',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BettingStreakData = {
|
export type BettingStreakData = {
|
||||||
|
|
|
@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
|
||||||
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
|
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
|
||||||
const profit = winnings - amount
|
const profit = winnings - amount
|
||||||
|
|
||||||
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
|
const payout = amount + (1 - DPM_FEES) * profit
|
||||||
return { userId, profit, payout }
|
return { userId, profit, payout }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -3,10 +3,19 @@ import { JSONContent } from '@tiptap/core'
|
||||||
export type Post = {
|
export type Post = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
subtitle: string
|
||||||
content: JSONContent
|
content: JSONContent
|
||||||
creatorId: string // User id
|
creatorId: string // User id
|
||||||
createdTime: number
|
createdTime: number
|
||||||
slug: string
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DateDoc = Post & {
|
||||||
|
bounty: number
|
||||||
|
birthday: number
|
||||||
|
type: 'date-doc'
|
||||||
|
contractSlug: string
|
||||||
|
}
|
||||||
|
|
||||||
export const MAX_POST_TITLE_LENGTH = 480
|
export const MAX_POST_TITLE_LENGTH = 480
|
||||||
|
export const MAX_POST_SUBTITLE_LENGTH = 480
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
export type Stats = {
|
export type Stats = {
|
||||||
startDate: number
|
startDate: number
|
||||||
dailyActiveUsers: number[]
|
dailyActiveUsers: number[]
|
||||||
|
dailyActiveUsersWeeklyAvg: number[]
|
||||||
weeklyActiveUsers: number[]
|
weeklyActiveUsers: number[]
|
||||||
monthlyActiveUsers: number[]
|
monthlyActiveUsers: number[]
|
||||||
|
d1: number[]
|
||||||
|
d1WeeklyAvg: number[]
|
||||||
|
nd1: number[]
|
||||||
|
nd1WeeklyAvg: number[]
|
||||||
|
nw1: number[]
|
||||||
dailyBetCounts: number[]
|
dailyBetCounts: number[]
|
||||||
dailyContractCounts: number[]
|
dailyContractCounts: number[]
|
||||||
dailyCommentCounts: number[]
|
dailyCommentCounts: number[]
|
||||||
dailySignups: number[]
|
dailySignups: number[]
|
||||||
weekOnWeekRetention: number[]
|
weekOnWeekRetention: number[]
|
||||||
monthlyRetention: number[]
|
monthlyRetention: number[]
|
||||||
weeklyActivationRate: number[]
|
dailyActivationRate: number[]
|
||||||
topTenthActions: {
|
dailyActivationRateWeeklyAvg: number[]
|
||||||
daily: number[]
|
|
||||||
weekly: number[]
|
|
||||||
monthly: number[]
|
|
||||||
}
|
|
||||||
manaBet: {
|
manaBet: {
|
||||||
daily: number[]
|
daily: number[]
|
||||||
weekly: number[]
|
weekly: number[]
|
||||||
|
|
|
@ -8,6 +8,7 @@ type AnyTxnType =
|
||||||
| UniqueBettorBonus
|
| UniqueBettorBonus
|
||||||
| BettingStreakBonus
|
| BettingStreakBonus
|
||||||
| CancelUniqueBettorBonus
|
| CancelUniqueBettorBonus
|
||||||
|
| CommentBountyRefund
|
||||||
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> = {
|
||||||
|
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
| 'UNIQUE_BETTOR_BONUS'
|
| 'UNIQUE_BETTOR_BONUS'
|
||||||
| 'BETTING_STREAK_BONUS'
|
| 'BETTING_STREAK_BONUS'
|
||||||
| 'CANCEL_UNIQUE_BETTOR_BONUS'
|
| 'CANCEL_UNIQUE_BETTOR_BONUS'
|
||||||
|
| 'COMMENT_BOUNTY'
|
||||||
|
| 'REFUND_COMMENT_BOUNTY'
|
||||||
|
|
||||||
// Any extra data
|
// Any extra data
|
||||||
data?: { [key: string]: any }
|
data?: { [key: string]: any }
|
||||||
|
@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CommentBountyDeposit = {
|
||||||
|
fromType: 'USER'
|
||||||
|
toType: 'BANK'
|
||||||
|
category: 'COMMENT_BOUNTY'
|
||||||
|
data: {
|
||||||
|
contractId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentBountyWithdrawal = {
|
||||||
|
fromType: 'BANK'
|
||||||
|
toType: 'USER'
|
||||||
|
category: 'COMMENT_BOUNTY'
|
||||||
|
data: {
|
||||||
|
contractId: string
|
||||||
|
commentId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentBountyRefund = {
|
||||||
|
fromType: 'BANK'
|
||||||
|
toType: 'USER'
|
||||||
|
category: 'REFUND_COMMENT_BOUNTY'
|
||||||
|
data: {
|
||||||
|
contractId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
|
||||||
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
|
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
|
||||||
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
|
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
|
||||||
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
|
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
|
||||||
|
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
|
||||||
|
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal
|
||||||
|
|
|
@ -54,6 +54,8 @@ export type notification_preferences = {
|
||||||
onboarding_flow: notification_destination_types[]
|
onboarding_flow: notification_destination_types[]
|
||||||
thank_you_for_purchases: notification_destination_types[]
|
thank_you_for_purchases: notification_destination_types[]
|
||||||
badges_awarded: notification_destination_types[]
|
badges_awarded: notification_destination_types[]
|
||||||
|
opt_out_all: notification_destination_types[]
|
||||||
|
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDefaultNotificationPreferences = (
|
export const getDefaultNotificationPreferences = (
|
||||||
|
@ -66,7 +68,7 @@ export const getDefaultNotificationPreferences = (
|
||||||
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
const email = noEmails ? undefined : emailIf ? 'email' : undefined
|
||||||
return filterDefined([browser, email]) as notification_destination_types[]
|
return filterDefined([browser, email]) as notification_destination_types[]
|
||||||
}
|
}
|
||||||
return {
|
const defaults: notification_preferences = {
|
||||||
// Watched Markets
|
// Watched Markets
|
||||||
all_comments_on_watched_markets: constructPref(true, false),
|
all_comments_on_watched_markets: constructPref(true, false),
|
||||||
all_answers_on_watched_markets: constructPref(true, false),
|
all_answers_on_watched_markets: constructPref(true, false),
|
||||||
|
@ -108,7 +110,7 @@ export const getDefaultNotificationPreferences = (
|
||||||
loan_income: constructPref(true, false),
|
loan_income: constructPref(true, false),
|
||||||
betting_streaks: constructPref(true, false),
|
betting_streaks: constructPref(true, false),
|
||||||
referral_bonuses: constructPref(true, true),
|
referral_bonuses: constructPref(true, true),
|
||||||
unique_bettors_on_your_contract: constructPref(true, false),
|
unique_bettors_on_your_contract: constructPref(true, true),
|
||||||
tipped_comments_on_watched_markets: constructPref(true, true),
|
tipped_comments_on_watched_markets: constructPref(true, true),
|
||||||
tips_on_your_markets: constructPref(true, true),
|
tips_on_your_markets: constructPref(true, true),
|
||||||
limit_order_fills: constructPref(true, false),
|
limit_order_fills: constructPref(true, false),
|
||||||
|
@ -122,7 +124,11 @@ export const getDefaultNotificationPreferences = (
|
||||||
probability_updates_on_watched_markets: constructPref(true, false),
|
probability_updates_on_watched_markets: constructPref(true, false),
|
||||||
thank_you_for_purchases: constructPref(false, false),
|
thank_you_for_purchases: constructPref(false, false),
|
||||||
onboarding_flow: constructPref(false, false),
|
onboarding_flow: constructPref(false, false),
|
||||||
} as notification_preferences
|
|
||||||
|
opt_out_all: [],
|
||||||
|
badges_awarded: constructPref(true, false),
|
||||||
|
}
|
||||||
|
return defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
|
||||||
|
@ -185,10 +191,18 @@ export const getNotificationDestinationsForUser = (
|
||||||
? notificationSettings[subscriptionType]
|
? notificationSettings[subscriptionType]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
const optOutOfAllSettings = notificationSettings['opt_out_all']
|
||||||
|
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
|
||||||
|
const optedOutOfEmail =
|
||||||
|
optOutOfAllSettings.includes('email') &&
|
||||||
|
subscriptionType !== 'your_contract_closed'
|
||||||
|
const optedOutOfBrowser =
|
||||||
|
optOutOfAllSettings.includes('browser') &&
|
||||||
|
subscriptionType !== 'your_contract_closed'
|
||||||
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||||
return {
|
return {
|
||||||
sendToEmail: destinations.includes('email'),
|
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
|
||||||
sendToBrowser: destinations.includes('browser'),
|
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
|
||||||
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
|
||||||
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,8 @@ export type User = {
|
||||||
allTime: number
|
allTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fractionResolvedCorrectly: number
|
||||||
|
|
||||||
nextLoanCached: number
|
nextLoanCached: number
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
|
@ -72,12 +74,8 @@ export type PrivateUser = {
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
|
||||||
email?: string
|
email?: string
|
||||||
unsubscribedFromResolutionEmails?: boolean
|
|
||||||
unsubscribedFromCommentEmails?: boolean
|
|
||||||
unsubscribedFromAnswerEmails?: boolean
|
|
||||||
unsubscribedFromGenericEmails?: boolean
|
|
||||||
unsubscribedFromWeeklyTrendingEmails?: boolean
|
|
||||||
weeklyTrendingEmailSent?: boolean
|
weeklyTrendingEmailSent?: boolean
|
||||||
|
weeklyPortfolioUpdateEmailSent?: boolean
|
||||||
manaBonusEmailSent?: boolean
|
manaBonusEmailSent?: boolean
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
|
@ -87,6 +85,7 @@ export type PrivateUser = {
|
||||||
twitchName: string
|
twitchName: string
|
||||||
controlToken: string
|
controlToken: string
|
||||||
botEnabled?: boolean
|
botEnabled?: boolean
|
||||||
|
needsRelinking?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,9 +101,11 @@ export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
|
||||||
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
|
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
|
||||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||||
|
|
||||||
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
|
// TODO: remove. Hardcoding the strings would be better.
|
||||||
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
|
// Different views require different language.
|
||||||
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
|
export const BETTOR = ENV_CONFIG.bettor ?? 'trader'
|
||||||
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
|
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'traders'
|
||||||
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
|
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'trade'
|
||||||
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
|
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'trades'
|
||||||
|
export const PAST_BET = ENV_CONFIG.pastBet ?? 'trade'
|
||||||
|
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'trades'
|
||||||
|
|
24
common/util/color.ts
Normal file
24
common/util/color.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
export const interpolateColor = (color1: string, color2: string, p: number) => {
|
||||||
|
const rgb1 = parseInt(color1.replace('#', ''), 16)
|
||||||
|
const rgb2 = parseInt(color2.replace('#', ''), 16)
|
||||||
|
|
||||||
|
const [r1, g1, b1] = toArray(rgb1)
|
||||||
|
const [r2, g2, b2] = toArray(rgb2)
|
||||||
|
|
||||||
|
const q = 1 - p
|
||||||
|
const rr = Math.round(r1 * q + r2 * p)
|
||||||
|
const rg = Math.round(g1 * q + g2 * p)
|
||||||
|
const rb = Math.round(b1 * q + b2 * p)
|
||||||
|
|
||||||
|
const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16)
|
||||||
|
const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}`
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(rgb: number) {
|
||||||
|
const r = rgb >> 16
|
||||||
|
const g = (rgb >> 8) % 256
|
||||||
|
const b = rgb % 256
|
||||||
|
|
||||||
|
return [r, g, b]
|
||||||
|
}
|
|
@ -8,7 +8,14 @@ const formatter = new Intl.NumberFormat('en-US', {
|
||||||
})
|
})
|
||||||
|
|
||||||
export function formatMoney(amount: number) {
|
export function formatMoney(amount: number) {
|
||||||
const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case
|
const newAmount =
|
||||||
|
// handle -0 case
|
||||||
|
Math.round(amount) === 0
|
||||||
|
? 0
|
||||||
|
: // Handle 499.9999999999999 case
|
||||||
|
(amount > 0 ? Math.floor : Math.ceil)(
|
||||||
|
amount + 0.00000000001 * Math.sign(amount)
|
||||||
|
)
|
||||||
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
|
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { MAX_TAG_LENGTH } from '../contract'
|
|
||||||
import { generateText, JSONContent } from '@tiptap/core'
|
import { generateText, JSONContent } from '@tiptap/core'
|
||||||
// Tiptap starter extensions
|
// Tiptap starter extensions
|
||||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
import { Blockquote } from '@tiptap/extension-blockquote'
|
||||||
|
@ -24,7 +23,8 @@ import { Mention } from '@tiptap/extension-mention'
|
||||||
import Iframe from './tiptap-iframe'
|
import Iframe from './tiptap-iframe'
|
||||||
import TiptapTweet from './tiptap-tweet-type'
|
import TiptapTweet from './tiptap-tweet-type'
|
||||||
import { find } from 'linkifyjs'
|
import { find } from 'linkifyjs'
|
||||||
import { uniq } from 'lodash'
|
import { cloneDeep, uniq } from 'lodash'
|
||||||
|
import { TiptapSpoiler } from './tiptap-spoiler'
|
||||||
|
|
||||||
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
|
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
|
||||||
export function getUrl(text: string) {
|
export function getUrl(text: string) {
|
||||||
|
@ -32,34 +32,6 @@ export function getUrl(text: string) {
|
||||||
return results.length ? results[0].href : null
|
return results.length ? results[0].href : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTags(text: string) {
|
|
||||||
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
|
|
||||||
const matches = (text.match(regex) || []).map((match) =>
|
|
||||||
match.trim().substring(1).substring(0, MAX_TAG_LENGTH)
|
|
||||||
)
|
|
||||||
const tagSet = new Set()
|
|
||||||
const uniqueTags: string[] = []
|
|
||||||
// Keep casing of last tag.
|
|
||||||
matches.reverse()
|
|
||||||
for (const tag of matches) {
|
|
||||||
const lowercase = tag.toLowerCase()
|
|
||||||
if (!tagSet.has(lowercase)) {
|
|
||||||
tagSet.add(lowercase)
|
|
||||||
uniqueTags.push(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uniqueTags.reverse()
|
|
||||||
return uniqueTags
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWordsAsTags(text: string) {
|
|
||||||
const taggedText = text
|
|
||||||
.split(/\s+/)
|
|
||||||
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
|
|
||||||
.join(' ')
|
|
||||||
return parseTags(taggedText)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: fuzzy matching
|
// TODO: fuzzy matching
|
||||||
export const wordIn = (word: string, corpus: string) =>
|
export const wordIn = (word: string, corpus: string) =>
|
||||||
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
|
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
|
||||||
|
@ -103,8 +75,31 @@ export const exhibitExts = [
|
||||||
Mention,
|
Mention,
|
||||||
Iframe,
|
Iframe,
|
||||||
TiptapTweet,
|
TiptapTweet,
|
||||||
|
TiptapSpoiler,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function richTextToString(text?: JSONContent) {
|
export function richTextToString(text?: JSONContent) {
|
||||||
return !text ? '' : generateText(text, exhibitExts)
|
if (!text) return ''
|
||||||
|
// remove spoiler tags.
|
||||||
|
const newText = cloneDeep(text)
|
||||||
|
dfs(newText, (current) => {
|
||||||
|
if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
|
||||||
|
current.text = '[spoiler]'
|
||||||
|
} else if (current.type === 'image') {
|
||||||
|
current.text = '[Image]'
|
||||||
|
// This is a hack, I've no idea how to change a tiptap extenstion's schema
|
||||||
|
current.type = 'text'
|
||||||
|
} else if (current.type === 'iframe') {
|
||||||
|
const src = current.attrs?.['src'] ? current.attrs['src'] : ''
|
||||||
|
current.text = '[Iframe]' + (src ? ` url:${src}` : '')
|
||||||
|
// This is a hack, I've no idea how to change a tiptap extenstion's schema
|
||||||
|
current.type = 'text'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return generateText(newText, exhibitExts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
|
||||||
|
data.content?.forEach((d) => dfs(d, f))
|
||||||
|
f(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => {
|
||||||
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]
|
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function chooseRandomSubset<T>(items: T[], count: number) {
|
||||||
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
|
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||||
|
shuffle(items, createRNG(seed))
|
||||||
|
return items.slice(0, count)
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
export const MINUTE_MS = 60 * 1000
|
export const MINUTE_MS = 60 * 1000
|
||||||
export const HOUR_MS = 60 * MINUTE_MS
|
export const HOUR_MS = 60 * MINUTE_MS
|
||||||
export const DAY_MS = 24 * HOUR_MS
|
export const DAY_MS = 24 * HOUR_MS
|
||||||
|
|
||||||
|
export const sleep = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
116
common/util/tiptap-spoiler.ts
Normal file
116
common/util/tiptap-spoiler.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
// adapted from @n8body/tiptap-spoiler
|
||||||
|
|
||||||
|
import {
|
||||||
|
Mark,
|
||||||
|
markInputRule,
|
||||||
|
markPasteRule,
|
||||||
|
mergeAttributes,
|
||||||
|
} from '@tiptap/core'
|
||||||
|
import type { ElementType } from 'react'
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
spoilerEditor: {
|
||||||
|
setSpoiler: () => ReturnType
|
||||||
|
toggleSpoiler: () => ReturnType
|
||||||
|
unsetSpoiler: () => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpoilerOptions = {
|
||||||
|
HTMLAttributes: Record<string, any>
|
||||||
|
spoilerOpenClass: string
|
||||||
|
spoilerCloseClass?: string
|
||||||
|
inputRegex: RegExp
|
||||||
|
pasteRegex: RegExp
|
||||||
|
as: ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
|
||||||
|
const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
|
||||||
|
|
||||||
|
export const TiptapSpoiler = Mark.create<SpoilerOptions>({
|
||||||
|
name: 'spoiler',
|
||||||
|
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
inclusive: false,
|
||||||
|
exitable: true,
|
||||||
|
content: 'inline*',
|
||||||
|
|
||||||
|
priority: 1001, // higher priority than other formatting so they go inside
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: { 'aria-label': 'spoiler' },
|
||||||
|
spoilerOpenClass: '',
|
||||||
|
spoilerCloseClass: undefined,
|
||||||
|
inputRegex: spoilerInputRegex,
|
||||||
|
pasteRegex: spoilerPasteRegex,
|
||||||
|
as: 'span',
|
||||||
|
editing: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setSpoiler:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.setMark(this.name),
|
||||||
|
toggleSpoiler:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.toggleMark(this.name),
|
||||||
|
unsetSpoiler:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.unsetMark(this.name),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
markInputRule({
|
||||||
|
find: this.options.inputRegex,
|
||||||
|
type: this.type,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addPasteRules() {
|
||||||
|
return [
|
||||||
|
markPasteRule({
|
||||||
|
find: this.options.pasteRegex,
|
||||||
|
type: this.type,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'span',
|
||||||
|
getAttrs: (node) =>
|
||||||
|
(node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const elem = document.createElement(this.options.as as string)
|
||||||
|
|
||||||
|
Object.entries(
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass,
|
||||||
|
})
|
||||||
|
).forEach(([attr, val]) => elem.setAttribute(attr, val))
|
||||||
|
|
||||||
|
elem.addEventListener('click', () => {
|
||||||
|
elem.setAttribute('class', this.options.spoilerOpenClass)
|
||||||
|
})
|
||||||
|
|
||||||
|
return elem
|
||||||
|
},
|
||||||
|
})
|
|
@ -55,6 +55,7 @@ Returns the authenticated user.
|
||||||
Gets all groups, in no particular order.
|
Gets all groups, in no particular order.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
||||||
- `availableToUserId`: Optional. if specified, only groups that the user can
|
- `availableToUserId`: Optional. if specified, only groups that the user can
|
||||||
join and groups they've already joined will be returned.
|
join and groups they've already joined will be returned.
|
||||||
|
|
||||||
|
@ -64,24 +65,23 @@ Requires no authorization.
|
||||||
|
|
||||||
Gets a group by its slug.
|
Gets a group by its slug.
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
Note: group is singular in the URL.
|
Note: group is singular in the URL.
|
||||||
|
|
||||||
### `GET /v0/group/by-id/[id]`
|
### `GET /v0/group/by-id/[id]`
|
||||||
|
|
||||||
Gets a group by its unique ID.
|
Gets a group by its unique ID.
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
Note: group is singular in the URL.
|
Note: group is singular in the URL.
|
||||||
|
|
||||||
### `GET /v0/group/by-id/[id]/markets`
|
### `GET /v0/group/by-id/[id]/markets`
|
||||||
|
|
||||||
Gets a group's markets by its unique ID.
|
Gets a group's markets by its unique ID.
|
||||||
|
|
||||||
Requires no authorization.
|
Requires no authorization.
|
||||||
Note: group is singular in the URL.
|
Note: group is singular in the URL.
|
||||||
|
|
||||||
|
|
||||||
### `GET /v0/markets`
|
### `GET /v0/markets`
|
||||||
|
|
||||||
Lists all markets, ordered by creation date descending.
|
Lists all markets, ordered by creation date descending.
|
||||||
|
@ -158,13 +158,16 @@ Requires no authorization.
|
||||||
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
|
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
|
||||||
url: string
|
url: string
|
||||||
|
|
||||||
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
|
outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC
|
||||||
mechanism: string // dpm-2 or cpmm-1
|
mechanism: string // dpm-2 or cpmm-1
|
||||||
|
|
||||||
probability: number
|
probability: number
|
||||||
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
|
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
|
||||||
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
|
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
|
||||||
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
|
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
|
||||||
|
min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
|
||||||
|
max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
|
||||||
|
isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
|
||||||
|
|
||||||
volume: number
|
volume: number
|
||||||
volume7Days: number
|
volume7Days: number
|
||||||
|
@ -408,7 +411,7 @@ Requires no authorization.
|
||||||
type FullMarket = LiteMarket & {
|
type FullMarket = LiteMarket & {
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
answers?: Answer[]
|
answers?: Answer[] // dpm-2 markets only
|
||||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||||
textDescription: string // string description without formatting, images, or embeds
|
textDescription: string // string description without formatting, images, or embeds
|
||||||
}
|
}
|
||||||
|
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
||||||
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
|
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`.
|
||||||
- `question`: Required. The headline question for the market.
|
- `question`: Required. The headline question for the market.
|
||||||
- `description`: Required. A long description describing the rules for the market.
|
- `description`: Required. A long description describing the rules for the market.
|
||||||
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
|
||||||
|
@ -569,6 +572,12 @@ For numeric markets, you must also provide:
|
||||||
|
|
||||||
- `min`: The minimum value that the market may resolve to.
|
- `min`: The minimum value that the market may resolve to.
|
||||||
- `max`: The maximum value that the market may resolve to.
|
- `max`: The maximum value that the market may resolve to.
|
||||||
|
- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
|
||||||
|
- `initialValue`: An initial value for the market, between min and max, exclusive.
|
||||||
|
|
||||||
|
For multiple choice markets, you must also provide:
|
||||||
|
|
||||||
|
- `answers`: An array of strings, each of which will be a valid answer for the market.
|
||||||
|
|
||||||
Example request:
|
Example request:
|
||||||
|
|
||||||
|
@ -582,6 +591,18 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
||||||
"initialProb":25}'
|
"initialProb":25}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `POST /v0/market/[marketId]/add-liquidity`
|
||||||
|
|
||||||
|
Adds a specified amount of liquidity into the market.
|
||||||
|
|
||||||
|
- `amount`: Required. The amount of liquidity to add, in M$.
|
||||||
|
|
||||||
|
### `POST /v0/market/[marketId]/close`
|
||||||
|
|
||||||
|
Closes a market on behalf of the authorized user.
|
||||||
|
|
||||||
|
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
|
||||||
|
|
||||||
### `POST /v0/market/[marketId]/resolve`
|
### `POST /v0/market/[marketId]/resolve`
|
||||||
|
|
||||||
Resolves a market on behalf of the authorized user.
|
Resolves a market on behalf of the authorized user.
|
||||||
|
@ -593,15 +614,18 @@ For binary markets:
|
||||||
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
|
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
|
||||||
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
|
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
|
||||||
|
|
||||||
For free response markets:
|
For free response or multiple choice markets:
|
||||||
|
|
||||||
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
|
- `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.
|
- `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. Note that the total weights must add to 100.
|
||||||
|
|
||||||
For numeric markets:
|
For numeric markets:
|
||||||
|
|
||||||
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
|
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
|
||||||
- `value`: The value that the market may resolves to.
|
- `value`: The value that the market may resolves to.
|
||||||
|
- `probabilityInt`: Required if `value` is present. Should be equal to
|
||||||
|
- If log scale: `log10(value - min + 1) / log10(max - min + 1)`
|
||||||
|
- Otherwise: `(value - min) / (max - min)`
|
||||||
|
|
||||||
Example request:
|
Example request:
|
||||||
|
|
||||||
|
@ -745,6 +769,7 @@ Requires no authorization.
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
|
||||||
- 2022-07-15: Add user by username and user by ID APIs
|
- 2022-07-15: Add user by username and user by ID APIs
|
||||||
- 2022-06-08: Add paging to markets endpoint
|
- 2022-06-08: Add paging to markets endpoint
|
||||||
- 2022-06-05: Add new authorized write endpoints
|
- 2022-06-05: Add new authorized write endpoints
|
||||||
|
|
|
@ -8,9 +8,8 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
|
|
||||||
## Sites using Manifold
|
## Sites using Manifold
|
||||||
|
|
||||||
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
|
||||||
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
|
||||||
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
|
||||||
|
- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety!
|
||||||
|
|
||||||
## API / Dev
|
## API / Dev
|
||||||
|
|
||||||
|
@ -28,6 +27,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
|
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
|
||||||
|
|
||||||
## Writeups
|
## Writeups
|
||||||
|
|
||||||
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
|
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
|
||||||
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
|
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
|
||||||
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
|
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
|
||||||
|
@ -36,5 +36,12 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
|
|
||||||
## Art
|
## Art
|
||||||
|
|
||||||
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) 
|
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) 
|
||||||
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
|
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
|
||||||
|
|
||||||
|
## Alumni
|
||||||
|
|
||||||
|
_These projects are no longer active, but were really really cool!_
|
||||||
|
|
||||||
|
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
|
||||||
|
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
|
||||||
|
|
|
@ -4,11 +4,7 @@
|
||||||
|
|
||||||
### Do I have to pay real money in order to participate?
|
### Do I have to pay real money in order to participate?
|
||||||
|
|
||||||
Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site.
|
||||||
|
|
||||||
### What is the name for the currency Manifold uses, represented by M$?
|
|
||||||
|
|
||||||
Manifold Dollars, or mana for short.
|
|
||||||
|
|
||||||
### Can M$ be sold for real money?
|
### Can M$ be sold for real money?
|
||||||
|
|
||||||
|
|
|
@ -100,6 +100,20 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "comments",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "comments",
|
"collectionGroup": "comments",
|
||||||
"queryScope": "COLLECTION_GROUP",
|
"queryScope": "COLLECTION_GROUP",
|
||||||
|
|
|
@ -27,7 +27,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||||
// User referral rules
|
// User referral rules
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
@ -78,7 +78,7 @@ service cloud.firestore {
|
||||||
allow read: if userId == request.auth.uid || isAdmin();
|
allow read: if userId == request.auth.uid || isAdmin();
|
||||||
allow update: if (userId == request.auth.uid || isAdmin())
|
allow update: if (userId == request.auth.uid || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
|
.hasOnly(['apiKey', 'notificationPreferences', 'twitchInfo']);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId}/views/{viewId} {
|
match /private-users/{userId}/views/{viewId} {
|
||||||
|
@ -102,7 +102,7 @@ service cloud.firestore {
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
|
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
|
||||||
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
allow update: if request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['description', 'closeTime', 'question'])
|
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
|
||||||
&& resource.data.creatorId == request.auth.uid;
|
&& resource.data.creatorId == request.auth.uid;
|
||||||
allow update: if isAdmin();
|
allow update: if isAdmin();
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
|
@ -171,33 +171,32 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
||||||
match /groups/{groupId} {
|
match /groups/{groupId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data)
|
&& request.resource.data.diff(resource.data)
|
||||||
.affectedKeys()
|
.affectedKeys()
|
||||||
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
|
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
|
||||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||||
|
|
||||||
match /groupContracts/{contractId} {
|
match /groupContracts/{contractId} {
|
||||||
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
|
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
|
||||||
}
|
}
|
||||||
|
|
||||||
match /groupMembers/{memberId}{
|
match /groupMembers/{memberId}{
|
||||||
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
||||||
allow delete: if request.auth.uid == resource.data.userId;
|
allow delete: if request.auth.uid == resource.data.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGroupMember() {
|
function isGroupMember() {
|
||||||
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
|
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
|
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
||||||
match /posts/{postId} {
|
match /posts/{postId} {
|
||||||
allow read;
|
allow read;
|
||||||
|
|
|
@ -65,5 +65,6 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
||||||
|
|
||||||
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows:
|
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows:
|
||||||
|
|
||||||
- Set a secret: `$ firebase functions:secrets:set stripe.test_secret="THE-API-KEY"`
|
- Set a secret: `$ firebase functions:secrets:set STRIPE_APIKEY`
|
||||||
|
- Then, enter the secret in the prompt.
|
||||||
- Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY`
|
- Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY`
|
||||||
|
|
|
@ -39,14 +39,16 @@
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mailgun-js": "0.22.0",
|
"mailgun-js": "0.22.0",
|
||||||
"module-alias": "2.2.2",
|
"module-alias": "2.2.2",
|
||||||
"react-masonry-css": "1.0.16",
|
"node-fetch": "2",
|
||||||
"stripe": "8.194.0",
|
"stripe": "8.194.0",
|
||||||
"zod": "3.17.2"
|
"zod": "3.17.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mailgun-js": "0.22.12",
|
"@types/mailgun-js": "0.22.12",
|
||||||
"@types/module-alias": "2.0.1",
|
"@types/module-alias": "2.0.1",
|
||||||
"firebase-functions-test": "0.3.3"
|
"@types/node-fetch": "2.6.2",
|
||||||
|
"firebase-functions-test": "0.3.3",
|
||||||
|
"puppeteer": "18.0.5"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
export { APIError } from '../../common/api'
|
export { APIError } from '../../common/api'
|
||||||
|
|
||||||
type Output = Record<string, unknown>
|
type Output = Record<string, unknown>
|
||||||
type AuthedUser = {
|
export type AuthedUser = {
|
||||||
uid: string
|
uid: string
|
||||||
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||||
}
|
}
|
||||||
|
|
58
functions/src/close-market.ts
Normal file
58
functions/src/close-market.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { Contract } from '../../common/contract'
|
||||||
|
import { getUser } from './utils'
|
||||||
|
|
||||||
|
import { isAdmin, isManifoldId } from '../../common/envs/constants'
|
||||||
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
contractId: z.string(),
|
||||||
|
closeTime: z.number().int().nonnegative().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const closemarket = newEndpoint({}, async (req, auth) => {
|
||||||
|
const { contractId, closeTime } = validate(bodySchema, req.body)
|
||||||
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
|
const contractSnap = await contractDoc.get()
|
||||||
|
if (!contractSnap.exists)
|
||||||
|
throw new APIError(404, 'No contract exists with the provided ID')
|
||||||
|
const contract = contractSnap.data() as Contract
|
||||||
|
const { creatorId } = contract
|
||||||
|
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||||
|
|
||||||
|
if (
|
||||||
|
creatorId !== auth.uid &&
|
||||||
|
!isManifoldId(auth.uid) &&
|
||||||
|
!isAdmin(firebaseUser.email)
|
||||||
|
)
|
||||||
|
throw new APIError(403, 'User is not creator of contract')
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (!closeTime && contract.closeTime && contract.closeTime < now)
|
||||||
|
throw new APIError(400, 'Contract already closed')
|
||||||
|
|
||||||
|
if (closeTime && closeTime < now)
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
'Close time must be in the future. ' +
|
||||||
|
'Alternatively, do not provide a close time to close immediately.'
|
||||||
|
)
|
||||||
|
|
||||||
|
const creator = await getUser(creatorId)
|
||||||
|
if (!creator) throw new APIError(500, 'Creator not found')
|
||||||
|
|
||||||
|
const updatedContract = {
|
||||||
|
...contract,
|
||||||
|
closeTime: closeTime ? closeTime : now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await contractDoc.update(updatedContract)
|
||||||
|
|
||||||
|
console.log('contract ', contractId, 'closed')
|
||||||
|
|
||||||
|
return updatedContract
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
|
@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
|
||||||
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
|
||||||
import { getValues } from './utils'
|
import { getValues } from './utils'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string().max(MAX_ANSWER_LENGTH),
|
contractId: z.string().max(MAX_ANSWER_LENGTH),
|
||||||
|
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contractId, auth.uid)
|
||||||
|
|
||||||
return answer
|
return answer
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -61,6 +61,8 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||||
anyoneCanJoin,
|
anyoneCanJoin,
|
||||||
totalContracts: 0,
|
totalContracts: 0,
|
||||||
totalMembers: memberIds.length,
|
totalMembers: memberIds.length,
|
||||||
|
postIds: [],
|
||||||
|
pinnedItems: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
await groupRef.create(group)
|
await groupRef.create(group)
|
||||||
|
|
|
@ -15,8 +15,8 @@ import {
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
|
|
||||||
import { chargeUser, getContract, isProd } from './utils'
|
import { isProd } from './utils'
|
||||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api'
|
||||||
|
|
||||||
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
||||||
import {
|
import {
|
||||||
|
@ -36,7 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { uniq, zip } from 'lodash'
|
import { uniq, zip } from 'lodash'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue, Transaction } from 'firebase-admin/firestore'
|
||||||
|
|
||||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
z.intersection(
|
z.intersection(
|
||||||
|
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
|
||||||
answers: z.string().trim().min(1).array().min(2),
|
answers: z.string().trim().min(1).array().min(2),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
export const createmarket = newEndpoint({}, (req, auth) => {
|
||||||
|
return createMarketHelper(req.body, auth)
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function createMarketHelper(body: any, auth: AuthedUser) {
|
||||||
const {
|
const {
|
||||||
question,
|
question,
|
||||||
description,
|
description,
|
||||||
|
@ -101,234 +105,244 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
outcomeType,
|
outcomeType,
|
||||||
groupId,
|
groupId,
|
||||||
visibility = 'public',
|
visibility = 'public',
|
||||||
} = validate(bodySchema, req.body)
|
} = validate(bodySchema, body)
|
||||||
|
|
||||||
let min, max, initialProb, isLogScale, answers
|
return await firestore.runTransaction(async (trans) => {
|
||||||
|
let min, max, initialProb, isLogScale, answers
|
||||||
|
|
||||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||||
let initialValue
|
let initialValue
|
||||||
;({ min, max, initialValue, isLogScale } = validate(
|
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
|
||||||
numericSchema,
|
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
|
||||||
req.body
|
throw new APIError(400, 'Invalid range.')
|
||||||
))
|
|
||||||
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
|
|
||||||
throw new APIError(400, 'Invalid range.')
|
|
||||||
|
|
||||||
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
initialProb =
|
||||||
|
getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||||
|
|
||||||
if (initialProb < 1 || initialProb > 99)
|
if (initialProb < 1 || initialProb > 99)
|
||||||
if (outcomeType === 'PSEUDO_NUMERIC')
|
if (outcomeType === 'PSEUDO_NUMERIC')
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
|
||||||
|
)
|
||||||
|
else throw new APIError(400, 'Invalid initial probability.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcomeType === 'BINARY') {
|
||||||
|
;({ initialProb } = validate(binarySchema, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||||
|
;({ answers } = validate(multipleChoiceSchema, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDoc = await trans.get(firestore.collection('users').doc(auth.uid))
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||||
|
}
|
||||||
|
const user = userDoc.data() as User
|
||||||
|
|
||||||
|
const ante = FIXED_ANTE
|
||||||
|
const deservesFreeMarket =
|
||||||
|
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||||
|
// TODO: this is broken because it's not in a transaction
|
||||||
|
if (ante > user.balance && !deservesFreeMarket)
|
||||||
|
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||||
|
|
||||||
|
let group: Group | null = null
|
||||||
|
if (groupId) {
|
||||||
|
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||||
|
const groupDoc = await trans.get(groupDocRef)
|
||||||
|
if (!groupDoc.exists) {
|
||||||
|
throw new APIError(400, 'No group exists with the given group ID.')
|
||||||
|
}
|
||||||
|
|
||||||
|
group = groupDoc.data() as Group
|
||||||
|
const groupMembersSnap = await trans.get(
|
||||||
|
firestore.collection(`groups/${groupId}/groupMembers`)
|
||||||
|
)
|
||||||
|
const groupMemberDocs = groupMembersSnap.docs.map(
|
||||||
|
(doc) => doc.data() as { userId: string; createdTime: number }
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
|
||||||
|
!group.anyoneCanJoin &&
|
||||||
|
group.creatorId !== user.id
|
||||||
|
) {
|
||||||
throw new APIError(
|
throw new APIError(
|
||||||
400,
|
400,
|
||||||
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
|
'User must be a member/creator of the group or group must be open to add markets to it.'
|
||||||
)
|
)
|
||||||
else throw new APIError(400, 'Invalid initial probability.')
|
}
|
||||||
}
|
}
|
||||||
|
const slug = await getSlug(trans, question)
|
||||||
|
const contractRef = firestore.collection('contracts').doc()
|
||||||
|
|
||||||
if (outcomeType === 'BINARY') {
|
console.log(
|
||||||
;({ initialProb } = validate(binarySchema, req.body))
|
'creating contract for',
|
||||||
}
|
user.username,
|
||||||
|
'on',
|
||||||
|
question,
|
||||||
|
'ante:',
|
||||||
|
ante || 0
|
||||||
|
)
|
||||||
|
|
||||||
if (outcomeType === 'MULTIPLE_CHOICE') {
|
// convert string descriptions into JSONContent
|
||||||
;({ answers } = validate(multipleChoiceSchema, req.body))
|
const newDescription =
|
||||||
}
|
!description || typeof description === 'string'
|
||||||
|
? {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: description || ' ' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: description
|
||||||
|
|
||||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
const contract = getNewContract(
|
||||||
if (!userDoc.exists) {
|
contractRef.id,
|
||||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
slug,
|
||||||
}
|
user,
|
||||||
const user = userDoc.data() as User
|
question,
|
||||||
|
outcomeType,
|
||||||
|
newDescription,
|
||||||
|
initialProb ?? 0,
|
||||||
|
ante,
|
||||||
|
closeTime.getTime(),
|
||||||
|
tags ?? [],
|
||||||
|
NUMERIC_BUCKET_COUNT,
|
||||||
|
min ?? 0,
|
||||||
|
max ?? 0,
|
||||||
|
isLogScale ?? false,
|
||||||
|
answers ?? [],
|
||||||
|
visibility
|
||||||
|
)
|
||||||
|
|
||||||
const ante = FIXED_ANTE
|
const providerId = deservesFreeMarket
|
||||||
const deservesFreeMarket =
|
? isProd()
|
||||||
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
// TODO: this is broken because it's not in a transaction
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
if (ante > user.balance && !deservesFreeMarket)
|
: user.id
|
||||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
|
||||||
|
|
||||||
let group: Group | null = null
|
if (ante) {
|
||||||
if (groupId) {
|
const delta = FieldValue.increment(-ante)
|
||||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
const providerDoc = firestore.collection('users').doc(providerId)
|
||||||
const groupDoc = await groupDocRef.get()
|
await trans.update(providerDoc, { balance: delta, totalDeposits: delta })
|
||||||
if (!groupDoc.exists) {
|
|
||||||
throw new APIError(400, 'No group exists with the given group ID.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = groupDoc.data() as Group
|
if (deservesFreeMarket) {
|
||||||
const groupMembersSnap = await firestore
|
await trans.update(firestore.collection('users').doc(user.id), {
|
||||||
.collection(`groups/${groupId}/groupMembers`)
|
freeMarketsCreated: FieldValue.increment(1),
|
||||||
.get()
|
|
||||||
const groupMemberDocs = groupMembersSnap.docs.map(
|
|
||||||
(doc) => doc.data() as { userId: string; createdTime: number }
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
|
|
||||||
!group.anyoneCanJoin &&
|
|
||||||
group.creatorId !== user.id
|
|
||||||
) {
|
|
||||||
throw new APIError(
|
|
||||||
400,
|
|
||||||
'User must be a member/creator of the group or group must be open to add markets to it.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const slug = await getSlug(question)
|
|
||||||
const contractRef = firestore.collection('contracts').doc()
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'creating contract for',
|
|
||||||
user.username,
|
|
||||||
'on',
|
|
||||||
question,
|
|
||||||
'ante:',
|
|
||||||
ante || 0
|
|
||||||
)
|
|
||||||
|
|
||||||
// convert string descriptions into JSONContent
|
|
||||||
const newDescription =
|
|
||||||
typeof description === 'string'
|
|
||||||
? {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'paragraph',
|
|
||||||
content: [{ type: 'text', text: description }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: description ?? {}
|
|
||||||
|
|
||||||
const contract = getNewContract(
|
|
||||||
contractRef.id,
|
|
||||||
slug,
|
|
||||||
user,
|
|
||||||
question,
|
|
||||||
outcomeType,
|
|
||||||
newDescription,
|
|
||||||
initialProb ?? 0,
|
|
||||||
ante,
|
|
||||||
closeTime.getTime(),
|
|
||||||
tags ?? [],
|
|
||||||
NUMERIC_BUCKET_COUNT,
|
|
||||||
min ?? 0,
|
|
||||||
max ?? 0,
|
|
||||||
isLogScale ?? false,
|
|
||||||
answers ?? [],
|
|
||||||
visibility
|
|
||||||
)
|
|
||||||
|
|
||||||
const providerId = deservesFreeMarket
|
|
||||||
? isProd()
|
|
||||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
: user.id
|
|
||||||
|
|
||||||
if (ante) await chargeUser(providerId, ante, true)
|
|
||||||
if (deservesFreeMarket)
|
|
||||||
await firestore
|
|
||||||
.collection('users')
|
|
||||||
.doc(user.id)
|
|
||||||
.update({ freeMarketsCreated: FieldValue.increment(1) })
|
|
||||||
|
|
||||||
await contractRef.create(contract)
|
|
||||||
|
|
||||||
if (group != null) {
|
|
||||||
const groupContractsSnap = await firestore
|
|
||||||
.collection(`groups/${groupId}/groupContracts`)
|
|
||||||
.get()
|
|
||||||
const groupContracts = groupContractsSnap.docs.map(
|
|
||||||
(doc) => doc.data() as { contractId: string; createdTime: number }
|
|
||||||
)
|
|
||||||
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
|
|
||||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
|
||||||
const groupContractRef = firestore
|
|
||||||
.collection(`groups/${groupId}/groupContracts`)
|
|
||||||
.doc(contract.id)
|
|
||||||
await groupContractRef.set({
|
|
||||||
contractId: contract.id,
|
|
||||||
createdTime: Date.now(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
await contractRef.create(contract)
|
||||||
const liquidityDoc = firestore
|
|
||||||
.collection(`contracts/${contract.id}/liquidity`)
|
|
||||||
.doc()
|
|
||||||
|
|
||||||
const lp = getCpmmInitialLiquidity(
|
if (group != null) {
|
||||||
providerId,
|
const groupContractsSnap = await trans.get(
|
||||||
contract as CPMMBinaryContract,
|
firestore.collection(`groups/${groupId}/groupContracts`)
|
||||||
liquidityDoc.id,
|
)
|
||||||
ante
|
const groupContracts = groupContractsSnap.docs.map(
|
||||||
)
|
(doc) => doc.data() as { contractId: string; createdTime: number }
|
||||||
|
|
||||||
await liquidityDoc.set(lp)
|
|
||||||
} else if (outcomeType === 'MULTIPLE_CHOICE') {
|
|
||||||
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
|
|
||||||
const betDocs = (answers ?? []).map(() => betCol.doc())
|
|
||||||
|
|
||||||
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
|
|
||||||
const answerDocs = (answers ?? []).map((_, i) =>
|
|
||||||
answerCol.doc(i.toString())
|
|
||||||
)
|
|
||||||
|
|
||||||
const { bets, answerObjects } = getMultipleChoiceAntes(
|
|
||||||
user,
|
|
||||||
contract as MultipleChoiceContract,
|
|
||||||
answers ?? [],
|
|
||||||
betDocs.map((bd) => bd.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
|
|
||||||
)
|
|
||||||
await Promise.all(
|
|
||||||
zip(answerObjects, answerDocs).map(([answer, doc]) =>
|
|
||||||
doc?.create(answer as Answer)
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
await contractRef.update({ answers: answerObjects })
|
|
||||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
|
||||||
const noneAnswerDoc = firestore
|
|
||||||
.collection(`contracts/${contract.id}/answers`)
|
|
||||||
.doc('0')
|
|
||||||
|
|
||||||
const noneAnswer = getNoneAnswer(contract.id, user)
|
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
|
||||||
await noneAnswerDoc.set(noneAnswer)
|
await createGroupLinks(trans, group, [contractRef.id], auth.uid)
|
||||||
|
|
||||||
const anteBetDoc = firestore
|
const groupContractRef = firestore
|
||||||
.collection(`contracts/${contract.id}/bets`)
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
.doc()
|
.doc(contract.id)
|
||||||
|
|
||||||
const anteBet = getFreeAnswerAnte(
|
await trans.set(groupContractRef, {
|
||||||
providerId,
|
contractId: contract.id,
|
||||||
contract as FreeResponseContract,
|
createdTime: Date.now(),
|
||||||
anteBetDoc.id
|
})
|
||||||
)
|
}
|
||||||
await anteBetDoc.set(anteBet)
|
}
|
||||||
} else if (outcomeType === 'NUMERIC') {
|
|
||||||
const anteBetDoc = firestore
|
|
||||||
.collection(`contracts/${contract.id}/bets`)
|
|
||||||
.doc()
|
|
||||||
|
|
||||||
const anteBet = getNumericAnte(
|
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
providerId,
|
const liquidityDoc = firestore
|
||||||
contract as NumericContract,
|
.collection(`contracts/${contract.id}/liquidity`)
|
||||||
ante,
|
.doc()
|
||||||
anteBetDoc.id
|
|
||||||
)
|
|
||||||
|
|
||||||
await anteBetDoc.set(anteBet)
|
const lp = getCpmmInitialLiquidity(
|
||||||
}
|
providerId,
|
||||||
|
contract as CPMMBinaryContract,
|
||||||
|
liquidityDoc.id,
|
||||||
|
ante
|
||||||
|
)
|
||||||
|
|
||||||
return contract
|
await trans.set(liquidityDoc, lp)
|
||||||
})
|
} else if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||||
|
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
|
||||||
|
const betDocs = (answers ?? []).map(() => betCol.doc())
|
||||||
|
|
||||||
const getSlug = async (question: string) => {
|
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
|
||||||
|
const answerDocs = (answers ?? []).map((_, i) =>
|
||||||
|
answerCol.doc(i.toString())
|
||||||
|
)
|
||||||
|
|
||||||
|
const { bets, answerObjects } = getMultipleChoiceAntes(
|
||||||
|
user,
|
||||||
|
contract as MultipleChoiceContract,
|
||||||
|
answers ?? [],
|
||||||
|
betDocs.map((bd) => bd.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
zip(bets, betDocs).map(([bet, doc]) =>
|
||||||
|
doc ? trans.create(doc, bet as Bet) : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await Promise.all(
|
||||||
|
zip(answerObjects, answerDocs).map(([answer, doc]) =>
|
||||||
|
doc ? trans.create(doc, answer as Answer) : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await trans.update(contractRef, { answers: answerObjects })
|
||||||
|
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||||
|
const noneAnswerDoc = firestore
|
||||||
|
.collection(`contracts/${contract.id}/answers`)
|
||||||
|
.doc('0')
|
||||||
|
|
||||||
|
const noneAnswer = getNoneAnswer(contract.id, user)
|
||||||
|
await trans.set(noneAnswerDoc, noneAnswer)
|
||||||
|
|
||||||
|
const anteBetDoc = firestore
|
||||||
|
.collection(`contracts/${contract.id}/bets`)
|
||||||
|
.doc()
|
||||||
|
|
||||||
|
const anteBet = getFreeAnswerAnte(
|
||||||
|
providerId,
|
||||||
|
contract as FreeResponseContract,
|
||||||
|
anteBetDoc.id
|
||||||
|
)
|
||||||
|
await trans.set(anteBetDoc, anteBet)
|
||||||
|
} else if (outcomeType === 'NUMERIC') {
|
||||||
|
const anteBetDoc = firestore
|
||||||
|
.collection(`contracts/${contract.id}/bets`)
|
||||||
|
.doc()
|
||||||
|
|
||||||
|
const anteBet = getNumericAnte(
|
||||||
|
providerId,
|
||||||
|
contract as NumericContract,
|
||||||
|
ante,
|
||||||
|
anteBetDoc.id
|
||||||
|
)
|
||||||
|
|
||||||
|
await trans.set(anteBetDoc, anteBet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSlug = async (trans: Transaction, question: string) => {
|
||||||
const proposedSlug = slugify(question)
|
const proposedSlug = slugify(question)
|
||||||
|
|
||||||
const preexistingContract = await getContractFromSlug(proposedSlug)
|
const preexistingContract = await getContractFromSlug(trans, proposedSlug)
|
||||||
|
|
||||||
return preexistingContract
|
return preexistingContract
|
||||||
? proposedSlug + '-' + randomString()
|
? proposedSlug + '-' + randomString()
|
||||||
|
@ -337,46 +351,42 @@ const getSlug = async (question: string) => {
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export async function getContractFromSlug(slug: string) {
|
async function getContractFromSlug(trans: Transaction, slug: string) {
|
||||||
const snap = await firestore
|
const snap = await trans.get(
|
||||||
.collection('contracts')
|
firestore.collection('contracts').where('slug', '==', slug)
|
||||||
.where('slug', '==', slug)
|
)
|
||||||
.get()
|
|
||||||
|
|
||||||
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGroupLinks(
|
async function createGroupLinks(
|
||||||
|
trans: Transaction,
|
||||||
group: Group,
|
group: Group,
|
||||||
contractIds: string[],
|
contractIds: string[],
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
for (const contractId of contractIds) {
|
for (const contractId of contractIds) {
|
||||||
const contract = await getContract(contractId)
|
const contractRef = firestore.collection('contracts').doc(contractId)
|
||||||
|
const contract = (await trans.get(contractRef)).data() as Contract
|
||||||
|
|
||||||
if (!contract?.groupSlugs?.includes(group.slug)) {
|
if (!contract?.groupSlugs?.includes(group.slug)) {
|
||||||
await firestore
|
await trans.update(contractRef, {
|
||||||
.collection('contracts')
|
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
||||||
.doc(contractId)
|
})
|
||||||
.update({
|
|
||||||
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
|
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
|
||||||
await firestore
|
await trans.update(contractRef, {
|
||||||
.collection('contracts')
|
groupLinks: [
|
||||||
.doc(contractId)
|
{
|
||||||
.update({
|
groupId: group.id,
|
||||||
groupLinks: [
|
name: group.name,
|
||||||
{
|
slug: group.slug,
|
||||||
groupId: group.id,
|
userId,
|
||||||
name: group.name,
|
createdTime: Date.now(),
|
||||||
slug: group.slug,
|
} as GroupLink,
|
||||||
userId,
|
...(contract?.groupLinks ?? []),
|
||||||
createdTime: Date.now(),
|
],
|
||||||
} as GroupLink,
|
})
|
||||||
...(contract?.groupLinks ?? []),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async (
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createBountyNotification = async (
|
||||||
|
fromUser: User,
|
||||||
|
toUserId: string,
|
||||||
|
amount: number,
|
||||||
|
idempotencyKey: string,
|
||||||
|
contract: Contract,
|
||||||
|
commentId?: string
|
||||||
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUserId)
|
||||||
|
if (!privateUser) return
|
||||||
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'tip_received'
|
||||||
|
)
|
||||||
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
|
const slug = commentId
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${toUserId}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: toUserId,
|
||||||
|
reason: 'tip_received',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: commentId ? commentId : contract.id,
|
||||||
|
sourceType: 'tip',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: fromUser.name,
|
||||||
|
sourceUserUsername: fromUser.username,
|
||||||
|
sourceUserAvatarUrl: fromUser.avatarUrl,
|
||||||
|
sourceText: amount.toString(),
|
||||||
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
|
sourceContractTitle: contract.question,
|
||||||
|
sourceContractSlug: contract.slug,
|
||||||
|
sourceSlug: slug,
|
||||||
|
sourceTitle: contract.question,
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
|
||||||
|
// maybe TODO: send email notification to comment creator
|
||||||
|
}
|
||||||
|
|
|
@ -3,10 +3,17 @@ import * as admin from 'firebase-admin'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
|
import {
|
||||||
|
Post,
|
||||||
|
MAX_POST_TITLE_LENGTH,
|
||||||
|
MAX_POST_SUBTITLE_LENGTH,
|
||||||
|
} from '../../common/post'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
|
import { createMarketHelper } from './create-market'
|
||||||
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
|
||||||
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
z.intersection(
|
z.intersection(
|
||||||
|
@ -33,12 +40,21 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
|
|
||||||
const postSchema = z.object({
|
const postSchema = z.object({
|
||||||
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
||||||
|
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
|
||||||
content: contentSchema,
|
content: contentSchema,
|
||||||
|
groupId: z.string().optional(),
|
||||||
|
|
||||||
|
// Date doc fields:
|
||||||
|
bounty: z.number().optional(),
|
||||||
|
birthday: z.number().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
question: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createpost = newEndpoint({}, async (req, auth) => {
|
export const createpost = newEndpoint({}, async (req, auth) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const { title, content } = validate(postSchema, req.body)
|
const { title, subtitle, content, groupId, question, ...otherProps } =
|
||||||
|
validate(postSchema, req.body)
|
||||||
|
|
||||||
const creator = await getUser(auth.uid)
|
const creator = await getUser(auth.uid)
|
||||||
if (!creator)
|
if (!creator)
|
||||||
|
@ -50,16 +66,51 @@ export const createpost = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
const postRef = firestore.collection('posts').doc()
|
const postRef = firestore.collection('posts').doc()
|
||||||
|
|
||||||
const post: Post = {
|
// If this is a date doc, create a market for it.
|
||||||
|
let contractSlug
|
||||||
|
if (question) {
|
||||||
|
const closeTime = Date.now() + DAY_MS * 30 * 3
|
||||||
|
|
||||||
|
const result = await createMarketHelper(
|
||||||
|
{
|
||||||
|
question,
|
||||||
|
closeTime,
|
||||||
|
outcomeType: 'BINARY',
|
||||||
|
visibility: 'unlisted',
|
||||||
|
initialProb: 50,
|
||||||
|
// Dating group!
|
||||||
|
groupId: 'j3ZE8fkeqiKmRGumy3O1',
|
||||||
|
},
|
||||||
|
auth
|
||||||
|
)
|
||||||
|
contractSlug = result.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
const post: Post = removeUndefinedProps({
|
||||||
|
...otherProps,
|
||||||
id: postRef.id,
|
id: postRef.id,
|
||||||
creatorId: creator.id,
|
creatorId: creator.id,
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
content: content,
|
content: content,
|
||||||
}
|
contractSlug,
|
||||||
|
})
|
||||||
|
|
||||||
await postRef.create(post)
|
await postRef.create(post)
|
||||||
|
if (groupId) {
|
||||||
|
const groupRef = firestore.collection('groups').doc(groupId)
|
||||||
|
const group = await groupRef.get()
|
||||||
|
if (group.exists) {
|
||||||
|
const groupData = group.data()
|
||||||
|
if (groupData) {
|
||||||
|
const postIds = groupData.postIds ?? []
|
||||||
|
postIds.push(postRef.id)
|
||||||
|
await groupRef.update({ postIds })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { status: 'success', post }
|
return { status: 'success', post }
|
||||||
})
|
})
|
||||||
|
|
|
@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
followerCountCached: 0,
|
followerCountCached: 0,
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
shouldShowWelcome: true,
|
shouldShowWelcome: true,
|
||||||
|
fractionResolvedCorrectly: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('users').doc(auth.uid).create(user)
|
await firestore.collection('users').doc(auth.uid).create(user)
|
||||||
|
|
|
@ -483,11 +483,7 @@
|
||||||
color: #999;
|
color: #999;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
">our Discord</a>! Or,
|
">our Discord</a>!
|
||||||
<a href="{{unsubscribeUrl}}" style="
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<title>New unique predictors on your market</title>
|
<title>New unique traders on your market</title>
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
img {
|
img {
|
||||||
|
@ -214,14 +214,14 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user!
|
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first trade from a user!
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
|
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
|
||||||
creating a market that appeals to others, and we'll do so for each new predictor.
|
creating a market that appeals to others, and we'll do so for each new trader.
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
Keep up the good work and check out your newest predictor below!
|
Keep up the good work and check out your newest trader below!
|
||||||
</span></p>
|
</span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<title>New unique predictors on your market</title>
|
<title>New unique traders on your market</title>
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
img {
|
img {
|
||||||
|
@ -214,14 +214,14 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users!
|
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> has attracted {{totalPredictors}} total traders!
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
|
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new traders,
|
||||||
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market).
|
and we'll continue to do so for each new trader, (although we won't send you any more emails about it for this market).
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
Keep up the good work and check out your newest predictors below!
|
Keep up the good work and check out your newest traders below!
|
||||||
</span></p>
|
</span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -192,7 +192,7 @@
|
||||||
tips on comments and markets</span></li>
|
tips on comments and markets</span></li>
|
||||||
<li style="line-height:23px;"><span
|
<li style="line-height:23px;"><span
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||||
predictor bonus for each user who predicts on your
|
trader bonus for each user who trades on your
|
||||||
markets</span></li>
|
markets</span></li>
|
||||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
|
|
|
@ -0,0 +1,411 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Weekly Portfolio Update on Manifold</title>
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
table { margin: 0 auto; }
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0;
|
||||||
|
mso-table-rspace: 0;
|
||||||
|
}
|
||||||
|
th {color:#000000; font-size:17px;}
|
||||||
|
th, td {padding: 10px; }
|
||||||
|
td{ font-size: 17px}
|
||||||
|
th, td { vertical-align: center; text-align: left }
|
||||||
|
a { vertical-align: center; text-align: left}
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
p.change{
|
||||||
|
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||||
|
}
|
||||||
|
p.prob{
|
||||||
|
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||||
|
}
|
||||||
|
a.question{
|
||||||
|
font-size: 18px;display: inline; vertical-align: middle;
|
||||||
|
}
|
||||||
|
td.question{
|
||||||
|
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||||
|
}
|
||||||
|
td.probs{
|
||||||
|
text-align: right; padding-left: 10px; min-width: 115px
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG />
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width: 480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
[owa] .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align:top;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:550px;">
|
||||||
|
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
|
||||||
|
<img alt="banner logo" height="auto"
|
||||||
|
src="https://manifold.markets/logo-banner.png"
|
||||||
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
|
title="" width="550">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align: top; margin-bottom: 30px" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||||
|
data-testid="4XoHRGw1Y">
|
||||||
|
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
We ran the numbers and here's how you did this past week!
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||||
|
<tr>
|
||||||
|
<tr>
|
||||||
|
<th style='font-size: 22px; text-align: center'>
|
||||||
|
Profit
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='padding-bottom: 30px; text-align: center'>
|
||||||
|
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||||
|
{{profit}}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px; ">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style='width: 170px'>
|
||||||
|
🔥 Prediction streak
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{prediction_streak}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
💸 Tips received
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{tips_received}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
📈 Markets traded
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{markets_traded}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
❓ Markets created
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{{markets_created}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style='width: 55px'>
|
||||||
|
🥳 Traders attracted
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{unique_bettors}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 20px 0px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0"
|
||||||
|
role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
">
|
||||||
|
<div style="
|
||||||
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
">
|
||||||
|
<p style="margin: 10px 0">
|
||||||
|
This e-mail has been sent to
|
||||||
|
{{name}},
|
||||||
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
510
functions/src/email-templates/weekly-portfolio-update.html
Normal file
|
@ -0,0 +1,510 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Weekly Portfolio Update on Manifold</title>
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
font-family:"Readex Pro", Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
table { margin: 0 auto; }
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0;
|
||||||
|
mso-table-rspace: 0;
|
||||||
|
}
|
||||||
|
th {color:#000000; font-size:17px;}
|
||||||
|
th, td {padding: 10px; }
|
||||||
|
td{ font-size: 17px}
|
||||||
|
th, td { vertical-align: center; text-align: left }
|
||||||
|
a { vertical-align: center; text-align: left}
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
p.change{
|
||||||
|
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
|
||||||
|
}
|
||||||
|
p.prob{
|
||||||
|
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
|
||||||
|
}
|
||||||
|
a.question{
|
||||||
|
font-size: 18px;display: inline; vertical-align: middle;
|
||||||
|
}
|
||||||
|
td.question{
|
||||||
|
vertical-align: middle; padding-bottom: 15px; text-align: left;
|
||||||
|
}
|
||||||
|
td.probs{
|
||||||
|
text-align: right; padding-left: 10px; min-width: 115px
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG />
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width: 480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
[owa] .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width: 480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align:top;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="center"
|
||||||
|
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:550px;">
|
||||||
|
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
|
||||||
|
<img alt="banner logo" height="auto"
|
||||||
|
src="https://manifold.markets/logo-banner.png"
|
||||||
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
|
title="" width="550">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="
|
||||||
|
background: #ffffff;
|
||||||
|
background-color: #ffffff;
|
||||||
|
margin: 0px auto;
|
||||||
|
max-width: 600px;
|
||||||
|
">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 0px 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="vertical-align: top" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
</span>Hi {{name}},</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
|
||||||
|
data-testid="4XoHRGw1Y">
|
||||||
|
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
We ran the numbers and here's how you did this past week!
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
|
||||||
|
<tr>
|
||||||
|
<tr>
|
||||||
|
<th style='font-size: 22px; text-align: center'>
|
||||||
|
Profit
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style='padding-bottom: 30px; text-align: center'>
|
||||||
|
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
|
||||||
|
{{profit}}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<td align="center"
|
||||||
|
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px; ">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style='width: 170px'>
|
||||||
|
🔥 Prediction streak
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{prediction_streak}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
💸 Tips received
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{tips_received}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
📈 Markets traded
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{markets_traded}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
❓ Markets created
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{{markets_created}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style='width: 55px'>
|
||||||
|
🥳 Traders attracted
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{{unique_bettors}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
|
||||||
|
data-testid="4XoHRGw1Y">
|
||||||
|
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
And here's some recent changes in your investments:
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
|
||||||
|
<table role="presentation">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class='question'>
|
||||||
|
<a class='question' href='{{question1Url}}'>
|
||||||
|
{{question1Title}}
|
||||||
|
<!-- Will the US economy recover from the pandemic?-->
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class='probs'>
|
||||||
|
<p class='prob'>
|
||||||
|
{{question1Prob}}
|
||||||
|
<!-- 9.9%-->
|
||||||
|
<p class='change' style='{{question1ChangeStyle}}'>
|
||||||
|
{{question1Change}}
|
||||||
|
<!-- +17%-->
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr><tr>
|
||||||
|
<td class='question'>
|
||||||
|
<a class='question' href='{{question2Url}}'>
|
||||||
|
{{question2Title}}
|
||||||
|
<!-- Will the US economy recover from the pandemic? blah blah blah-->
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class='probs'>
|
||||||
|
<p class='prob'>
|
||||||
|
{{question2Prob}}
|
||||||
|
<!-- 99.9%-->
|
||||||
|
<p class='change' style='{{question2ChangeStyle}}'>
|
||||||
|
{{question2Change}}
|
||||||
|
<!-- +7%-->
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr><tr>
|
||||||
|
<!-- <td style="{{investment_value_style}}">-->
|
||||||
|
<td class='question'>
|
||||||
|
<a class='question' href='{{question3Url}}'>
|
||||||
|
{{question3Title}}
|
||||||
|
<!-- Will the US economy recover from the pandemic?-->
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class='probs'>
|
||||||
|
<p class='prob'>
|
||||||
|
{{question3Prob}}
|
||||||
|
<!-- 99.9%-->
|
||||||
|
<p class='change' style='{{question3ChangeStyle}}'>
|
||||||
|
{{question3Change}}
|
||||||
|
<!-- +17%-->
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr><tr>
|
||||||
|
<!-- <td style="{{investment_value_style}}">-->
|
||||||
|
<td class='question'>
|
||||||
|
<a class='question' href='{{question4Url}}'>
|
||||||
|
{{question4Title}}
|
||||||
|
<!-- Will the US economy recover from the pandemic?-->
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class='probs'>
|
||||||
|
<p class='prob'>
|
||||||
|
{{question4Prob}}
|
||||||
|
<!-- 99.9%-->
|
||||||
|
<p class='change' style='{{question4ChangeStyle}}'>
|
||||||
|
{{question4Change}}
|
||||||
|
<!-- +17%-->
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 0 0 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
|
||||||
|
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="margin: 0px auto; max-width: 600px">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="width: 100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="
|
||||||
|
direction: ltr;
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 20px 0px 20px 0px;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||||
|
font-size: 0px;
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: top; padding: 0">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0"
|
||||||
|
role="presentation" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
">
|
||||||
|
<div style="
|
||||||
|
font-family: Ubuntu, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
">
|
||||||
|
<p style="margin: 10px 0">
|
||||||
|
This e-mail has been sent to
|
||||||
|
{{name}},
|
||||||
|
<a href="{{unsubscribeUrl}}" style="
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
" target="_blank">click here to unsubscribe from this type of notification</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="
|
||||||
|
font-size: 0px;
|
||||||
|
padding: 10px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
|
@ -210,7 +210,7 @@
|
||||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
target="_blank" href="https://manifold.markets/referrals"><span
|
target="_blank" href="https://manifold.markets/referrals"><span
|
||||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
|
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
|
||||||
your friends</u></span></a> and earn M$500 for each signup!</span></li>
|
your friends</u></span></a> and earn M$250 for each signup!</span></li>
|
||||||
|
|
||||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
|
|
|
@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
|
||||||
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
import { formatNumericProbability } from '../../common/pseudo-numeric'
|
||||||
|
|
||||||
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
import { sendTemplateEmail, sendTextEmail } from './send-email'
|
||||||
import { getUser } from './utils'
|
import { contractUrl, getUser } from './utils'
|
||||||
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
|
||||||
import { notification_reason_types } from '../../common/notification'
|
import { notification_reason_types } from '../../common/notification'
|
||||||
import { Dictionary } from 'lodash'
|
import { Dictionary } from 'lodash'
|
||||||
|
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
|
||||||
import {
|
import {
|
||||||
getNotificationDestinationsForUser,
|
PerContractInvestmentsData,
|
||||||
notification_preference,
|
OverallPerformanceData,
|
||||||
} from '../../common/user-notification-preferences'
|
} from './weekly-portfolio-emails'
|
||||||
|
|
||||||
export const sendMarketResolutionEmail = async (
|
export const sendMarketResolutionEmail = async (
|
||||||
reason: notification_reason_types,
|
reason: notification_reason_types,
|
||||||
|
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
'onboarding_flow' as notification_preference
|
privateUser,
|
||||||
}`
|
'onboarding_flow'
|
||||||
|
)
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
'onboarding_flow' as notification_preference
|
privateUser,
|
||||||
}`
|
'onboarding_flow'
|
||||||
|
)
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold Markets one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
|
@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
privateUser,
|
||||||
'onboarding_flow' as notification_preference
|
'onboarding_flow'
|
||||||
}`
|
)
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Create your own prediction market',
|
'Create your own prediction market',
|
||||||
|
@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
privateUser,
|
||||||
'thank_you_for_purchases' as notification_preference
|
'thank_you_for_purchases'
|
||||||
}`
|
)
|
||||||
|
|
||||||
return await sendTemplateEmail(
|
return await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
|
@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${
|
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
'trending_markets' as notification_preference
|
privateUser,
|
||||||
}`
|
'trending_markets'
|
||||||
|
)
|
||||||
|
|
||||||
const { name } = user
|
const { name } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function contractUrl(contract: Contract) {
|
|
||||||
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageSourceUrl(contract: Contract) {
|
function imageSourceUrl(contract: Contract) {
|
||||||
return buildCardUrl(getOpenGraphProps(contract))
|
return buildCardUrl(getOpenGraphProps(contract))
|
||||||
}
|
}
|
||||||
|
@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendWeeklyPortfolioUpdateEmail = async (
|
||||||
|
user: User,
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
investments: PerContractInvestmentsData[],
|
||||||
|
overallPerformance: OverallPerformanceData
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!privateUser ||
|
||||||
|
!privateUser.email ||
|
||||||
|
!privateUser.notificationPreferences.profit_loss_updates.includes('email')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const { unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||||
|
privateUser,
|
||||||
|
'profit_loss_updates'
|
||||||
|
)
|
||||||
|
|
||||||
|
const { name } = user
|
||||||
|
const firstName = name.split(' ')[0]
|
||||||
|
const templateData: Record<string, string> = {
|
||||||
|
name: firstName,
|
||||||
|
unsubscribeUrl,
|
||||||
|
...overallPerformance,
|
||||||
|
}
|
||||||
|
investments.forEach((investment, i) => {
|
||||||
|
templateData[`question${i + 1}Title`] = investment.questionTitle
|
||||||
|
templateData[`question${i + 1}Url`] = investment.questionUrl
|
||||||
|
templateData[`question${i + 1}Prob`] = investment.questionProb
|
||||||
|
templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
|
||||||
|
templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendTemplateEmail(
|
||||||
|
privateUser.email,
|
||||||
|
// 'iansphilips@gmail.com',
|
||||||
|
`Here's your weekly portfolio update!`,
|
||||||
|
investments.length === 0
|
||||||
|
? 'portfolio-update-no-movers'
|
||||||
|
: 'portfolio-update',
|
||||||
|
templateData
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -27,9 +27,10 @@ export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
export * from './weekly-markets-emails'
|
export * from './weekly-markets-emails'
|
||||||
export * from './reset-betting-streaks'
|
export * from './reset-betting-streaks'
|
||||||
export * from './reset-weekly-emails-flag'
|
export * from './reset-weekly-emails-flags'
|
||||||
export * from './on-update-contract-follow'
|
export * from './on-update-contract-follow'
|
||||||
export * from './on-update-like'
|
export * from './on-update-like'
|
||||||
|
export * from './weekly-portfolio-emails'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -50,6 +51,8 @@ export * from './resolve-market'
|
||||||
export * from './unsubscribe'
|
export * from './unsubscribe'
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
export * from './mana-bonus-email'
|
export * from './mana-bonus-email'
|
||||||
|
export * from './close-market'
|
||||||
|
export * from './update-comment-bounty'
|
||||||
|
|
||||||
import { health } from './health'
|
import { health } from './health'
|
||||||
import { transact } from './transact'
|
import { transact } from './transact'
|
||||||
|
@ -63,9 +66,11 @@ import { sellshares } from './sell-shares'
|
||||||
import { claimmanalink } from './claim-manalink'
|
import { claimmanalink } from './claim-manalink'
|
||||||
import { createmarket } from './create-market'
|
import { createmarket } from './create-market'
|
||||||
import { addliquidity } from './add-liquidity'
|
import { addliquidity } from './add-liquidity'
|
||||||
|
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
||||||
import { withdrawliquidity } from './withdraw-liquidity'
|
import { withdrawliquidity } from './withdraw-liquidity'
|
||||||
import { creategroup } from './create-group'
|
import { creategroup } from './create-group'
|
||||||
import { resolvemarket } from './resolve-market'
|
import { resolvemarket } from './resolve-market'
|
||||||
|
import { closemarket } from './close-market'
|
||||||
import { unsubscribe } from './unsubscribe'
|
import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
@ -88,9 +93,12 @@ const sellSharesFunction = toCloudFunction(sellshares)
|
||||||
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
||||||
const createMarketFunction = toCloudFunction(createmarket)
|
const createMarketFunction = toCloudFunction(createmarket)
|
||||||
const addLiquidityFunction = toCloudFunction(addliquidity)
|
const addLiquidityFunction = toCloudFunction(addliquidity)
|
||||||
|
const addCommentBounty = toCloudFunction(addcommentbounty)
|
||||||
|
const awardCommentBounty = toCloudFunction(awardcommentbounty)
|
||||||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
||||||
const createGroupFunction = toCloudFunction(creategroup)
|
const createGroupFunction = toCloudFunction(creategroup)
|
||||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||||
|
const closeMarketFunction = toCloudFunction(closemarket)
|
||||||
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
|
@ -115,11 +123,14 @@ export {
|
||||||
withdrawLiquidityFunction as withdrawliquidity,
|
withdrawLiquidityFunction as withdrawliquidity,
|
||||||
createGroupFunction as creategroup,
|
createGroupFunction as creategroup,
|
||||||
resolveMarketFunction as resolvemarket,
|
resolveMarketFunction as resolvemarket,
|
||||||
|
closeMarketFunction as closemarket,
|
||||||
unsubscribeFunction as unsubscribe,
|
unsubscribeFunction as unsubscribe,
|
||||||
stripeWebhookFunction as stripewebhook,
|
stripeWebhookFunction as stripewebhook,
|
||||||
createCheckoutSessionFunction as createcheckoutsession,
|
createCheckoutSessionFunction as createcheckoutsession,
|
||||||
getCurrentUserFunction as getcurrentuser,
|
getCurrentUserFunction as getcurrentuser,
|
||||||
acceptChallenge as acceptchallenge,
|
acceptChallenge as acceptchallenge,
|
||||||
createPostFunction as createpost,
|
createPostFunction as createpost,
|
||||||
saveTwitchCredentials as savetwitchcredentials
|
saveTwitchCredentials as savetwitchcredentials,
|
||||||
|
addCommentBounty as addcommentbounty,
|
||||||
|
awardCommentBounty as awardcommentbounty,
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,7 +60,7 @@ async function sendMarketCloseEmails() {
|
||||||
'contract',
|
'contract',
|
||||||
'closed',
|
'closed',
|
||||||
user,
|
user,
|
||||||
'closed' + contract.id.slice(6, contract.id.length),
|
contract.id + '-closed-at-' + contract.closeTime,
|
||||||
contract.closeTime?.toString() ?? new Date().toString(),
|
contract.closeTime?.toString() ?? new Date().toString(),
|
||||||
{ contract }
|
{ contract }
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,7 +3,14 @@ import * as admin from 'firebase-admin'
|
||||||
import { keyBy, uniq } from 'lodash'
|
import { keyBy, uniq } from 'lodash'
|
||||||
|
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
import { getUser, getValues, isProd, log } from './utils'
|
import {
|
||||||
|
getContractPath,
|
||||||
|
getUser,
|
||||||
|
getValues,
|
||||||
|
isProd,
|
||||||
|
log,
|
||||||
|
revalidateStaticProps,
|
||||||
|
} from './utils'
|
||||||
import {
|
import {
|
||||||
createBetFillNotification,
|
createBetFillNotification,
|
||||||
createBettingStreakBonusNotification,
|
createBettingStreakBonusNotification,
|
||||||
|
@ -24,8 +31,6 @@ import {
|
||||||
} from '../../common/antes'
|
} from '../../common/antes'
|
||||||
import { APIError } from '../../common/api'
|
import { APIError } from '../../common/api'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
|
|
||||||
import { addHouseLiquidity } from './add-liquidity'
|
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||||
import {
|
import {
|
||||||
|
@ -37,7 +42,7 @@ const firestore = admin.firestore()
|
||||||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||||
|
|
||||||
export const onCreateBet = functions
|
export const onCreateBet = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY', 'API_SECRET'] })
|
||||||
.firestore.document('contracts/{contractId}/bets/{betId}')
|
.firestore.document('contracts/{contractId}/bets/{betId}')
|
||||||
.onCreate(async (change, context) => {
|
.onCreate(async (change, context) => {
|
||||||
const { contractId } = context.params as {
|
const { contractId } = context.params as {
|
||||||
|
@ -77,7 +82,7 @@ export const onCreateBet = functions
|
||||||
await notifyFills(bet, contract, eventId, bettor)
|
await notifyFills(bet, contract, eventId, bettor)
|
||||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||||
|
|
||||||
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
|
await revalidateStaticProps(getContractPath(contract))
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateBettingStreak = async (
|
const updateBettingStreak = async (
|
||||||
|
@ -86,36 +91,42 @@ const updateBettingStreak = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
eventId: string
|
eventId: string
|
||||||
) => {
|
) => {
|
||||||
const now = Date.now()
|
const { newBettingStreak } = await firestore.runTransaction(async (trans) => {
|
||||||
const currentDateResetTime = currentDateBettingStreakResetTime()
|
const userDoc = firestore.collection('users').doc(user.id)
|
||||||
// if now is before reset time, use yesterday's reset time
|
const bettor = (await trans.get(userDoc)).data() as User
|
||||||
const lastDateResetTime = currentDateResetTime - DAY_MS
|
const now = Date.now()
|
||||||
const betStreakResetTime =
|
const currentDateResetTime = currentDateBettingStreakResetTime()
|
||||||
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
|
// if now is before reset time, use yesterday's reset time
|
||||||
const lastBetTime = user?.lastBetTime ?? 0
|
const lastDateResetTime = currentDateResetTime - DAY_MS
|
||||||
|
const betStreakResetTime =
|
||||||
|
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
|
||||||
|
const lastBetTime = bettor?.lastBetTime ?? 0
|
||||||
|
|
||||||
// If they've already bet after the reset time
|
// If they've already bet after the reset time
|
||||||
if (lastBetTime > betStreakResetTime) return
|
if (lastBetTime > betStreakResetTime) return { newBettingStreak: undefined }
|
||||||
|
|
||||||
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
|
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
|
||||||
// Otherwise, add 1 to their betting streak
|
// Otherwise, add 1 to their betting streak
|
||||||
await firestore.collection('users').doc(user.id).update({
|
await trans.update(userDoc, {
|
||||||
currentBettingStreak: newBettingStreak,
|
currentBettingStreak: newBettingStreak,
|
||||||
|
lastBetTime: bet.createdTime,
|
||||||
|
})
|
||||||
|
return { newBettingStreak }
|
||||||
})
|
})
|
||||||
|
if (!newBettingStreak) return
|
||||||
// Send them the bonus times their streak
|
|
||||||
const bonusAmount = Math.min(
|
|
||||||
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
|
||||||
BETTING_STREAK_BONUS_MAX
|
|
||||||
)
|
|
||||||
const fromUserId = isProd()
|
|
||||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
|
||||||
const bonusTxnDetails = {
|
|
||||||
currentBettingStreak: newBettingStreak,
|
|
||||||
}
|
|
||||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
|
// Send them the bonus times their streak
|
||||||
|
const bonusAmount = Math.min(
|
||||||
|
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
||||||
|
BETTING_STREAK_BONUS_MAX
|
||||||
|
)
|
||||||
|
const fromUserId = isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
const bonusTxnDetails = {
|
||||||
|
currentBettingStreak: newBettingStreak,
|
||||||
|
}
|
||||||
|
|
||||||
const bonusTxn: TxnData = {
|
const bonusTxn: TxnData = {
|
||||||
fromId: fromUserId,
|
fromId: fromUserId,
|
||||||
fromType: 'BANK',
|
fromType: 'BANK',
|
||||||
|
@ -127,75 +138,82 @@ const updateBettingStreak = async (
|
||||||
description: JSON.stringify(bonusTxnDetails),
|
description: JSON.stringify(bonusTxnDetails),
|
||||||
data: bonusTxnDetails,
|
data: bonusTxnDetails,
|
||||||
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
|
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
|
||||||
return await runTxn(trans, bonusTxn)
|
const { message, txn, status } = await runTxn(trans, bonusTxn)
|
||||||
|
return { message, txn, status, bonusAmount }
|
||||||
})
|
})
|
||||||
if (!result.txn) {
|
if (result.status != 'success') {
|
||||||
log("betting streak bonus txn couldn't be made")
|
log("betting streak bonus txn couldn't be made")
|
||||||
log('status:', result.status)
|
log('status:', result.status)
|
||||||
log('message:', result.message)
|
log('message:', result.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (result.txn) {
|
||||||
await createBettingStreakBonusNotification(
|
await createBettingStreakBonusNotification(
|
||||||
user,
|
user,
|
||||||
result.txn.id,
|
result.txn.id,
|
||||||
bet,
|
bet,
|
||||||
contract,
|
contract,
|
||||||
bonusAmount,
|
result.bonusAmount,
|
||||||
newBettingStreak,
|
newBettingStreak,
|
||||||
eventId
|
eventId
|
||||||
)
|
)
|
||||||
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
await handleBettingStreakBadgeAward(user, newBettingStreak)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
contract: Contract,
|
oldContract: Contract,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
bettor: User
|
bettor: User
|
||||||
) => {
|
) => {
|
||||||
let previousUniqueBettorIds = contract.uniqueBettorIds
|
const { newUniqueBettorIds } = await firestore.runTransaction(
|
||||||
|
async (trans) => {
|
||||||
|
const contractDoc = firestore.collection(`contracts`).doc(oldContract.id)
|
||||||
|
const contract = (await trans.get(contractDoc)).data() as Contract
|
||||||
|
let previousUniqueBettorIds = contract.uniqueBettorIds
|
||||||
|
|
||||||
if (!previousUniqueBettorIds) {
|
const betsSnap = await trans.get(
|
||||||
const contractBets = (
|
firestore.collection(`contracts/${contract.id}/bets`)
|
||||||
await firestore.collection(`contracts/${contract.id}/bets`).get()
|
)
|
||||||
).docs.map((doc) => doc.data() as Bet)
|
if (!previousUniqueBettorIds) {
|
||||||
|
const contractBets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
|
|
||||||
if (contractBets.length === 0) {
|
if (contractBets.length === 0) {
|
||||||
log(`No bets for contract ${contract.id}`)
|
return { newUniqueBettorIds: undefined }
|
||||||
return
|
}
|
||||||
|
|
||||||
|
previousUniqueBettorIds = uniq(
|
||||||
|
contractBets
|
||||||
|
.filter((bet) => bet.createdTime < BONUS_START_DATE)
|
||||||
|
.map((bet) => bet.userId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
|
||||||
|
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
|
||||||
|
|
||||||
|
// Update contract unique bettors
|
||||||
|
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||||
|
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||||
|
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||||
|
|
||||||
|
await trans.update(contractDoc, {
|
||||||
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to give a bonus for the creator's bet
|
||||||
|
if (!isNewUniqueBettor || bettor.id == contract.creatorId)
|
||||||
|
return { newUniqueBettorIds: undefined }
|
||||||
|
|
||||||
|
return { newUniqueBettorIds }
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
if (!newUniqueBettorIds) return
|
||||||
|
|
||||||
previousUniqueBettorIds = uniq(
|
|
||||||
contractBets
|
|
||||||
.filter((bet) => bet.createdTime < BONUS_START_DATE)
|
|
||||||
.map((bet) => bet.userId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
|
|
||||||
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
|
|
||||||
|
|
||||||
// Update contract unique bettors
|
|
||||||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
|
||||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
|
||||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
|
||||||
|
|
||||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// No need to give a bonus for the creator's bet
|
|
||||||
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
|
|
||||||
|
|
||||||
if (contract.mechanism === 'cpmm-1') {
|
|
||||||
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create combined txn for all new unique bettors
|
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
contractId: contract.id,
|
contractId: oldContract.id,
|
||||||
uniqueNewBettorId: bettor.id,
|
uniqueNewBettorId: bettor.id,
|
||||||
}
|
}
|
||||||
const fromUserId = isProd()
|
const fromUserId = isProd()
|
||||||
|
@ -204,12 +222,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||||
const fromUser = fromSnap.data() as User
|
const fromUser = fromSnap.data() as User
|
||||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
const bonusTxn: TxnData = {
|
const bonusTxn: TxnData = {
|
||||||
fromId: fromUser.id,
|
fromId: fromUser.id,
|
||||||
fromType: 'BANK',
|
fromType: 'BANK',
|
||||||
toId: contract.creatorId,
|
toId: oldContract.creatorId,
|
||||||
toType: 'USER',
|
toType: 'USER',
|
||||||
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
|
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
token: 'M$',
|
token: 'M$',
|
||||||
|
@ -217,21 +234,25 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
description: JSON.stringify(bonusTxnDetails),
|
description: JSON.stringify(bonusTxnDetails),
|
||||||
data: bonusTxnDetails,
|
data: bonusTxnDetails,
|
||||||
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||||
return await runTxn(trans, bonusTxn)
|
const { status, message, txn } = await runTxn(trans, bonusTxn)
|
||||||
|
return { status, newUniqueBettorIds, message, txn }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.status != 'success' || !result.txn) {
|
if (result.status != 'success' || !result.txn) {
|
||||||
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
|
log(`No bonus for user: ${oldContract.creatorId} - status:`, result.status)
|
||||||
log('message:', result.message)
|
log('message:', result.message)
|
||||||
} else {
|
} else {
|
||||||
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
log(
|
||||||
|
`Bonus txn for user: ${oldContract.creatorId} completed:`,
|
||||||
|
result.txn?.id
|
||||||
|
)
|
||||||
await createUniqueBettorBonusNotification(
|
await createUniqueBettorBonusNotification(
|
||||||
contract.creatorId,
|
oldContract.creatorId,
|
||||||
bettor,
|
bettor,
|
||||||
result.txn.id,
|
result.txn.id,
|
||||||
contract,
|
oldContract,
|
||||||
result.txn.amount,
|
result.txn.amount,
|
||||||
newUniqueBettorIds,
|
result.newUniqueBettorIds,
|
||||||
eventId + '-unique-bettor-bonus'
|
eventId + '-unique-bettor-bonus'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,18 @@
|
||||||
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 { compact } from 'lodash'
|
import { compact } from 'lodash'
|
||||||
import { getContract, getUser, getValues } from './utils'
|
import {
|
||||||
|
getContract,
|
||||||
|
getContractPath,
|
||||||
|
getUser,
|
||||||
|
getValues,
|
||||||
|
revalidateStaticProps,
|
||||||
|
} from './utils'
|
||||||
import { ContractComment } from '../../common/comment'
|
import { ContractComment } from '../../common/comment'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
|
import { getLargestPosition } from '../../common/calculate'
|
||||||
|
import { maxBy } from 'lodash'
|
||||||
import {
|
import {
|
||||||
createCommentOrAnswerOrUpdatedContractNotification,
|
createCommentOrAnswerOrUpdatedContractNotification,
|
||||||
replied_users_info,
|
replied_users_info,
|
||||||
|
@ -14,6 +22,60 @@ import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
function getMostRecentCommentableBet(
|
||||||
|
before: number,
|
||||||
|
betsByCurrentUser: Bet[],
|
||||||
|
commentsByCurrentUser: ContractComment[],
|
||||||
|
answerOutcome?: string
|
||||||
|
) {
|
||||||
|
let sortedBetsByCurrentUser = betsByCurrentUser.sort(
|
||||||
|
(a, b) => b.createdTime - a.createdTime
|
||||||
|
)
|
||||||
|
if (answerOutcome) {
|
||||||
|
sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1)
|
||||||
|
}
|
||||||
|
return sortedBetsByCurrentUser
|
||||||
|
.filter((bet) => {
|
||||||
|
const { createdTime, isRedemption } = bet
|
||||||
|
// You can comment on bets posted in the last hour
|
||||||
|
const commentable = !isRedemption && before - createdTime < 60 * 60 * 1000
|
||||||
|
const alreadyCommented = commentsByCurrentUser.some(
|
||||||
|
(comment) => comment.createdTime > bet.createdTime
|
||||||
|
)
|
||||||
|
if (commentable && !alreadyCommented) {
|
||||||
|
if (!answerOutcome) return true
|
||||||
|
return answerOutcome === bet.outcome
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPriorUserComments(
|
||||||
|
contractId: string,
|
||||||
|
userId: string,
|
||||||
|
before: number
|
||||||
|
) {
|
||||||
|
const priorCommentsQuery = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.collection('comments')
|
||||||
|
.where('createdTime', '<', before)
|
||||||
|
.where('userId', '==', userId)
|
||||||
|
.get()
|
||||||
|
return priorCommentsQuery.docs.map((d) => d.data() as ContractComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPriorContractBets(contractId: string, before: number) {
|
||||||
|
const priorBetsQuery = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.collection('bets')
|
||||||
|
.where('createdTime', '<', before)
|
||||||
|
.get()
|
||||||
|
return priorBetsQuery.docs.map((d) => d.data() as Bet)
|
||||||
|
}
|
||||||
|
|
||||||
export const onCreateCommentOnContract = 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}')
|
||||||
|
@ -32,6 +94,8 @@ export const onCreateCommentOnContract = functions
|
||||||
contractQuestion: contract.question,
|
contractQuestion: contract.question,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await revalidateStaticProps(getContractPath(contract))
|
||||||
|
|
||||||
const comment = change.data() as ContractComment
|
const comment = change.data() as ContractComment
|
||||||
const lastCommentTime = comment.createdTime
|
const lastCommentTime = comment.createdTime
|
||||||
|
|
||||||
|
@ -45,7 +109,48 @@ export const onCreateCommentOnContract = functions
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
.update({ lastCommentTime, lastUpdatedTime: Date.now() })
|
.update({ lastCommentTime, lastUpdatedTime: Date.now() })
|
||||||
|
|
||||||
let bet: Bet | undefined
|
const priorBets = await getPriorContractBets(
|
||||||
|
contractId,
|
||||||
|
comment.createdTime
|
||||||
|
)
|
||||||
|
const priorUserBets = priorBets.filter(
|
||||||
|
(b) => b.userId === comment.userId && !b.isAnte
|
||||||
|
)
|
||||||
|
const priorUserComments = await getPriorUserComments(
|
||||||
|
contractId,
|
||||||
|
comment.userId,
|
||||||
|
comment.createdTime
|
||||||
|
)
|
||||||
|
const bet = getMostRecentCommentableBet(
|
||||||
|
comment.createdTime,
|
||||||
|
priorUserBets,
|
||||||
|
priorUserComments,
|
||||||
|
comment.answerOutcome
|
||||||
|
)
|
||||||
|
if (bet) {
|
||||||
|
await change.ref.update({
|
||||||
|
betId: bet.id,
|
||||||
|
betOutcome: bet.outcome,
|
||||||
|
betAmount: bet.amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = getLargestPosition(contract, priorUserBets)
|
||||||
|
if (position) {
|
||||||
|
const fields: { [k: string]: unknown } = {
|
||||||
|
commenterPositionShares: position.shares,
|
||||||
|
commenterPositionOutcome: position.outcome,
|
||||||
|
}
|
||||||
|
const previousProb =
|
||||||
|
contract.outcomeType === 'BINARY'
|
||||||
|
? maxBy(priorBets, (bet) => bet.createdTime)?.probAfter
|
||||||
|
: undefined
|
||||||
|
if (previousProb != null) {
|
||||||
|
fields.commenterPositionProb = previousProb
|
||||||
|
}
|
||||||
|
await change.ref.update(fields)
|
||||||
|
}
|
||||||
|
|
||||||
let answer: Answer | undefined
|
let answer: Answer | undefined
|
||||||
if (comment.answerOutcome) {
|
if (comment.answerOutcome) {
|
||||||
answer =
|
answer =
|
||||||
|
@ -54,23 +159,6 @@ export const onCreateCommentOnContract = functions
|
||||||
(answer) => answer.id === comment.answerOutcome
|
(answer) => answer.id === comment.answerOutcome
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
} else if (comment.betId) {
|
|
||||||
const betSnapshot = await firestore
|
|
||||||
.collection('contracts')
|
|
||||||
.doc(contractId)
|
|
||||||
.collection('bets')
|
|
||||||
.doc(comment.betId)
|
|
||||||
.get()
|
|
||||||
bet = betSnapshot.data() as Bet
|
|
||||||
answer =
|
|
||||||
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
|
|
||||||
? contract.answers.find((answer) => answer.id === bet?.outcome)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
await change.ref.update({
|
|
||||||
betOutcome: bet.outcome,
|
|
||||||
betAmount: bet.amount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const comments = await getValues<ContractComment>(
|
const comments = await getValues<ContractComment>(
|
||||||
|
|
|
@ -23,12 +23,12 @@ export const onCreateUser = functions
|
||||||
|
|
||||||
await sendWelcomeEmail(user, privateUser)
|
await sendWelcomeEmail(user, privateUser)
|
||||||
|
|
||||||
const guideSendTime = dayjs().add(28, 'hours').toString()
|
|
||||||
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
|
|
||||||
|
|
||||||
const followupSendTime = dayjs().add(48, 'hours').toString()
|
const followupSendTime = dayjs().add(48, 'hours').toString()
|
||||||
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
|
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
|
||||||
|
|
||||||
|
const guideSendTime = dayjs().add(96, 'hours').toString()
|
||||||
|
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
|
||||||
|
|
||||||
// skip email if weekly email is about to go out
|
// skip email if weekly email is about to go out
|
||||||
const day = dayjs().utc().day()
|
const day = dayjs().utc().day()
|
||||||
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return
|
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return
|
||||||
|
|
|
@ -10,48 +10,28 @@ import {
|
||||||
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
|
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
|
||||||
ProvenCorrectBadge,
|
ProvenCorrectBadge,
|
||||||
} from '../../common/badge'
|
} from '../../common/badge'
|
||||||
|
import { GroupContractDoc } from '../../common/group'
|
||||||
|
|
||||||
export const onUpdateContract = functions.firestore
|
export const onUpdateContract = functions.firestore
|
||||||
.document('contracts/{contractId}')
|
.document('contracts/{contractId}')
|
||||||
.onUpdate(async (change, context) => {
|
.onUpdate(async (change, context) => {
|
||||||
const contract = change.after.data() as Contract
|
const contract = change.after.data() as Contract
|
||||||
|
const previousContract = change.before.data() as Contract
|
||||||
const { eventId } = context
|
const { eventId } = context
|
||||||
|
const { closeTime, question } = contract
|
||||||
|
|
||||||
const contractUpdater = await getUser(contract.creatorId)
|
if (!previousContract.isResolved && contract.isResolved) {
|
||||||
if (!contractUpdater) throw new Error('Could not find contract updater')
|
// No need to notify users of resolution, that's handled in resolve-market
|
||||||
|
|
||||||
const previousValue = change.before.data() as Contract
|
|
||||||
|
|
||||||
// Notifications for market resolution are also handled in resolve-market.ts
|
|
||||||
if (!previousValue.isResolved && contract.isResolved)
|
|
||||||
return await handleResolvedContract(contract)
|
return await handleResolvedContract(contract)
|
||||||
|
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
|
||||||
if (
|
await handleContractGroupUpdated(previousContract, contract)
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
} else if (
|
||||||
previousValue.question !== contract.question
|
previousContract.closeTime !== closeTime ||
|
||||||
|
previousContract.question !== question
|
||||||
) {
|
) {
|
||||||
let sourceText = ''
|
await handleUpdatedCloseTime(previousContract, contract, eventId)
|
||||||
if (
|
|
||||||
previousValue.closeTime !== contract.closeTime &&
|
|
||||||
contract.closeTime
|
|
||||||
) {
|
|
||||||
sourceText = contract.closeTime.toString()
|
|
||||||
} else if (previousValue.question !== contract.question) {
|
|
||||||
sourceText = contract.question
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
|
||||||
contract.id,
|
|
||||||
'contract',
|
|
||||||
'updated',
|
|
||||||
contractUpdater,
|
|
||||||
eventId,
|
|
||||||
sourceText,
|
|
||||||
contract
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const firestore = admin.firestore()
|
|
||||||
|
|
||||||
async function handleResolvedContract(contract: Contract) {
|
async function handleResolvedContract(contract: Contract) {
|
||||||
if (
|
if (
|
||||||
|
@ -109,3 +89,68 @@ async function handleResolvedContract(contract: Contract) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdatedCloseTime(
|
||||||
|
previousContract: Contract,
|
||||||
|
contract: Contract,
|
||||||
|
eventId: string
|
||||||
|
) {
|
||||||
|
const contractUpdater = await getUser(contract.creatorId)
|
||||||
|
if (!contractUpdater) throw new Error('Could not find contract updater')
|
||||||
|
let sourceText = ''
|
||||||
|
if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
|
||||||
|
sourceText = contract.closeTime.toString()
|
||||||
|
} else if (previousContract.question !== contract.question) {
|
||||||
|
sourceText = contract.question
|
||||||
|
}
|
||||||
|
|
||||||
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
|
contract.id,
|
||||||
|
'contract',
|
||||||
|
'updated',
|
||||||
|
contractUpdater,
|
||||||
|
eventId,
|
||||||
|
sourceText,
|
||||||
|
contract
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContractGroupUpdated(
|
||||||
|
previousContract: Contract,
|
||||||
|
contract: Contract
|
||||||
|
) {
|
||||||
|
const prevLength = previousContract.groupSlugs?.length ?? 0
|
||||||
|
const newLength = contract.groupSlugs?.length ?? 0
|
||||||
|
if (prevLength < newLength) {
|
||||||
|
// Contract was added to a new group
|
||||||
|
const groupId = contract.groupLinks?.find(
|
||||||
|
(link) =>
|
||||||
|
!previousContract.groupLinks
|
||||||
|
?.map((l) => l.groupId)
|
||||||
|
.includes(link.groupId)
|
||||||
|
)?.groupId
|
||||||
|
if (!groupId) throw new Error('Could not find new group id')
|
||||||
|
|
||||||
|
await firestore
|
||||||
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
|
.doc(contract.id)
|
||||||
|
.set({
|
||||||
|
contractId: contract.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
} as GroupContractDoc)
|
||||||
|
}
|
||||||
|
if (prevLength > newLength) {
|
||||||
|
// Contract was removed from a group
|
||||||
|
const groupId = previousContract.groupLinks?.find(
|
||||||
|
(link) =>
|
||||||
|
!contract.groupLinks?.map((l) => l.groupId).includes(link.groupId)
|
||||||
|
)?.groupId
|
||||||
|
if (!groupId) throw new Error('Could not find old group id')
|
||||||
|
|
||||||
|
await firestore
|
||||||
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
|
.doc(contract.id)
|
||||||
|
.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import { getAllPrivateUsers } from './utils'
|
|
||||||
|
|
||||||
export const resetWeeklyEmailsFlag = functions
|
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
|
||||||
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
|
|
||||||
.pubsub.schedule('0 7 * * 1')
|
|
||||||
.timeZone('Etc/UTC')
|
|
||||||
.onRun(async () => {
|
|
||||||
const privateUsers = await getAllPrivateUsers()
|
|
||||||
// get all users that haven't unsubscribed from weekly emails
|
|
||||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
|
||||||
return !user.unsubscribedFromWeeklyTrendingEmails
|
|
||||||
})
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
await Promise.all(
|
|
||||||
privateUsersToSendEmailsTo.map(async (user) => {
|
|
||||||
return firestore.collection('private-users').doc(user.id).update({
|
|
||||||
weeklyTrendingEmailSent: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
24
functions/src/reset-weekly-emails-flags.ts
Normal file
24
functions/src/reset-weekly-emails-flags.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { getAllPrivateUsers } from './utils'
|
||||||
|
|
||||||
|
export const resetWeeklyEmailsFlags = functions
|
||||||
|
.runWith({
|
||||||
|
timeoutSeconds: 300,
|
||||||
|
memory: '4GB',
|
||||||
|
})
|
||||||
|
.pubsub // every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
|
||||||
|
.schedule('0 7 * * 1')
|
||||||
|
.timeZone('Etc/UTC')
|
||||||
|
.onRun(async () => {
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
await Promise.all(
|
||||||
|
privateUsers.map(async (user) => {
|
||||||
|
return firestore.collection('private-users').doc(user.id).update({
|
||||||
|
weeklyTrendingEmailSent: false,
|
||||||
|
weeklyPortfolioUpdateEmailSent: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
|
@ -9,7 +9,7 @@ import {
|
||||||
RESOLUTIONS,
|
RESOLUTIONS,
|
||||||
} from '../../common/contract'
|
} from '../../common/contract'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, getValues, isProd, log, payUser } from './utils'
|
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
|
||||||
import {
|
import {
|
||||||
getLoanPayouts,
|
getLoanPayouts,
|
||||||
getPayouts,
|
getPayouts,
|
||||||
|
@ -171,6 +171,8 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
await processPayouts([...payouts, ...loanPayouts])
|
await processPayouts([...payouts, ...loanPayouts])
|
||||||
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
|
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
|
||||||
|
|
||||||
|
await revalidateStaticProps(getContractPath(contract))
|
||||||
|
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
const userInvestments = mapValues(
|
const userInvestments = mapValues(
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
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 { Bet } from 'common/bet'
|
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Contract } from 'common/contract'
|
import { Bet } from '../../common/bet'
|
||||||
|
import { Contract } from '../../common/contract'
|
||||||
import { log } from './utils'
|
import { log } from './utils'
|
||||||
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
|
import { DAY_MS, HOUR_MS } from '../../common/util/time'
|
||||||
|
|
||||||
export const scoreContracts = functions.pubsub
|
export const scoreContracts = functions
|
||||||
.schedule('every 1 hours')
|
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||||
|
.pubsub.schedule('every 1 hours')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await scoreContractsInternal()
|
await scoreContractsInternal()
|
||||||
})
|
})
|
||||||
|
@ -14,11 +17,12 @@ const firestore = admin.firestore()
|
||||||
|
|
||||||
async function scoreContractsInternal() {
|
async function scoreContractsInternal() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lastHour = now - 60 * 60 * 1000
|
const hourAgo = now - HOUR_MS
|
||||||
const last3Days = now - 1000 * 60 * 60 * 24 * 3
|
const dayAgo = now - DAY_MS
|
||||||
|
const threeDaysAgo = now - DAY_MS * 3
|
||||||
const activeContractsSnap = await firestore
|
const activeContractsSnap = await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.where('lastUpdatedTime', '>', lastHour)
|
.where('lastUpdatedTime', '>', hourAgo)
|
||||||
.get()
|
.get()
|
||||||
const activeContracts = activeContractsSnap.docs.map(
|
const activeContracts = activeContractsSnap.docs.map(
|
||||||
(doc) => doc.data() as Contract
|
(doc) => doc.data() as Contract
|
||||||
|
@ -39,16 +43,33 @@ async function scoreContractsInternal() {
|
||||||
for (const contract of contracts) {
|
for (const contract of contracts) {
|
||||||
const bets = await firestore
|
const bets = await firestore
|
||||||
.collection(`contracts/${contract.id}/bets`)
|
.collection(`contracts/${contract.id}/bets`)
|
||||||
.where('createdTime', '>', last3Days)
|
.where('createdTime', '>', threeDaysAgo)
|
||||||
.get()
|
.get()
|
||||||
const bettors = bets.docs
|
const bettors = bets.docs
|
||||||
.map((doc) => doc.data() as Bet)
|
.map((doc) => doc.data() as Bet)
|
||||||
.map((bet) => bet.userId)
|
.map((bet) => bet.userId)
|
||||||
const score = uniq(bettors).length
|
const popularityScore = uniq(bettors).length
|
||||||
if (contract.popularityScore !== score)
|
|
||||||
|
const wasCreatedToday = contract.createdTime > dayAgo
|
||||||
|
|
||||||
|
let dailyScore: number | undefined
|
||||||
|
if (
|
||||||
|
contract.outcomeType === 'BINARY' &&
|
||||||
|
contract.mechanism === 'cpmm-1' &&
|
||||||
|
!wasCreatedToday
|
||||||
|
) {
|
||||||
|
const percentChange = Math.abs(contract.probChanges.day)
|
||||||
|
dailyScore = popularityScore * percentChange
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
contract.popularityScore !== popularityScore ||
|
||||||
|
contract.dailyScore !== dailyScore
|
||||||
|
) {
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
.update({ popularityScore: score })
|
.update(removeUndefinedProps({ popularityScore, dailyScore }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
27
functions/src/scripts/add-new-notification-preference.ts
Normal file
27
functions/src/scripts/add-new-notification-preference.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { getAllPrivateUsers } from 'functions/src/utils'
|
||||||
|
initAdmin()
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const privateUsers = await getAllPrivateUsers()
|
||||||
|
await Promise.all(
|
||||||
|
privateUsers.map((privateUser) => {
|
||||||
|
if (!privateUser.id) return Promise.resolve()
|
||||||
|
return firestore
|
||||||
|
.collection('private-users')
|
||||||
|
.doc(privateUser.id)
|
||||||
|
.update({
|
||||||
|
notificationPreferences: {
|
||||||
|
...privateUser.notificationPreferences,
|
||||||
|
badges_awarded: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main().then(() => process.exit())
|
92
functions/src/scripts/backfill-comment-position-data.ts
Normal file
92
functions/src/scripts/backfill-comment-position-data.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
// Filling in historical bet positions on comments.
|
||||||
|
|
||||||
|
// Warning: This just recalculates all of them, rather than trying to
|
||||||
|
// figure out which ones are out of date, since I'm using it to fill them
|
||||||
|
// in once in the first place.
|
||||||
|
|
||||||
|
import { maxBy } from 'lodash'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { filterDefined } from '../../../common/util/array'
|
||||||
|
import { Bet } from '../../../common/bet'
|
||||||
|
import { Comment } from '../../../common/comment'
|
||||||
|
import { Contract } from '../../../common/contract'
|
||||||
|
import { getLargestPosition } from '../../../common/calculate'
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { DocumentSnapshot } from 'firebase-admin/firestore'
|
||||||
|
import { log, writeAsync } from '../utils'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function getContractsById() {
|
||||||
|
const contracts = await firestore.collection('contracts').get()
|
||||||
|
const results = Object.fromEntries(
|
||||||
|
contracts.docs.map((doc) => [doc.id, doc.data() as Contract])
|
||||||
|
)
|
||||||
|
log(`Found ${contracts.size} contracts.`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCommentsByContractId() {
|
||||||
|
const comments = await firestore
|
||||||
|
.collectionGroup('comments')
|
||||||
|
.where('contractId', '!=', null)
|
||||||
|
.get()
|
||||||
|
const results = new Map<string, DocumentSnapshot[]>()
|
||||||
|
comments.forEach((doc) => {
|
||||||
|
const contractId = doc.get('contractId')
|
||||||
|
const contractComments = results.get(contractId) || []
|
||||||
|
contractComments.push(doc)
|
||||||
|
results.set(contractId, contractComments)
|
||||||
|
})
|
||||||
|
log(`Found ${comments.size} comments on ${results.size} contracts.`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// not in a transaction for speed -- may need to be run more than once
|
||||||
|
async function denormalize() {
|
||||||
|
const contractsById = await getContractsById()
|
||||||
|
const commentsByContractId = await getCommentsByContractId()
|
||||||
|
for (const [contractId, comments] of commentsByContractId.entries()) {
|
||||||
|
const betsQuery = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.collection('bets')
|
||||||
|
.get()
|
||||||
|
log(`Loaded ${betsQuery.size} bets for contract ${contractId}.`)
|
||||||
|
const bets = betsQuery.docs.map((d) => d.data() as Bet)
|
||||||
|
const updates = comments.map((doc) => {
|
||||||
|
const comment = doc.data() as Comment
|
||||||
|
const contract = contractsById[contractId]
|
||||||
|
const previousBets = bets.filter(
|
||||||
|
(b) => b.createdTime < comment.createdTime
|
||||||
|
)
|
||||||
|
const position = getLargestPosition(
|
||||||
|
contract,
|
||||||
|
previousBets.filter((b) => b.userId === comment.userId && !b.isAnte)
|
||||||
|
)
|
||||||
|
if (position) {
|
||||||
|
const fields: { [k: string]: unknown } = {
|
||||||
|
commenterPositionShares: position.shares,
|
||||||
|
commenterPositionOutcome: position.outcome,
|
||||||
|
}
|
||||||
|
const previousProb =
|
||||||
|
contract.outcomeType === 'BINARY'
|
||||||
|
? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter
|
||||||
|
: undefined
|
||||||
|
if (previousProb != null) {
|
||||||
|
fields.commenterPositionProb = previousProb
|
||||||
|
}
|
||||||
|
return { doc: doc.ref, fields }
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
log(`Updating ${updates.length} comments.`)
|
||||||
|
await writeAsync(firestore, filterDefined(updates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
denormalize().catch((e) => console.error(e))
|
||||||
|
}
|
54
functions/src/scripts/contest/bulk-add-liquidity.ts
Normal file
54
functions/src/scripts/contest/bulk-add-liquidity.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
|
||||||
|
|
||||||
|
const DOMAIN = 'http://localhost:3000'
|
||||||
|
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||||
|
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||||
|
// DEV API key for Criticism and Red Teaming (@CARTBot)
|
||||||
|
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
|
||||||
|
|
||||||
|
// Warning: Checking these in can be dangerous!
|
||||||
|
// Prod API key for @CEPBot
|
||||||
|
|
||||||
|
// Can just curl /v0/group/{slug} to get a group
|
||||||
|
async function getGroupBySlug(slug: string) {
|
||||||
|
const resp = await fetch(`${DOMAIN}/api/v0/group/${slug}`)
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMarketsByGroupId(id: string) {
|
||||||
|
// API structure: /v0/group/by-id/[id]/markets
|
||||||
|
const resp = await fetch(`${DOMAIN}/api/v0/group/by-id/${id}/markets`)
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLiquidityById(id: string, amount: number) {
|
||||||
|
const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Key ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount: amount,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const group = await getGroupBySlug('cart-contest')
|
||||||
|
const markets = await getMarketsByGroupId(group.id)
|
||||||
|
|
||||||
|
// Count up some metrics
|
||||||
|
console.log('Number of markets', markets.length)
|
||||||
|
|
||||||
|
// Resolve each market to NO
|
||||||
|
for (const market of markets.slice(0, 3)) {
|
||||||
|
console.log(market.slug, market.totalLiquidity)
|
||||||
|
const resp = await addLiquidityById(market.id, 200)
|
||||||
|
console.log(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
|
||||||
|
export {}
|
115
functions/src/scripts/contest/bulk-create-markets.ts
Normal file
115
functions/src/scripts/contest/bulk-create-markets.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
// Run with `npx ts-node src/scripts/contest/create-markets.ts`
|
||||||
|
|
||||||
|
import { data } from './criticism-and-red-teaming'
|
||||||
|
|
||||||
|
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||||
|
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||||
|
// DEV API key for Criticism and Red Teaming (@CARTBot)
|
||||||
|
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
|
||||||
|
|
||||||
|
type CEPSubmission = {
|
||||||
|
title: string
|
||||||
|
author?: string
|
||||||
|
link: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the API to create a new market for this Cause Exploration Prize submission
|
||||||
|
async function postMarket(submission: CEPSubmission) {
|
||||||
|
const { title, author } = submission
|
||||||
|
const response = await fetch('https://dev.manifold.markets/api/v0/market', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Key ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
outcomeType: 'BINARY',
|
||||||
|
question: `"${title}" by ${author ?? 'anonymous'}`,
|
||||||
|
description: makeDescription(submission),
|
||||||
|
closeTime: Date.parse('2022-09-30').valueOf(),
|
||||||
|
initialProb: 10,
|
||||||
|
// Super secret options:
|
||||||
|
// groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament
|
||||||
|
// groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP
|
||||||
|
groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART
|
||||||
|
// groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART
|
||||||
|
visibility: 'unlisted',
|
||||||
|
// TODO: Increase liquidity?
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Created market:', data.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAll() {
|
||||||
|
for (const submission of data.slice(0, 3)) {
|
||||||
|
await postMarket(submission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postAll()
|
||||||
|
|
||||||
|
/* Example curl request:
|
||||||
|
$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Key {...}'
|
||||||
|
--data-raw '{"outcomeType":"BINARY", \
|
||||||
|
"question":"Is there life on Mars?", \
|
||||||
|
"description":"I'm not going to type some long ass example description.", \
|
||||||
|
"closeTime":1700000000000, \
|
||||||
|
"initialProb":25}'
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeDescription(submission: CEPSubmission) {
|
||||||
|
const { title, author, link } = submission
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
{ text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' },
|
||||||
|
{
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
attrs: {
|
||||||
|
target: '_blank',
|
||||||
|
href: link,
|
||||||
|
class:
|
||||||
|
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||||
|
},
|
||||||
|
type: 'link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'text',
|
||||||
|
text: title,
|
||||||
|
},
|
||||||
|
{ text: '" win any prize in the ', type: 'text' },
|
||||||
|
{
|
||||||
|
text: 'EA Criticism and Red Teaming Contest',
|
||||||
|
type: 'text',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
attrs: {
|
||||||
|
target: '_blank',
|
||||||
|
class:
|
||||||
|
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||||
|
href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming',
|
||||||
|
},
|
||||||
|
type: 'link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ text: '?', type: 'text' },
|
||||||
|
],
|
||||||
|
type: 'paragraph',
|
||||||
|
},
|
||||||
|
{ type: 'paragraph' },
|
||||||
|
{
|
||||||
|
type: 'iframe',
|
||||||
|
attrs: {
|
||||||
|
allowfullscreen: true,
|
||||||
|
src: link,
|
||||||
|
frameborder: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'doc',
|
||||||
|
}
|
||||||
|
}
|
65
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal file
65
functions/src/scripts/contest/bulk-resolve-markets.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
|
||||||
|
|
||||||
|
const DOMAIN = 'dev.manifold.markets'
|
||||||
|
// Dev API key for Cause Exploration Prizes (@CEP)
|
||||||
|
const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
|
||||||
|
const GROUP_SLUG = 'cart-contest'
|
||||||
|
|
||||||
|
// Can just curl /v0/group/{slug} to get a group
|
||||||
|
async function getGroupBySlug(slug: string) {
|
||||||
|
const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMarketsByGroupId(id: string) {
|
||||||
|
// API structure: /v0/group/by-id/[id]/markets
|
||||||
|
const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example curl 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"}'
|
||||||
|
*/
|
||||||
|
async function resolveMarketById(
|
||||||
|
id: string,
|
||||||
|
outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
||||||
|
) {
|
||||||
|
const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Key ${API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
outcome,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const group = await getGroupBySlug(GROUP_SLUG)
|
||||||
|
const markets = await getMarketsByGroupId(group.id)
|
||||||
|
|
||||||
|
// Count up some metrics
|
||||||
|
console.log('Number of markets', markets.length)
|
||||||
|
console.log(
|
||||||
|
'Number of resolved markets',
|
||||||
|
markets.filter((m: any) => m.isResolved).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolve each market to NO
|
||||||
|
for (const market of markets) {
|
||||||
|
if (!market.isResolved) {
|
||||||
|
console.log(`Resolving market ${market.url} to NO`)
|
||||||
|
await resolveMarketById(market.id, 'NO')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
|
||||||
|
export {}
|
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
1219
functions/src/scripts/contest/criticism-and-red-teaming.ts
Normal file
File diff suppressed because it is too large
Load Diff
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
55
functions/src/scripts/contest/scrape-ea.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// Run with `npx ts-node src/scripts/contest/scrape-ea.ts`
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as puppeteer from 'puppeteer'
|
||||||
|
|
||||||
|
export function scrapeEA(contestLink: string, fileName: string) {
|
||||||
|
;(async () => {
|
||||||
|
const browser = await puppeteer.launch({ headless: true })
|
||||||
|
const page = await browser.newPage()
|
||||||
|
await page.goto(contestLink)
|
||||||
|
|
||||||
|
let loadMoreButton = await page.$('.LoadMore-root')
|
||||||
|
|
||||||
|
while (loadMoreButton) {
|
||||||
|
await loadMoreButton.click()
|
||||||
|
await page.waitForNetworkIdle()
|
||||||
|
loadMoreButton = await page.$('.LoadMore-root')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Run javascript inside the page */
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const list = []
|
||||||
|
const items = document.querySelectorAll('.PostsItem2-root')
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const link =
|
||||||
|
'https://forum.effectivealtruism.org' +
|
||||||
|
item?.querySelector('a')?.getAttribute('href')
|
||||||
|
|
||||||
|
// Replace '&' with '&'
|
||||||
|
const clean = (str: string | undefined) => str?.replace(/&/g, '&')
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
title: clean(item?.querySelector('a>span>span')?.innerHTML),
|
||||||
|
author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML,
|
||||||
|
link: link,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
`./src/scripts/contest/${fileName}.ts`,
|
||||||
|
`export const data = ${JSON.stringify(data, null, 2)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(data)
|
||||||
|
await browser.close()
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeEA(
|
||||||
|
'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest',
|
||||||
|
'criticism-and-red-teaming'
|
||||||
|
)
|
|
@ -41,6 +41,8 @@ const createGroup = async (
|
||||||
anyoneCanJoin: true,
|
anyoneCanJoin: true,
|
||||||
totalContracts: contracts.length,
|
totalContracts: contracts.length,
|
||||||
totalMembers: 1,
|
totalMembers: 1,
|
||||||
|
postIds: [],
|
||||||
|
pinnedItems: [],
|
||||||
}
|
}
|
||||||
await groupRef.create(group)
|
await groupRef.create(group)
|
||||||
// create a GroupMemberDoc for the creator
|
// create a GroupMemberDoc for the creator
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import { uniq } from 'lodash'
|
|
||||||
|
|
||||||
import { initAdmin } from './script-init'
|
|
||||||
initAdmin()
|
|
||||||
|
|
||||||
import { Contract } from '../../../common/contract'
|
|
||||||
import { parseTags } from '../../../common/util/parse'
|
|
||||||
import { getValues } from '../utils'
|
|
||||||
|
|
||||||
async function updateContractTags() {
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
console.log('Updating contracts tags')
|
|
||||||
|
|
||||||
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
|
||||||
|
|
||||||
console.log('Loaded', contracts.length, 'contracts')
|
|
||||||
|
|
||||||
for (const contract of contracts) {
|
|
||||||
const contractRef = firestore.doc(`contracts/${contract.id}`)
|
|
||||||
|
|
||||||
const tags = uniq([
|
|
||||||
...parseTags(contract.question + contract.description),
|
|
||||||
...(contract.tags ?? []),
|
|
||||||
])
|
|
||||||
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
'Updating tags',
|
|
||||||
contract.slug,
|
|
||||||
'from',
|
|
||||||
contract.tags,
|
|
||||||
'to',
|
|
||||||
tags
|
|
||||||
)
|
|
||||||
|
|
||||||
await contractRef.update({
|
|
||||||
tags,
|
|
||||||
lowercaseTags,
|
|
||||||
} as Partial<Contract>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
updateContractTags().then(() => process.exit())
|
|
||||||
}
|
|
|
@ -89,17 +89,20 @@ const getGroups = async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async function updateTotalContractsAndMembers() {
|
async function updateTotalContractsAndMembers() {
|
||||||
const groups = await getGroups()
|
const groups = await getGroups()
|
||||||
for (const group of groups) {
|
await Promise.all(
|
||||||
log('updating group total contracts and members', group.slug)
|
groups.map(async (group) => {
|
||||||
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
log('updating group total contracts and members', group.slug)
|
||||||
const totalMembers = (await groupRef.collection('groupMembers').get()).size
|
const groupRef = admin.firestore().collection('groups').doc(group.id)
|
||||||
const totalContracts = (await groupRef.collection('groupContracts').get())
|
const totalMembers = (await groupRef.collection('groupMembers').get())
|
||||||
.size
|
.size
|
||||||
await groupRef.update({
|
const totalContracts = (await groupRef.collection('groupContracts').get())
|
||||||
totalMembers,
|
.size
|
||||||
totalContracts,
|
await groupRef.update({
|
||||||
|
totalMembers,
|
||||||
|
totalContracts,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async function removeUnusedMemberAndContractFields() {
|
async function removeUnusedMemberAndContractFields() {
|
||||||
|
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
initAdmin()
|
initAdmin()
|
||||||
// convertGroupFieldsToGroupDocuments()
|
// convertGroupFieldsToGroupDocuments()
|
||||||
// updateTotalContractsAndMembers()
|
updateTotalContractsAndMembers()
|
||||||
removeUnusedMemberAndContractFields()
|
// removeUnusedMemberAndContractFields()
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,8 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||||
|
import { testscheduledfunction } from './test-scheduled-function'
|
||||||
|
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
|
||||||
|
|
||||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||||
const app = express()
|
const app = express()
|
||||||
|
@ -60,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
|
||||||
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
||||||
addJsonEndpointRoute('/createmarket', createmarket)
|
addJsonEndpointRoute('/createmarket', createmarket)
|
||||||
addJsonEndpointRoute('/addliquidity', addliquidity)
|
addJsonEndpointRoute('/addliquidity', addliquidity)
|
||||||
|
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
|
||||||
|
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
|
||||||
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
||||||
addJsonEndpointRoute('/creategroup', creategroup)
|
addJsonEndpointRoute('/creategroup', creategroup)
|
||||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
|
@ -69,6 +73,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||||
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
|
||||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||||
addEndpointRoute('/createpost', createpost)
|
addEndpointRoute('/createpost', createpost)
|
||||||
|
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
|
||||||
|
|
||||||
app.listen(PORT)
|
app.listen(PORT)
|
||||||
console.log(`Serving functions on port ${PORT}.`)
|
console.log(`Serving functions on port ${PORT}.`)
|
||||||
|
|
17
functions/src/test-scheduled-function.ts
Normal file
17
functions/src/test-scheduled-function.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { APIError, newEndpoint } from './api'
|
||||||
|
import { isProd } from './utils'
|
||||||
|
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails'
|
||||||
|
|
||||||
|
// Function for testing scheduled functions locally
|
||||||
|
export const testscheduledfunction = newEndpoint(
|
||||||
|
{ method: 'GET', memory: '4GiB' },
|
||||||
|
async (_req) => {
|
||||||
|
if (isProd())
|
||||||
|
throw new APIError(400, 'This function is only available in dev mode')
|
||||||
|
|
||||||
|
// Replace your function here
|
||||||
|
await sendTrendingMarketsEmailsToAllUsers()
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
)
|
|
@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
|
||||||
import { PrivateUser } from '../../common/user'
|
import { PrivateUser } from '../../common/user'
|
||||||
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
|
||||||
import { notification_preference } from '../../common/user-notification-preferences'
|
import { notification_preference } from '../../common/user-notification-preferences'
|
||||||
|
import { getFunctionUrl } from '../../common/api'
|
||||||
|
|
||||||
export const unsubscribe: EndpointDefinition = {
|
export const unsubscribe: EndpointDefinition = {
|
||||||
opts: { method: 'GET', minInstances: 1 },
|
opts: { method: 'GET', minInstances: 1 },
|
||||||
|
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
res.status(400).send('Invalid subscription type parameter.')
|
res.status(400).send('Invalid subscription type parameter.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const optOutAllType: notification_preference = 'opt_out_all'
|
||||||
|
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
|
||||||
|
|
||||||
const user = await getPrivateUser(id)
|
const user = await getPrivateUser(id)
|
||||||
|
|
||||||
|
@ -31,28 +34,36 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
const previousDestinations =
|
const previousDestinations =
|
||||||
user.notificationPreferences[notificationSubscriptionType]
|
user.notificationPreferences[notificationSubscriptionType]
|
||||||
|
|
||||||
|
let newDestinations = previousDestinations
|
||||||
|
if (wantsToOptOutAll) newDestinations.push('email')
|
||||||
|
else
|
||||||
|
newDestinations = previousDestinations.filter(
|
||||||
|
(destination) => destination !== 'email'
|
||||||
|
)
|
||||||
|
|
||||||
console.log(previousDestinations)
|
console.log(previousDestinations)
|
||||||
const { email } = user
|
const { email } = user
|
||||||
|
|
||||||
const update: Partial<PrivateUser> = {
|
const update: Partial<PrivateUser> = {
|
||||||
notificationPreferences: {
|
notificationPreferences: {
|
||||||
...user.notificationPreferences,
|
...user.notificationPreferences,
|
||||||
[notificationSubscriptionType]: previousDestinations.filter(
|
[notificationSubscriptionType]: newDestinations,
|
||||||
(destination) => destination !== 'email'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('private-users').doc(id).update(update)
|
await firestore.collection('private-users').doc(id).update(update)
|
||||||
|
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
|
||||||
|
|
||||||
res.send(
|
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
|
||||||
`
|
if (wantsToOptOutAll) {
|
||||||
<!DOCTYPE html>
|
res.send(
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
<title>Unsubscribe from Manifold Markets emails</title>
|
||||||
<!--[if !mso]><!-->
|
<!--[if !mso]><!-->
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
|
@ -163,19 +174,6 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td align="left"
|
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
|
||||||
<div
|
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
|
||||||
<p class="text-build-content"
|
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
|
||||||
data-testid="4XoHRGw1Y"><span
|
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
|
||||||
Hello!</span></p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left"
|
<td align="left"
|
||||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
@ -186,20 +184,9 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
data-testid="4XoHRGw1Y">
|
data-testid="4XoHRGw1Y">
|
||||||
<span
|
<span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
${email} has been unsubscribed from email notifications related to:
|
${email} has opted out of receiving unnecessary email notifications
|
||||||
</span>
|
</span>
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
|
|
||||||
</p>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
<span>Click
|
|
||||||
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
|
||||||
to manage the rest of your notification settings.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
@ -219,9 +206,193 @@ export const unsubscribe: EndpointDefinition = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
</html>`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
res.send(
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Unsubscribe from Manifold Markets emails</title>
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
[owa] .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||||
|
<div style="background-color:#F4F4F4;">
|
||||||
|
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||||
|
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
|
||||||
|
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:550px;">
|
||||||
|
<a href="https://manifold.markets" target="_blank">
|
||||||
|
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
|
||||||
|
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||||
|
title="" width="550">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y"><span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
Hello!</span></p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<p class="text-build-content"
|
||||||
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
|
data-testid="4XoHRGw1Y">
|
||||||
|
<span
|
||||||
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
|
${email} has been unsubscribed from email notifications related to:
|
||||||
|
</span>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span>
|
||||||
|
</p>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<span>Click
|
||||||
|
<a href=${optOutAllUrl}>here</a>
|
||||||
|
to unsubscribe from all unnecessary emails.
|
||||||
|
</span>
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<span>Click
|
||||||
|
<a href='https://manifold.markets/notifications?tab=settings'>here</a>
|
||||||
|
to manage the rest of your notification settings.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
162
functions/src/update-comment-bounty.ts
Normal file
162
functions/src/update-comment-bounty.ts
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { Contract } from '../../common/contract'
|
||||||
|
import { User } from '../../common/user'
|
||||||
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
import {
|
||||||
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
} from '../../common/antes'
|
||||||
|
import { isProd } from './utils'
|
||||||
|
import {
|
||||||
|
CommentBountyDepositTxn,
|
||||||
|
CommentBountyWithdrawalTxn,
|
||||||
|
} from '../../common/txn'
|
||||||
|
import { runTxn } from './transact'
|
||||||
|
import { Comment } from '../../common/comment'
|
||||||
|
import { createBountyNotification } from './create-notification'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
contractId: z.string(),
|
||||||
|
amount: z.number().gt(0),
|
||||||
|
})
|
||||||
|
const awardBodySchema = z.object({
|
||||||
|
contractId: z.string(),
|
||||||
|
commentId: z.string(),
|
||||||
|
amount: z.number().gt(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const addcommentbounty = newEndpoint({}, async (req, auth) => {
|
||||||
|
const { amount, contractId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
|
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
|
||||||
|
|
||||||
|
// run as transaction to prevent race conditions
|
||||||
|
return await firestore.runTransaction(async (transaction) => {
|
||||||
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
|
const userSnap = await transaction.get(userDoc)
|
||||||
|
if (!userSnap.exists) throw new APIError(400, 'User not found')
|
||||||
|
const user = userSnap.data() as User
|
||||||
|
|
||||||
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
|
const contractSnap = await transaction.get(contractDoc)
|
||||||
|
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
|
||||||
|
const contract = contractSnap.data() as Contract
|
||||||
|
|
||||||
|
if (user.balance < amount)
|
||||||
|
throw new APIError(400, 'Insufficient user balance')
|
||||||
|
|
||||||
|
const newCommentBountyTxn = {
|
||||||
|
fromId: user.id,
|
||||||
|
fromType: 'USER',
|
||||||
|
toId: isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
toType: 'BANK',
|
||||||
|
amount,
|
||||||
|
token: 'M$',
|
||||||
|
category: 'COMMENT_BOUNTY',
|
||||||
|
data: {
|
||||||
|
contractId,
|
||||||
|
},
|
||||||
|
description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`,
|
||||||
|
} as CommentBountyDepositTxn
|
||||||
|
|
||||||
|
const result = await runTxn(transaction, newCommentBountyTxn)
|
||||||
|
|
||||||
|
transaction.update(
|
||||||
|
contractDoc,
|
||||||
|
removeUndefinedProps({
|
||||||
|
openCommentBounties: (contract.openCommentBounties ?? 0) + amount,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
})
|
||||||
|
export const awardcommentbounty = newEndpoint({}, async (req, auth) => {
|
||||||
|
const { amount, commentId, contractId } = validate(awardBodySchema, req.body)
|
||||||
|
|
||||||
|
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
|
||||||
|
|
||||||
|
// run as transaction to prevent race conditions
|
||||||
|
const res = await firestore.runTransaction(async (transaction) => {
|
||||||
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
|
const userSnap = await transaction.get(userDoc)
|
||||||
|
if (!userSnap.exists) throw new APIError(400, 'User not found')
|
||||||
|
const user = userSnap.data() as User
|
||||||
|
|
||||||
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
|
const contractSnap = await transaction.get(contractDoc)
|
||||||
|
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
|
||||||
|
const contract = contractSnap.data() as Contract
|
||||||
|
|
||||||
|
if (user.id !== contract.creatorId)
|
||||||
|
throw new APIError(
|
||||||
|
400,
|
||||||
|
'Only contract creator can award comment bounties'
|
||||||
|
)
|
||||||
|
|
||||||
|
const commentDoc = firestore.doc(
|
||||||
|
`contracts/${contractId}/comments/${commentId}`
|
||||||
|
)
|
||||||
|
const commentSnap = await transaction.get(commentDoc)
|
||||||
|
if (!commentSnap.exists) throw new APIError(400, 'Invalid comment')
|
||||||
|
|
||||||
|
const comment = commentSnap.data() as Comment
|
||||||
|
const amountAvailable = contract.openCommentBounties ?? 0
|
||||||
|
if (amountAvailable < amount)
|
||||||
|
throw new APIError(400, 'Insufficient open bounty balance')
|
||||||
|
|
||||||
|
const newCommentBountyTxn = {
|
||||||
|
fromId: isProd()
|
||||||
|
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
|
fromType: 'BANK',
|
||||||
|
toId: comment.userId,
|
||||||
|
toType: 'USER',
|
||||||
|
amount,
|
||||||
|
token: 'M$',
|
||||||
|
category: 'COMMENT_BOUNTY',
|
||||||
|
data: {
|
||||||
|
contractId,
|
||||||
|
commentId,
|
||||||
|
},
|
||||||
|
description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`,
|
||||||
|
} as CommentBountyWithdrawalTxn
|
||||||
|
|
||||||
|
const result = await runTxn(transaction, newCommentBountyTxn)
|
||||||
|
|
||||||
|
await transaction.update(
|
||||||
|
contractDoc,
|
||||||
|
removeUndefinedProps({
|
||||||
|
openCommentBounties: amountAvailable - amount,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await transaction.update(
|
||||||
|
commentDoc,
|
||||||
|
removeUndefinedProps({
|
||||||
|
bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return { ...result, comment, contract, user }
|
||||||
|
})
|
||||||
|
if (res.txn?.id) {
|
||||||
|
const { comment, contract, user } = res
|
||||||
|
await createBountyNotification(
|
||||||
|
user,
|
||||||
|
comment.userId,
|
||||||
|
amount,
|
||||||
|
res.txn.id,
|
||||||
|
contract,
|
||||||
|
comment.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
|
@ -7,11 +7,12 @@ import { Contract } from '../../common/contract'
|
||||||
import { PortfolioMetrics, User } from '../../common/user'
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { getLoanUpdates } from '../../common/loans'
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
import { createLoanIncomeNotification } from './create-notification'
|
import { createLoanIncomeNotification } from './create-notification'
|
||||||
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const updateLoans = functions
|
export const updateLoans = functions
|
||||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
.runWith({ memory: '8GB', timeoutSeconds: 540 })
|
||||||
// Run every day at midnight.
|
// Run every day at midnight.
|
||||||
.pubsub.schedule('0 0 * * *')
|
.pubsub.schedule('0 0 * * *')
|
||||||
.timeZone('America/Los_Angeles')
|
.timeZone('America/Los_Angeles')
|
||||||
|
@ -30,16 +31,18 @@ async function updateLoansCore() {
|
||||||
log(
|
log(
|
||||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||||
)
|
)
|
||||||
const userPortfolios = await Promise.all(
|
const userPortfolios = filterDefined(
|
||||||
users.map(async (user) => {
|
await Promise.all(
|
||||||
const portfolio = await getValues<PortfolioMetrics>(
|
users.map(async (user) => {
|
||||||
firestore
|
const portfolio = await getValues<PortfolioMetrics>(
|
||||||
.collection(`users/${user.id}/portfolioHistory`)
|
firestore
|
||||||
.orderBy('timestamp', 'desc')
|
.collection(`users/${user.id}/portfolioHistory`)
|
||||||
.limit(1)
|
.orderBy('timestamp', 'desc')
|
||||||
)
|
.limit(1)
|
||||||
return portfolio[0]
|
)
|
||||||
})
|
return portfolio[0]
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
log(`Loaded ${userPortfolios.length} portfolios`)
|
log(`Loaded ${userPortfolios.length} portfolios`)
|
||||||
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
|
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
|
||||||
|
|
|
@ -17,38 +17,57 @@ import {
|
||||||
computeVolume,
|
computeVolume,
|
||||||
} from '../../common/calculate-metrics'
|
} from '../../common/calculate-metrics'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Group } from 'common/group'
|
import { Group } from '../../common/group'
|
||||||
|
import { batchedWaitAll } from '../../common/util/promise'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const updateMetrics = functions
|
export const updateMetrics = functions
|
||||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
.runWith({ memory: '8GB', timeoutSeconds: 540 })
|
||||||
.pubsub.schedule('every 15 minutes')
|
.pubsub.schedule('every 15 minutes')
|
||||||
.onRun(updateMetricsCore)
|
.onRun(updateMetricsCore)
|
||||||
|
|
||||||
export async function updateMetricsCore() {
|
export async function updateMetricsCore() {
|
||||||
const [users, contracts, bets, allPortfolioHistories, groups] =
|
console.log('Loading users')
|
||||||
await Promise.all([
|
const users = await getValues<User>(firestore.collection('users'))
|
||||||
getValues<User>(firestore.collection('users')),
|
|
||||||
getValues<Contract>(firestore.collection('contracts')),
|
|
||||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
|
||||||
getValues<PortfolioMetrics>(
|
|
||||||
firestore
|
|
||||||
.collectionGroup('portfolioHistory')
|
|
||||||
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
|
||||||
),
|
|
||||||
getValues<Group>(firestore.collection('groups')),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
console.log('Loading contracts')
|
||||||
|
const contracts = await getValues<Contract>(firestore.collection('contracts'))
|
||||||
|
|
||||||
|
console.log('Loading portfolio history')
|
||||||
|
const allPortfolioHistories = await getValues<PortfolioMetrics>(
|
||||||
|
firestore
|
||||||
|
.collectionGroup('portfolioHistory')
|
||||||
|
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Loading groups')
|
||||||
|
const groups = await getValues<Group>(firestore.collection('groups'))
|
||||||
|
|
||||||
|
console.log('Loading bets')
|
||||||
|
const contractBets = await batchedWaitAll(
|
||||||
|
contracts
|
||||||
|
.filter((c) => c.id)
|
||||||
|
.map(
|
||||||
|
(c) => () =>
|
||||||
|
getValues<Bet>(
|
||||||
|
firestore.collection('contracts').doc(c.id).collection('bets')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
100
|
||||||
|
)
|
||||||
|
const bets = contractBets.flat()
|
||||||
|
|
||||||
|
console.log('Loading group contracts')
|
||||||
const contractsByGroup = await Promise.all(
|
const contractsByGroup = await Promise.all(
|
||||||
groups.map((group) => {
|
groups.map((group) =>
|
||||||
return getValues(
|
getValues(
|
||||||
firestore
|
firestore
|
||||||
.collection('groups')
|
.collection('groups')
|
||||||
.doc(group.id)
|
.doc(group.id)
|
||||||
.collection('groupContracts')
|
.collection('groupContracts')
|
||||||
)
|
)
|
||||||
})
|
)
|
||||||
)
|
)
|
||||||
log(
|
log(
|
||||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||||
|
@ -116,6 +135,28 @@ export async function updateMetricsCore() {
|
||||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||||
|
|
||||||
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
||||||
|
const contractRatios = userContracts
|
||||||
|
.map((contract) => {
|
||||||
|
if (
|
||||||
|
!contract.flaggedByUsernames ||
|
||||||
|
contract.flaggedByUsernames?.length === 0
|
||||||
|
) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const contractRatio =
|
||||||
|
contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1)
|
||||||
|
|
||||||
|
return contractRatio
|
||||||
|
})
|
||||||
|
.filter((ratio) => ratio > 0)
|
||||||
|
const badResolutions = contractRatios.filter(
|
||||||
|
(ratio) => ratio > BAD_RESOLUTION_THRESHOLD
|
||||||
|
)
|
||||||
|
let newFractionResolvedCorrectly = 0
|
||||||
|
if (userContracts.length > 0) {
|
||||||
|
newFractionResolvedCorrectly =
|
||||||
|
(userContracts.length - badResolutions.length) / userContracts.length
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
|
@ -123,6 +164,7 @@ export async function updateMetricsCore() {
|
||||||
newPortfolio,
|
newPortfolio,
|
||||||
newProfit,
|
newProfit,
|
||||||
didPortfolioChange,
|
didPortfolioChange,
|
||||||
|
newFractionResolvedCorrectly,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -144,6 +186,7 @@ export async function updateMetricsCore() {
|
||||||
newPortfolio,
|
newPortfolio,
|
||||||
newProfit,
|
newProfit,
|
||||||
didPortfolioChange,
|
didPortfolioChange,
|
||||||
|
newFractionResolvedCorrectly,
|
||||||
}) => {
|
}) => {
|
||||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||||
return {
|
return {
|
||||||
|
@ -153,6 +196,7 @@ export async function updateMetricsCore() {
|
||||||
creatorVolumeCached: newCreatorVolume,
|
creatorVolumeCached: newCreatorVolume,
|
||||||
profitCached: newProfit,
|
profitCached: newProfit,
|
||||||
nextLoanCached,
|
nextLoanCached,
|
||||||
|
fractionResolvedCorrectly: newFractionResolvedCorrectly,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -224,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: number }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupContractDoc = { contractId: string; createdTime: number }
|
type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
|
||||||
|
const BAD_RESOLUTION_THRESHOLD = 0.1
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
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 { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash'
|
import * as dayjs from 'dayjs'
|
||||||
|
import * as utc from 'dayjs/plugin/utc'
|
||||||
|
import * as timezone from 'dayjs/plugin/timezone'
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
import { range, zip, uniq, sum, sumBy } from 'lodash'
|
||||||
import { getValues, log, logMemory } from './utils'
|
import { getValues, log, logMemory } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
|
import { Stats } from '../../common/stats'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { average } from '../../common/util/math'
|
import { average } from '../../common/util/math'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
const numberOfDays = 90
|
const numberOfDays = 180
|
||||||
|
|
||||||
const getBetsQuery = (startTime: number, endTime: number) =>
|
const getBetsQuery = (startTime: number, endTime: number) =>
|
||||||
firestore
|
firestore
|
||||||
|
@ -103,7 +110,7 @@ export async function getDailyNewUsers(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateStatsCore = async () => {
|
export const updateStatsCore = async () => {
|
||||||
const today = Date.now()
|
const today = dayjs().tz('America/Los_Angeles').startOf('day').valueOf()
|
||||||
const startDate = today - numberOfDays * DAY_MS
|
const startDate = today - numberOfDays * DAY_MS
|
||||||
|
|
||||||
log('Fetching data for stats update...')
|
log('Fetching data for stats update...')
|
||||||
|
@ -139,73 +146,128 @@ export const updateStatsCore = async () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
|
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
|
||||||
|
const dailyActiveUsersWeeklyAvg = dailyUserIds.map((_, i) => {
|
||||||
|
const start = Math.max(0, i - 6)
|
||||||
|
const end = i + 1
|
||||||
|
return average(dailyActiveUsers.slice(start, end))
|
||||||
|
})
|
||||||
|
|
||||||
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
|
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
|
||||||
const start = Math.max(0, i - 6)
|
const start = Math.max(0, i - 6)
|
||||||
const end = i
|
const end = i + 1
|
||||||
const uniques = new Set<string>()
|
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
|
||||||
for (let j = start; j <= end; j++)
|
|
||||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
|
||||||
return uniques.size
|
return uniques.size
|
||||||
})
|
})
|
||||||
|
|
||||||
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
|
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
|
||||||
const start = Math.max(0, i - 29)
|
const start = Math.max(0, i - 29)
|
||||||
const end = i
|
const end = i + 1
|
||||||
const uniques = new Set<string>()
|
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
|
||||||
for (let j = start; j <= end; j++)
|
|
||||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
|
||||||
return uniques.size
|
return uniques.size
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const d1 = dailyUserIds.map((userIds, i) => {
|
||||||
|
if (i === 0) return 0
|
||||||
|
|
||||||
|
const uniques = new Set(userIds)
|
||||||
|
const yesterday = dailyUserIds[i - 1]
|
||||||
|
|
||||||
|
const retainedCount = sumBy(yesterday, (userId) =>
|
||||||
|
uniques.has(userId) ? 1 : 0
|
||||||
|
)
|
||||||
|
return retainedCount / uniques.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const d1WeeklyAvg = d1.map((_, i) => {
|
||||||
|
const start = Math.max(0, i - 6)
|
||||||
|
const end = i + 1
|
||||||
|
return average(d1.slice(start, end))
|
||||||
|
})
|
||||||
|
|
||||||
|
const dailyNewUserIds = dailyNewUsers.map((users) => users.map((u) => u.id))
|
||||||
|
const nd1 = dailyUserIds.map((userIds, i) => {
|
||||||
|
if (i === 0) return 0
|
||||||
|
|
||||||
|
const uniques = new Set(userIds)
|
||||||
|
const yesterday = dailyNewUserIds[i - 1]
|
||||||
|
|
||||||
|
const retainedCount = sumBy(yesterday, (userId) =>
|
||||||
|
uniques.has(userId) ? 1 : 0
|
||||||
|
)
|
||||||
|
return retainedCount / uniques.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const nd1WeeklyAvg = nd1.map((_, i) => {
|
||||||
|
const start = Math.max(0, i - 6)
|
||||||
|
const end = i + 1
|
||||||
|
return average(nd1.slice(start, end))
|
||||||
|
})
|
||||||
|
const nw1 = dailyNewUserIds.map((_userIds, i) => {
|
||||||
|
if (i < 13) return 0
|
||||||
|
|
||||||
|
const twoWeeksAgo = {
|
||||||
|
start: Math.max(0, i - 13),
|
||||||
|
end: Math.max(0, i - 6),
|
||||||
|
}
|
||||||
|
const lastWeek = {
|
||||||
|
start: Math.max(0, i - 6),
|
||||||
|
end: i + 1,
|
||||||
|
}
|
||||||
|
const newTwoWeeksAgo = new Set<string>(
|
||||||
|
dailyNewUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
|
||||||
|
)
|
||||||
|
const activeLastWeek = new Set<string>(
|
||||||
|
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
|
||||||
|
)
|
||||||
|
const retainedCount = sumBy(Array.from(newTwoWeeksAgo), (userId) =>
|
||||||
|
activeLastWeek.has(userId) ? 1 : 0
|
||||||
|
)
|
||||||
|
return retainedCount / newTwoWeeksAgo.size
|
||||||
|
})
|
||||||
|
|
||||||
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
|
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
|
||||||
const twoWeeksAgo = {
|
const twoWeeksAgo = {
|
||||||
start: Math.max(0, i - 13),
|
start: Math.max(0, i - 13),
|
||||||
end: Math.max(0, i - 7),
|
end: Math.max(0, i - 6),
|
||||||
}
|
}
|
||||||
const lastWeek = {
|
const lastWeek = {
|
||||||
start: Math.max(0, i - 6),
|
start: Math.max(0, i - 6),
|
||||||
end: i,
|
end: i + 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTwoWeeksAgo = new Set<string>()
|
const activeTwoWeeksAgo = new Set<string>(
|
||||||
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
|
dailyUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
|
||||||
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
|
)
|
||||||
}
|
const activeLastWeek = new Set<string>(
|
||||||
const activeLastWeek = new Set<string>()
|
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
|
||||||
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
|
)
|
||||||
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
|
|
||||||
}
|
|
||||||
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
|
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
|
||||||
activeLastWeek.has(userId) ? 1 : 0
|
activeLastWeek.has(userId) ? 1 : 0
|
||||||
)
|
)
|
||||||
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
|
return retainedCount / activeTwoWeeksAgo.size
|
||||||
return Math.round(retainedFrac * 100 * 100) / 100
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const monthlyRetention = dailyUserIds.map((_userId, i) => {
|
const monthlyRetention = dailyUserIds.map((_userId, i) => {
|
||||||
const twoMonthsAgo = {
|
const twoMonthsAgo = {
|
||||||
start: Math.max(0, i - 60),
|
start: Math.max(0, i - 59),
|
||||||
end: Math.max(0, i - 30),
|
end: Math.max(0, i - 29),
|
||||||
}
|
}
|
||||||
const lastMonth = {
|
const lastMonth = {
|
||||||
start: Math.max(0, i - 30),
|
start: Math.max(0, i - 29),
|
||||||
end: i,
|
end: i + 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTwoMonthsAgo = new Set<string>()
|
const activeTwoMonthsAgo = new Set<string>(
|
||||||
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
|
dailyUserIds.slice(twoMonthsAgo.start, twoMonthsAgo.end).flat()
|
||||||
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
|
)
|
||||||
}
|
const activeLastMonth = new Set<string>(
|
||||||
const activeLastMonth = new Set<string>()
|
dailyUserIds.slice(lastMonth.start, lastMonth.end).flat()
|
||||||
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
|
)
|
||||||
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
|
|
||||||
}
|
|
||||||
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
|
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
|
||||||
activeLastMonth.has(userId) ? 1 : 0
|
activeLastMonth.has(userId) ? 1 : 0
|
||||||
)
|
)
|
||||||
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
|
if (activeTwoMonthsAgo.size === 0) return 0
|
||||||
return Math.round(retainedFrac * 100 * 100) / 100
|
return retainedCount / activeTwoMonthsAgo.size
|
||||||
})
|
})
|
||||||
|
|
||||||
const firstBetDict: { [userId: string]: number } = {}
|
const firstBetDict: { [userId: string]: number } = {}
|
||||||
|
@ -216,52 +278,20 @@ export const updateStatsCore = async () => {
|
||||||
firstBetDict[bet.userId] = i
|
firstBetDict[bet.userId] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
|
const dailyActivationRate = dailyNewUsers.map((newUsers, i) => {
|
||||||
const start = Math.max(0, i - 6)
|
const activedCount = sumBy(newUsers, (user) => {
|
||||||
const end = i
|
const firstBet = firstBetDict[user.id]
|
||||||
let activatedCount = 0
|
return firstBet === i ? 1 : 0
|
||||||
let newUsers = 0
|
})
|
||||||
for (let j = start; j <= end; j++) {
|
return activedCount / newUsers.length
|
||||||
const userIds = dailyNewUsers[j].map((user) => user.id)
|
})
|
||||||
newUsers += userIds.length
|
const dailyActivationRateWeeklyAvg = dailyActivationRate.map((_, i) => {
|
||||||
for (const userId of userIds) {
|
const start = Math.max(0, i - 6)
|
||||||
const dayIndex = firstBetDict[userId]
|
const end = i + 1
|
||||||
if (dayIndex !== undefined && dayIndex <= end) {
|
return average(dailyActivationRate.slice(start, end))
|
||||||
activatedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const frac = activatedCount / (newUsers || 1)
|
|
||||||
return Math.round(frac * 100 * 100) / 100
|
|
||||||
})
|
})
|
||||||
const dailySignups = dailyNewUsers.map((users) => users.length)
|
|
||||||
|
|
||||||
const dailyTopTenthActions = zip(
|
const dailySignups = dailyNewUsers.map((users) => users.length)
|
||||||
dailyContracts,
|
|
||||||
dailyBets,
|
|
||||||
dailyComments
|
|
||||||
).map(([contracts, bets, comments]) => {
|
|
||||||
const userIds = concat(
|
|
||||||
contracts?.map((c) => c.creatorId) ?? [],
|
|
||||||
bets?.map((b) => b.userId) ?? [],
|
|
||||||
comments?.map((c) => c.userId) ?? []
|
|
||||||
)
|
|
||||||
const counts = Object.values(countBy(userIds))
|
|
||||||
const sortedCounts = sortBy(counts, (count) => count).reverse()
|
|
||||||
if (sortedCounts.length === 0) return 0
|
|
||||||
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
|
|
||||||
return tenthPercentile
|
|
||||||
})
|
|
||||||
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
|
||||||
const start = Math.max(0, i - 6)
|
|
||||||
const end = i
|
|
||||||
return average(dailyTopTenthActions.slice(start, end))
|
|
||||||
})
|
|
||||||
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
|
||||||
const start = Math.max(0, i - 29)
|
|
||||||
const end = i
|
|
||||||
return average(dailyTopTenthActions.slice(start, end))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Total mana divided by 100.
|
// Total mana divided by 100.
|
||||||
const dailyManaBet = dailyBets.map((bets) => {
|
const dailyManaBet = dailyBets.map((bets) => {
|
||||||
|
@ -269,37 +299,39 @@ export const updateStatsCore = async () => {
|
||||||
})
|
})
|
||||||
const weeklyManaBet = dailyManaBet.map((_, i) => {
|
const weeklyManaBet = dailyManaBet.map((_, i) => {
|
||||||
const start = Math.max(0, i - 6)
|
const start = Math.max(0, i - 6)
|
||||||
const end = i
|
const end = i + 1
|
||||||
const total = sum(dailyManaBet.slice(start, end))
|
const total = sum(dailyManaBet.slice(start, end))
|
||||||
if (end - start < 7) return (total * 7) / (end - start)
|
if (end - start < 7) return (total * 7) / (end - start)
|
||||||
return total
|
return total
|
||||||
})
|
})
|
||||||
const monthlyManaBet = dailyManaBet.map((_, i) => {
|
const monthlyManaBet = dailyManaBet.map((_, i) => {
|
||||||
const start = Math.max(0, i - 29)
|
const start = Math.max(0, i - 29)
|
||||||
const end = i
|
const end = i + 1
|
||||||
const total = sum(dailyManaBet.slice(start, end))
|
const total = sum(dailyManaBet.slice(start, end))
|
||||||
const range = end - start + 1
|
const range = end - start
|
||||||
if (range < 30) return (total * 30) / range
|
if (range < 30) return (total * 30) / range
|
||||||
return total
|
return total
|
||||||
})
|
})
|
||||||
|
|
||||||
const statsData = {
|
const statsData: Stats = {
|
||||||
startDate: startDate.valueOf(),
|
startDate: startDate.valueOf(),
|
||||||
dailyActiveUsers,
|
dailyActiveUsers,
|
||||||
|
dailyActiveUsersWeeklyAvg,
|
||||||
weeklyActiveUsers,
|
weeklyActiveUsers,
|
||||||
monthlyActiveUsers,
|
monthlyActiveUsers,
|
||||||
|
d1,
|
||||||
|
d1WeeklyAvg,
|
||||||
|
nd1,
|
||||||
|
nd1WeeklyAvg,
|
||||||
|
nw1,
|
||||||
dailyBetCounts,
|
dailyBetCounts,
|
||||||
dailyContractCounts,
|
dailyContractCounts,
|
||||||
dailyCommentCounts,
|
dailyCommentCounts,
|
||||||
dailySignups,
|
dailySignups,
|
||||||
weekOnWeekRetention,
|
weekOnWeekRetention,
|
||||||
weeklyActivationRate,
|
dailyActivationRate,
|
||||||
|
dailyActivationRateWeeklyAvg,
|
||||||
monthlyRetention,
|
monthlyRetention,
|
||||||
topTenthActions: {
|
|
||||||
daily: dailyTopTenthActions,
|
|
||||||
weekly: weeklyTopTenthActions,
|
|
||||||
monthly: monthlyTopTenthActions,
|
|
||||||
},
|
|
||||||
manaBet: {
|
manaBet: {
|
||||||
daily: dailyManaBet,
|
daily: dailyManaBet,
|
||||||
weekly: weeklyManaBet,
|
weekly: weeklyManaBet,
|
||||||
|
@ -311,6 +343,6 @@ export const updateStatsCore = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateStats = functions
|
export const updateStats = functions
|
||||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||||
.pubsub.schedule('every 60 minutes')
|
.pubsub.schedule('every 60 minutes')
|
||||||
.onRun(updateStatsCore)
|
.onRun(updateStatsCore)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
import { chunk } from 'lodash'
|
import { chunk } from 'lodash'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
@ -17,6 +18,18 @@ export const logMemory = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const revalidateStaticProps = async (
|
||||||
|
// Path after domain: e.g. "/JamesGrugett/will-pete-buttigieg-ever-be-us-pres"
|
||||||
|
pathToRevalidate: string
|
||||||
|
) => {
|
||||||
|
if (isProd()) {
|
||||||
|
const apiSecret = process.env.API_SECRET as string
|
||||||
|
const queryStr = `?pathToRevalidate=${pathToRevalidate}&apiSecret=${apiSecret}`
|
||||||
|
await fetch('https://manifold.markets/api/v0/revalidate' + queryStr)
|
||||||
|
console.log('Revalidated', pathToRevalidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type UpdateSpec = {
|
export type UpdateSpec = {
|
||||||
doc: admin.firestore.DocumentReference
|
doc: admin.firestore.DocumentReference
|
||||||
fields: { [k: string]: unknown }
|
fields: { [k: string]: unknown }
|
||||||
|
@ -153,3 +166,11 @@ export const chargeUser = (
|
||||||
|
|
||||||
return updateUserBalance(userId, -charge, isAnte)
|
return updateUserBalance(userId, -charge, isAnte)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getContractPath = (contract: Contract) => {
|
||||||
|
return `/${contract.creatorUsername}/${contract.slug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contractUrl(contract: Contract) {
|
||||||
|
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
|
||||||
|
}
|
||||||
|
|
|
@ -4,21 +4,24 @@ import * as admin from 'firebase-admin'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import {
|
import {
|
||||||
getAllPrivateUsers,
|
getAllPrivateUsers,
|
||||||
|
getGroup,
|
||||||
getPrivateUser,
|
getPrivateUser,
|
||||||
getUser,
|
getUser,
|
||||||
getValues,
|
getValues,
|
||||||
isProd,
|
isProd,
|
||||||
log,
|
log,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { sendInterestingMarketsEmail } from './emails'
|
|
||||||
import { createRNG, shuffle } from '../../common/util/random'
|
import { createRNG, shuffle } from '../../common/util/random'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS, HOUR_MS } from '../../common/util/time'
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
import { Follow } from '../../common/follow'
|
||||||
|
import { countBy, uniq, uniqBy } from 'lodash'
|
||||||
|
import { sendInterestingMarketsEmail } from './emails'
|
||||||
|
|
||||||
export const weeklyMarketsEmails = functions
|
export const weeklyMarketsEmails = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
||||||
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
|
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
|
||||||
.pubsub.schedule('* 19 * * 1')
|
.pubsub.schedule('* 19-20 * * 1')
|
||||||
.timeZone('Etc/UTC')
|
.timeZone('Etc/UTC')
|
||||||
.onRun(async () => {
|
.onRun(async () => {
|
||||||
await sendTrendingMarketsEmailsToAllUsers()
|
await sendTrendingMarketsEmailsToAllUsers()
|
||||||
|
@ -40,18 +43,30 @@ export async function getTrendingContracts() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTrendingMarketsEmailsToAllUsers() {
|
export async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
const numContractsToSend = 6
|
const numContractsToSend = 6
|
||||||
const privateUsers = isProd()
|
const privateUsers = isProd()
|
||||||
? await getAllPrivateUsers()
|
? await getAllPrivateUsers()
|
||||||
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
: filterDefined([
|
||||||
// get all users that haven't unsubscribed from weekly emails
|
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
|
||||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
])
|
||||||
return (
|
const privateUsersToSendEmailsTo = privateUsers
|
||||||
!user.unsubscribedFromWeeklyTrendingEmails &&
|
// Get all users that haven't unsubscribed from weekly emails
|
||||||
!user.weeklyTrendingEmailSent
|
.filter(
|
||||||
|
(user) =>
|
||||||
|
user.notificationPreferences.trending_markets.includes('email') &&
|
||||||
|
!user.weeklyTrendingEmailSent
|
||||||
)
|
)
|
||||||
})
|
.slice(0, 90) // Send the emails out in batches
|
||||||
|
|
||||||
|
// For testing different users on prod: (only send ian an email though)
|
||||||
|
// const privateUsersToSendEmailsTo = filterDefined([
|
||||||
|
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
|
||||||
|
// // isProd()
|
||||||
|
// await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik
|
||||||
|
// // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
|
||||||
|
// ])
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'Sending weekly trending emails to',
|
'Sending weekly trending emails to',
|
||||||
privateUsersToSendEmailsTo.length,
|
privateUsersToSendEmailsTo.length,
|
||||||
|
@ -68,38 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
!contract.groupSlugs?.includes('manifold-features') &&
|
!contract.groupSlugs?.includes('manifold-features') &&
|
||||||
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
||||||
)
|
)
|
||||||
.slice(0, 20)
|
.slice(0, 50)
|
||||||
log(
|
|
||||||
`Found ${trendingContracts.length} trending contracts:\n`,
|
const uniqueTrendingContracts = removeSimilarQuestions(
|
||||||
trendingContracts.map((c) => c.question).join('\n ')
|
trendingContracts,
|
||||||
|
trendingContracts,
|
||||||
|
true
|
||||||
|
).slice(0, 20)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||||
|
if (!privateUser.email) {
|
||||||
|
log(`No email for ${privateUser.username}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
|
||||||
|
privateUser.id
|
||||||
|
)
|
||||||
|
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
|
||||||
|
privateUser.id,
|
||||||
|
unbetOnFollowedMarkets
|
||||||
|
)
|
||||||
|
const similarBettorsMarkets = await getSimilarBettorsMarkets(
|
||||||
|
privateUser.id,
|
||||||
|
unBetOnGroupMarkets
|
||||||
|
)
|
||||||
|
|
||||||
|
const marketsAvailableToSend = uniqBy(
|
||||||
|
[
|
||||||
|
...chooseRandomSubset(unbetOnFollowedMarkets, 2),
|
||||||
|
// // Most people will belong to groups but may not follow other users,
|
||||||
|
// so choose more from the other subsets if the followed markets is sparse
|
||||||
|
...chooseRandomSubset(
|
||||||
|
unBetOnGroupMarkets,
|
||||||
|
unbetOnFollowedMarkets.length < 2 ? 3 : 2
|
||||||
|
),
|
||||||
|
...chooseRandomSubset(
|
||||||
|
similarBettorsMarkets,
|
||||||
|
unbetOnFollowedMarkets.length < 2 ? 3 : 2
|
||||||
|
),
|
||||||
|
],
|
||||||
|
(contract) => contract.id
|
||||||
|
)
|
||||||
|
// // at least send them trending contracts if nothing else
|
||||||
|
if (marketsAvailableToSend.length < numContractsToSend) {
|
||||||
|
const trendingMarketsToSend =
|
||||||
|
numContractsToSend - marketsAvailableToSend.length
|
||||||
|
log(
|
||||||
|
`not enough personalized markets, sending ${trendingMarketsToSend} trending`
|
||||||
|
)
|
||||||
|
marketsAvailableToSend.push(
|
||||||
|
...removeSimilarQuestions(
|
||||||
|
uniqueTrendingContracts,
|
||||||
|
marketsAvailableToSend,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
|
||||||
|
)
|
||||||
|
.slice(0, trendingMarketsToSend)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketsAvailableToSend.length < numContractsToSend) {
|
||||||
|
log(
|
||||||
|
'not enough new, unbet-on contracts to send to user',
|
||||||
|
privateUser.id
|
||||||
|
)
|
||||||
|
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||||
|
weeklyTrendingEmailSent: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// choose random subset of contracts to send to user
|
||||||
|
const contractsToSend = chooseRandomSubset(
|
||||||
|
marketsAvailableToSend,
|
||||||
|
numContractsToSend
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = await getUser(privateUser.id)
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
log(
|
||||||
|
'sending contracts:',
|
||||||
|
contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
|
||||||
|
)
|
||||||
|
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
|
||||||
|
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||||
|
await firestore.collection('private-users').doc(user.id).update({
|
||||||
|
weeklyTrendingEmailSent: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINIMUM_POPULARITY_SCORE = 10
|
||||||
|
|
||||||
|
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
|
||||||
|
const follows = await getValues<Follow>(
|
||||||
|
firestore.collection('users').doc(userId).collection('follows')
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
const unBetOnContractsFromFollows = await Promise.all(
|
||||||
if (!privateUser.email) {
|
follows.map(async (follow) => {
|
||||||
log(`No email for ${privateUser.username}`)
|
const unresolvedContracts = await getValues<Contract>(
|
||||||
continue
|
firestore
|
||||||
}
|
.collection('contracts')
|
||||||
const contractsAvailableToSend = trendingContracts.filter((contract) => {
|
.where('isResolved', '==', false)
|
||||||
return !contract.uniqueBettorIds?.includes(privateUser.id)
|
.where('visibility', '==', 'public')
|
||||||
|
.where('creatorId', '==', follow.userId)
|
||||||
|
// can't use multiple inequality (/orderBy) operators on different fields,
|
||||||
|
// so have to filter for closed contracts separately
|
||||||
|
.orderBy('popularityScore', 'desc')
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
// filter out contracts that have close times less than 6 hours from now
|
||||||
|
const openContracts = unresolvedContracts.filter(
|
||||||
|
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
return openContracts.filter(
|
||||||
|
(contract) => !contract.uniqueBettorIds?.includes(userId)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
if (contractsAvailableToSend.length < numContractsToSend) {
|
)
|
||||||
log('not enough new, unbet-on contracts to send to user', privateUser.id)
|
|
||||||
continue
|
const sortedMarkets = uniqBy(
|
||||||
}
|
unBetOnContractsFromFollows.flat(),
|
||||||
// choose random subset of contracts to send to user
|
(contract) => contract.id
|
||||||
const contractsToSend = chooseRandomSubset(
|
)
|
||||||
contractsAvailableToSend,
|
.filter(
|
||||||
numContractsToSend
|
(contract) =>
|
||||||
|
contract.popularityScore !== undefined &&
|
||||||
|
contract.popularityScore > MINIMUM_POPULARITY_SCORE
|
||||||
)
|
)
|
||||||
|
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
|
||||||
|
|
||||||
const user = await getUser(privateUser.id)
|
const uniqueSortedMarkets = removeSimilarQuestions(
|
||||||
if (!user) continue
|
sortedMarkets,
|
||||||
|
sortedMarkets,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
|
||||||
await firestore.collection('private-users').doc(user.id).update({
|
// log(
|
||||||
weeklyTrendingEmailSent: true,
|
// 'top 10 sorted markets by followed users',
|
||||||
|
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
|
||||||
|
// )
|
||||||
|
return topSortedMarkets
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserUnBetOnGroupsMarkets = async (
|
||||||
|
userId: string,
|
||||||
|
differentThanTheseContracts: Contract[]
|
||||||
|
) => {
|
||||||
|
const snap = await firestore
|
||||||
|
.collectionGroup('groupMembers')
|
||||||
|
.where('userId', '==', userId)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const groupIds = filterDefined(
|
||||||
|
snap.docs.map((doc) => doc.ref.parent.parent?.id)
|
||||||
|
)
|
||||||
|
const groups = filterDefined(
|
||||||
|
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
|
||||||
|
)
|
||||||
|
if (groups.length === 0) return []
|
||||||
|
|
||||||
|
const unBetOnContractsFromGroups = await Promise.all(
|
||||||
|
groups.map(async (group) => {
|
||||||
|
const unresolvedContracts = await getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('isResolved', '==', false)
|
||||||
|
.where('visibility', '==', 'public')
|
||||||
|
.where('groupSlugs', 'array-contains', group.slug)
|
||||||
|
// can't use multiple inequality (/orderBy) operators on different fields,
|
||||||
|
// so have to filter for closed contracts separately
|
||||||
|
.orderBy('popularityScore', 'desc')
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
// filter out contracts that have close times less than 6 hours from now
|
||||||
|
const openContracts = unresolvedContracts.filter(
|
||||||
|
(contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
return openContracts.filter(
|
||||||
|
(contract) => !contract.uniqueBettorIds?.includes(userId)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
)
|
||||||
|
|
||||||
|
const sortedMarkets = uniqBy(
|
||||||
|
unBetOnContractsFromGroups.flat(),
|
||||||
|
(contract) => contract.id
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(contract) =>
|
||||||
|
contract.popularityScore !== undefined &&
|
||||||
|
contract.popularityScore > MINIMUM_POPULARITY_SCORE
|
||||||
|
)
|
||||||
|
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
|
||||||
|
|
||||||
|
const uniqueSortedMarkets = removeSimilarQuestions(
|
||||||
|
sortedMarkets,
|
||||||
|
sortedMarkets,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
const topSortedMarkets = removeSimilarQuestions(
|
||||||
|
uniqueSortedMarkets,
|
||||||
|
differentThanTheseContracts,
|
||||||
|
false
|
||||||
|
).slice(0, 10)
|
||||||
|
|
||||||
|
// log(
|
||||||
|
// 'top 10 sorted group markets',
|
||||||
|
// topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
|
||||||
|
// )
|
||||||
|
return topSortedMarkets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets markets followed by similar bettors and bet on by similar bettors
|
||||||
|
const getSimilarBettorsMarkets = async (
|
||||||
|
userId: string,
|
||||||
|
differentThanTheseContracts: Contract[]
|
||||||
|
) => {
|
||||||
|
// get contracts with unique bettor ids with this user
|
||||||
|
const contractsUserHasBetOn = await getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('uniqueBettorIds', 'array-contains', userId)
|
||||||
|
)
|
||||||
|
if (contractsUserHasBetOn.length === 0) return []
|
||||||
|
// count the number of times each unique bettor id appears on those contracts
|
||||||
|
const bettorIdsToCounts = countBy(
|
||||||
|
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
|
||||||
|
(bettorId) => bettorId
|
||||||
|
)
|
||||||
|
|
||||||
|
// sort by number of times they appear with at least 2 appearances
|
||||||
|
const sortedBettorIds = Object.entries(bettorIdsToCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.filter((bettorId) => bettorId[1] > 2)
|
||||||
|
.map((entry) => entry[0])
|
||||||
|
.filter((bettorId) => bettorId !== userId)
|
||||||
|
|
||||||
|
// get the top 10 most similar bettors (excluding this user)
|
||||||
|
const similarBettorIds = sortedBettorIds.slice(0, 10)
|
||||||
|
if (similarBettorIds.length === 0) return []
|
||||||
|
|
||||||
|
// get contracts with unique bettor ids with this user
|
||||||
|
const contractsSimilarBettorsHaveBetOn = uniqBy(
|
||||||
|
(
|
||||||
|
await getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where(
|
||||||
|
'uniqueBettorIds',
|
||||||
|
'array-contains-any',
|
||||||
|
similarBettorIds.slice(0, 10)
|
||||||
|
)
|
||||||
|
.orderBy('popularityScore', 'desc')
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
).filter(
|
||||||
|
(contract) =>
|
||||||
|
!contract.uniqueBettorIds?.includes(userId) &&
|
||||||
|
(contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE
|
||||||
|
),
|
||||||
|
(contract) => contract.id
|
||||||
|
)
|
||||||
|
|
||||||
|
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array
|
||||||
|
const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
|
||||||
|
.map((contract) => {
|
||||||
|
const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
|
||||||
|
similarBettorIds.includes(bettorId)
|
||||||
|
).length
|
||||||
|
return [contract, appearances] as [Contract, number]
|
||||||
|
})
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map((entry) => entry[0])
|
||||||
|
|
||||||
|
const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
|
||||||
|
sortedContractsInSimilarBettorsBets,
|
||||||
|
sortedContractsInSimilarBettorsBets,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
const topMostSimilarContracts = removeSimilarQuestions(
|
||||||
|
uniqueSortedContractsInSimilarBettorsBets,
|
||||||
|
differentThanTheseContracts,
|
||||||
|
false
|
||||||
|
).slice(0, 10)
|
||||||
|
|
||||||
|
// log(
|
||||||
|
// 'top 10 sorted contracts other similar bettors have bet on',
|
||||||
|
// topMostSimilarContracts.map((c) => c.question)
|
||||||
|
// )
|
||||||
|
|
||||||
|
return topMostSimilarContracts
|
||||||
|
}
|
||||||
|
|
||||||
|
// search contract array by question and remove contracts with 3 matching words in the question
|
||||||
|
const removeSimilarQuestions = (
|
||||||
|
contractsToFilter: Contract[],
|
||||||
|
byContracts: Contract[],
|
||||||
|
allowExactSameContracts: boolean
|
||||||
|
) => {
|
||||||
|
// log(
|
||||||
|
// 'contracts to filter by',
|
||||||
|
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
|
||||||
|
// )
|
||||||
|
let contractsToRemove: Contract[] = []
|
||||||
|
byContracts.length > 0 &&
|
||||||
|
byContracts.forEach((contract) => {
|
||||||
|
const contractQuestion = stripNonAlphaChars(
|
||||||
|
contract.question.toLowerCase()
|
||||||
|
)
|
||||||
|
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
|
||||||
|
(w) => !IGNORE_WORDS.includes(w)
|
||||||
|
)
|
||||||
|
contractsToRemove = contractsToRemove.concat(
|
||||||
|
contractsToFilter.filter(
|
||||||
|
// Remove contracts with more than 2 matching (uncommon) words and a lower popularity score
|
||||||
|
(c2) => {
|
||||||
|
const significantOverlap =
|
||||||
|
// TODO: we should probably use a library for comparing strings/sentiments
|
||||||
|
uniq(
|
||||||
|
stripNonAlphaChars(c2.question.toLowerCase()).split(' ')
|
||||||
|
).filter((word) => contractQuestionWords.includes(word)).length >
|
||||||
|
2
|
||||||
|
const lessPopular =
|
||||||
|
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
|
||||||
|
return (
|
||||||
|
(significantOverlap && lessPopular) ||
|
||||||
|
(allowExactSameContracts ? false : c2.id === contract.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// log(
|
||||||
|
// 'contracts to filter out',
|
||||||
|
// contractsToRemove.map((c) => c.question)
|
||||||
|
// )
|
||||||
|
|
||||||
|
const returnContracts = contractsToFilter.filter(
|
||||||
|
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return returnContracts
|
||||||
}
|
}
|
||||||
|
|
||||||
const fiveMinutes = 5 * 60 * 1000
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
|
@ -110,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
shuffle(contracts, rng)
|
shuffle(contracts, rng)
|
||||||
return contracts.slice(0, count)
|
return contracts.slice(0, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripNonAlphaChars(str: string) {
|
||||||
|
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const IGNORE_WORDS = [
|
||||||
|
'the',
|
||||||
|
'a',
|
||||||
|
'an',
|
||||||
|
'and',
|
||||||
|
'or',
|
||||||
|
'of',
|
||||||
|
'to',
|
||||||
|
'in',
|
||||||
|
'on',
|
||||||
|
'will',
|
||||||
|
'be',
|
||||||
|
'is',
|
||||||
|
'are',
|
||||||
|
'for',
|
||||||
|
'by',
|
||||||
|
'at',
|
||||||
|
'from',
|
||||||
|
'what',
|
||||||
|
'when',
|
||||||
|
'which',
|
||||||
|
'that',
|
||||||
|
'it',
|
||||||
|
'as',
|
||||||
|
'if',
|
||||||
|
'then',
|
||||||
|
'than',
|
||||||
|
'but',
|
||||||
|
'have',
|
||||||
|
'has',
|
||||||
|
'had',
|
||||||
|
]
|
||||||
|
|
289
functions/src/weekly-portfolio-emails.ts
Normal file
289
functions/src/weekly-portfolio-emails.ts
Normal file
|
@ -0,0 +1,289 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { Contract, CPMMContract } from '../../common/contract'
|
||||||
|
import {
|
||||||
|
getAllPrivateUsers,
|
||||||
|
getPrivateUser,
|
||||||
|
getUser,
|
||||||
|
getValue,
|
||||||
|
getValues,
|
||||||
|
isProd,
|
||||||
|
log,
|
||||||
|
} from './utils'
|
||||||
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
import { partition, sortBy, sum, uniq } from 'lodash'
|
||||||
|
import { Bet } from '../../common/bet'
|
||||||
|
import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics'
|
||||||
|
import { sendWeeklyPortfolioUpdateEmail } from './emails'
|
||||||
|
import { contractUrl } from './utils'
|
||||||
|
import { Txn } from '../../common/txn'
|
||||||
|
import { formatMoney } from '../../common/util/format'
|
||||||
|
import { getContractBetMetrics } from '../../common/calculate'
|
||||||
|
|
||||||
|
export const weeklyPortfolioUpdateEmails = functions
|
||||||
|
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
|
||||||
|
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
|
||||||
|
.pubsub.schedule('* 19 * * 5')
|
||||||
|
.timeZone('Etc/UTC')
|
||||||
|
.onRun(async () => {
|
||||||
|
await sendPortfolioUpdateEmailsToAllUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export async function sendPortfolioUpdateEmailsToAllUsers() {
|
||||||
|
const privateUsers = isProd()
|
||||||
|
? // ian & stephen's ids
|
||||||
|
// filterDefined([
|
||||||
|
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
|
||||||
|
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
|
||||||
|
// ])
|
||||||
|
await getAllPrivateUsers()
|
||||||
|
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||||
|
// get all users that haven't unsubscribed from weekly emails
|
||||||
|
const privateUsersToSendEmailsTo = privateUsers
|
||||||
|
.filter((user) => {
|
||||||
|
return isProd()
|
||||||
|
? user.notificationPreferences.profit_loss_updates.includes('email') &&
|
||||||
|
!user.weeklyPortfolioUpdateEmailSent
|
||||||
|
: user.notificationPreferences.profit_loss_updates.includes('email')
|
||||||
|
})
|
||||||
|
// Send emails in batches
|
||||||
|
.slice(0, 200)
|
||||||
|
log(
|
||||||
|
'Sending weekly portfolio emails to',
|
||||||
|
privateUsersToSendEmailsTo.length,
|
||||||
|
'users'
|
||||||
|
)
|
||||||
|
|
||||||
|
const usersBets: { [userId: string]: Bet[] } = {}
|
||||||
|
// get all bets made by each user
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (user) => {
|
||||||
|
return getValues<Bet>(
|
||||||
|
firestore.collectionGroup('bets').where('userId', '==', user.id)
|
||||||
|
).then((bets) => {
|
||||||
|
usersBets[user.id] = bets
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const usersToContractsCreated: { [userId: string]: Contract[] } = {}
|
||||||
|
// Get all contracts created by each user
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (user) => {
|
||||||
|
return getValues<Contract>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.where('creatorId', '==', user.id)
|
||||||
|
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||||
|
).then((contracts) => {
|
||||||
|
usersToContractsCreated[user.id] = contracts
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get all txns the users received over the past week
|
||||||
|
const usersToTxnsReceived: { [userId: string]: Txn[] } = {}
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (user) => {
|
||||||
|
return getValues<Txn>(
|
||||||
|
firestore
|
||||||
|
.collection(`txns`)
|
||||||
|
.where('toId', '==', user.id)
|
||||||
|
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||||
|
).then((txn) => {
|
||||||
|
usersToTxnsReceived[user.id] = txn
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get a flat map of all the bets that users made to get the contracts they bet on
|
||||||
|
const contractsUsersBetOn = filterDefined(
|
||||||
|
await Promise.all(
|
||||||
|
uniq(
|
||||||
|
Object.values(usersBets).flatMap((bets) =>
|
||||||
|
bets.map((bet) => bet.contractId)
|
||||||
|
)
|
||||||
|
).map((contractId) =>
|
||||||
|
getValue<Contract>(firestore.collection('contracts').doc(contractId))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log('Found', contractsUsersBetOn.length, 'contracts')
|
||||||
|
let count = 0
|
||||||
|
await Promise.all(
|
||||||
|
privateUsersToSendEmailsTo.map(async (privateUser) => {
|
||||||
|
const user = await getUser(privateUser.id)
|
||||||
|
// Don't send to a user unless they're over 5 days old
|
||||||
|
if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return
|
||||||
|
const userBets = usersBets[privateUser.id] as Bet[]
|
||||||
|
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
|
||||||
|
userBets.some((bet) => bet.contractId === contract.id)
|
||||||
|
)
|
||||||
|
const contractsBetOnInLastWeek = uniq(
|
||||||
|
userBets
|
||||||
|
.filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS)
|
||||||
|
.map((bet) => bet.contractId)
|
||||||
|
)
|
||||||
|
const totalTips = sum(
|
||||||
|
usersToTxnsReceived[privateUser.id]
|
||||||
|
.filter((txn) => txn.category === 'TIP')
|
||||||
|
.map((txn) => txn.amount)
|
||||||
|
)
|
||||||
|
const greenBg = 'rgba(0,160,0,0.2)'
|
||||||
|
const redBg = 'rgba(160,0,0,0.2)'
|
||||||
|
const clearBg = 'rgba(255,255,255,0)'
|
||||||
|
const roundedProfit =
|
||||||
|
Math.round(user.profitCached.weekly) === 0
|
||||||
|
? 0
|
||||||
|
: Math.floor(user.profitCached.weekly)
|
||||||
|
const performanceData = {
|
||||||
|
profit: formatMoney(user.profitCached.weekly),
|
||||||
|
profit_style: `background-color: ${
|
||||||
|
roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg
|
||||||
|
}`,
|
||||||
|
markets_created:
|
||||||
|
usersToContractsCreated[privateUser.id].length.toString(),
|
||||||
|
tips_received: formatMoney(totalTips),
|
||||||
|
unique_bettors: usersToTxnsReceived[privateUser.id]
|
||||||
|
.filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
|
||||||
|
.length.toString(),
|
||||||
|
markets_traded: contractsBetOnInLastWeek.length.toString(),
|
||||||
|
prediction_streak:
|
||||||
|
(user.currentBettingStreak?.toString() ?? '0') + ' days',
|
||||||
|
// More options: bonuses, tips given,
|
||||||
|
} as OverallPerformanceData
|
||||||
|
|
||||||
|
const investmentValueDifferences = sortBy(
|
||||||
|
filterDefined(
|
||||||
|
contractsUserBetOn.map((contract) => {
|
||||||
|
const cpmmContract = contract as CPMMContract
|
||||||
|
if (cpmmContract === undefined || cpmmContract.prob === undefined)
|
||||||
|
return
|
||||||
|
const bets = userBets.filter(
|
||||||
|
(bet) => bet.contractId === contract.id
|
||||||
|
)
|
||||||
|
const previousBets = bets.filter(
|
||||||
|
(b) => b.createdTime < Date.now() - 7 * DAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
const betsInLastWeek = bets.filter(
|
||||||
|
(b) => b.createdTime >= Date.now() - 7 * DAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
const marketProbabilityAWeekAgo =
|
||||||
|
cpmmContract.prob - cpmmContract.probChanges.week
|
||||||
|
const currentMarketProbability = cpmmContract.resolutionProbability
|
||||||
|
? cpmmContract.resolutionProbability
|
||||||
|
: cpmmContract.prob
|
||||||
|
|
||||||
|
// TODO: returns 0 for resolved markets - doesn't include them
|
||||||
|
const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb(
|
||||||
|
previousBets,
|
||||||
|
contract,
|
||||||
|
marketProbabilityAWeekAgo
|
||||||
|
)
|
||||||
|
const currentBetsMadeAWeekAgoValue =
|
||||||
|
computeInvestmentValueCustomProb(
|
||||||
|
previousBets,
|
||||||
|
contract,
|
||||||
|
currentMarketProbability
|
||||||
|
)
|
||||||
|
const betsMadeInLastWeekProfit = getContractBetMetrics(
|
||||||
|
contract,
|
||||||
|
betsInLastWeek
|
||||||
|
).profit
|
||||||
|
const profit =
|
||||||
|
betsMadeInLastWeekProfit +
|
||||||
|
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
|
||||||
|
return {
|
||||||
|
currentValue: currentBetsMadeAWeekAgoValue,
|
||||||
|
pastValue: betsMadeAWeekAgoValue,
|
||||||
|
profit,
|
||||||
|
contractSlug: contract.slug,
|
||||||
|
marketProbAWeekAgo: marketProbabilityAWeekAgo,
|
||||||
|
questionTitle: contract.question,
|
||||||
|
questionUrl: contractUrl(contract),
|
||||||
|
questionProb: cpmmContract.resolution
|
||||||
|
? cpmmContract.resolution
|
||||||
|
: Math.round(cpmmContract.prob * 100) + '%',
|
||||||
|
profitStyle: `color: ${
|
||||||
|
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
|
||||||
|
};`,
|
||||||
|
} as PerContractInvestmentsData
|
||||||
|
})
|
||||||
|
),
|
||||||
|
(differences) => Math.abs(differences.profit)
|
||||||
|
).reverse()
|
||||||
|
|
||||||
|
log(
|
||||||
|
'Found',
|
||||||
|
investmentValueDifferences.length,
|
||||||
|
'investment differences for user',
|
||||||
|
privateUser.id
|
||||||
|
)
|
||||||
|
|
||||||
|
const [winningInvestments, losingInvestments] = partition(
|
||||||
|
investmentValueDifferences.filter(
|
||||||
|
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
|
||||||
|
),
|
||||||
|
(investmentsData: PerContractInvestmentsData) => {
|
||||||
|
return investmentsData.profit > 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// pick 3 winning investments and 3 losing investments
|
||||||
|
const topInvestments = winningInvestments.slice(0, 2)
|
||||||
|
const worstInvestments = losingInvestments.slice(0, 2)
|
||||||
|
// if no bets in the last week ANd no market movers AND no markets created, don't send email
|
||||||
|
if (
|
||||||
|
contractsBetOnInLastWeek.length === 0 &&
|
||||||
|
topInvestments.length === 0 &&
|
||||||
|
worstInvestments.length === 0 &&
|
||||||
|
usersToContractsCreated[privateUser.id].length === 0
|
||||||
|
) {
|
||||||
|
log(
|
||||||
|
'No bets in last week, no market movers, no markets created. Not sending an email.'
|
||||||
|
)
|
||||||
|
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||||
|
weeklyPortfolioUpdateEmailSent: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await sendWeeklyPortfolioUpdateEmail(
|
||||||
|
user,
|
||||||
|
privateUser,
|
||||||
|
topInvestments.concat(worstInvestments) as PerContractInvestmentsData[],
|
||||||
|
performanceData
|
||||||
|
)
|
||||||
|
await firestore.collection('private-users').doc(privateUser.id).update({
|
||||||
|
weeklyPortfolioUpdateEmailSent: true,
|
||||||
|
})
|
||||||
|
log('Sent weekly portfolio update email to', privateUser.email)
|
||||||
|
count++
|
||||||
|
log('sent out emails to users:', count)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PerContractInvestmentsData = {
|
||||||
|
questionTitle: string
|
||||||
|
questionUrl: string
|
||||||
|
questionProb: string
|
||||||
|
profitStyle: string
|
||||||
|
currentValue: number
|
||||||
|
pastValue: number
|
||||||
|
profit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverallPerformanceData = {
|
||||||
|
profit: string
|
||||||
|
prediction_streak: string
|
||||||
|
markets_traded: string
|
||||||
|
profit_style: string
|
||||||
|
tips_received: string
|
||||||
|
markets_created: string
|
||||||
|
unique_bettors: string
|
||||||
|
}
|
|
@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
numericValue,
|
numericValue,
|
||||||
resolution,
|
resolution,
|
||||||
} = parsedReq
|
} = parsedReq
|
||||||
const MAX_QUESTION_CHARS = 100
|
|
||||||
const truncatedQuestion =
|
|
||||||
question.length > MAX_QUESTION_CHARS
|
|
||||||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
|
||||||
: question
|
|
||||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||||
|
|
||||||
let resolutionColor = 'text-primary'
|
let resolutionColor = 'text-primary'
|
||||||
|
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Generated Image</title>
|
<title>Generated Image</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script>
|
||||||
</head>
|
</head>
|
||||||
<style>
|
<style>
|
||||||
${getTemplateCss(theme, fontSize)}
|
${getTemplateCss(theme, fontSize)}
|
||||||
|
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row justify-between gap-12 pt-36">
|
<div class="flex flex-row justify-between gap-12 pt-36">
|
||||||
<div class="text-indigo-700 text-6xl leading-tight">
|
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
|
||||||
${truncatedQuestion}
|
${question}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
${
|
${
|
||||||
|
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="absolute bottom-16">
|
<div class="absolute bottom-16">
|
||||||
<div class="text-gray-500 text-3xl max-w-[80vw]">
|
<div class="text-gray-500 text-3xl max-w-[80vw] line-clamp-2">
|
||||||
${metadata}
|
${metadata}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,8 +24,5 @@
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.7.1",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "4.8.2"
|
"typescript": "4.8.2"
|
||||||
},
|
|
||||||
"resolutions": {
|
|
||||||
"@types/react": "17.0.43"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ export function SEO(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title} | Manifold Markets</title>
|
<title>{`${title} | Manifold Markets`}</title>
|
||||||
|
|
||||||
<meta
|
<meta
|
||||||
property="og:title"
|
property="og:title"
|
||||||
|
|
|
@ -1,84 +0,0 @@
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
|
||||||
import { checkoutURL } from 'web/lib/service/stripe'
|
|
||||||
import { FundsSelector } from './yes-no-selector'
|
|
||||||
|
|
||||||
export function AddFundsButton(props: { className?: string }) {
|
|
||||||
const { className } = props
|
|
||||||
const user = useUser()
|
|
||||||
|
|
||||||
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
|
|
||||||
2500
|
|
||||||
)
|
|
||||||
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<label
|
|
||||||
htmlFor="add-funds"
|
|
||||||
className={clsx(
|
|
||||||
'btn btn-xs btn-outline modal-button font-normal normal-case',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Get M$
|
|
||||||
</label>
|
|
||||||
<input type="checkbox" id="add-funds" className="modal-toggle" />
|
|
||||||
|
|
||||||
<div className="modal">
|
|
||||||
<div className="modal-box">
|
|
||||||
<div className="mb-6 text-xl">Get Manifold Dollars</div>
|
|
||||||
|
|
||||||
<div className="mb-6 text-gray-500">
|
|
||||||
Use Manifold Dollars to trade in your favorite markets. <br /> (Not
|
|
||||||
redeemable for cash.)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-2 text-sm text-gray-500">Amount</div>
|
|
||||||
<FundsSelector
|
|
||||||
selected={amountSelected}
|
|
||||||
onSelect={setAmountSelected}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="mb-1 text-sm text-gray-500">Price USD</div>
|
|
||||||
<div className="text-xl">
|
|
||||||
${Math.round(amountSelected / 100)}.00
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-action">
|
|
||||||
<label htmlFor="add-funds" className={clsx('btn btn-ghost')}>
|
|
||||||
Back
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<form
|
|
||||||
action={checkoutURL(user?.id || '', amountSelected, location)}
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
|
|
||||||
>
|
|
||||||
Checkout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// needed in next js
|
|
||||||
// window not loaded at runtime
|
|
||||||
const useLocation = () => {
|
|
||||||
const [href, setHref] = useState('')
|
|
||||||
useEffect(() => {
|
|
||||||
setHref(window.location.href)
|
|
||||||
}, [])
|
|
||||||
return href
|
|
||||||
}
|
|
58
web/components/add-funds-modal.tsx
Normal file
58
web/components/add-funds-modal.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { manaToUSD } from 'common/util/format'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { checkoutURL } from 'web/lib/service/stripe'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { Modal } from './layout/modal'
|
||||||
|
import { FundsSelector } from './yes-no-selector'
|
||||||
|
|
||||||
|
export function AddFundsModal(props: {
|
||||||
|
open: boolean
|
||||||
|
setOpen(open: boolean): void
|
||||||
|
}) {
|
||||||
|
const { open, setOpen } = props
|
||||||
|
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>(
|
||||||
|
2500
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} setOpen={setOpen} className="rounded-md bg-white p-8">
|
||||||
|
<div className="mb-6 text-xl text-indigo-700">Get Mana</div>
|
||||||
|
|
||||||
|
<div className="mb-6 text-gray-700">
|
||||||
|
Buy mana (M$) to trade in your favorite markets. <br /> (Not redeemable
|
||||||
|
for cash.)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 text-sm text-gray-500">Amount</div>
|
||||||
|
<FundsSelector selected={amountSelected} onSelect={setAmountSelected} />
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="mb-1 text-sm text-gray-500">Price USD</div>
|
||||||
|
<div className="text-xl">{manaToUSD(amountSelected)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action">
|
||||||
|
<Button color="gray-white" onClick={() => setOpen(false)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={checkoutURL(
|
||||||
|
user?.id || '',
|
||||||
|
amountSelected,
|
||||||
|
window.location.href
|
||||||
|
)}
|
||||||
|
method="POST"
|
||||||
|
>
|
||||||
|
<Button type="submit" color="gradient">
|
||||||
|
Checkout
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { SiteLink } from './site-link'
|
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { Row } from './layout/row'
|
||||||
|
import { AddFundsModal } from './add-funds-modal'
|
||||||
|
|
||||||
export function AmountInput(props: {
|
export function AmountInput(props: {
|
||||||
amount: number | undefined
|
amount: number | undefined
|
||||||
|
@ -34,46 +34,58 @@ export function AmountInput(props: {
|
||||||
const isInvalid = !str || isNaN(amount)
|
const isInvalid = !str || isNaN(amount)
|
||||||
onChange(isInvalid ? undefined : amount)
|
onChange(isInvalid ? undefined : amount)
|
||||||
}
|
}
|
||||||
const { width } = useWindowSize()
|
|
||||||
const isMobile = (width ?? 0) < 768
|
|
||||||
return (
|
|
||||||
<Col className={className}>
|
|
||||||
<label className="input-group mb-4">
|
|
||||||
<span className="bg-gray-200 text-sm">{label}</span>
|
|
||||||
<input
|
|
||||||
className={clsx(
|
|
||||||
'input input-bordered max-w-[200px] text-lg placeholder:text-gray-400',
|
|
||||||
error && 'input-error',
|
|
||||||
inputClassName
|
|
||||||
)}
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="0"
|
|
||||||
maxLength={6}
|
|
||||||
autoFocus={!isMobile}
|
|
||||||
value={amount ?? ''}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onAmountChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
|
||||||
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
|
||||||
{error === 'Insufficient balance' ? (
|
return (
|
||||||
<>
|
<>
|
||||||
Not enough funds.
|
<Col className={className}>
|
||||||
<span className="ml-1 text-indigo-500">
|
<label className="font-sm md:font-lg relative">
|
||||||
<SiteLink href="/add-funds">Buy more?</SiteLink>
|
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
|
||||||
</span>
|
{label}
|
||||||
</>
|
</span>
|
||||||
) : (
|
<input
|
||||||
error
|
className={clsx(
|
||||||
)}
|
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
|
||||||
</div>
|
error && 'input-error',
|
||||||
)}
|
'w-24 md:w-auto',
|
||||||
</Col>
|
inputClassName
|
||||||
|
)}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="0"
|
||||||
|
maxLength={6}
|
||||||
|
value={amount ?? ''}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onAmountChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute mt-11 whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
|
||||||
|
{error === 'Insufficient balance' ? (
|
||||||
|
<>
|
||||||
|
Not enough funds.
|
||||||
|
<button
|
||||||
|
className="ml-1 text-indigo-500 hover:underline hover:decoration-indigo-400"
|
||||||
|
onClick={() => setAddFundsModalOpen(true)}
|
||||||
|
>
|
||||||
|
Buy more?
|
||||||
|
</button>
|
||||||
|
<AddFundsModal
|
||||||
|
open={addFundsModalOpen}
|
||||||
|
setOpen={setAddFundsModalOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
error
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,27 +148,29 @@ export function BuyAmountInput(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AmountInput
|
<Row className="gap-4">
|
||||||
amount={amount}
|
<AmountInput
|
||||||
onChange={onAmountChange}
|
amount={amount}
|
||||||
label={ENV_CONFIG.moneyMoniker}
|
onChange={onAmountChange}
|
||||||
error={error}
|
label={ENV_CONFIG.moneyMoniker}
|
||||||
disabled={disabled}
|
error={error}
|
||||||
className={className}
|
disabled={disabled}
|
||||||
inputClassName={inputClassName}
|
className={className}
|
||||||
inputRef={inputRef}
|
inputClassName={inputClassName}
|
||||||
/>
|
inputRef={inputRef}
|
||||||
{showSlider && (
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="205"
|
|
||||||
value={getRaw(amount ?? 0)}
|
|
||||||
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
|
|
||||||
className="range range-lg only-thumb z-40 mb-2 xl:hidden"
|
|
||||||
step="5"
|
|
||||||
/>
|
/>
|
||||||
)}
|
{showSlider && (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="205"
|
||||||
|
value={getRaw(amount ?? 0)}
|
||||||
|
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
|
||||||
|
className="range range-lg only-thumb my-auto align-middle xl:hidden"
|
||||||
|
step="5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,134 +0,0 @@
|
||||||
import { Point, ResponsiveLine } from '@nivo/line'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { zip } from 'lodash'
|
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
import { Col } from '../layout/col'
|
|
||||||
|
|
||||||
export function DailyCountChart(props: {
|
|
||||||
startDate: number
|
|
||||||
dailyCounts: number[]
|
|
||||||
small?: boolean
|
|
||||||
}) {
|
|
||||||
const { dailyCounts, startDate, small } = props
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
|
|
||||||
const dates = dailyCounts.map((_, i) =>
|
|
||||||
dayjs(startDate).add(i, 'day').toDate()
|
|
||||||
)
|
|
||||||
|
|
||||||
const points = zip(dates, dailyCounts).map(([date, betCount]) => ({
|
|
||||||
x: date,
|
|
||||||
y: betCount,
|
|
||||||
}))
|
|
||||||
const data = [{ id: 'Count', data: points, color: '#11b981' }]
|
|
||||||
|
|
||||||
const bottomAxisTicks = width && width < 600 ? 6 : undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'h-[250px] w-full overflow-hidden',
|
|
||||||
!small && 'md:h-[400px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ResponsiveLine
|
|
||||||
data={data}
|
|
||||||
yScale={{ type: 'linear', stacked: false }}
|
|
||||||
xScale={{
|
|
||||||
type: 'time',
|
|
||||||
}}
|
|
||||||
axisBottom={{
|
|
||||||
tickValues: bottomAxisTicks,
|
|
||||||
format: (date) => dayjs(date).format('MMM DD'),
|
|
||||||
}}
|
|
||||||
colors={{ datum: 'color' }}
|
|
||||||
pointSize={0}
|
|
||||||
pointBorderWidth={1}
|
|
||||||
pointBorderColor="#fff"
|
|
||||||
enableSlices="x"
|
|
||||||
enableGridX={!!width && width >= 800}
|
|
||||||
enableArea
|
|
||||||
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
|
|
||||||
sliceTooltip={({ slice }) => {
|
|
||||||
const point = slice.points[0]
|
|
||||||
return <Tooltip point={point} />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DailyPercentChart(props: {
|
|
||||||
startDate: number
|
|
||||||
dailyPercent: number[]
|
|
||||||
small?: boolean
|
|
||||||
}) {
|
|
||||||
const { dailyPercent, startDate, small } = props
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
|
|
||||||
const dates = dailyPercent.map((_, i) =>
|
|
||||||
dayjs(startDate).add(i, 'day').toDate()
|
|
||||||
)
|
|
||||||
|
|
||||||
const points = zip(dates, dailyPercent).map(([date, betCount]) => ({
|
|
||||||
x: date,
|
|
||||||
y: betCount,
|
|
||||||
}))
|
|
||||||
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
|
|
||||||
|
|
||||||
const bottomAxisTicks = width && width < 600 ? 6 : undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'h-[250px] w-full overflow-hidden',
|
|
||||||
!small && 'md:h-[400px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ResponsiveLine
|
|
||||||
data={data}
|
|
||||||
yScale={{ type: 'linear', stacked: false }}
|
|
||||||
xScale={{
|
|
||||||
type: 'time',
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
format: (value) => `${value}%`,
|
|
||||||
}}
|
|
||||||
axisBottom={{
|
|
||||||
tickValues: bottomAxisTicks,
|
|
||||||
format: (date) => dayjs(date).format('MMM DD'),
|
|
||||||
}}
|
|
||||||
colors={{ datum: 'color' }}
|
|
||||||
pointSize={0}
|
|
||||||
pointBorderWidth={1}
|
|
||||||
pointBorderColor="#fff"
|
|
||||||
enableSlices="x"
|
|
||||||
enableGridX={!!width && width >= 800}
|
|
||||||
enableArea
|
|
||||||
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
|
|
||||||
sliceTooltip={({ slice }) => {
|
|
||||||
const point = slice.points[0]
|
|
||||||
return <Tooltip point={point} />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tooltip(props: { point: Point }) {
|
|
||||||
const { point } = props
|
|
||||||
return (
|
|
||||||
<Col className="border border-gray-300 bg-white py-2 px-3">
|
|
||||||
<div
|
|
||||||
className="pb-1"
|
|
||||||
style={{
|
|
||||||
color: point.serieColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{point.serieId}</strong> {point.data.yFormatted}
|
|
||||||
</div>
|
|
||||||
<div>{dayjs(point.data.x).format('MMM DD')}</div>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -182,18 +182,17 @@ export function AnswerBetPanel(props: {
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<WarningConfirmationButton
|
<WarningConfirmationButton
|
||||||
|
size="xl"
|
||||||
|
marketType="freeResponse"
|
||||||
|
amount={betAmount}
|
||||||
warning={warning}
|
warning={warning}
|
||||||
onSubmit={submitBet}
|
onSubmit={submitBet}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
disabled={!!betDisabled}
|
disabled={!!betDisabled}
|
||||||
openModalButtonClass={clsx(
|
color={'indigo'}
|
||||||
'btn self-stretch',
|
actionLabel="Buy"
|
||||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
|
||||||
isSubmitting ? 'loading' : ''
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BetSignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sum } from 'lodash'
|
import { sum } from 'lodash'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
|
@ -9,6 +9,7 @@ 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'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
import { BETTOR, PAST_BETS } from 'common/user'
|
||||||
|
|
||||||
export function AnswerResolvePanel(props: {
|
export function AnswerResolvePanel(props: {
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
@ -32,6 +33,18 @@ export function AnswerResolvePanel(props: {
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | undefined>(undefined)
|
const [error, setError] = useState<string | undefined>(undefined)
|
||||||
|
const [warning, setWarning] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolveOption === 'CANCEL') {
|
||||||
|
setWarning(
|
||||||
|
`All ${PAST_BETS} will be returned. Unique ${BETTOR} bonuses will be
|
||||||
|
withdrawn from your account.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setWarning(undefined)
|
||||||
|
}
|
||||||
|
}, [resolveOption])
|
||||||
|
|
||||||
const onResolve = async () => {
|
const onResolve = async () => {
|
||||||
if (resolveOption === 'CHOOSE' && answers.length !== 1) return
|
if (resolveOption === 'CHOOSE' && answers.length !== 1) return
|
||||||
|
@ -72,17 +85,6 @@ export function AnswerResolvePanel(props: {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolutionButtonClass =
|
|
||||||
resolveOption === 'CANCEL'
|
|
||||||
? 'bg-yellow-400 hover:bg-yellow-500'
|
|
||||||
: resolveOption === 'CHOOSE' && answers.length
|
|
||||||
? 'btn-primary'
|
|
||||||
: resolveOption === 'CHOOSE_MULTIPLE' &&
|
|
||||||
answers.length > 1 &&
|
|
||||||
answers.every((answer) => chosenAnswers[answer] > 0)
|
|
||||||
? 'bg-blue-400 hover:bg-blue-500'
|
|
||||||
: 'btn-disabled'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-4 rounded">
|
<Col className="gap-4 rounded">
|
||||||
<Row className="justify-between">
|
<Row className="justify-between">
|
||||||
|
@ -116,16 +118,34 @@ export function AnswerResolvePanel(props: {
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ResolveConfirmationButton
|
<ResolveConfirmationButton
|
||||||
|
color={
|
||||||
|
resolveOption === 'CANCEL'
|
||||||
|
? 'yellow'
|
||||||
|
: resolveOption === 'CHOOSE' && answers.length
|
||||||
|
? 'green'
|
||||||
|
: resolveOption === 'CHOOSE_MULTIPLE' &&
|
||||||
|
answers.length > 1 &&
|
||||||
|
answers.every((answer) => chosenAnswers[answer] > 0)
|
||||||
|
? 'blue'
|
||||||
|
: 'indigo'
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!resolveOption ||
|
||||||
|
(resolveOption === 'CHOOSE' && !answers.length) ||
|
||||||
|
(resolveOption === 'CHOOSE_MULTIPLE' &&
|
||||||
|
(!(answers.length > 1) ||
|
||||||
|
!answers.every((answer) => chosenAnswers[answer] > 0)))
|
||||||
|
}
|
||||||
onResolve={onResolve}
|
onResolve={onResolve}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
openModalButtonClass={resolutionButtonClass}
|
|
||||||
submitButtonClass={resolutionButtonClass}
|
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{!!error && <div className="text-red-500">{error}</div>}
|
{!!error && <div className="text-red-500">{error}</div>}
|
||||||
|
{!!warning && <div className="text-warning">{warning}</div>}
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,238 +0,0 @@
|
||||||
import { DatumValue } from '@nivo/core'
|
|
||||||
import { ResponsiveLine } from '@nivo/line'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { groupBy, sortBy, sumBy } from 'lodash'
|
|
||||||
import { memo } from 'react'
|
|
||||||
|
|
||||||
import { Bet } from 'common/bet'
|
|
||||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
|
||||||
import { getOutcomeProbability } from 'common/calculate'
|
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
|
|
||||||
const NUM_LINES = 6
|
|
||||||
|
|
||||||
export const AnswersGraph = memo(function AnswersGraph(props: {
|
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
|
||||||
bets: Bet[]
|
|
||||||
height?: number
|
|
||||||
}) {
|
|
||||||
const { contract, bets, height } = props
|
|
||||||
const { createdTime, resolutionTime, closeTime, answers } = contract
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
|
|
||||||
bets,
|
|
||||||
contract
|
|
||||||
)
|
|
||||||
|
|
||||||
const isClosed = !!closeTime && now > closeTime
|
|
||||||
const latestTime = dayjs(
|
|
||||||
resolutionTime && isClosed
|
|
||||||
? Math.min(resolutionTime, closeTime)
|
|
||||||
: isClosed
|
|
||||||
? closeTime
|
|
||||||
: resolutionTime ?? now
|
|
||||||
)
|
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
|
|
||||||
const isLargeWidth = !width || width > 800
|
|
||||||
const labelLength = isLargeWidth ? 50 : 20
|
|
||||||
|
|
||||||
// Add a fake datapoint so the line continues to the right
|
|
||||||
const endTime = latestTime.valueOf()
|
|
||||||
|
|
||||||
const times = sortBy([
|
|
||||||
createdTime,
|
|
||||||
...bets.map((bet) => bet.createdTime),
|
|
||||||
endTime,
|
|
||||||
])
|
|
||||||
const dateTimes = times.map((time) => new Date(time))
|
|
||||||
|
|
||||||
const data = sortedOutcomes.map((outcome) => {
|
|
||||||
const betProbs = probsByOutcome[outcome]
|
|
||||||
// Add extra point for contract start and end.
|
|
||||||
const probs = [0, ...betProbs, betProbs[betProbs.length - 1]]
|
|
||||||
|
|
||||||
const points = probs.map((prob, i) => ({
|
|
||||||
x: dateTimes[i],
|
|
||||||
y: Math.round(prob * 100),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const answer =
|
|
||||||
answers?.find((answer) => answer.id === outcome)?.text ?? 'None'
|
|
||||||
const answerText =
|
|
||||||
answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '')
|
|
||||||
|
|
||||||
return { id: answerText, data: points }
|
|
||||||
})
|
|
||||||
|
|
||||||
data.reverse()
|
|
||||||
|
|
||||||
const yTickValues = [0, 25, 50, 75, 100]
|
|
||||||
|
|
||||||
const numXTickValues = isLargeWidth ? 5 : 2
|
|
||||||
const startDate = dayjs(contract.createdTime)
|
|
||||||
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
|
|
||||||
? latestTime.add(1, 'hours')
|
|
||||||
: latestTime
|
|
||||||
const includeMinute = endDate.diff(startDate, 'hours') < 2
|
|
||||||
|
|
||||||
const multiYear = !startDate.isSame(latestTime, 'year')
|
|
||||||
const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full"
|
|
||||||
style={{ height: height ?? (isLargeWidth ? 350 : 250) }}
|
|
||||||
>
|
|
||||||
<ResponsiveLine
|
|
||||||
data={data}
|
|
||||||
yScale={{ min: 0, max: 100, type: 'linear', stacked: true }}
|
|
||||||
yFormat={formatPercent}
|
|
||||||
gridYValues={yTickValues}
|
|
||||||
axisLeft={{
|
|
||||||
tickValues: yTickValues,
|
|
||||||
format: formatPercent,
|
|
||||||
}}
|
|
||||||
xScale={{
|
|
||||||
type: 'time',
|
|
||||||
min: startDate.toDate(),
|
|
||||||
max: endDate.toDate(),
|
|
||||||
}}
|
|
||||||
xFormat={(d) =>
|
|
||||||
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
|
||||||
}
|
|
||||||
axisBottom={{
|
|
||||||
tickValues: numXTickValues,
|
|
||||||
format: (time) =>
|
|
||||||
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
|
|
||||||
}}
|
|
||||||
colors={[
|
|
||||||
'#fca5a5', // red-300
|
|
||||||
'#a5b4fc', // indigo-300
|
|
||||||
'#86efac', // green-300
|
|
||||||
'#fef08a', // yellow-200
|
|
||||||
'#fdba74', // orange-300
|
|
||||||
'#c084fc', // purple-400
|
|
||||||
]}
|
|
||||||
pointSize={0}
|
|
||||||
curve="stepAfter"
|
|
||||||
enableSlices="x"
|
|
||||||
enableGridX={!!width && width >= 800}
|
|
||||||
enableArea
|
|
||||||
areaOpacity={1}
|
|
||||||
margin={{ top: 20, right: 20, bottom: 25, left: 40 }}
|
|
||||||
legends={[
|
|
||||||
{
|
|
||||||
anchor: 'top-left',
|
|
||||||
direction: 'column',
|
|
||||||
justify: false,
|
|
||||||
translateX: isLargeWidth ? 5 : 2,
|
|
||||||
translateY: 0,
|
|
||||||
itemsSpacing: 0,
|
|
||||||
itemTextColor: 'black',
|
|
||||||
itemDirection: 'left-to-right',
|
|
||||||
itemWidth: isLargeWidth ? 288 : 138,
|
|
||||||
itemHeight: 20,
|
|
||||||
itemBackground: 'white',
|
|
||||||
itemOpacity: 0.9,
|
|
||||||
symbolSize: 12,
|
|
||||||
effects: [
|
|
||||||
{
|
|
||||||
on: 'hover',
|
|
||||||
style: {
|
|
||||||
itemBackground: 'rgba(255, 255, 255, 1)',
|
|
||||||
itemOpacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatPercent(y: DatumValue) {
|
|
||||||
return `${Math.round(+y.toString())}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(
|
|
||||||
now: number,
|
|
||||||
time: number,
|
|
||||||
includeYear: boolean,
|
|
||||||
includeHour: boolean,
|
|
||||||
includeMinute: boolean
|
|
||||||
) {
|
|
||||||
const d = dayjs(time)
|
|
||||||
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
|
|
||||||
return 'Now'
|
|
||||||
|
|
||||||
let format: string
|
|
||||||
if (d.isSame(now, 'day')) {
|
|
||||||
format = '[Today]'
|
|
||||||
} else if (d.add(1, 'day').isSame(now, 'day')) {
|
|
||||||
format = '[Yesterday]'
|
|
||||||
} else {
|
|
||||||
format = 'MMM D'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeMinute) {
|
|
||||||
format += ', h:mma'
|
|
||||||
} else if (includeHour) {
|
|
||||||
format += ', ha'
|
|
||||||
} else if (includeYear) {
|
|
||||||
format += ', YYYY'
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.format(format)
|
|
||||||
}
|
|
||||||
|
|
||||||
const computeProbsByOutcome = (
|
|
||||||
bets: Bet[],
|
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
|
||||||
) => {
|
|
||||||
const { totalBets, outcomeType } = contract
|
|
||||||
|
|
||||||
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
|
|
||||||
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
|
|
||||||
const maxProb = Math.max(
|
|
||||||
...betsByOutcome[outcome].map((bet) => bet.probAfter)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
|
||||||
maxProb > 0.02 &&
|
|
||||||
totalBets[outcome] > 0.000000001
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const trackedOutcomes = sortBy(
|
|
||||||
outcomes,
|
|
||||||
(outcome) => -1 * getOutcomeProbability(contract, outcome)
|
|
||||||
).slice(0, NUM_LINES)
|
|
||||||
|
|
||||||
const probsByOutcome = Object.fromEntries(
|
|
||||||
trackedOutcomes.map((outcome) => [outcome, [] as number[]])
|
|
||||||
)
|
|
||||||
const sharesByOutcome = Object.fromEntries(
|
|
||||||
Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const bet of bets) {
|
|
||||||
const { outcome, shares } = bet
|
|
||||||
sharesByOutcome[outcome] += shares
|
|
||||||
|
|
||||||
const sharesSquared = sumBy(
|
|
||||||
Object.values(sharesByOutcome).map((shares) => shares ** 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const outcome of trackedOutcomes) {
|
|
||||||
probsByOutcome[outcome].push(
|
|
||||||
sharesByOutcome[outcome] ** 2 / sharesSquared
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { probsByOutcome, sortedOutcomes: trackedOutcomes }
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { sortBy, partition, sum, uniq } from 'lodash'
|
import { sortBy, partition, sum } from 'lodash'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||||
|
@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
|
||||||
import { CreateAnswerPanel } from './create-answer-panel'
|
import { CreateAnswerPanel } from './create-answer-panel'
|
||||||
import { AnswerResolvePanel } from './answer-resolve-panel'
|
import { AnswerResolvePanel } from './answer-resolve-panel'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { User } from 'common/user'
|
|
||||||
import { getOutcomeProbability } from 'common/calculate'
|
import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
@ -21,11 +20,11 @@ import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
|
||||||
import { UserLink } from 'web/components/user-link'
|
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
|
||||||
|
import { CATEGORY_COLORS } from '../charts/contract/choice'
|
||||||
|
import { useChartAnswers } from '../charts/contract/choice'
|
||||||
|
|
||||||
export function AnswersPanel(props: {
|
export function AnswersPanel(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
@ -39,6 +38,7 @@ export function AnswersPanel(props: {
|
||||||
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
|
||||||
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
||||||
|
|
||||||
const [winningAnswers, losingAnswers] = partition(
|
const [winningAnswers, losingAnswers] = partition(
|
||||||
|
@ -56,6 +56,11 @@ export function AnswersPanel(props: {
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const answerItems = sortBy(
|
||||||
|
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
|
||||||
|
(answer) => -getOutcomeProbability(contract, answer.id)
|
||||||
|
)
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
|
||||||
const [resolveOption, setResolveOption] = useState<
|
const [resolveOption, setResolveOption] = useState<
|
||||||
|
@ -67,12 +72,6 @@ export function AnswersPanel(props: {
|
||||||
|
|
||||||
const chosenTotal = sum(Object.values(chosenAnswers))
|
const chosenTotal = sum(Object.values(chosenAnswers))
|
||||||
|
|
||||||
const answerItems = getAnswerItems(
|
|
||||||
contract,
|
|
||||||
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
|
|
||||||
const onChoose = (answerId: string, prob: number) => {
|
const onChoose = (answerId: string, prob: number) => {
|
||||||
if (resolveOption === 'CHOOSE') {
|
if (resolveOption === 'CHOOSE') {
|
||||||
setChosenAnswers({ [answerId]: prob })
|
setChosenAnswers({ [answerId]: prob })
|
||||||
|
@ -106,6 +105,10 @@ export function AnswersPanel(props: {
|
||||||
? 'checkbox'
|
? 'checkbox'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const colorSortedAnswer = useChartAnswers(contract).map(
|
||||||
|
(value, _index) => value.text
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className="gap-3">
|
<Col className="gap-3">
|
||||||
{(resolveOption || resolution) &&
|
{(resolveOption || resolution) &&
|
||||||
|
@ -123,28 +126,31 @@ export function AnswersPanel(props: {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!resolveOption && (
|
{!resolveOption && (
|
||||||
<div className={clsx('flow-root pr-2 md:pr-0')}>
|
<Col
|
||||||
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
|
className={clsx(
|
||||||
{answerItems.map((item) => (
|
'gap-2 pr-2 md:pr-0',
|
||||||
<div key={item.id} className={'relative pb-2'}>
|
tradingAllowed(contract) ? '' : '-mb-6'
|
||||||
<div className="relative flex items-start space-x-3">
|
)}
|
||||||
<OpenAnswer {...item} />
|
>
|
||||||
</div>
|
{answerItems.map((item) => (
|
||||||
</div>
|
<OpenAnswer
|
||||||
))}
|
key={item.id}
|
||||||
<Row className={'justify-end'}>
|
answer={item}
|
||||||
{hasZeroBetAnswers && !showAllAnswers && (
|
contract={contract}
|
||||||
<Button
|
colorIndex={colorSortedAnswer.indexOf(item.text)}
|
||||||
color={'gray-white'}
|
/>
|
||||||
onClick={() => setShowAllAnswers(true)}
|
))}
|
||||||
size={'md'}
|
{hasZeroBetAnswers && !showAllAnswers && (
|
||||||
>
|
<Button
|
||||||
Show More
|
className="self-end"
|
||||||
</Button>
|
color="gray-white"
|
||||||
)}
|
onClick={() => setShowAllAnswers(true)}
|
||||||
</Row>
|
size="md"
|
||||||
</div>
|
>
|
||||||
</div>
|
Show More
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{answers.length <= 1 && (
|
{answers.length <= 1 && (
|
||||||
|
@ -175,44 +181,21 @@ export function AnswersPanel(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAnswerItems(
|
|
||||||
contract: FreeResponseContract | MultipleChoiceContract,
|
|
||||||
answers: Answer[],
|
|
||||||
user: User | undefined | null
|
|
||||||
) {
|
|
||||||
let outcomes = uniq(answers.map((answer) => answer.number.toString()))
|
|
||||||
outcomes = sortBy(outcomes, (outcome) =>
|
|
||||||
getOutcomeProbability(contract, outcome)
|
|
||||||
).reverse()
|
|
||||||
|
|
||||||
return outcomes
|
|
||||||
.map((outcome) => {
|
|
||||||
const answer = answers.find((answer) => answer.id === outcome) as Answer
|
|
||||||
//unnecessary
|
|
||||||
return {
|
|
||||||
id: outcome,
|
|
||||||
type: 'answer' as const,
|
|
||||||
contract,
|
|
||||||
answer,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((group) => group.answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OpenAnswer(props: {
|
function OpenAnswer(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
answer: Answer
|
answer: Answer
|
||||||
type: string
|
colorIndex: number | undefined
|
||||||
}) {
|
}) {
|
||||||
const { answer, contract } = props
|
const { answer, contract, colorIndex } = props
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, text } = answer
|
||||||
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
|
||||||
const probPercent = formatPercent(prob)
|
const probPercent = formatPercent(prob)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const color =
|
||||||
|
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
|
<Col className="my-1 px-2">
|
||||||
<Modal open={open} setOpen={setOpen} position="center">
|
<Modal open={open} setOpen={setOpen} position="center">
|
||||||
<AnswerBetPanel
|
<AnswerBetPanel
|
||||||
answer={answer}
|
answer={answer}
|
||||||
|
@ -223,47 +206,44 @@ function OpenAnswer(props: {
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<div
|
<Col
|
||||||
className="pointer-events-none absolute -mx-2 h-full rounded-tl-md bg-green-600 bg-opacity-10"
|
className={clsx(
|
||||||
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
|
'bg-greyscale-1 relative w-full rounded-lg transition-all',
|
||||||
/>
|
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
|
||||||
|
)}
|
||||||
<Row className="my-4 gap-3">
|
>
|
||||||
<div className="px-1">
|
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
|
||||||
<Avatar username={username} avatarUrl={avatarUrl} />
|
<Row>
|
||||||
</div>
|
<Avatar
|
||||||
<Col className="min-w-0 flex-1 lg:gap-1">
|
className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
|
||||||
<div className="text-sm text-gray-500">
|
username={username}
|
||||||
<UserLink username={username} name={name} /> answered
|
avatarUrl={avatarUrl}
|
||||||
</div>
|
/>
|
||||||
|
<Linkify
|
||||||
<Col className="align-items justify-between gap-4 sm:flex-row">
|
className="text-md cursor-pointer whitespace-pre-line"
|
||||||
<span className="whitespace-pre-line text-lg">
|
text={text}
|
||||||
<Linkify text={text} />
|
/>
|
||||||
</span>
|
</Row>
|
||||||
|
<Row className="gap-2">
|
||||||
<Row className="items-center justify-center gap-4">
|
<div className="my-auto text-xl">{probPercent}</div>
|
||||||
<div className={'align-items flex w-full justify-end gap-4 '}>
|
{tradingAllowed(contract) && (
|
||||||
<span
|
<Button
|
||||||
className={clsx(
|
size="2xs"
|
||||||
'text-2xl',
|
color="gray-outline"
|
||||||
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
|
onClick={() => setOpen(true)}
|
||||||
)}
|
className="my-auto"
|
||||||
>
|
>
|
||||||
{probPercent}
|
BUY
|
||||||
</span>
|
</Button>
|
||||||
<BuyButton
|
)}
|
||||||
className={clsx(
|
</Row>
|
||||||
'btn-sm flex-initial !px-6 sm:flex',
|
</Row>
|
||||||
tradingAllowed(contract) ? '' : '!hidden'
|
<hr
|
||||||
)}
|
color={color}
|
||||||
onClick={() => setOpen(true)}
|
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
|
||||||
/>
|
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
|
||||||
</div>
|
/>
|
||||||
</Row>
|
</Col>
|
||||||
</Col>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,27 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
|
||||||
import { MenuIcon } from '@heroicons/react/solid'
|
import { MenuIcon } from '@heroicons/react/solid'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Subtitle } from 'web/components/subtitle'
|
import { Subtitle } from 'web/components/subtitle'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { keyBy } from 'lodash'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { XCircleIcon } from '@heroicons/react/outline'
|
||||||
import { isArray, keyBy } from 'lodash'
|
import { Button } from './button'
|
||||||
|
import { updateUser } from 'web/lib/firebase/users'
|
||||||
|
import { leaveGroup } from 'web/lib/firebase/groups'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
export function ArrangeHome(props: {
|
export function ArrangeHome(props: {
|
||||||
user: User | null | undefined
|
sections: { label: string; id: string; group?: Group }[]
|
||||||
homeSections: string[]
|
setSectionIds: (sections: string[]) => void
|
||||||
setHomeSections: (sections: string[]) => void
|
|
||||||
}) {
|
}) {
|
||||||
const { user, homeSections, setHomeSections } = props
|
const { sections, setSectionIds } = props
|
||||||
|
|
||||||
const groups = useMemberGroups(user?.id) ?? []
|
const sectionsById = keyBy(sections, 'id')
|
||||||
const { itemsById, sections } = getHomeItems(groups, homeSections)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext
|
<DragDropContext
|
||||||
|
@ -27,14 +29,14 @@ export function ArrangeHome(props: {
|
||||||
const { destination, source, draggableId } = e
|
const { destination, source, draggableId } = e
|
||||||
if (!destination) return
|
if (!destination) return
|
||||||
|
|
||||||
const item = itemsById[draggableId]
|
const section = sectionsById[draggableId]
|
||||||
|
|
||||||
const newHomeSections = sections.map((section) => section.id)
|
const newSectionIds = sections.map((section) => section.id)
|
||||||
|
|
||||||
newHomeSections.splice(source.index, 1)
|
newSectionIds.splice(source.index, 1)
|
||||||
newHomeSections.splice(destination.index, 0, item.id)
|
newSectionIds.splice(destination.index, 0, section.id)
|
||||||
|
|
||||||
setHomeSections(newHomeSections)
|
setSectionIds(newSectionIds)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Row className="relative max-w-md gap-4">
|
<Row className="relative max-w-md gap-4">
|
||||||
|
@ -46,8 +48,9 @@ export function ArrangeHome(props: {
|
||||||
|
|
||||||
function DraggableList(props: {
|
function DraggableList(props: {
|
||||||
title: string
|
title: string
|
||||||
items: { id: string; label: string }[]
|
items: { id: string; label: string; group?: Group }[]
|
||||||
}) {
|
}) {
|
||||||
|
const user = useUser()
|
||||||
const { title, items } = props
|
const { title, items } = props
|
||||||
return (
|
return (
|
||||||
<Droppable droppableId={title.toLowerCase()}>
|
<Droppable droppableId={title.toLowerCase()}>
|
||||||
|
@ -72,6 +75,7 @@ function DraggableList(props: {
|
||||||
snapshot.isDragging && 'z-[9000] bg-gray-200'
|
snapshot.isDragging && 'z-[9000] bg-gray-200'
|
||||||
)}
|
)}
|
||||||
item={item}
|
item={item}
|
||||||
|
user={user}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -85,49 +89,53 @@ function DraggableList(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionItem = (props: {
|
const SectionItem = (props: {
|
||||||
item: { id: string; label: string }
|
item: { id: string; label: string; group?: Group }
|
||||||
|
user: User | null | undefined
|
||||||
className?: string
|
className?: string
|
||||||
}) => {
|
}) => {
|
||||||
const { item, className } = props
|
const { item, user, className } = props
|
||||||
|
const { group } = item
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Row
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
className,
|
||||||
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
|
'items-center justify-between gap-4 rounded bg-gray-50 p-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MenuIcon
|
<Row className="items-center gap-4">
|
||||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
<MenuIcon
|
||||||
aria-hidden="true"
|
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||||
/>{' '}
|
aria-hidden="true"
|
||||||
{item.label}
|
/>{' '}
|
||||||
</div>
|
{item.label}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{group && (
|
||||||
|
<Button
|
||||||
|
className="pt-1 pb-1"
|
||||||
|
color="gray-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (user) {
|
||||||
|
const homeSections = (user.homeSections ?? []).filter(
|
||||||
|
(id) => id !== group.id
|
||||||
|
)
|
||||||
|
updateUser(user.id, { homeSections })
|
||||||
|
|
||||||
|
toast.promise(leaveGroup(group, user.id), {
|
||||||
|
loading: 'Unfollowing group...',
|
||||||
|
success: `Unfollowed ${group.name}`,
|
||||||
|
error: "Couldn't unfollow group, try again?",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircleIcon
|
||||||
|
className={clsx('h-5 w-5 flex-shrink-0')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getHomeItems = (groups: Group[], sections: string[]) => {
|
|
||||||
// Accommodate old home sections.
|
|
||||||
if (!isArray(sections)) sections = []
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{ label: 'Trending', id: 'score' },
|
|
||||||
{ label: 'New for you', id: 'newest' },
|
|
||||||
{ label: 'Daily movers', id: 'daily-movers' },
|
|
||||||
...groups.map((g) => ({
|
|
||||||
label: g.name,
|
|
||||||
id: g.id,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
const itemsById = keyBy(items, 'id')
|
|
||||||
|
|
||||||
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
|
|
||||||
|
|
||||||
// Add unmentioned items to the end.
|
|
||||||
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
|
|
||||||
|
|
||||||
return {
|
|
||||||
sections: sectionItems,
|
|
||||||
itemsById,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { setCookie } from 'web/lib/util/cookie'
|
||||||
|
|
||||||
// Either we haven't looked up the logged in user yet (undefined), or we know
|
// Either we haven't looked up the logged in user yet (undefined), or we know
|
||||||
// the user is not logged in (null), or we know the user is logged in.
|
// the user is not logged in (null), or we know the user is logged in.
|
||||||
type AuthUser = undefined | null | UserAndPrivateUser
|
export type AuthUser = undefined | null | UserAndPrivateUser
|
||||||
|
|
||||||
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||||
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
||||||
|
@ -68,11 +68,11 @@ export function AuthProvider(props: {
|
||||||
}, [setAuthUser, serverUser])
|
}, [setAuthUser, serverUser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authUser != null) {
|
if (authUser) {
|
||||||
// Persist to local storage, to reduce login blink next time.
|
// Persist to local storage, to reduce login blink next time.
|
||||||
// Note: Cap on localStorage size is ~5mb
|
// Note: Cap on localStorage size is ~5mb
|
||||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
||||||
} else {
|
} else if (authUser === null) {
|
||||||
localStorage.removeItem(CACHED_USER_KEY)
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
}
|
}
|
||||||
}, [authUser])
|
}, [authUser])
|
||||||
|
|
|
@ -8,13 +8,14 @@ export function Avatar(props: {
|
||||||
username?: string
|
username?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
noLink?: boolean
|
noLink?: boolean
|
||||||
size?: number | 'xs' | 'sm'
|
size?: number | 'xxs' | 'xs' | 'sm'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { username, noLink, size, className } = props
|
const { username, noLink, size, className } = props
|
||||||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
const s =
|
||||||
|
size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||||
const sizeInPx = s * 4
|
const sizeInPx = s * 4
|
||||||
|
|
||||||
const onClick =
|
const onClick =
|
||||||
|
@ -40,7 +41,7 @@ export function Avatar(props: {
|
||||||
style={{ maxWidth: `${s * 0.25}rem` }}
|
style={{ maxWidth: `${s * 0.25}rem` }}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
alt={username}
|
alt={`${username ?? 'Unknown user'} avatar`}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
// If the image doesn't load, clear the avatarUrl to show the default
|
// If the image doesn't load, clear the avatarUrl to show the default
|
||||||
// Mostly for localhost, when getting a 403 from googleusercontent
|
// Mostly for localhost, when getting a 403 from googleusercontent
|
||||||
|
|
46
web/components/award-bounty-button.tsx
Normal file
46
web/components/award-bounty-button.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { ContractComment } from 'common/comment'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { awardCommentBounty } from 'web/lib/firebase/api'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { TextButton } from 'web/components/text-button'
|
||||||
|
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
|
export function AwardBountyButton(prop: {
|
||||||
|
comment: ContractComment
|
||||||
|
contract: Contract
|
||||||
|
}) {
|
||||||
|
const { comment, contract } = prop
|
||||||
|
|
||||||
|
const me = useUser()
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const data = {
|
||||||
|
amount: COMMENT_BOUNTY_AMOUNT,
|
||||||
|
commentId: comment.id,
|
||||||
|
contractId: contract.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
awardCommentBounty(data)
|
||||||
|
.then((_) => {
|
||||||
|
console.log('success')
|
||||||
|
track('award comment bounty', data)
|
||||||
|
})
|
||||||
|
.catch((reason) => console.log('Server error:', reason))
|
||||||
|
|
||||||
|
track('award comment bounty', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUp = me && me.id !== comment.userId && contract.creatorId === me.id
|
||||||
|
if (!canUp) return <div />
|
||||||
|
return (
|
||||||
|
<Row className={clsx('-ml-2 items-center gap-0.5', !canUp ? '-ml-6' : '')}>
|
||||||
|
<TextButton className={'font-bold'} onClick={submit}>
|
||||||
|
Award {formatMoney(COMMENT_BOUNTY_AMOUNT)}
|
||||||
|
</TextButton>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,12 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { SimpleBetPanel } from './bet-panel'
|
import { BuyPanel, SimpleBetPanel } from './bet-panel'
|
||||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
import {
|
||||||
|
BinaryContract,
|
||||||
|
CPMMBinaryContract,
|
||||||
|
PseudoNumericContract,
|
||||||
|
} from 'common/contract'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
|
@ -10,7 +14,10 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
import { PRESENT_BET } from 'common/user'
|
import { User } from 'web/lib/firebase/users'
|
||||||
|
import { SellRow } from './sell-row'
|
||||||
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
|
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||||
|
|
||||||
/** Button that opens BetPanel in a new modal */
|
/** Button that opens BetPanel in a new modal */
|
||||||
export default function BetButton(props: {
|
export default function BetButton(props: {
|
||||||
|
@ -42,7 +49,7 @@ export default function BetButton(props: {
|
||||||
)}
|
)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
{PRESENT_BET}
|
Predict
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<BetSignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
|
@ -65,7 +72,6 @@ export default function BetButton(props: {
|
||||||
<SimpleBetPanel
|
<SimpleBetPanel
|
||||||
className={betPanelClassName}
|
className={betPanelClassName}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
selected="YES"
|
|
||||||
onBetSuccess={() => setOpen(false)}
|
onBetSuccess={() => setOpen(false)}
|
||||||
hasShares={hasYesShares || hasNoShares}
|
hasShares={hasYesShares || hasNoShares}
|
||||||
/>
|
/>
|
||||||
|
@ -73,3 +79,49 @@ export default function BetButton(props: {
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BinaryMobileBetting(props: { contract: BinaryContract }) {
|
||||||
|
const { contract } = props
|
||||||
|
const user = useUser()
|
||||||
|
if (user) {
|
||||||
|
return <SignedInBinaryMobileBetting contract={contract} user={user} />
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Col className="w-full">
|
||||||
|
<BetSignUpPrompt className="w-full" />
|
||||||
|
<PlayMoneyDisclaimer />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignedInBinaryMobileBetting(props: {
|
||||||
|
contract: BinaryContract
|
||||||
|
user: User
|
||||||
|
}) {
|
||||||
|
const { contract, user } = props
|
||||||
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Col className="w-full gap-2 px-1">
|
||||||
|
<Col>
|
||||||
|
<BuyPanel
|
||||||
|
hidden={false}
|
||||||
|
contract={contract as CPMMBinaryContract}
|
||||||
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
mobileView={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<SellRow
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
className={
|
||||||
|
'border-greyscale-3 bg-greyscale-1 rounded-md border-2 px-4 py-2'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
NoLabel,
|
NoLabel,
|
||||||
YesLabel,
|
YesLabel,
|
||||||
} from './outcome-label'
|
} from './outcome-label'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getContractBetMetrics, getProbability } from 'common/calculate'
|
||||||
import { useFocus } from 'web/hooks/use-focus'
|
import { useFocus } from 'web/hooks/use-focus'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
|
@ -43,6 +43,10 @@ import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||||
import { isAndroid, isIOS } from 'web/lib/util/device'
|
import { isAndroid, isIOS } from 'web/lib/util/device'
|
||||||
import { WarningConfirmationButton } from './warning-confirmation-button'
|
import { WarningConfirmationButton } from './warning-confirmation-button'
|
||||||
import { MarketIntroPanel } from './market-intro-panel'
|
import { MarketIntroPanel } from './market-intro-panel'
|
||||||
|
import { Modal } from './layout/modal'
|
||||||
|
import { Title } from './title'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { CheckIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
export function BetPanel(props: {
|
export function BetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
|
@ -105,11 +109,10 @@ export function BetPanel(props: {
|
||||||
export function SimpleBetPanel(props: {
|
export function SimpleBetPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
className?: string
|
className?: string
|
||||||
selected?: 'YES' | 'NO'
|
|
||||||
hasShares?: boolean
|
hasShares?: boolean
|
||||||
onBetSuccess?: () => void
|
onBetSuccess?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, className, selected, hasShares, onBetSuccess } = props
|
const { contract, className, hasShares, onBetSuccess } = props
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
const [isLimitOrder, setIsLimitOrder] = useState(false)
|
||||||
|
@ -139,7 +142,6 @@ export function SimpleBetPanel(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
selected={selected}
|
|
||||||
onBuySuccess={onBetSuccess}
|
onBuySuccess={onBetSuccess}
|
||||||
/>
|
/>
|
||||||
<LimitOrderPanel
|
<LimitOrderPanel
|
||||||
|
@ -162,38 +164,47 @@ export function SimpleBetPanel(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BuyPanel(props: {
|
export function BuyPanel(props: {
|
||||||
contract: CPMMBinaryContract | PseudoNumericContract
|
contract: CPMMBinaryContract | PseudoNumericContract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
unfilledBets: Bet[]
|
unfilledBets: Bet[]
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
selected?: 'YES' | 'NO'
|
|
||||||
onBuySuccess?: () => void
|
onBuySuccess?: () => void
|
||||||
|
mobileView?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, unfilledBets, hidden, selected, onBuySuccess } = props
|
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
|
||||||
|
props
|
||||||
|
|
||||||
const initialProb = getProbability(contract)
|
const initialProb = getProbability(contract)
|
||||||
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
|
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
|
||||||
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
|
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
||||||
const [error, setError] = useState<string | undefined>()
|
const [error, setError] = useState<string | undefined>()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
|
||||||
|
|
||||||
const [inputRef, focusAmountInput] = useFocus()
|
const [inputRef, focusAmountInput] = useFocus()
|
||||||
|
|
||||||
function onBetChoice(choice: 'YES' | 'NO') {
|
function onBetChoice(choice: 'YES' | 'NO') {
|
||||||
setOutcome(choice)
|
setOutcome(choice)
|
||||||
setWasSubmitted(false)
|
|
||||||
|
|
||||||
if (!isIOS() && !isAndroid()) {
|
if (!isIOS() && !isAndroid()) {
|
||||||
focusAmountInput()
|
focusAmountInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
|
||||||
|
if (outcome === choice) {
|
||||||
|
setOutcome(undefined)
|
||||||
|
} else {
|
||||||
|
setOutcome(choice)
|
||||||
|
}
|
||||||
|
if (!isIOS() && !isAndroid()) {
|
||||||
|
focusAmountInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onBetChange(newAmount: number | undefined) {
|
function onBetChange(newAmount: number | undefined) {
|
||||||
setWasSubmitted(false)
|
|
||||||
setBetAmount(newAmount)
|
setBetAmount(newAmount)
|
||||||
if (!outcome) {
|
if (!outcome) {
|
||||||
setOutcome('YES')
|
setOutcome('YES')
|
||||||
|
@ -214,9 +225,13 @@ function BuyPanel(props: {
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
console.log('placed bet. Result:', r)
|
console.log('placed bet. Result:', r)
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setWasSubmitted(true)
|
|
||||||
setBetAmount(undefined)
|
setBetAmount(undefined)
|
||||||
if (onBuySuccess) onBuySuccess()
|
if (onBuySuccess) onBuySuccess()
|
||||||
|
else {
|
||||||
|
toast('Trade submitted!', {
|
||||||
|
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e instanceof APIError) {
|
if (e instanceof APIError) {
|
||||||
|
@ -249,6 +264,7 @@ function BuyPanel(props: {
|
||||||
unfilledBets as LimitBet[]
|
unfilledBets as LimitBet[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [seeLimit, setSeeLimit] = useState(false)
|
||||||
const resultProb = getCpmmProbability(newPool, newP)
|
const resultProb = getCpmmProbability(newPool, newP)
|
||||||
const probStayedSame =
|
const probStayedSame =
|
||||||
formatPercent(resultProb) === formatPercent(initialProb)
|
formatPercent(resultProb) === formatPercent(initialProb)
|
||||||
|
@ -281,92 +297,133 @@ function BuyPanel(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={hidden ? 'hidden' : ''}>
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">
|
|
||||||
{isPseudoNumeric ? 'Direction' : 'Outcome'}
|
|
||||||
</div>
|
|
||||||
<YesNoSelector
|
<YesNoSelector
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
btnClassName="flex-1"
|
btnClassName="flex-1"
|
||||||
selected={outcome}
|
selected={outcome}
|
||||||
onSelect={(choice) => onBetChoice(choice)}
|
onSelect={(choice) => {
|
||||||
|
if (mobileView) {
|
||||||
|
mobileOnBetChoice(choice)
|
||||||
|
} else {
|
||||||
|
onBetChoice(choice)
|
||||||
|
}
|
||||||
|
}}
|
||||||
isPseudoNumeric={isPseudoNumeric}
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
<Col
|
||||||
Amount
|
className={clsx(
|
||||||
<span className={'xl:hidden'}>
|
mobileView
|
||||||
Balance: {formatMoney(user?.balance ?? 0)}
|
? outcome === 'NO'
|
||||||
</span>
|
? 'bg-red-25'
|
||||||
</Row>
|
|
||||||
|
|
||||||
<BuyAmountInput
|
|
||||||
inputClassName="w-full max-w-none"
|
|
||||||
amount={betAmount}
|
|
||||||
onChange={onBetChange}
|
|
||||||
error={error}
|
|
||||||
setError={setError}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
inputRef={inputRef}
|
|
||||||
showSliderOnMobile
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
|
||||||
<Row className="items-center justify-between text-sm">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
|
||||||
</div>
|
|
||||||
{probStayedSame ? (
|
|
||||||
<div>{format(initialProb)}</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{format(initialProb)}
|
|
||||||
<span className="mx-2">→</span>
|
|
||||||
{format(resultProb)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row className="items-center justify-between gap-2 text-sm">
|
|
||||||
<Row className="flex-nowrap items-center gap-2 whitespace-nowrap text-gray-500">
|
|
||||||
<div>
|
|
||||||
{isPseudoNumeric ? (
|
|
||||||
'Max payout'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Payout if <BinaryOutcomeLabel outcome={outcome ?? 'YES'} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
<div>
|
|
||||||
<span className="mr-2 whitespace-nowrap">
|
|
||||||
{formatMoney(currentPayout)}
|
|
||||||
</span>
|
|
||||||
(+{currentReturnPercent})
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Spacer h={8} />
|
|
||||||
|
|
||||||
{user && (
|
|
||||||
<WarningConfirmationButton
|
|
||||||
warning={warning}
|
|
||||||
onSubmit={submitBet}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
disabled={!!betDisabled}
|
|
||||||
openModalButtonClass={clsx(
|
|
||||||
'btn mb-2 flex-1',
|
|
||||||
betDisabled
|
|
||||||
? 'btn-disabled'
|
|
||||||
: outcome === 'YES'
|
: outcome === 'YES'
|
||||||
? 'btn-primary'
|
? 'bg-teal-50'
|
||||||
: 'border-none bg-red-400 hover:bg-red-500'
|
: 'hidden'
|
||||||
)}
|
: 'bg-white',
|
||||||
/>
|
mobileView ? 'rounded-lg px-4 py-2' : 'px-0'
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Row className="mt-3 w-full gap-3">
|
||||||
|
<Col className="w-1/2 text-sm">
|
||||||
|
<Col className="text-greyscale-4 flex-nowrap whitespace-nowrap text-xs">
|
||||||
|
<div>
|
||||||
|
{isPseudoNumeric ? (
|
||||||
|
'Max payout'
|
||||||
|
) : (
|
||||||
|
<>Payout if {outcome ?? 'YES'}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<div>
|
||||||
|
<span className="whitespace-nowrap text-xl">
|
||||||
|
{formatMoney(currentPayout)}
|
||||||
|
</span>
|
||||||
|
<span className="text-greyscale-4 text-xs">
|
||||||
|
{' '}
|
||||||
|
+{currentReturnPercent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col className="w-1/2 text-sm">
|
||||||
|
<div className="text-greyscale-4 text-xs">
|
||||||
|
{isPseudoNumeric ? 'Estimated value' : 'New Probability'}
|
||||||
|
</div>
|
||||||
|
{probStayedSame ? (
|
||||||
|
<div className="text-xl">{format(initialProb)}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xl">
|
||||||
|
{format(resultProb)}
|
||||||
|
<span className={clsx('text-greyscale-4 text-xs')}>
|
||||||
|
{isPseudoNumeric ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
{outcome != 'NO' && '+'}
|
||||||
|
{format(resultProb - initialProb)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="text-greyscale-4 mt-4 mb-1 justify-between text-left text-xs">
|
||||||
|
Amount
|
||||||
|
</Row>
|
||||||
|
|
||||||
{wasSubmitted && <div className="mt-4">Trade submitted!</div>}
|
<BuyAmountInput
|
||||||
|
inputClassName="w-full max-w-none"
|
||||||
|
amount={betAmount}
|
||||||
|
onChange={onBetChange}
|
||||||
|
error={error}
|
||||||
|
setError={setError}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
inputRef={inputRef}
|
||||||
|
showSliderOnMobile
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Spacer h={8} />
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<WarningConfirmationButton
|
||||||
|
marketType="binary"
|
||||||
|
amount={betAmount}
|
||||||
|
warning={warning}
|
||||||
|
onSubmit={submitBet}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
disabled={!!betDisabled || outcome === undefined}
|
||||||
|
size="xl"
|
||||||
|
color={outcome === 'NO' ? 'red' : 'green'}
|
||||||
|
actionLabel="Wager"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="text-greyscale-6 mx-auto mt-3 select-none text-sm underline xl:hidden"
|
||||||
|
onClick={() => setSeeLimit(true)}
|
||||||
|
>
|
||||||
|
Advanced
|
||||||
|
</button>
|
||||||
|
<Modal
|
||||||
|
open={seeLimit}
|
||||||
|
setOpen={setSeeLimit}
|
||||||
|
position="center"
|
||||||
|
className="rounded-lg bg-white px-4 pb-4"
|
||||||
|
>
|
||||||
|
<Title text="Limit Order" />
|
||||||
|
<LimitOrderPanel
|
||||||
|
hidden={!seeLimit}
|
||||||
|
contract={contract}
|
||||||
|
user={user}
|
||||||
|
unfilledBets={unfilledBets}
|
||||||
|
/>
|
||||||
|
<LimitBets
|
||||||
|
contract={contract}
|
||||||
|
bets={unfilledBets as LimitBet[]}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -389,7 +446,6 @@ function LimitOrderPanel(props: {
|
||||||
const betChoice = 'YES'
|
const betChoice = 'YES'
|
||||||
const [error, setError] = useState<string | undefined>()
|
const [error, setError] = useState<string | undefined>()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [wasSubmitted, setWasSubmitted] = useState(false)
|
|
||||||
|
|
||||||
const rangeError =
|
const rangeError =
|
||||||
lowLimitProb !== undefined &&
|
lowLimitProb !== undefined &&
|
||||||
|
@ -437,7 +493,6 @@ function LimitOrderPanel(props: {
|
||||||
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
const noAmount = shares * (1 - (noLimitProb ?? 0))
|
||||||
|
|
||||||
function onBetChange(newAmount: number | undefined) {
|
function onBetChange(newAmount: number | undefined) {
|
||||||
setWasSubmitted(false)
|
|
||||||
setBetAmount(newAmount)
|
setBetAmount(newAmount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -482,7 +537,6 @@ function LimitOrderPanel(props: {
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
console.log('placed bet. Result:', r)
|
console.log('placed bet. Result:', r)
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setWasSubmitted(true)
|
|
||||||
setBetAmount(undefined)
|
setBetAmount(undefined)
|
||||||
setLowLimitProb(undefined)
|
setLowLimitProb(undefined)
|
||||||
setHighLimitProb(undefined)
|
setHighLimitProb(undefined)
|
||||||
|
@ -718,8 +772,6 @@ function LimitOrderPanel(props: {
|
||||||
: `Submit order${hasTwoBets ? 's' : ''}`}
|
: `Submit order${hasTwoBets ? 's' : ''}`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
|
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -780,13 +832,21 @@ export function SellPanel(props: {
|
||||||
|
|
||||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||||
|
|
||||||
const betDisabled = isSubmitting || !amount || error
|
const betDisabled = isSubmitting || !amount || error !== undefined
|
||||||
|
|
||||||
// Sell all shares if remaining shares would be < 1
|
// Sell all shares if remaining shares would be < 1
|
||||||
const isSellingAllShares = amount === Math.floor(shares)
|
const isSellingAllShares = amount === Math.floor(shares)
|
||||||
|
|
||||||
const sellQuantity = isSellingAllShares ? shares : amount
|
const sellQuantity = isSellingAllShares ? shares : amount
|
||||||
|
|
||||||
|
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||||
|
const soldShares = Math.min(sellQuantity ?? 0, shares)
|
||||||
|
const saleFrac = soldShares / shares
|
||||||
|
const loanPaid = saleFrac * loanAmount
|
||||||
|
|
||||||
|
const { invested } = getContractBetMetrics(contract, userBets)
|
||||||
|
const costBasis = invested * saleFrac
|
||||||
|
|
||||||
async function submitSell() {
|
async function submitSell() {
|
||||||
if (!user || !amount) return
|
if (!user || !amount) return
|
||||||
|
|
||||||
|
@ -831,8 +891,23 @@ export function SellPanel(props: {
|
||||||
sharesOutcome,
|
sharesOutcome,
|
||||||
unfilledBets
|
unfilledBets
|
||||||
)
|
)
|
||||||
|
const netProceeds = saleValue - loanPaid
|
||||||
|
const profit = saleValue - costBasis
|
||||||
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
const resultProb = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||||
|
|
||||||
|
const getValue = getMappedValue(contract)
|
||||||
|
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
|
||||||
|
const displayedDifference =
|
||||||
|
contract.outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
? formatLargeNumber(rawDifference)
|
||||||
|
: formatPercent(rawDifference)
|
||||||
|
const probChange = Math.abs(resultProb - initialProb)
|
||||||
|
|
||||||
|
const warning =
|
||||||
|
probChange >= 0.3
|
||||||
|
? `Are you sure you want to move the market by ${displayedDifference}?`
|
||||||
|
: undefined
|
||||||
|
|
||||||
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
|
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
|
||||||
const [yesBets, noBets] = partition(
|
const [yesBets, noBets] = partition(
|
||||||
openUserBets,
|
openUserBets,
|
||||||
|
@ -866,24 +941,24 @@ export function SellPanel(props: {
|
||||||
<>
|
<>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
amount={
|
amount={
|
||||||
amount
|
amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0
|
||||||
? Math.round(amount) === 0
|
|
||||||
? 0
|
|
||||||
: Math.floor(amount)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
onChange={onAmountChange}
|
onChange={onAmountChange}
|
||||||
label="Qty"
|
label="Qty"
|
||||||
error={error}
|
error={error}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
inputClassName="w-full"
|
inputClassName="w-full ml-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3 text-sm">
|
<Col className="mt-3 w-full gap-3 text-sm">
|
||||||
<Row className="items-center justify-between gap-2 text-gray-500">
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
||||||
Sale proceeds
|
Sale amount
|
||||||
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
<span className="text-neutral">{formatMoney(saleValue)}</span>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
||||||
|
Profit
|
||||||
|
<span className="text-neutral">{formatMoney(profit)}</span>
|
||||||
|
</Row>
|
||||||
<Row className="items-center justify-between">
|
<Row className="items-center justify-between">
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
|
||||||
|
@ -894,24 +969,33 @@ export function SellPanel(props: {
|
||||||
{format(resultProb)}
|
{format(resultProb)}
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
{loanPaid !== 0 && (
|
||||||
|
<>
|
||||||
|
<Row className="mt-6 items-center justify-between gap-2 text-gray-500">
|
||||||
|
Loan payment
|
||||||
|
<span className="text-neutral">{formatMoney(-loanPaid)}</span>
|
||||||
|
</Row>
|
||||||
|
<Row className="items-center justify-between gap-2 text-gray-500">
|
||||||
|
Net proceeds
|
||||||
|
<span className="text-neutral">{formatMoney(netProceeds)}</span>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Spacer h={8} />
|
<Spacer h={8} />
|
||||||
|
|
||||||
<button
|
<WarningConfirmationButton
|
||||||
className={clsx(
|
marketType="binary"
|
||||||
'btn flex-1',
|
amount={undefined}
|
||||||
betDisabled
|
warning={warning}
|
||||||
? 'btn-disabled'
|
isSubmitting={isSubmitting}
|
||||||
: sharesOutcome === 'YES'
|
onSubmit={betDisabled ? undefined : submitSell}
|
||||||
? 'btn-primary'
|
disabled={!!betDisabled}
|
||||||
: 'border-none bg-red-400 hover:bg-red-500',
|
size="xl"
|
||||||
isSubmitting ? 'loading' : ''
|
color="blue"
|
||||||
)}
|
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
|
||||||
onClick={betDisabled ? undefined : submitSell}
|
/>
|
||||||
>
|
|
||||||
{isSubmitting ? 'Submitting...' : 'Submit sell'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
|
{wasSubmitted && <div className="mt-4">Sell submitted!</div>}
|
||||||
</>
|
</>
|
||||||
|
|
120
web/components/bet-summary.tsx
Normal file
120
web/components/bet-summary.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { sumBy } from 'lodash'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
import { Bet } from 'web/lib/firebase/bets'
|
||||||
|
import { formatMoney, formatWithCommas } from 'common/util/format'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Contract } from 'web/lib/firebase/contracts'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { YesLabel, NoLabel } from './outcome-label'
|
||||||
|
import {
|
||||||
|
calculatePayout,
|
||||||
|
getContractBetMetrics,
|
||||||
|
getProbability,
|
||||||
|
} from 'common/calculate'
|
||||||
|
import { InfoTooltip } from './info-tooltip'
|
||||||
|
import { ProfitBadge } from './profit-badge'
|
||||||
|
|
||||||
|
export function BetsSummary(props: {
|
||||||
|
contract: Contract
|
||||||
|
userBets: Bet[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { contract, className } = props
|
||||||
|
const { resolution, outcomeType } = contract
|
||||||
|
const isBinary = outcomeType === 'BINARY'
|
||||||
|
|
||||||
|
const bets = props.userBets.filter((b) => !b.isAnte)
|
||||||
|
const { profitPercent, payout, profit, invested } = getContractBetMetrics(
|
||||||
|
contract,
|
||||||
|
bets
|
||||||
|
)
|
||||||
|
|
||||||
|
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
|
||||||
|
const yesWinnings = sumBy(excludeSales, (bet) =>
|
||||||
|
calculatePayout(contract, bet, 'YES')
|
||||||
|
)
|
||||||
|
const noWinnings = sumBy(excludeSales, (bet) =>
|
||||||
|
calculatePayout(contract, bet, 'NO')
|
||||||
|
)
|
||||||
|
|
||||||
|
const position = yesWinnings - noWinnings
|
||||||
|
|
||||||
|
const prob = isBinary ? getProbability(contract) : 0
|
||||||
|
const expectation = prob * yesWinnings + (1 - prob) * noWinnings
|
||||||
|
|
||||||
|
if (bets.length === 0) return <></>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className={clsx(className, 'gap-4')}>
|
||||||
|
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
|
||||||
|
{resolution ? (
|
||||||
|
<Col>
|
||||||
|
<div className="text-sm text-gray-500">Payout</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(payout)}{' '}
|
||||||
|
<ProfitBadge profitPercent={profitPercent} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
) : isBinary ? (
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Position{' '}
|
||||||
|
<InfoTooltip text="Number of shares you own on net. 1 YES share = M$1 if the market resolves YES." />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{position > 1e-7 ? (
|
||||||
|
<>
|
||||||
|
<YesLabel /> {formatWithCommas(position)}
|
||||||
|
</>
|
||||||
|
) : position < -1e-7 ? (
|
||||||
|
<>
|
||||||
|
<NoLabel /> {formatWithCommas(-position)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'——'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
) : (
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Expectation{''}
|
||||||
|
<InfoTooltip text="The estimated payout of your position using the current market probability." />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Col className="hidden sm:inline">
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Invested{' '}
|
||||||
|
<InfoTooltip text="Cash currently invested in this market." />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{isBinary && !resolution && (
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Expectation{' '}
|
||||||
|
<InfoTooltip text="The estimated payout of your position using the current market probability." />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">{formatMoney(expectation)}</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Col>
|
||||||
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
Profit{' '}
|
||||||
|
<InfoTooltip text="Includes both realized & unrealized gains/losses." />
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{formatMoney(profit)}
|
||||||
|
<ProfitBadge profitPercent={profitPercent} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,7 +2,6 @@ import Link from 'next/link'
|
||||||
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Bet } from 'web/lib/firebase/bets'
|
import { Bet } from 'web/lib/firebase/bets'
|
||||||
|
@ -22,7 +21,7 @@ import {
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { sellBet } from 'web/lib/firebase/api'
|
import { sellBet } from 'web/lib/firebase/api'
|
||||||
import { ConfirmationButton } from './confirmation-button'
|
import { ConfirmationButton } from './confirmation-button'
|
||||||
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
|
import { OutcomeLabel } from './outcome-label'
|
||||||
import { LoadingIndicator } from './loading-indicator'
|
import { LoadingIndicator } from './loading-indicator'
|
||||||
import { SiteLink } from './site-link'
|
import { SiteLink } from './site-link'
|
||||||
import {
|
import {
|
||||||
|
@ -38,14 +37,19 @@ import { NumericContract } from 'common/contract'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useUserBets } from 'web/hooks/use-user-bets'
|
import { useUserBets } from 'web/hooks/use-user-bets'
|
||||||
import { SellSharesModal } from './sell-modal'
|
|
||||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
import { LimitBet } from 'common/bet'
|
import { LimitBet } from 'common/bet'
|
||||||
import { floatingEqual } from 'common/util/math'
|
|
||||||
import { Pagination } from './pagination'
|
import { Pagination } from './pagination'
|
||||||
import { LimitOrderTable } from './limit-bets'
|
import { LimitOrderTable } from './limit-bets'
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { useUserBetContracts } from 'web/hooks/use-contracts'
|
import { useUserBetContracts } from 'web/hooks/use-contracts'
|
||||||
|
import { BetsSummary } from './bet-summary'
|
||||||
|
import { ProfitBadge } from './profit-badge'
|
||||||
|
import {
|
||||||
|
storageStore,
|
||||||
|
usePersistentState,
|
||||||
|
} from 'web/hooks/use-persistent-state'
|
||||||
|
import { safeLocalStorage } from 'web/lib/util/local'
|
||||||
|
|
||||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
||||||
|
@ -76,8 +80,14 @@ export function BetsList(props: { user: User }) {
|
||||||
return contractList ? keyBy(contractList, 'id') : undefined
|
return contractList ? keyBy(contractList, 'id') : undefined
|
||||||
}, [contractList])
|
}, [contractList])
|
||||||
|
|
||||||
const [sort, setSort] = useState<BetSort>('newest')
|
const [sort, setSort] = usePersistentState<BetSort>('newest', {
|
||||||
const [filter, setFilter] = useState<BetFilter>('open')
|
key: 'bets-list-sort',
|
||||||
|
store: storageStore(safeLocalStorage()),
|
||||||
|
})
|
||||||
|
const [filter, setFilter] = usePersistentState<BetFilter>('all', {
|
||||||
|
key: 'bets-list-filter',
|
||||||
|
store: storageStore(safeLocalStorage()),
|
||||||
|
})
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const start = page * CONTRACTS_PER_PAGE
|
const start = page * CONTRACTS_PER_PAGE
|
||||||
const end = start + CONTRACTS_PER_PAGE
|
const end = start + CONTRACTS_PER_PAGE
|
||||||
|
@ -150,39 +160,35 @@ export function BetsList(props: { user: User }) {
|
||||||
unsettled,
|
unsettled,
|
||||||
(c) => contractsMetrics[c.id].payout
|
(c) => contractsMetrics[c.id].payout
|
||||||
)
|
)
|
||||||
const currentNetInvestment = sumBy(
|
const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
|
||||||
unsettled,
|
|
||||||
(c) => contractsMetrics[c.id].netPayout
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalPnl = user.profitCached.allTime
|
|
||||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
|
||||||
const investedProfitPercent =
|
const investedProfitPercent =
|
||||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
<Col>
|
||||||
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
|
<Col className="justify-between gap-4 sm:flex-row">
|
||||||
<Row className="gap-8">
|
<Row className="gap-4">
|
||||||
<Col>
|
<Col>
|
||||||
<div className="text-sm text-gray-500">Investment value</div>
|
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||||
|
Investment value
|
||||||
|
</div>
|
||||||
<div className="text-lg">
|
<div className="text-lg">
|
||||||
{formatMoney(currentNetInvestment)}{' '}
|
{formatMoney(currentBetsValue)}{' '}
|
||||||
<ProfitBadge profitPercent={investedProfitPercent} />
|
<ProfitBadge profitPercent={investedProfitPercent} />
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col>
|
<Col>
|
||||||
<div className="text-sm text-gray-500">Total profit</div>
|
<div className="text-greyscale-6 text-xs sm:text-sm">
|
||||||
<div className="text-lg">
|
Total loans
|
||||||
{formatMoney(totalPnl)}{' '}
|
|
||||||
<ProfitBadge profitPercent={totalProfitPercent} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-lg">{formatMoney(currentLoan)}</div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row className="gap-8">
|
<Row className="gap-2">
|
||||||
<select
|
<select
|
||||||
className="select select-bordered self-start"
|
className="border-greyscale-4 self-start overflow-hidden rounded border px-2 py-2 text-sm"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value as BetFilter)}
|
onChange={(e) => setFilter(e.target.value as BetFilter)}
|
||||||
>
|
>
|
||||||
|
@ -195,7 +201,7 @@ export function BetsList(props: { user: User }) {
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="select select-bordered self-start"
|
className="border-greyscale-4 self-start overflow-hidden rounded px-2 py-2 text-sm"
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(e) => setSort(e.target.value as BetSort)}
|
onChange={(e) => setSort(e.target.value as BetSort)}
|
||||||
>
|
>
|
||||||
|
@ -346,8 +352,7 @@ function ContractBets(props: {
|
||||||
<BetsSummary
|
<BetsSummary
|
||||||
className="mt-8 mr-5 flex-1 sm:mr-8"
|
className="mt-8 mr-5 flex-1 sm:mr-8"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
userBets={bets}
|
||||||
isYourBets={isYourBets}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
||||||
|
@ -373,125 +378,6 @@ function ContractBets(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BetsSummary(props: {
|
|
||||||
contract: Contract
|
|
||||||
bets: Bet[]
|
|
||||||
isYourBets: boolean
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { contract, isYourBets, className } = props
|
|
||||||
const { resolution, closeTime, outcomeType, mechanism } = contract
|
|
||||||
const isBinary = outcomeType === 'BINARY'
|
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
|
||||||
const isCpmm = mechanism === 'cpmm-1'
|
|
||||||
const isClosed = closeTime && Date.now() > closeTime
|
|
||||||
|
|
||||||
const bets = props.bets.filter((b) => !b.isAnte)
|
|
||||||
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
|
|
||||||
getContractBetMetrics(contract, bets)
|
|
||||||
|
|
||||||
const excludeSales = bets.filter((b) => !b.isSold && !b.sale)
|
|
||||||
const yesWinnings = sumBy(excludeSales, (bet) =>
|
|
||||||
calculatePayout(contract, bet, 'YES')
|
|
||||||
)
|
|
||||||
const noWinnings = sumBy(excludeSales, (bet) =>
|
|
||||||
calculatePayout(contract, bet, 'NO')
|
|
||||||
)
|
|
||||||
|
|
||||||
const [showSellModal, setShowSellModal] = useState(false)
|
|
||||||
const user = useUser()
|
|
||||||
|
|
||||||
const sharesOutcome = floatingEqual(totalShares.YES ?? 0, 0)
|
|
||||||
? floatingEqual(totalShares.NO ?? 0, 0)
|
|
||||||
? undefined
|
|
||||||
: 'NO'
|
|
||||||
: 'YES'
|
|
||||||
|
|
||||||
const canSell =
|
|
||||||
isYourBets &&
|
|
||||||
isCpmm &&
|
|
||||||
(isBinary || isPseudoNumeric) &&
|
|
||||||
!isClosed &&
|
|
||||||
!resolution &&
|
|
||||||
hasShares &&
|
|
||||||
sharesOutcome &&
|
|
||||||
user
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col className={clsx(className, 'gap-4')}>
|
|
||||||
<Row className="flex-wrap gap-4 sm:flex-nowrap sm:gap-6">
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Invested
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
{canSell && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm self-end"
|
|
||||||
onClick={() => setShowSellModal(true)}
|
|
||||||
>
|
|
||||||
Sell
|
|
||||||
</button>
|
|
||||||
{showSellModal && (
|
|
||||||
<SellSharesModal
|
|
||||||
contract={contract}
|
|
||||||
user={user}
|
|
||||||
userBets={bets}
|
|
||||||
shares={totalShares[sharesOutcome]}
|
|
||||||
sharesOutcome={sharesOutcome}
|
|
||||||
setOpen={setShowSellModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
<Row className="flex-wrap-none gap-4">
|
|
||||||
{resolution ? (
|
|
||||||
<Col>
|
|
||||||
<div className="text-sm text-gray-500">Payout</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(payout)}{' '}
|
|
||||||
<ProfitBadge profitPercent={profitPercent} />
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
) : isBinary ? (
|
|
||||||
<>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if <YesLabel />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(yesWinnings)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if <NoLabel />
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Expected value
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractBetsTable(props: {
|
export function ContractBetsTable(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
|
@ -610,18 +496,24 @@ function BetRow(props: {
|
||||||
const isNumeric = outcomeType === 'NUMERIC'
|
const isNumeric = outcomeType === 'NUMERIC'
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||||
|
|
||||||
const saleAmount = saleBet?.sale?.amount
|
// calculateSaleAmount is very slow right now so that's why we memoized this
|
||||||
|
const payout = useMemo(() => {
|
||||||
|
const saleBetAmount = saleBet?.sale?.amount
|
||||||
|
if (saleBetAmount) {
|
||||||
|
return saleBetAmount
|
||||||
|
} else if (contract.isResolved) {
|
||||||
|
return resolvedPayout(contract, bet)
|
||||||
|
} else {
|
||||||
|
return calculateSaleAmount(contract, bet, unfilledBets)
|
||||||
|
}
|
||||||
|
}, [contract, bet, saleBet, unfilledBets])
|
||||||
|
|
||||||
const saleDisplay = isAnte ? (
|
const saleDisplay = isAnte ? (
|
||||||
'ANTE'
|
'ANTE'
|
||||||
) : saleAmount !== undefined ? (
|
) : saleBet ? (
|
||||||
<>{formatMoney(saleAmount)} (sold)</>
|
<>{formatMoney(payout)} (sold)</>
|
||||||
) : (
|
) : (
|
||||||
formatMoney(
|
formatMoney(payout)
|
||||||
isResolved
|
|
||||||
? resolvedPayout(contract, bet)
|
|
||||||
: calculateSaleAmount(contract, bet, unfilledBets)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const payoutIfChosenDisplay =
|
const payoutIfChosenDisplay =
|
||||||
|
@ -722,10 +614,10 @@ function SellButton(props: {
|
||||||
return (
|
return (
|
||||||
<ConfirmationButton
|
<ConfirmationButton
|
||||||
openModalBtn={{
|
openModalBtn={{
|
||||||
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
|
|
||||||
label: 'Sell',
|
label: 'Sell',
|
||||||
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
submitBtn={{ className: 'btn-primary', label: 'Sell' }}
|
submitBtn={{ label: 'Sell', color: 'green' }}
|
||||||
onSubmit={async () => {
|
onSubmit={async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
await sellBet({ contractId: contract.id, betId: bet.id })
|
await sellBet({ contractId: contract.id, betId: bet.id })
|
||||||
|
@ -753,27 +645,3 @@ function SellButton(props: {
|
||||||
</ConfirmationButton>
|
</ConfirmationButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfitBadge(props: {
|
|
||||||
profitPercent: number
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { profitPercent, className } = props
|
|
||||||
if (!profitPercent) return null
|
|
||||||
const colors =
|
|
||||||
profitPercent > 0
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
|
|
||||||
colors,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { MouseEventHandler, ReactNode } from 'react'
|
import { MouseEventHandler, ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
|
||||||
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
export type ColorType =
|
export type ColorType =
|
||||||
|
@ -9,18 +10,57 @@ export type ColorType =
|
||||||
| 'indigo'
|
| 'indigo'
|
||||||
| 'yellow'
|
| 'yellow'
|
||||||
| 'gray'
|
| 'gray'
|
||||||
|
| 'gray-outline'
|
||||||
| 'gradient'
|
| 'gradient'
|
||||||
| 'gray-white'
|
| 'gray-white'
|
||||||
| 'highlight-blue'
|
| 'highlight-blue'
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
'2xs': 'px-2 py-1 text-xs',
|
||||||
|
xs: 'px-2.5 py-1.5 text-sm',
|
||||||
|
sm: 'px-3 py-2 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-4 py-2 text-base',
|
||||||
|
xl: 'px-6 py-2.5 text-base font-semibold',
|
||||||
|
'2xl': 'px-6 py-3 text-xl font-semibold',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buttonClass(size: SizeType, color: ColorType | 'override') {
|
||||||
|
return clsx(
|
||||||
|
'font-md inline-flex items-center justify-center rounded-md border border-transparent shadow-sm transition-colors disabled:cursor-not-allowed',
|
||||||
|
sizeClasses[size],
|
||||||
|
color === 'green' &&
|
||||||
|
'disabled:bg-greyscale-2 bg-teal-500 text-white hover:bg-teal-600',
|
||||||
|
color === 'red' &&
|
||||||
|
'disabled:bg-greyscale-2 bg-red-400 text-white hover:bg-red-500',
|
||||||
|
color === 'yellow' &&
|
||||||
|
'disabled:bg-greyscale-2 bg-yellow-400 text-white hover:bg-yellow-500',
|
||||||
|
color === 'blue' &&
|
||||||
|
'disabled:bg-greyscale-2 bg-blue-400 text-white hover:bg-blue-500',
|
||||||
|
color === 'indigo' &&
|
||||||
|
'disabled:bg-greyscale-2 bg-indigo-500 text-white hover:bg-indigo-600',
|
||||||
|
color === 'gray' &&
|
||||||
|
'bg-greyscale-1 text-greyscale-6 hover:bg-greyscale-2 disabled:opacity-50',
|
||||||
|
color === 'gray-outline' &&
|
||||||
|
'border-greyscale-4 text-greyscale-4 hover:bg-greyscale-4 border-2 hover:text-white disabled:opacity-50',
|
||||||
|
color === 'gradient' &&
|
||||||
|
'disabled:bg-greyscale-2 border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
|
||||||
|
color === 'gray-white' &&
|
||||||
|
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none disabled:opacity-50',
|
||||||
|
color === 'highlight-blue' &&
|
||||||
|
'text-highlight-blue disabled:bg-greyscale-2 border-none shadow-none'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Button(props: {
|
export function Button(props: {
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: MouseEventHandler<any> | undefined
|
onClick?: MouseEventHandler<any> | undefined
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
size?: SizeType
|
size?: SizeType
|
||||||
color?: ColorType
|
color?: ColorType | 'override'
|
||||||
type?: 'button' | 'reset' | 'submit'
|
type?: 'button' | 'reset' | 'submit'
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
|
@ -30,41 +70,17 @@ export function Button(props: {
|
||||||
color = 'indigo',
|
color = 'indigo',
|
||||||
type = 'button',
|
type = 'button',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
loading,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
'2xs': 'px-2 py-1 text-xs',
|
|
||||||
xs: 'px-2.5 py-1.5 text-sm',
|
|
||||||
sm: 'px-3 py-2 text-sm',
|
|
||||||
md: 'px-4 py-2 text-sm',
|
|
||||||
lg: 'px-4 py-2 text-base',
|
|
||||||
xl: 'px-6 py-2.5 text-base font-semibold',
|
|
||||||
'2xl': 'px-6 py-3 text-xl font-semibold',
|
|
||||||
}[size]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx(
|
className={clsx(buttonClass(size, color), className)}
|
||||||
'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
disabled={disabled || loading}
|
||||||
sizeClasses,
|
|
||||||
color === 'green' && 'btn-primary text-white',
|
|
||||||
color === 'red' && 'bg-red-400 text-white hover:bg-red-500',
|
|
||||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
|
||||||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
|
||||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
|
||||||
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
|
|
||||||
color === 'gradient' &&
|
|
||||||
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
|
|
||||||
color === 'gray-white' &&
|
|
||||||
'text-greyscale-6 hover:bg-greyscale-2 border-none shadow-none',
|
|
||||||
color === 'highlight-blue' &&
|
|
||||||
'text-highlight-blue border-none shadow-none',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
{loading && <LoadingIndicator className={'mr-2 border-gray-500'} />}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,9 +6,10 @@ export function PillButton(props: {
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
color?: string
|
color?: string
|
||||||
xs?: boolean
|
xs?: boolean
|
||||||
|
className?: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { children, selected, onSelect, color, xs } = props
|
const { children, selected, onSelect, color, xs, className } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
@ -17,7 +18,8 @@ export function PillButton(props: {
|
||||||
xs ? 'text-xs' : '',
|
xs ? 'text-xs' : '',
|
||||||
selected
|
selected
|
||||||
? ['text-white', color ?? 'bg-greyscale-6']
|
? ['text-white', color ?? 'bg-greyscale-6']
|
||||||
: 'bg-greyscale-2 hover:bg-greyscale-3'
|
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
|
|
16
web/components/card.tsx
Normal file
16
web/components/card.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function Card(props: JSX.IntrinsicElements['div']) {
|
||||||
|
const { children, className, ...rest } = props
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'cursor-pointer rounded-lg border bg-white transition-shadow hover:shadow-md focus:shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user