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 periodFilteredContracts = userContracts.filter(
(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 {
calculateCpmmSale,
@ -210,7 +210,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
}
}
const netPayout = payout - loan
const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = (profit / totalInvested) * 100
@ -221,8 +220,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return {
invested,
loan,
payout,
netPayout,
profit,
profitPercent,
totalShares,
@ -233,8 +232,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() {
return {
invested: 0,
loan: 0,
payout: 0,
netPayout: 0,
profit: 0,
profitPercent: 0,
totalShares: {} as { [outcome: string]: number },
@ -255,3 +254,43 @@ export function getTopAnswer(
)
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.`,
},
{
{
name: 'CaRLA',
website: 'https://carlaef.org/',
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.`,
},
{
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) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -18,6 +18,7 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userName: string
userUsername: string
userAvatarUrl?: string
bountiesAwarded?: number
} & T
export type OnContract = {
@ -33,6 +34,11 @@ export type OnContract = {
// denormalized from bet
betAmount?: number
betOutcome?: string
// denormalized based on betting history
commenterPositionProb?: number // binary only
commenterPositionShares?: number
commenterPositionOutcome?: string
}
export type OnGroup = {

View File

@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
const { closeTime, groupLinks } = 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 (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +

View File

@ -57,10 +57,14 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[]
uniqueBettorCount?: number
popularityScore?: number
dailyScore?: number
followerCount?: number
featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
flaggedByUsernames?: string[]
openCommentBounties?: number
unlistedById?: string
} & T
export type BinaryContract = Contract & Binary
@ -148,7 +152,7 @@ export const OUTCOME_TYPES = [
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480
export const MAX_QUESTION_LENGTH = 240
export const MAX_DESCRIPTION_LENGTH = 16000
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
// for sus users, i.e. multiple sign ups for same person
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 BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250

View File

@ -16,4 +16,7 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc',
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
firebaseConfig: FirebaseConfig
amplitudeApiKey?: string
twitchBotEndpoint?: string
sprigEnvironmentId?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
@ -39,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
}
type FirebaseConfig = {
@ -55,6 +58,7 @@ type FirebaseConfig = {
export const PROD_CONFIG: EnvConfig = {
domain: 'manifold.markets',
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
sprigEnvironmentId: 'sQcrq9TDqkib',
firebaseConfig: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
@ -66,6 +70,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
},
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc',
adminEmails: [
@ -82,9 +87,9 @@ export const PROD_CONFIG: EnvConfig = {
visibility: 'PUBLIC',
moneyMoniker: 'M$',
bettor: 'predictor',
pastBet: 'prediction',
presentBet: 'predict',
bettor: 'trader',
pastBet: 'trade',
presentBet: 'trade',
navbarLogoPath: '',
faviconPath: '/favicon.ico',
newQuestionPlaceholders: [

View File

@ -10,6 +10,7 @@ export type Group = {
totalContracts: number
totalMembers: number
aboutPostId?: string
postIds: string[]
chatDisabled?: boolean
mostRecentContractAddedTime?: number
cachedLeaderboard?: {
@ -22,6 +23,7 @@ export type Group = {
score: number
}[]
}
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}
export const MAX_GROUP_NAME_LENGTH = 75
@ -37,3 +39,4 @@ export type GroupLink = {
createdTime: number
userId?: string
}
export type GroupContractDoc = { contractId: string; createdTime: number }

View File

@ -5,4 +5,4 @@ export type Like = {
createdTime: number
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,
} from './contract'
import { User } from './user'
import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core'
@ -38,15 +37,6 @@ export function getNewContract(
answers: string[],
visibility: visibility
) {
const tags = parseTags(
[
question,
richTextToString(description),
...extraTags.map((tag) => `#${tag}`),
].join(' ')
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
@ -70,8 +60,8 @@ export function getNewContract(
question: question.trim(),
description,
tags,
lowercaseTags,
tags: [],
lowercaseTags: [],
visibility,
isResolved: false,
createdTime: Date.now(),

View File

@ -97,6 +97,7 @@ type notification_descriptions = {
[key in notification_preference]: {
simple: string
detailed: string
necessary?: boolean
}
}
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",
},
betting_streaks: {
simple: 'For predictions made over consecutive days',
detailed: 'Bonuses for predictions made over consecutive days',
simple: `For prediction streaks`,
detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
},
comments_by_followed_users_on_watched_markets: {
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',
},
profit_loss_updates: {
simple: 'Weekly profit and loss updates',
detailed: 'Weekly profit and loss updates',
simple: 'Weekly portfolio updates',
detailed: 'Weekly portfolio updates',
},
referral_bonuses: {
simple: 'For referring new users',
@ -209,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets',
},
your_contract_closed: {
simple: 'Your market has closed and you need to resolve it',
detailed: '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 (necessary)',
necessary: true,
},
all_comments_on_watched_markets: {
simple: 'All new comments',
@ -240,6 +242,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: 'New badges awarded',
detailed: 'New badges you have earned',
},
opt_out_all: {
simple: 'Opt out of all notifications (excludes when your markets close)',
detailed:
'Opt out of all notifications excluding your own market closure notifications',
},
}
export type BettingStreakData = {

View File

@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
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 }
})

View File

@ -3,10 +3,19 @@ import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
subtitle: string
content: JSONContent
creatorId: string // User id
createdTime: number
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_SUBTITLE_LENGTH = 480

View File

@ -1,20 +1,22 @@
export type Stats = {
startDate: number
dailyActiveUsers: number[]
dailyActiveUsersWeeklyAvg: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
d1: number[]
d1WeeklyAvg: number[]
nd1: number[]
nd1WeeklyAvg: number[]
nw1: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
dailyActivationRate: number[]
dailyActivationRateWeeklyAvg: number[]
manaBet: {
daily: number[]
weekly: number[]

View File

@ -8,6 +8,7 @@ type AnyTxnType =
| UniqueBettorBonus
| BettingStreakBonus
| CancelUniqueBettorBonus
| CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -31,6 +32,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS'
| 'COMMENT_BOUNTY'
| 'REFUND_COMMENT_BOUNTY'
// Any extra data
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 TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink
@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
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[]
thank_you_for_purchases: notification_destination_types[]
badges_awarded: notification_destination_types[]
opt_out_all: notification_destination_types[]
// When adding a new notification preference, use add-new-notification-preference.ts to existing users
}
export const getDefaultNotificationPreferences = (
@ -66,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
return {
const defaults: notification_preferences = {
// Watched Markets
all_comments_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),
betting_streaks: constructPref(true, false),
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),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
@ -122,7 +124,11 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: 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
@ -185,10 +191,18 @@ export const getNotificationDestinationsForUser = (
? notificationSettings[subscriptionType]
: []
}
const optOutOfAllSettings = notificationSettings['opt_out_all']
// Your market closure notifications are high priority, opt-out doesn't affect their delivery
const optedOutOfEmail =
optOutOfAllSettings.includes('email') &&
subscriptionType !== 'your_contract_closed'
const optedOutOfBrowser =
optOutOfAllSettings.includes('browser') &&
subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser'),
sendToEmail: destinations.includes('email') && !optedOutOfEmail,
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`,
}

View File

@ -34,6 +34,8 @@ export type User = {
allTime: number
}
fractionResolvedCorrectly: number
nextLoanCached: number
followerCountCached: number
@ -72,12 +74,8 @@ export type PrivateUser = {
username: string // denormalized from User
email?: string
unsubscribedFromResolutionEmails?: boolean
unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean
weeklyTrendingEmailSent?: boolean
weeklyPortfolioUpdateEmailSent?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string
@ -87,6 +85,7 @@ export type PrivateUser = {
twitchName: string
controlToken: string
botEnabled?: boolean
needsRelinking?: boolean
}
}
@ -102,9 +101,11 @@ export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
// TODO: remove. Hardcoding the strings would be better.
// Different views require different language.
export const BETTOR = ENV_CONFIG.bettor ?? 'trader'
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'traders'
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'trade'
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) {
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('$', '')
}

View File

@ -1,4 +1,3 @@
import { MAX_TAG_LENGTH } from '../contract'
import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote'
@ -24,7 +23,8 @@ import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
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 */
export function getUrl(text: string) {
@ -32,34 +32,6 @@ export function getUrl(text: string) {
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
export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
@ -103,8 +75,31 @@ export const exhibitExts = [
Mention,
Iframe,
TiptapTweet,
TiptapSpoiler,
]
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]]
}
}
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 HOUR_MS = 60 * MINUTE_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.
Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned.
@ -64,24 +65,23 @@ Requires no authorization.
Gets a group by its slug.
Requires no authorization.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets`
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
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
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.
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
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
volume7Days: number
@ -408,7 +411,7 @@ Requires no authorization.
type FullMarket = LiteMarket & {
bets: Bet[]
comments: Comment[]
answers?: Answer[]
answers?: Answer[] // dpm-2 markets only
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds
}
@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
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.
- `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).
@ -569,6 +572,12 @@ For numeric markets, you must also provide:
- `min`: The minimum 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:
@ -582,6 +591,18 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"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`
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`.
- `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.
- `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:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `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:
@ -745,6 +769,7 @@ Requires no authorization.
## 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-06-08: Add paging to markets endpoint
- 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
- [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$.
- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety!
## 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)
## Writeups
- [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
- [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
- 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)
- 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)
## 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?
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.
### What is the name for the currency Manifold uses, represented by M$?
Manifold Dollars, or mana for short.
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.
### 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",
"queryScope": "COLLECTION_GROUP",

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -78,7 +78,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& 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} {
@ -102,7 +102,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
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;
allow update: if isAdmin();
match /comments/{commentId} {
@ -171,33 +171,32 @@ service cloud.firestore {
allow read;
}
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
}
match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
}
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 delete: if request.auth.uid == resource.data.userId;
}
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 delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /posts/{postId} {
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:
- 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`

View File

@ -39,14 +39,16 @@
"lodash": "4.17.21",
"mailgun-js": "0.22.0",
"module-alias": "2.2.2",
"react-masonry-css": "1.0.16",
"node-fetch": "2",
"stripe": "8.194.0",
"zod": "3.17.2"
},
"devDependencies": {
"@types/mailgun-js": "0.22.12",
"@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
}

View File

@ -14,7 +14,7 @@ import {
export { APIError } from '../../common/api'
type Output = Record<string, unknown>
type AuthedUser = {
export type AuthedUser = {
uid: string
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 { getValues } from './utils'
import { APIError, newEndpoint, validate } from './api'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({
contractId: z.string().max(MAX_ANSWER_LENGTH),
@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer
})
await addUserToContractFollowers(contractId, auth.uid)
return answer
})

View File

@ -61,6 +61,8 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
anyoneCanJoin,
totalContracts: 0,
totalMembers: memberIds.length,
postIds: [],
pinnedItems: [],
}
await groupRef.create(group)

View File

@ -15,8 +15,8 @@ import {
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { chargeUser, getContract, isProd } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { isProd } from './utils'
import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import {
@ -36,7 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { uniq, zip } from 'lodash'
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(() =>
z.intersection(
@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({
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 {
question,
description,
@ -101,234 +105,244 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
outcomeType,
groupId,
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') {
let initialValue
;({ min, max, initialValue, isLogScale } = validate(
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.')
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body))
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 (outcomeType === 'PSEUDO_NUMERIC')
if (initialProb < 1 || initialProb > 99)
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(
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') {
;({ initialProb } = validate(binarySchema, req.body))
}
console.log(
'creating contract for',
user.username,
'on',
question,
'ante:',
ante || 0
)
if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body))
}
// convert string descriptions into JSONContent
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()
if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
const user = userDoc.data() as User
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 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}.`)
const providerId = deservesFreeMarket
? isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
: user.id
let group: Group | null = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
if (ante) {
const delta = FieldValue.increment(-ante)
const providerDoc = firestore.collection('users').doc(providerId)
await trans.update(providerDoc, { balance: delta, totalDeposits: delta })
}
group = groupDoc.data() as Group
const groupMembersSnap = await firestore
.collection(`groups/${groupId}/groupMembers`)
.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 (deservesFreeMarket) {
await trans.update(firestore.collection('users').doc(user.id), {
freeMarketsCreated: FieldValue.increment(1),
})
}
}
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
await contractRef.create(contract)
const lp = getCpmmInitialLiquidity(
providerId,
contract as CPMMBinaryContract,
liquidityDoc.id,
ante
)
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)
if (group != null) {
const groupContractsSnap = await trans.get(
firestore.collection(`groups/${groupId}/groupContracts`)
)
const groupContracts = groupContractsSnap.docs.map(
(doc) => doc.data() as { contractId: string; createdTime: number }
)
)
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)
await noneAnswerDoc.set(noneAnswer)
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
await createGroupLinks(trans, group, [contractRef.id], auth.uid)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const groupContractRef = firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
const anteBet = getFreeAnswerAnte(
providerId,
contract as FreeResponseContract,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
} else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
await trans.set(groupContractRef, {
contractId: contract.id,
createdTime: Date.now(),
})
}
}
const anteBet = getNumericAnte(
providerId,
contract as NumericContract,
ante,
anteBetDoc.id
)
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
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 preexistingContract = await getContractFromSlug(proposedSlug)
const preexistingContract = await getContractFromSlug(trans, proposedSlug)
return preexistingContract
? proposedSlug + '-' + randomString()
@ -337,46 +351,42 @@ const getSlug = async (question: string) => {
const firestore = admin.firestore()
export async function getContractFromSlug(slug: string) {
const snap = await firestore
.collection('contracts')
.where('slug', '==', slug)
.get()
async function getContractFromSlug(trans: Transaction, slug: string) {
const snap = await trans.get(
firestore.collection('contracts').where('slug', '==', slug)
)
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}
async function createGroupLinks(
trans: Transaction,
group: Group,
contractIds: string[],
userId: string
) {
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)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
})
await trans.update(contractRef, {
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
})
}
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
createdTime: Date.now(),
} as GroupLink,
...(contract?.groupLinks ?? []),
],
})
await trans.update(contractRef, {
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
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 { slugify } from '../../common/util/slugify'
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 { JSONContent } from '@tiptap/core'
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(() =>
z.intersection(
@ -33,12 +40,21 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
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) => {
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)
if (!creator)
@ -50,16 +66,51 @@ export const createpost = newEndpoint({}, async (req, auth) => {
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,
creatorId: creator.id,
slug,
title,
subtitle,
createdTime: Date.now(),
content: content,
}
contractSlug,
})
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 }
})

View File

@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
}
await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -483,11 +483,7 @@
color: #999;
text-decoration: underline;
margin: 0;
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
">our Discord</a>!
</td>
</tr>
</table>

View File

@ -9,7 +9,7 @@
<head>
<meta name="viewport" content="width=device-width" />
<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">
img {
@ -214,14 +214,14 @@
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;">
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/>
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/>
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>
</div>
</td>

View File

@ -9,7 +9,7 @@
<head>
<meta name="viewport" content="width=device-width" />
<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">
img {
@ -214,14 +214,14 @@
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;">
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/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
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).
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 trader, (although we won't send you any more emails about it for this market).
<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>
</div>
</td>

View File

@ -192,7 +192,7 @@
tips on comments and markets</span></li>
<li style="line-height:23px;"><span
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>
<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;"

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;"
target="_blank" href="https://manifold.markets/referrals"><span
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
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 { sendTemplateEmail, sendTextEmail } from './send-email'
import { getUser } from './utils'
import { contractUrl, getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import {
getNotificationDestinationsForUser,
notification_preference,
} from '../../common/user-notification-preferences'
PerContractInvestmentsData,
OverallPerformanceData,
} from './weekly-portfolio-emails'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@ -152,9 +153,10 @@ export const sendWelcomeEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as notification_preference
}`
const { unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
return await sendTemplateEmail(
privateUser.email,
@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as notification_preference
}`
const { unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
return await sendTemplateEmail(
privateUser.email,
'Manifold Markets one week anniversary gift',
@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' as notification_preference
}`
const { unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'onboarding_flow'
)
return await sendTemplateEmail(
privateUser.email,
'Create your own prediction market',
@ -286,10 +290,10 @@ export const sendThankYouEmail = async (
const { name } = user
const firstName = name.split(' ')[0]
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'thank_you_for_purchases' as notification_preference
}`
const { unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'thank_you_for_purchases'
)
return await sendTemplateEmail(
privateUser.email,
@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async (
)
return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'trending_markets' as notification_preference
}`
const { unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'trending_markets'
)
const { name } = user
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) {
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 './weekly-markets-emails'
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-like'
export * from './weekly-portfolio-emails'
// v2
export * from './health'
@ -50,6 +51,8 @@ export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
export * from './close-market'
export * from './update-comment-bounty'
import { health } from './health'
import { transact } from './transact'
@ -63,9 +66,11 @@ import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
@ -88,9 +93,12 @@ const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
const addCommentBounty = toCloudFunction(addcommentbounty)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
@ -115,11 +123,14 @@ export {
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket,
unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
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',
'closed',
user,
'closed' + contract.id.slice(6, contract.id.length),
contract.id + '-closed-at-' + contract.closeTime,
contract.closeTime?.toString() ?? new Date().toString(),
{ contract }
)

View File

@ -3,7 +3,14 @@ import * as admin from 'firebase-admin'
import { keyBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { getUser, getValues, isProd, log } from './utils'
import {
getContractPath,
getUser,
getValues,
isProd,
log,
revalidateStaticProps,
} from './utils'
import {
createBetFillNotification,
createBettingStreakBonusNotification,
@ -24,8 +31,6 @@ import {
} from '../../common/antes'
import { APIError } from '../../common/api'
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 { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import {
@ -37,7 +42,7 @@ const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.runWith({ secrets: ['MAILGUN_KEY', 'API_SECRET'] })
.firestore.document('contracts/{contractId}/bets/{betId}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
@ -77,7 +82,7 @@ export const onCreateBet = functions
await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId)
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
await revalidateStaticProps(getContractPath(contract))
})
const updateBettingStreak = async (
@ -86,36 +91,42 @@ const updateBettingStreak = async (
contract: Contract,
eventId: string
) => {
const now = Date.now()
const currentDateResetTime = currentDateBettingStreakResetTime()
// if now is before reset time, use yesterday's reset time
const lastDateResetTime = currentDateResetTime - DAY_MS
const betStreakResetTime =
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
const lastBetTime = user?.lastBetTime ?? 0
const { newBettingStreak } = await firestore.runTransaction(async (trans) => {
const userDoc = firestore.collection('users').doc(user.id)
const bettor = (await trans.get(userDoc)).data() as User
const now = Date.now()
const currentDateResetTime = currentDateBettingStreakResetTime()
// if now is before reset time, use yesterday's reset time
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 (lastBetTime > betStreakResetTime) return
// If they've already bet after the reset time
if (lastBetTime > betStreakResetTime) return { newBettingStreak: undefined }
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await firestore.collection('users').doc(user.id).update({
currentBettingStreak: newBettingStreak,
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak
await trans.update(userDoc, {
currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime,
})
return { newBettingStreak }
})
// 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
if (!newBettingStreak) return
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 = {
fromId: fromUserId,
fromType: 'BANK',
@ -127,75 +138,82 @@ const updateBettingStreak = async (
description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails,
} 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('status:', result.status)
log('message:', result.message)
return
}
await createBettingStreakBonusNotification(
user,
result.txn.id,
bet,
contract,
bonusAmount,
newBettingStreak,
eventId
)
await handleBettingStreakBadgeAward(user, newBettingStreak)
if (result.txn) {
await createBettingStreakBonusNotification(
user,
result.txn.id,
bet,
contract,
result.bonusAmount,
newBettingStreak,
eventId
)
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
}
const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract,
oldContract: Contract,
eventId: string,
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 contractBets = (
await firestore.collection(`contracts/${contract.id}/bets`).get()
).docs.map((doc) => doc.data() as Bet)
const betsSnap = await trans.get(
firestore.collection(`contracts/${contract.id}/bets`)
)
if (!previousUniqueBettorIds) {
const contractBets = betsSnap.docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
log(`No bets for contract ${contract.id}`)
return
if (contractBets.length === 0) {
return { newUniqueBettorIds: undefined }
}
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 = {
contractId: contract.id,
contractId: oldContract.id,
uniqueNewBettorId: bettor.id,
}
const fromUserId = isProd()
@ -204,12 +222,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: contract.creatorId,
toId: oldContract.creatorId,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
token: 'M$',
@ -217,21 +234,25 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails,
} 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) {
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
log(`No bonus for user: ${oldContract.creatorId} - status:`, result.status)
log('message:', result.message)
} 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(
contract.creatorId,
oldContract.creatorId,
bettor,
result.txn.id,
contract,
oldContract,
result.txn.amount,
newUniqueBettorIds,
result.newUniqueBettorIds,
eventId + '-unique-bettor-bonus'
)
}

View File

@ -1,10 +1,18 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import {
getContract,
getContractPath,
getUser,
getValues,
revalidateStaticProps,
} from './utils'
import { ContractComment } from '../../common/comment'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getLargestPosition } from '../../common/calculate'
import { maxBy } from 'lodash'
import {
createCommentOrAnswerOrUpdatedContractNotification,
replied_users_info,
@ -14,6 +22,60 @@ import { addUserToContractFollowers } from './follow-market'
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
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}')
@ -32,6 +94,8 @@ export const onCreateCommentOnContract = functions
contractQuestion: contract.question,
})
await revalidateStaticProps(getContractPath(contract))
const comment = change.data() as ContractComment
const lastCommentTime = comment.createdTime
@ -45,7 +109,48 @@ export const onCreateCommentOnContract = functions
.doc(contract.id)
.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
if (comment.answerOutcome) {
answer =
@ -54,23 +159,6 @@ export const onCreateCommentOnContract = functions
(answer) => answer.id === comment.answerOutcome
)
: 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>(

View File

@ -23,12 +23,12 @@ export const onCreateUser = functions
await sendWelcomeEmail(user, privateUser)
const guideSendTime = dayjs().add(28, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
const followupSendTime = dayjs().add(48, 'hours').toString()
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
const day = dayjs().utc().day()
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return

View File

@ -10,48 +10,28 @@ import {
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE,
ProvenCorrectBadge,
} from '../../common/badge'
import { GroupContractDoc } from '../../common/group'
export const onUpdateContract = functions.firestore
.document('contracts/{contractId}')
.onUpdate(async (change, context) => {
const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context
const { closeTime, question } = contract
const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Notifications for market resolution are also handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved)
if (!previousContract.isResolved && contract.isResolved) {
// No need to notify users of resolution, that's handled in resolve-market
return await handleResolvedContract(contract)
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
await handleContractGroupUpdated(previousContract, contract)
} else if (
previousContract.closeTime !== closeTime ||
previousContract.question !== question
) {
let sourceText = ''
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
)
await handleUpdatedCloseTime(previousContract, contract, eventId)
}
})
const firestore = admin.firestore()
async function handleResolvedContract(contract: Contract) {
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,
} from '../../common/contract'
import { Bet } from '../../common/bet'
import { getUser, getValues, isProd, log, payUser } from './utils'
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
import {
getLoanPayouts,
getPayouts,
@ -171,6 +171,8 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
const userInvestments = mapValues(

View File

@ -1,12 +1,15 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Bet } from 'common/bet'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { log } from './utils'
import { removeUndefinedProps } from '../../common/util/object'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
export const scoreContracts = functions.pubsub
.schedule('every 1 hours')
export const scoreContracts = functions
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 1 hours')
.onRun(async () => {
await scoreContractsInternal()
})
@ -14,11 +17,12 @@ const firestore = admin.firestore()
async function scoreContractsInternal() {
const now = Date.now()
const lastHour = now - 60 * 60 * 1000
const last3Days = now - 1000 * 60 * 60 * 24 * 3
const hourAgo = now - HOUR_MS
const dayAgo = now - DAY_MS
const threeDaysAgo = now - DAY_MS * 3
const activeContractsSnap = await firestore
.collection('contracts')
.where('lastUpdatedTime', '>', lastHour)
.where('lastUpdatedTime', '>', hourAgo)
.get()
const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract
@ -39,16 +43,33 @@ async function scoreContractsInternal() {
for (const contract of contracts) {
const bets = await firestore
.collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', last3Days)
.where('createdTime', '>', threeDaysAgo)
.get()
const bettors = bets.docs
.map((doc) => doc.data() as Bet)
.map((bet) => bet.userId)
const score = uniq(bettors).length
if (contract.popularityScore !== score)
const popularityScore = uniq(bettors).length
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
.collection('contracts')
.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,
totalContracts: contracts.length,
totalMembers: 1,
postIds: [],
pinnedItems: [],
}
await groupRef.create(group)
// 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
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
await Promise.all(
groups.map(async (group) => {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get())
.size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
})
})
}
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() {
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
updateTotalContractsAndMembers()
// removeUnusedMemberAndContractFields()
}

View File

@ -28,6 +28,8 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post'
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
const app = express()
@ -60,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)
@ -69,6 +73,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
app.listen(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 { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 },
@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.')
return
}
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id)
@ -31,28 +34,36 @@ export const unsubscribe: EndpointDefinition = {
const previousDestinations =
user.notificationPreferences[notificationSubscriptionType]
let newDestinations = previousDestinations
if (wantsToOptOutAll) newDestinations.push('email')
else
newDestinations = previousDestinations.filter(
(destination) => destination !== 'email'
)
console.log(previousDestinations)
const { email } = user
const update: Partial<PrivateUser> = {
notificationPreferences: {
...user.notificationPreferences,
[notificationSubscriptionType]: previousDestinations.filter(
(destination) => destination !== 'email'
),
[notificationSubscriptionType]: newDestinations,
},
}
await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
res.send(
`
<!DOCTYPE html>
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
if (wantsToOptOutAll) {
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>Manifold Markets 7th Day Anniversary Gift!</title>
<title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
@ -163,19 +174,6 @@ export const unsubscribe: EndpointDefinition = {
</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;">
@ -186,20 +184,9 @@ export const unsubscribe: EndpointDefinition = {
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:
${email} has opted out of receiving unnecessary email notifications
</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>
</td>
@ -219,9 +206,193 @@ export const unsubscribe: EndpointDefinition = {
</div>
</div>
</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>
`
)
)
}
},
}

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 { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '8GB', timeoutSeconds: 540 })
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
@ -30,16 +31,18 @@ async function updateLoansCore() {
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
const userPortfolios = await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
const userPortfolios = filterDefined(
await Promise.all(
users.map(async (user) => {
const portfolio = await getValues<PortfolioMetrics>(
firestore
.collection(`users/${user.id}/portfolioHistory`)
.orderBy('timestamp', 'desc')
.limit(1)
)
return portfolio[0]
})
)
)
log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)

View File

@ -17,38 +17,57 @@ import {
computeVolume,
} from '../../common/calculate-metrics'
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()
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories, groups] =
await Promise.all([
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 users')
const users = await getValues<User>(firestore.collection('users'))
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(
groups.map((group) => {
return getValues(
groups.map((group) =>
getValues(
firestore
.collection('groups')
.doc(group.id)
.collection('groupContracts')
)
})
)
)
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
@ -116,6 +135,28 @@ export async function updateMetricsCore() {
lastPortfolio.investmentValue !== newPortfolio.investmentValue
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 {
user,
@ -123,6 +164,7 @@ export async function updateMetricsCore() {
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}
})
@ -144,6 +186,7 @@ export async function updateMetricsCore() {
newPortfolio,
newProfit,
didPortfolioChange,
newFractionResolvedCorrectly,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
@ -153,6 +196,7 @@ export async function updateMetricsCore() {
creatorVolumeCached: newCreatorVolume,
profitCached: newProfit,
nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
},
},
@ -224,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: 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 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 { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment'
import { User } from '../../common/user'
import { Stats } from '../../common/stats'
import { DAY_MS } from '../../common/util/time'
import { average } from '../../common/util/math'
const firestore = admin.firestore()
const numberOfDays = 90
const numberOfDays = 180
const getBetsQuery = (startTime: number, endTime: number) =>
firestore
@ -103,7 +110,7 @@ export async function getDailyNewUsers(
}
export const updateStatsCore = async () => {
const today = Date.now()
const today = dayjs().tz('America/Los_Angeles').startOf('day').valueOf()
const startDate = today - numberOfDays * DAY_MS
log('Fetching data for stats update...')
@ -139,73 +146,128 @@ export const updateStatsCore = async () => {
)
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 start = Math.max(0, i - 6)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
const end = i + 1
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
return uniques.size
})
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
const end = i + 1
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
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 twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 7),
end: Math.max(0, i - 6),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i,
end: i + 1,
}
const activeTwoWeeksAgo = new Set<string>()
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
}
const activeLastWeek = new Set<string>()
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
}
const activeTwoWeeksAgo = new Set<string>(
dailyUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
)
const activeLastWeek = new Set<string>(
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
)
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
return retainedCount / activeTwoWeeksAgo.size
})
const monthlyRetention = dailyUserIds.map((_userId, i) => {
const twoMonthsAgo = {
start: Math.max(0, i - 60),
end: Math.max(0, i - 30),
start: Math.max(0, i - 59),
end: Math.max(0, i - 29),
}
const lastMonth = {
start: Math.max(0, i - 30),
end: i,
start: Math.max(0, i - 29),
end: i + 1,
}
const activeTwoMonthsAgo = new Set<string>()
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
}
const activeLastMonth = new Set<string>()
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
}
const activeTwoMonthsAgo = new Set<string>(
dailyUserIds.slice(twoMonthsAgo.start, twoMonthsAgo.end).flat()
)
const activeLastMonth = new Set<string>(
dailyUserIds.slice(lastMonth.start, lastMonth.end).flat()
)
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
activeLastMonth.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
if (activeTwoMonthsAgo.size === 0) return 0
return retainedCount / activeTwoMonthsAgo.size
})
const firstBetDict: { [userId: string]: number } = {}
@ -216,52 +278,20 @@ export const updateStatsCore = async () => {
firstBetDict[bet.userId] = i
}
}
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
let activatedCount = 0
let newUsers = 0
for (let j = start; j <= end; j++) {
const userIds = dailyNewUsers[j].map((user) => user.id)
newUsers += userIds.length
for (const userId of userIds) {
const dayIndex = firstBetDict[userId]
if (dayIndex !== undefined && dayIndex <= end) {
activatedCount++
}
}
}
const frac = activatedCount / (newUsers || 1)
return Math.round(frac * 100 * 100) / 100
const dailyActivationRate = dailyNewUsers.map((newUsers, i) => {
const activedCount = sumBy(newUsers, (user) => {
const firstBet = firstBetDict[user.id]
return firstBet === i ? 1 : 0
})
return activedCount / newUsers.length
})
const dailyActivationRateWeeklyAvg = dailyActivationRate.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(dailyActivationRate.slice(start, end))
})
const dailySignups = dailyNewUsers.map((users) => users.length)
const dailyTopTenthActions = zip(
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))
})
const dailySignups = dailyNewUsers.map((users) => users.length)
// Total mana divided by 100.
const dailyManaBet = dailyBets.map((bets) => {
@ -269,37 +299,39 @@ export const updateStatsCore = async () => {
})
const weeklyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const end = i + 1
const total = sum(dailyManaBet.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start)
return total
})
const monthlyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const end = i + 1
const total = sum(dailyManaBet.slice(start, end))
const range = end - start + 1
const range = end - start
if (range < 30) return (total * 30) / range
return total
})
const statsData = {
const statsData: Stats = {
startDate: startDate.valueOf(),
dailyActiveUsers,
dailyActiveUsersWeeklyAvg,
weeklyActiveUsers,
monthlyActiveUsers,
d1,
d1WeeklyAvg,
nd1,
nd1WeeklyAvg,
nw1,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
dailySignups,
weekOnWeekRetention,
weeklyActivationRate,
dailyActivationRate,
dailyActivationRateWeeklyAvg,
monthlyRetention,
topTenthActions: {
daily: dailyTopTenthActions,
weekly: weeklyTopTenthActions,
monthly: monthlyTopTenthActions,
},
manaBet: {
daily: dailyManaBet,
weekly: weeklyManaBet,
@ -311,6 +343,6 @@ export const updateStatsCore = async () => {
}
export const updateStats = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

@ -1,4 +1,5 @@
import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { chunk } from 'lodash'
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 = {
doc: admin.firestore.DocumentReference
fields: { [k: string]: unknown }
@ -153,3 +166,11 @@ export const chargeUser = (
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 {
getAllPrivateUsers,
getGroup,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { sendInterestingMarketsEmail } from './emails'
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 { Follow } from '../../common/follow'
import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1')
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19-20 * * 1')
.timeZone('Etc/UTC')
.onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers()
@ -40,18 +43,30 @@ export async function getTrendingContracts() {
)
}
async function sendTrendingMarketsEmailsToAllUsers() {
export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6
const privateUsers = isProd()
? await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return (
!user.unsubscribedFromWeeklyTrendingEmails &&
!user.weeklyTrendingEmailSent
: filterDefined([
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
])
const privateUsersToSendEmailsTo = privateUsers
// Get all users that haven't unsubscribed from weekly emails
.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(
'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length,
@ -68,38 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e')
)
.slice(0, 20)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
.slice(0, 50)
const uniqueTrendingContracts = removeSimilarQuestions(
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) {
if (!privateUser.email) {
log(`No email for ${privateUser.username}`)
continue
}
const contractsAvailableToSend = trendingContracts.filter((contract) => {
return !contract.uniqueBettorIds?.includes(privateUser.id)
const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => {
const unresolvedContracts = await getValues<Contract>(
firestore
.collection('contracts')
.where('isResolved', '==', false)
.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
}
// choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset(
contractsAvailableToSend,
numContractsToSend
)
const sortedMarkets = uniqBy(
unBetOnContractsFromFollows.flat(),
(contract) => contract.id
)
.filter(
(contract) =>
contract.popularityScore !== undefined &&
contract.popularityScore > MINIMUM_POPULARITY_SCORE
)
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
const user = await getUser(privateUser.id)
if (!user) continue
const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets,
sortedMarkets,
true
)
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true,
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
// log(
// '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
@ -110,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng)
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,
resolution,
} = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<meta charset="utf-8">
<title>Generated Image</title>
<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>
<style>
${getTemplateCss(theme, fontSize)}
@ -109,8 +104,8 @@ export function getHtml(parsedReq: ParsedRequest) {
</div>
<div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-tight">
${truncatedQuestion}
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4">
${question}
</div>
<div class="flex flex-col">
${
@ -127,7 +122,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<!-- Metadata -->
<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}
</div>
</div>

View File

@ -24,8 +24,5 @@
"prettier": "2.7.1",
"ts-node": "10.9.1",
"typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
}
}

View File

@ -15,7 +15,7 @@ export function SEO(props: {
return (
<Head>
<title>{title} | Manifold Markets</title>
<title>{`${title} | Manifold Markets`}</title>
<meta
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 React from 'react'
import React, { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { Col } from './layout/col'
import { SiteLink } from './site-link'
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: {
amount: number | undefined
@ -34,46 +34,58 @@ export function AmountInput(props: {
const isInvalid = !str || isNaN(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 && (
<div className="mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide text-red-500">
{error === 'Insufficient balance' ? (
<>
Not enough funds.
<span className="ml-1 text-indigo-500">
<SiteLink href="/add-funds">Buy more?</SiteLink>
</span>
</>
) : (
error
)}
</div>
)}
</Col>
const [addFundsModalOpen, setAddFundsModalOpen] = useState(false)
return (
<>
<Col className={className}>
<label className="font-sm md:font-lg relative">
<span className="text-greyscale-4 absolute top-1/2 my-auto ml-2 -translate-y-1/2">
{label}
</span>
<input
className={clsx(
'placeholder:text-greyscale-4 border-greyscale-2 rounded-md pl-9',
error && 'input-error',
'w-24 md:w-auto',
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 (
<>
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
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"
<Row className="gap-4">
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
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 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>
<Spacer h={6} />
{user ? (
<WarningConfirmationButton
size="xl"
marketType="freeResponse"
amount={betAmount}
warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : ''
)}
color={'indigo'}
actionLabel="Buy"
/>
) : (
<BetSignUpPrompt />

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import { sum } from 'lodash'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col'
@ -9,6 +9,7 @@ import { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object'
import { BETTOR, PAST_BETS } from 'common/user'
export function AnswerResolvePanel(props: {
isAdmin: boolean
@ -32,6 +33,18 @@ export function AnswerResolvePanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
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 () => {
if (resolveOption === 'CHOOSE' && answers.length !== 1) return
@ -72,17 +85,6 @@ export function AnswerResolvePanel(props: {
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 (
<Col className="gap-4 rounded">
<Row className="justify-between">
@ -116,16 +118,34 @@ export function AnswerResolvePanel(props: {
Clear
</button>
)}
<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}
isSubmitting={isSubmitting}
openModalButtonClass={resolutionButtonClass}
submitButtonClass={resolutionButtonClass}
/>
</Row>
</Col>
{!!error && <div className="text-red-500">{error}</div>}
{!!warning && <div className="text-warning">{warning}</div>}
</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 { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
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 { Avatar } from 'web/components/avatar'
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 { useAdmin } from 'web/hooks/use-admin'
import { needsAdminToResolve } from 'web/pages/[username]/[contractSlug]'
import { CATEGORY_COLORS } from '../charts/contract/choice'
import { useChartAnswers } from '../charts/contract/choice'
export function AnswersPanel(props: {
contract: FreeResponseContract | MultipleChoiceContract
@ -39,6 +38,7 @@ export function AnswersPanel(props: {
const answers = (useAnswers(contract.id) ?? contract.answers).filter(
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
)
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
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 [resolveOption, setResolveOption] = useState<
@ -67,12 +72,6 @@ export function AnswersPanel(props: {
const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems(
contract,
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
user
)
const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob })
@ -106,6 +105,10 @@ export function AnswersPanel(props: {
? 'checkbox'
: undefined
const colorSortedAnswer = useChartAnswers(contract).map(
(value, _index) => value.text
)
return (
<Col className="gap-3">
{(resolveOption || resolution) &&
@ -123,28 +126,31 @@ export function AnswersPanel(props: {
))}
{!resolveOption && (
<div className={clsx('flow-root pr-2 md:pr-0')}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
{answerItems.map((item) => (
<div key={item.id} className={'relative pb-2'}>
<div className="relative flex items-start space-x-3">
<OpenAnswer {...item} />
</div>
</div>
))}
<Row className={'justify-end'}>
{hasZeroBetAnswers && !showAllAnswers && (
<Button
color={'gray-white'}
onClick={() => setShowAllAnswers(true)}
size={'md'}
>
Show More
</Button>
)}
</Row>
</div>
</div>
<Col
className={clsx(
'gap-2 pr-2 md:pr-0',
tradingAllowed(contract) ? '' : '-mb-6'
)}
>
{answerItems.map((item) => (
<OpenAnswer
key={item.id}
answer={item}
contract={contract}
colorIndex={colorSortedAnswer.indexOf(item.text)}
/>
))}
{hasZeroBetAnswers && !showAllAnswers && (
<Button
className="self-end"
color="gray-white"
onClick={() => setShowAllAnswers(true)}
size="md"
>
Show More
</Button>
)}
</Col>
)}
{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: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
type: string
colorIndex: number | undefined
}) {
const { answer, contract } = props
const { username, avatarUrl, name, text } = answer
const { answer, contract, colorIndex } = props
const { username, avatarUrl, text } = answer
const prob = getDpmOutcomeProbability(contract.totalShares, answer.id)
const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false)
const color =
colorIndex != undefined ? CATEGORY_COLORS[colorIndex] : '#B1B1C7'
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">
<AnswerBetPanel
answer={answer}
@ -223,47 +206,44 @@ function OpenAnswer(props: {
/>
</Modal>
<div
className="pointer-events-none absolute -mx-2 h-full rounded-tl-md bg-green-600 bg-opacity-10"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
<Row className="my-4 gap-3">
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
<div className={'align-items flex w-full justify-end gap-4 '}>
<span
className={clsx(
'text-2xl',
tradingAllowed(contract) ? 'text-primary' : 'text-gray-500'
)}
>
{probPercent}
</span>
<BuyButton
className={clsx(
'btn-sm flex-initial !px-6 sm:flex',
tradingAllowed(contract) ? '' : '!hidden'
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col>
</Col>
</Row>
<Col
className={clsx(
'bg-greyscale-1 relative w-full rounded-lg transition-all',
tradingAllowed(contract) ? 'text-greyscale-7' : 'text-greyscale-5'
)}
>
<Row className="z-20 -mb-1 justify-between gap-2 py-2 px-3">
<Row>
<Avatar
className="mt-0.5 mr-2 inline h-5 w-5 border border-transparent transition-transform hover:border-none"
username={username}
avatarUrl={avatarUrl}
/>
<Linkify
className="text-md cursor-pointer whitespace-pre-line"
text={text}
/>
</Row>
<Row className="gap-2">
<div className="my-auto text-xl">{probPercent}</div>
{tradingAllowed(contract) && (
<Button
size="2xs"
color="gray-outline"
onClick={() => setOpen(true)}
className="my-auto"
>
BUY
</Button>
)}
</Row>
</Row>
<hr
color={color}
className="absolute z-0 h-full w-full rounded-l-lg border-none opacity-30"
style={{ width: `${100 * Math.max(prob, 0.01)}%` }}
/>
</Col>
</Col>
)
}

View File

@ -1,25 +1,27 @@
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 { toast } from 'react-hot-toast'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { useMemberGroups } from 'web/hooks/use-group'
import { filterDefined } from 'common/util/array'
import { isArray, keyBy } from 'lodash'
import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null | undefined
homeSections: string[]
setHomeSections: (sections: string[]) => void
sections: { label: string; id: string; group?: Group }[]
setSectionIds: (sections: string[]) => void
}) {
const { user, homeSections, setHomeSections } = props
const { sections, setSectionIds } = props
const groups = useMemberGroups(user?.id) ?? []
const { itemsById, sections } = getHomeItems(groups, homeSections)
const sectionsById = keyBy(sections, 'id')
return (
<DragDropContext
@ -27,14 +29,14 @@ export function ArrangeHome(props: {
const { destination, source, draggableId } = e
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)
newHomeSections.splice(destination.index, 0, item.id)
newSectionIds.splice(source.index, 1)
newSectionIds.splice(destination.index, 0, section.id)
setHomeSections(newHomeSections)
setSectionIds(newSectionIds)
}}
>
<Row className="relative max-w-md gap-4">
@ -46,8 +48,9 @@ export function ArrangeHome(props: {
function DraggableList(props: {
title: string
items: { id: string; label: string }[]
items: { id: string; label: string; group?: Group }[]
}) {
const user = useUser()
const { title, items } = props
return (
<Droppable droppableId={title.toLowerCase()}>
@ -72,6 +75,7 @@ function DraggableList(props: {
snapshot.isDragging && 'z-[9000] bg-gray-200'
)}
item={item}
user={user}
/>
</div>
)}
@ -85,49 +89,53 @@ function DraggableList(props: {
}
const SectionItem = (props: {
item: { id: string; label: string }
item: { id: string; label: string; group?: Group }
user: User | null | undefined
className?: string
}) => {
const { item, className } = props
const { item, user, className } = props
const { group } = item
return (
<div
<Row
className={clsx(
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
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{item.label}
</div>
<Row className="items-center gap-4">
<MenuIcon
className="h-5 w-5 flex-shrink-0 text-gray-500"
aria-hidden="true"
/>{' '}
{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
// 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 CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
@ -68,11 +68,11 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser])
useEffect(() => {
if (authUser != null) {
if (authUser) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
} else if (authUser === null) {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])

View File

@ -8,13 +8,14 @@ export function Avatar(props: {
username?: string
avatarUrl?: string
noLink?: boolean
size?: number | 'xs' | 'sm'
size?: number | 'xxs' | 'xs' | 'sm'
className?: string
}) {
const { username, noLink, size, className } = props
const [avatarUrl, setAvatarUrl] = useState(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 onClick =
@ -40,7 +41,7 @@ export function Avatar(props: {
style={{ maxWidth: `${s * 0.25}rem` }}
src={avatarUrl}
onClick={onClick}
alt={username}
alt={`${username ?? 'Unknown user'} avatar`}
onError={() => {
// If the image doesn't load, clear the avatarUrl to show the default
// 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 clsx from 'clsx'
import { SimpleBetPanel } from './bet-panel'
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
import { BuyPanel, SimpleBetPanel } from './bet-panel'
import {
BinaryContract,
CPMMBinaryContract,
PseudoNumericContract,
} from 'common/contract'
import { Modal } from './layout/modal'
import { useUser } from 'web/hooks/use-user'
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 { Button } from 'web/components/button'
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 */
export default function BetButton(props: {
@ -42,7 +49,7 @@ export default function BetButton(props: {
)}
onClick={() => setOpen(true)}
>
{PRESENT_BET}
Predict
</Button>
) : (
<BetSignUpPrompt />
@ -65,7 +72,6 @@ export default function BetButton(props: {
<SimpleBetPanel
className={betPanelClassName}
contract={contract}
selected="YES"
onBetSuccess={() => setOpen(false)}
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,
YesLabel,
} from './outcome-label'
import { getProbability } from 'common/calculate'
import { getContractBetMetrics, getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
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 { WarningConfirmationButton } from './warning-confirmation-button'
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: {
contract: CPMMBinaryContract | PseudoNumericContract
@ -105,11 +109,10 @@ export function BetPanel(props: {
export function SimpleBetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
className?: string
selected?: 'YES' | 'NO'
hasShares?: boolean
onBetSuccess?: () => void
}) {
const { contract, className, selected, hasShares, onBetSuccess } = props
const { contract, className, hasShares, onBetSuccess } = props
const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -139,7 +142,6 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
selected={selected}
onBuySuccess={onBetSuccess}
/>
<LimitOrderPanel
@ -162,38 +164,47 @@ export function SimpleBetPanel(props: {
)
}
function BuyPanel(props: {
export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
hidden: boolean
selected?: 'YES' | 'NO'
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 isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>(selected)
const [betAmount, setBetAmount] = useState<number | undefined>(undefined)
const [outcome, setOutcome] = useState<'YES' | 'NO' | undefined>()
const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const [inputRef, focusAmountInput] = useFocus()
function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice)
setWasSubmitted(false)
if (!isIOS() && !isAndroid()) {
focusAmountInput()
}
}
function mobileOnBetChoice(choice: 'YES' | 'NO' | undefined) {
if (outcome === choice) {
setOutcome(undefined)
} else {
setOutcome(choice)
}
if (!isIOS() && !isAndroid()) {
focusAmountInput()
}
}
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
if (!outcome) {
setOutcome('YES')
@ -214,9 +225,13 @@ function BuyPanel(props: {
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
if (onBuySuccess) onBuySuccess()
else {
toast('Trade submitted!', {
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
})
}
})
.catch((e) => {
if (e instanceof APIError) {
@ -249,6 +264,7 @@ function BuyPanel(props: {
unfilledBets as LimitBet[]
)
const [seeLimit, setSeeLimit] = useState(false)
const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb)
@ -281,92 +297,133 @@ function BuyPanel(props: {
return (
<Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
{isPseudoNumeric ? 'Direction' : 'Outcome'}
</div>
<YesNoSelector
className="mb-4"
btnClassName="flex-1"
selected={outcome}
onSelect={(choice) => onBetChoice(choice)}
onSelect={(choice) => {
if (mobileView) {
mobileOnBetChoice(choice)
} else {
onBetChoice(choice)
}
}}
isPseudoNumeric={isPseudoNumeric}
/>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</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'
<Col
className={clsx(
mobileView
? outcome === 'NO'
? 'bg-red-25'
: outcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500'
)}
/>
)}
? 'bg-teal-50'
: '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>
)
}
@ -389,7 +446,6 @@ function LimitOrderPanel(props: {
const betChoice = 'YES'
const [error, setError] = useState<string | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const rangeError =
lowLimitProb !== undefined &&
@ -437,7 +493,6 @@ function LimitOrderPanel(props: {
const noAmount = shares * (1 - (noLimitProb ?? 0))
function onBetChange(newAmount: number | undefined) {
setWasSubmitted(false)
setBetAmount(newAmount)
}
@ -482,7 +537,6 @@ function LimitOrderPanel(props: {
.then((r) => {
console.log('placed bet. Result:', r)
setIsSubmitting(false)
setWasSubmitted(true)
setBetAmount(undefined)
setLowLimitProb(undefined)
setHighLimitProb(undefined)
@ -718,8 +772,6 @@ function LimitOrderPanel(props: {
: `Submit order${hasTwoBets ? 's' : ''}`}
</button>
)}
{wasSubmitted && <div className="mt-4">Order submitted!</div>}
</Col>
)
}
@ -780,13 +832,21 @@ export function SellPanel(props: {
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
const isSellingAllShares = amount === Math.floor(shares)
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() {
if (!user || !amount) return
@ -831,8 +891,23 @@ export function SellPanel(props: {
sharesOutcome,
unfilledBets
)
const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis
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 [yesBets, noBets] = partition(
openUserBets,
@ -866,24 +941,24 @@ export function SellPanel(props: {
<>
<AmountInput
amount={
amount
? Math.round(amount) === 0
? 0
: Math.floor(amount)
: undefined
amount ? (Math.round(amount) === 0 ? 0 : Math.floor(amount)) : 0
}
onChange={onAmountChange}
label="Qty"
error={error}
disabled={isSubmitting}
inputClassName="w-full"
inputClassName="w-full ml-1"
/>
<Col className="mt-3 w-full gap-3 text-sm">
<Row className="items-center justify-between gap-2 text-gray-500">
Sale proceeds
Sale amount
<span className="text-neutral">{formatMoney(saleValue)}</span>
</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">
<div className="text-gray-500">
{isPseudoNumeric ? 'Estimated value' : 'Probability'}
@ -894,24 +969,33 @@ export function SellPanel(props: {
{format(resultProb)}
</div>
</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>
<Spacer h={8} />
<button
className={clsx(
'btn flex-1',
betDisabled
? 'btn-disabled'
: sharesOutcome === 'YES'
? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500',
isSubmitting ? 'loading' : ''
)}
onClick={betDisabled ? undefined : submitSell}
>
{isSubmitting ? 'Submitting...' : 'Submit sell'}
</button>
<WarningConfirmationButton
marketType="binary"
amount={undefined}
warning={warning}
isSubmitting={isSubmitting}
onSubmit={betDisabled ? undefined : submitSell}
disabled={!!betDisabled}
size="xl"
color="blue"
actionLabel={`Sell ${Math.floor(soldShares)} shares`}
/>
{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 dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
@ -22,7 +21,7 @@ import {
import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api'
import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
import { OutcomeLabel } from './outcome-label'
import { LoadingIndicator } from './loading-indicator'
import { SiteLink } from './site-link'
import {
@ -38,14 +37,19 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
import { UserLink } from 'web/components/user-link'
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 BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -76,8 +80,14 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('open')
const [sort, setSort] = usePersistentState<BetSort>('newest', {
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 start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE
@ -150,39 +160,35 @@ export function BetsList(props: { user: User }) {
unsettled,
(c) => contractsMetrics[c.id].payout
)
const currentNetInvestment = sumBy(
unsettled,
(c) => contractsMetrics[c.id].netPayout
)
const currentLoan = sumBy(unsettled, (c) => contractsMetrics[c.id].loan)
const totalPnl = user.profitCached.allTime
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
<Row className="gap-8">
<Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4">
<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">
{formatMoney(currentNetInvestment)}{' '}
{formatMoney(currentBetsValue)}{' '}
<ProfitBadge profitPercent={investedProfitPercent} />
</div>
</Col>
<Col>
<div className="text-sm text-gray-500">Total profit</div>
<div className="text-lg">
{formatMoney(totalPnl)}{' '}
<ProfitBadge profitPercent={totalProfitPercent} />
<div className="text-greyscale-6 text-xs sm:text-sm">
Total loans
</div>
<div className="text-lg">{formatMoney(currentLoan)}</div>
</Col>
</Row>
<Row className="gap-8">
<Row className="gap-2">
<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}
onChange={(e) => setFilter(e.target.value as BetFilter)}
>
@ -195,7 +201,7 @@ export function BetsList(props: { user: User }) {
</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}
onChange={(e) => setSort(e.target.value as BetSort)}
>
@ -346,8 +352,7 @@ function ContractBets(props: {
<BetsSummary
className="mt-8 mr-5 flex-1 sm:mr-8"
contract={contract}
bets={bets}
isYourBets={isYourBets}
userBets={bets}
/>
{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: {
contract: Contract
bets: Bet[]
@ -610,18 +496,24 @@ function BetRow(props: {
const isNumeric = outcomeType === '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 ? (
'ANTE'
) : saleAmount !== undefined ? (
<>{formatMoney(saleAmount)} (sold)</>
) : saleBet ? (
<>{formatMoney(payout)} (sold)</>
) : (
formatMoney(
isResolved
? resolvedPayout(contract, bet)
: calculateSaleAmount(contract, bet, unfilledBets)
)
formatMoney(payout)
)
const payoutIfChosenDisplay =
@ -722,10 +614,10 @@ function SellButton(props: {
return (
<ConfirmationButton
openModalBtn={{
className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'),
label: 'Sell',
disabled: isSubmitting,
}}
submitBtn={{ className: 'btn-primary', label: 'Sell' }}
submitBtn={{ label: 'Sell', color: 'green' }}
onSubmit={async () => {
setIsSubmitting(true)
await sellBet({ contractId: contract.id, betId: bet.id })
@ -753,27 +645,3 @@ function SellButton(props: {
</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 clsx from 'clsx'
import { LoadingIndicator } from 'web/components/loading-indicator'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type ColorType =
@ -9,18 +10,57 @@ export type ColorType =
| 'indigo'
| 'yellow'
| 'gray'
| 'gray-outline'
| 'gradient'
| 'gray-white'
| '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: {
className?: string
onClick?: MouseEventHandler<any> | undefined
children?: ReactNode
size?: SizeType
color?: ColorType
color?: ColorType | 'override'
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
loading?: boolean
}) {
const {
children,
@ -30,41 +70,17 @@ export function Button(props: {
color = 'indigo',
type = 'button',
disabled = false,
loading,
} = 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 (
<button
type={type}
className={clsx(
'font-md items-center justify-center rounded-md border border-transparent shadow-sm hover:transition-colors disabled:cursor-not-allowed disabled:opacity-50',
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}
className={clsx(buttonClass(size, color), className)}
disabled={disabled || loading}
onClick={onClick}
>
{loading && <LoadingIndicator className={'mr-2 border-gray-500'} />}
{children}
</button>
)

View File

@ -6,9 +6,10 @@ export function PillButton(props: {
onSelect: () => void
color?: string
xs?: boolean
className?: string
children: ReactNode
}) {
const { children, selected, onSelect, color, xs } = props
const { children, selected, onSelect, color, xs, className } = props
return (
<button
@ -17,7 +18,8 @@ export function PillButton(props: {
xs ? 'text-xs' : '',
selected
? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3'
: 'bg-greyscale-2 hover:bg-greyscale-3',
className
)}
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