Merge branch 'main' into badges

This commit is contained in:
Ian Philips 2022-10-06 11:40:22 -04:00
commit 4d94f24892
287 changed files with 16347 additions and 6957 deletions

View File

@ -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

View File

@ -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 }
}

View File

@ -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 {

View File

@ -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 = {

View File

@ -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}` +

View File

@ -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

View File

@ -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

View File

@ -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',
} }

View File

@ -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: [

View File

@ -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 }

View File

@ -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

View File

@ -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(),

View File

@ -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 = {

View File

@ -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 }
}) })

View File

@ -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

View File

@ -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[]

View File

@ -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

View File

@ -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&section=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
} }

View File

@ -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
View 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]
}

View File

@ -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('$', '')
} }

View File

@ -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)
} }

View File

@ -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)
}

View File

@ -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))

View 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
},
})

View File

@ -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

View File

@ -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) ![](https://i.imgur.com/nVGY4pL.png) - Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png)
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) - Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)
## 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

View File

@ -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?

View File

@ -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",

View File

@ -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;

View File

@ -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`

View File

@ -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
} }

View File

@ -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 })
} }

View 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()

View File

@ -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
}) })

View File

@ -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)

View File

@ -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 ?? []),
],
})
} }
} }
} }

View File

@ -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
}

View File

@ -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 }
}) })

View File

@ -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)

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;"

View File

@ -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>

View 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>

View File

@ -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;"

View File

@ -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&section=${ 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&section=${ 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&section=${ 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&section=${ 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&section=${ 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
)
}

View File

@ -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,
} }

View File

@ -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 }
) )

View File

@ -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'
) )
} }

View File

@ -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>(

View File

@ -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

View File

@ -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()

View File

@ -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,
})
})
)
})

View 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,
})
})
)
})

View File

@ -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(

View File

@ -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 }))
}
} }
} }

View 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())

View 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))
}

View 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 {}

View 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',
}
}

View 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 {}

File diff suppressed because it is too large Load Diff

View 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 '&amp;' with '&'
const clean = (str: string | undefined) => str?.replace(/&amp;/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'
)

View File

@ -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

View File

@ -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())
}

View File

@ -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()
} }

View File

@ -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}.`)

View 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 }
}
)

View File

@ -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>
` `
) )
}
}, },
} }

View 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()

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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}`
}

View File

@ -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',
]

View 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
}

View File

@ -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>

View File

@ -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"
} }
} }

View File

@ -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"

View File

@ -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
}

View 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>
)
}

View File

@ -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>
</> </>
) )
} }

View File

@ -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>
)
}

View File

@ -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 />

View File

@ -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>
) )
} }

View File

@ -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 }
}

View File

@ -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>
) )
} }

View File

@ -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,
}
}

View File

@ -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])

View File

@ -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

View 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>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>}
</> </>

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
) )

View File

@ -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
View 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