This commit is contained in:
marsteralex 2022-09-20 12:48:57 -04:00
commit b1ac37ea87
72 changed files with 1371 additions and 1338 deletions

View File

@ -1,4 +1,4 @@
import { maxBy, sortBy, sum, sumBy } from 'lodash' import { maxBy, partition, sortBy, sum, sumBy } from 'lodash'
import { Bet, LimitBet } from './bet' import { Bet, LimitBet } from './bet'
import { import {
calculateCpmmSale, calculateCpmmSale,
@ -255,3 +255,43 @@ export function getTopAnswer(
) )
return top?.answer return top?.answer
} }
export function getLargestPosition(contract: Contract, userBets: Bet[]) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
if (userBets.length === 0) {
return null
}
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerCounts: { [outcome: string]: number } = {}
for (const bet of userBets) {
if (bet.outcome) {
if (!answerCounts[bet.outcome]) {
answerCounts[bet.outcome] = bet.amount
} else {
answerCounts[bet.outcome] += bet.amount
}
}
}
const majorityAnswer =
maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? ''
return {
prob: undefined,
shares: answerCounts[majorityAnswer] || 0,
outcome: majorityAnswer,
}
}
const [yesBets, noBets] = partition(userBets, (bet) => bet.outcome === 'YES')
yesShares = sumBy(yesBets, (bet) => bet.shares)
noShares = sumBy(noBets, (bet) => bet.shares)
yesFloorShares = Math.floor(yesShares)
noFloorShares = Math.floor(noShares)
const shares = yesFloorShares || noFloorShares
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
return { shares, outcome }
}

View File

@ -33,6 +33,11 @@ export type OnContract = {
// denormalized from bet // denormalized from bet
betAmount?: number betAmount?: number
betOutcome?: string betOutcome?: string
// denormalized based on betting history
commenterPositionProb?: number // binary only
commenterPositionShares?: number
commenterPositionOutcome?: string
} }
export type OnGroup = { export type OnGroup = {

View File

@ -148,7 +148,7 @@ export const OUTCOME_TYPES = [
'NUMERIC', 'NUMERIC',
] as const ] as const
export const MAX_QUESTION_LENGTH = 480 export const MAX_QUESTION_LENGTH = 240
export const MAX_DESCRIPTION_LENGTH = 16000 export const MAX_DESCRIPTION_LENGTH = 16000
export const MAX_TAG_LENGTH = 60 export const MAX_TAG_LENGTH = 60

View File

@ -7,7 +7,7 @@ export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000 export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
// for sus users, i.e. multiple sign ups for same person // for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10 export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500 export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_AMOUNT =

View File

@ -16,4 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba', cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
// this is Phil's deployment
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
} }

View File

@ -2,6 +2,7 @@ export type EnvConfig = {
domain: string domain: string
firebaseConfig: FirebaseConfig firebaseConfig: FirebaseConfig
amplitudeApiKey?: string amplitudeApiKey?: string
twitchBotEndpoint?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and // IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
@ -66,6 +67,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D', measurementId: 'G-SSFK1Q138D',
}, },
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
cloudRunId: 'nggbo3neva', cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
adminEmails: [ adminEmails: [
@ -82,9 +84,9 @@ export const PROD_CONFIG: EnvConfig = {
visibility: 'PUBLIC', visibility: 'PUBLIC',
moneyMoniker: 'M$', moneyMoniker: 'M$',
bettor: 'predictor', bettor: 'trader',
pastBet: 'prediction', pastBet: 'trade',
presentBet: 'predict', presentBet: 'trade',
navbarLogoPath: '', navbarLogoPath: '',
faviconPath: '/favicon.ico', faviconPath: '/favicon.ico',
newQuestionPlaceholders: [ newQuestionPlaceholders: [

View File

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

View File

@ -56,11 +56,6 @@ export type PrivateUser = {
username: string // denormalized from User username: string // denormalized from User
email?: string email?: string
unsubscribedFromResolutionEmails?: boolean
unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
unsubscribedFromWeeklyTrendingEmails?: boolean
weeklyTrendingEmailSent?: boolean weeklyTrendingEmailSent?: boolean
manaBonusEmailSent?: boolean manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
@ -86,9 +81,11 @@ export type PortfolioMetrics = {
export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor // TODO: remove. Hardcoding the strings would be better.
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors' // Different views require different language.
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict export const BETTOR = ENV_CONFIG.bettor ?? 'trader'
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets' export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'traders'
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'trade'
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'trades'
export const PAST_BET = ENV_CONFIG.pastBet ?? 'trade'
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'trades'

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read; allow read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']); .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules // User referral rules
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
@ -78,7 +78,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin(); allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin()) allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']); .hasOnly(['apiKey', 'notificationPreferences', 'twitchInfo']);
} }
match /private-users/{userId}/views/{viewId} { match /private-users/{userId}/views/{viewId} {
@ -171,33 +171,32 @@ service cloud.firestore {
allow read; allow read;
} }
match /groups/{groupId} { match /groups/{groupId} {
allow read; allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} { match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
} }
match /groupMembers/{memberId}{ match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId; allow delete: if request.auth.uid == resource.data.userId;
} }
function isGroupMember() { function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid)); return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
} }
match /comments/{commentId} { match /comments/{commentId} {
allow read; allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember(); allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
} }
}
}
match /posts/{postId} { match /posts/{postId} {
allow read; allow read;

View File

@ -65,5 +65,6 @@ Adapted from https://firebase.google.com/docs/functions/get-started
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows: Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows:
- Set a secret: `$ firebase functions:secrets:set stripe.test_secret="THE-API-KEY"` - Set a secret: `$ firebase functions:secrets:set STRIPE_APIKEY`
- Then, enter the secret in the prompt.
- Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY` - Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY`

View File

@ -39,6 +39,7 @@
"lodash": "4.17.21", "lodash": "4.17.21",
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"node-fetch": "2",
"react-masonry-css": "1.0.16", "react-masonry-css": "1.0.16",
"stripe": "8.194.0", "stripe": "8.194.0",
"zod": "3.17.2" "zod": "3.17.2"
@ -46,6 +47,7 @@
"devDependencies": { "devDependencies": {
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"@types/module-alias": "2.0.1", "@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3" "firebase-functions-test": "0.3.3"
}, },
"private": true "private": true

View File

@ -9,7 +9,7 @@
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>New unique predictors on your market</title> <title>New unique traders on your market</title>
<style type="text/css"> <style type="text/css">
img { img {
@ -214,14 +214,14 @@
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user! Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first trade from a user!
<br/> <br/>
<br/> <br/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
creating a market that appeals to others, and we'll do so for each new predictor. creating a market that appeals to others, and we'll do so for each new trader.
<br/> <br/>
<br/> <br/>
Keep up the good work and check out your newest predictor below! Keep up the good work and check out your newest trader below!
</span></p> </span></p>
</div> </div>
</td> </td>

View File

@ -9,7 +9,7 @@
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>New unique predictors on your market</title> <title>New unique traders on your market</title>
<style type="text/css"> <style type="text/css">
img { img {
@ -214,14 +214,14 @@
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users! Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> has attracted {{totalPredictors}} total traders!
<br/> <br/>
<br/> <br/>
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors, We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new traders,
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market). and we'll continue to do so for each new trader, (although we won't send you any more emails about it for this market).
<br/> <br/>
<br/> <br/>
Keep up the good work and check out your newest predictors below! Keep up the good work and check out your newest traders below!
</span></p> </span></p>
</div> </div>
</td> </td>

View File

@ -192,7 +192,7 @@
tips on comments and markets</span></li> tips on comments and markets</span></li>
<li style="line-height:23px;"><span <li style="line-height:23px;"><span
style="font-family:Arial, sans-serif;font-size:18px;">Unique style="font-family:Arial, sans-serif;font-size:18px;">Unique
predictor bonus for each user who predicts on your trader bonus for each user who trades on your
markets</span></li> markets</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a <li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;" class="link-build-content" style="color:inherit;; text-decoration: none;"

View File

@ -210,7 +210,7 @@
class="link-build-content" style="color:inherit;; text-decoration: none;" class="link-build-content" style="color:inherit;; text-decoration: none;"
target="_blank" href="https://manifold.markets/referrals"><span target="_blank" href="https://manifold.markets/referrals"><span
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
your friends</u></span></a> and earn M$500 for each signup!</span></li> your friends</u></span></a> and earn M$250 for each signup!</span></li>
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a <li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
class="link-build-content" style="color:inherit;; text-decoration: none;" class="link-build-content" style="color:inherit;; text-decoration: none;"

View File

@ -3,7 +3,14 @@ import * as admin from 'firebase-admin'
import { keyBy, uniq } from 'lodash' import { keyBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
import { getUser, getValues, isProd, log } from './utils' import {
getContractPath,
getUser,
getValues,
isProd,
log,
revalidateStaticProps,
} from './utils'
import { import {
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
@ -24,8 +31,6 @@ import {
} from '../../common/antes' } from '../../common/antes'
import { APIError } from '../../common/api' import { APIError } from '../../common/api'
import { User } from '../../common/user' import { User } from '../../common/user'
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
import { addHouseLiquidity } from './add-liquidity'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
@ -33,7 +38,7 @@ const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions export const onCreateBet = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY', 'API_SECRET'] })
.firestore.document('contracts/{contractId}/bets/{betId}') .firestore.document('contracts/{contractId}/bets/{betId}')
.onCreate(async (change, context) => { .onCreate(async (change, context) => {
const { contractId } = context.params as { const { contractId } = context.params as {
@ -73,7 +78,7 @@ export const onCreateBet = functions
await notifyFills(bet, contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId) await updateBettingStreak(bettor, bet, contract, eventId)
await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) await revalidateStaticProps(getContractPath(contract))
}) })
const updateBettingStreak = async ( const updateBettingStreak = async (
@ -82,36 +87,42 @@ const updateBettingStreak = async (
contract: Contract, contract: Contract,
eventId: string eventId: string
) => { ) => {
const now = Date.now() const { newBettingStreak } = await firestore.runTransaction(async (trans) => {
const currentDateResetTime = currentDateBettingStreakResetTime() const userDoc = firestore.collection('users').doc(user.id)
// if now is before reset time, use yesterday's reset time const bettor = (await trans.get(userDoc)).data() as User
const lastDateResetTime = currentDateResetTime - DAY_MS const now = Date.now()
const betStreakResetTime = const currentDateResetTime = currentDateBettingStreakResetTime()
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime // if now is before reset time, use yesterday's reset time
const lastBetTime = user?.lastBetTime ?? 0 const lastDateResetTime = currentDateResetTime - DAY_MS
const betStreakResetTime =
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
const lastBetTime = bettor?.lastBetTime ?? 0
// If they've already bet after the reset time // If they've already bet after the reset time
if (lastBetTime > betStreakResetTime) return if (lastBetTime > betStreakResetTime) return { newBettingStreak: undefined }
const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak // Otherwise, add 1 to their betting streak
await firestore.collection('users').doc(user.id).update({ await trans.update(userDoc, {
currentBettingStreak: newBettingStreak, currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime,
})
return { newBettingStreak }
}) })
if (!newBettingStreak) return
// Send them the bonus times their streak
const bonusAmount = Math.min(
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
BETTING_STREAK_BONUS_MAX
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
// Send them the bonus times their streak
const bonusAmount = Math.min(
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
BETTING_STREAK_BONUS_MAX
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const bonusTxnDetails = {
currentBettingStreak: newBettingStreak,
}
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUserId, fromId: fromUserId,
fromType: 'BANK', fromType: 'BANK',
@ -123,74 +134,80 @@ const updateBettingStreak = async (
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails, data: bonusTxnDetails,
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'> } as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn) const { message, txn, status } = await runTxn(trans, bonusTxn)
return { message, txn, status, bonusAmount }
}) })
if (!result.txn) { if (result.status != 'success') {
log("betting streak bonus txn couldn't be made") log("betting streak bonus txn couldn't be made")
log('status:', result.status) log('status:', result.status)
log('message:', result.message) log('message:', result.message)
return return
} }
if (result.txn)
await createBettingStreakBonusNotification( await createBettingStreakBonusNotification(
user, user,
result.txn.id, result.txn.id,
bet, bet,
contract, contract,
bonusAmount, result.bonusAmount,
newBettingStreak, newBettingStreak,
eventId eventId
) )
} }
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract, oldContract: Contract,
eventId: string, eventId: string,
bettor: User bettor: User
) => { ) => {
let previousUniqueBettorIds = contract.uniqueBettorIds const { newUniqueBettorIds } = await firestore.runTransaction(
async (trans) => {
const contractDoc = firestore.collection(`contracts`).doc(oldContract.id)
const contract = (await trans.get(contractDoc)).data() as Contract
let previousUniqueBettorIds = contract.uniqueBettorIds
if (!previousUniqueBettorIds) { const betsSnap = await trans.get(
const contractBets = ( firestore.collection(`contracts/${contract.id}/bets`)
await firestore.collection(`contracts/${contract.id}/bets`).get() )
).docs.map((doc) => doc.data() as Bet) if (!previousUniqueBettorIds) {
const contractBets = betsSnap.docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) { if (contractBets.length === 0) {
log(`No bets for contract ${contract.id}`) return { newUniqueBettorIds: undefined }
return }
previousUniqueBettorIds = uniq(
contractBets
.filter((bet) => bet.createdTime < BONUS_START_DATE)
.map((bet) => bet.userId)
)
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId)
return { newUniqueBettorIds: undefined }
return { newUniqueBettorIds }
} }
)
if (!newUniqueBettorIds) return
previousUniqueBettorIds = uniq(
contractBets
.filter((bet) => bet.createdTime < BONUS_START_DATE)
.map((bet) => bet.userId)
)
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
// No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
if (contract.mechanism === 'cpmm-1') {
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
}
// Create combined txn for all new unique bettors
const bonusTxnDetails = { const bonusTxnDetails = {
contractId: contract.id, contractId: oldContract.id,
uniqueNewBettorId: bettor.id, uniqueNewBettorId: bettor.id,
} }
const fromUserId = isProd() const fromUserId = isProd()
@ -199,12 +216,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
const fromSnap = await firestore.doc(`users/${fromUserId}`).get() const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.') if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User const fromUser = fromSnap.data() as User
// TODO: set the id of the txn to the eventId to prevent duplicates
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUser.id, fromId: fromUser.id,
fromType: 'BANK', fromType: 'BANK',
toId: contract.creatorId, toId: oldContract.creatorId,
toType: 'USER', toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT, amount: UNIQUE_BETTOR_BONUS_AMOUNT,
token: 'M$', token: 'M$',
@ -212,21 +228,25 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails, data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
return await runTxn(trans, bonusTxn) const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn }
}) })
if (result.status != 'success' || !result.txn) { if (result.status != 'success' || !result.txn) {
log(`No bonus for user: ${contract.creatorId} - status:`, result.status) log(`No bonus for user: ${oldContract.creatorId} - status:`, result.status)
log('message:', result.message) log('message:', result.message)
} else { } else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) log(
`Bonus txn for user: ${oldContract.creatorId} completed:`,
result.txn?.id
)
await createUniqueBettorBonusNotification( await createUniqueBettorBonusNotification(
contract.creatorId, oldContract.creatorId,
bettor, bettor,
result.txn.id, result.txn.id,
contract, oldContract,
result.txn.amount, result.txn.amount,
newUniqueBettorIds, result.newUniqueBettorIds,
eventId + '-unique-bettor-bonus' eventId + '-unique-bettor-bonus'
) )
} }

View File

@ -1,10 +1,18 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { compact } from 'lodash' import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils' import {
getContract,
getContractPath,
getUser,
getValues,
revalidateStaticProps,
} from './utils'
import { ContractComment } from '../../common/comment' import { ContractComment } from '../../common/comment'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getLargestPosition } from '../../common/calculate'
import { maxBy } from 'lodash'
import { import {
createCommentOrAnswerOrUpdatedContractNotification, createCommentOrAnswerOrUpdatedContractNotification,
replied_users_info, replied_users_info,
@ -32,6 +40,8 @@ export const onCreateCommentOnContract = functions
contractQuestion: contract.question, contractQuestion: contract.question,
}) })
await revalidateStaticProps(getContractPath(contract))
const comment = change.data() as ContractComment const comment = change.data() as ContractComment
const lastCommentTime = comment.createdTime const lastCommentTime = comment.createdTime
@ -45,6 +55,32 @@ export const onCreateCommentOnContract = functions
.doc(contract.id) .doc(contract.id)
.update({ lastCommentTime, lastUpdatedTime: Date.now() }) .update({ lastCommentTime, lastUpdatedTime: Date.now() })
const previousBetsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.where('createdTime', '<', comment.createdTime)
.get()
const previousBets = previousBetsQuery.docs.map((d) => d.data() as Bet)
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
}
await change.ref.update(fields)
}
let bet: Bet | undefined let bet: Bet | undefined
let answer: Answer | undefined let answer: Answer | undefined
if (comment.answerOutcome) { if (comment.answerOutcome) {

View File

@ -4,14 +4,8 @@ import * as utc from 'dayjs/plugin/utc'
dayjs.extend(utc) dayjs.extend(utc)
import { getPrivateUser } from './utils' import { getPrivateUser } from './utils'
import { User } from 'common/user' import { User } from '../../common/user'
import { import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
sendCreatorGuideEmail,
sendInterestingMarketsEmail,
sendPersonalFollowupEmail,
sendWelcomeEmail,
} from './emails'
import { getTrendingContracts } from './weekly-markets-emails'
export const onCreateUser = functions export const onCreateUser = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -23,23 +17,6 @@ export const onCreateUser = functions
await sendWelcomeEmail(user, privateUser) await sendWelcomeEmail(user, privateUser)
const guideSendTime = dayjs().add(28, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
const followupSendTime = dayjs().add(48, 'hours').toString() const followupSendTime = dayjs().add(48, 'hours').toString()
await sendPersonalFollowupEmail(user, privateUser, followupSendTime) await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
// 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
const contracts = await getTrendingContracts()
const marketsSendTime = dayjs().add(24, 'hours').toString()
await sendInterestingMarketsEmail(
user,
privateUser,
contracts,
marketsSendTime
)
}) })

View File

@ -3,19 +3,18 @@ import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils' import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlag = functions export const resetWeeklyEmailsFlag = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent) timeoutSeconds: 300,
.pubsub.schedule('0 7 * * 1') 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') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
const privateUsers = await getAllPrivateUsers() 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() const firestore = admin.firestore()
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => { privateUsers.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({ return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false, weeklyTrendingEmailSent: false,
}) })

View File

@ -9,7 +9,7 @@ import {
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, getValues, isProd, log, payUser } from './utils' import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
import { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
@ -171,6 +171,8 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
await processPayouts([...payouts, ...loanPayouts]) await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
const userInvestments = mapValues( const userInvestments = mapValues(

View File

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

@ -7,6 +7,7 @@ import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification' import { createLoanIncomeNotification } from './create-notification'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -30,16 +31,18 @@ async function updateLoansCore() {
log( log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
) )
const userPortfolios = await Promise.all( const userPortfolios = filterDefined(
users.map(async (user) => { await Promise.all(
const portfolio = await getValues<PortfolioMetrics>( users.map(async (user) => {
firestore const portfolio = await getValues<PortfolioMetrics>(
.collection(`users/${user.id}/portfolioHistory`) firestore
.orderBy('timestamp', 'desc') .collection(`users/${user.id}/portfolioHistory`)
.limit(1) .orderBy('timestamp', 'desc')
) .limit(1)
return portfolio[0] )
}) return portfolio[0]
})
)
) )
log(`Loaded ${userPortfolios.length} portfolios`) log(`Loaded ${userPortfolios.length} portfolios`)
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId) const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)

View File

@ -22,7 +22,7 @@ import { Group } from 'common/group'
const firestore = admin.firestore() const firestore = admin.firestore()
export const updateMetrics = functions export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 }) .runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes') .pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore) .onRun(updateMetricsCore)

View File

@ -1,11 +1,18 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash' import * as dayjs from 'dayjs'
import * as utc from 'dayjs/plugin/utc'
import * as timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
import { range, zip, uniq, sum, sumBy } from 'lodash'
import { getValues, log, logMemory } from './utils' import { getValues, log, logMemory } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Stats } from '../../common/stats'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { average } from '../../common/util/math' import { average } from '../../common/util/math'
@ -103,7 +110,7 @@ export async function getDailyNewUsers(
} }
export const updateStatsCore = async () => { export const updateStatsCore = async () => {
const today = Date.now() const today = dayjs().tz('America/Los_Angeles').startOf('day').valueOf()
const startDate = today - numberOfDays * DAY_MS const startDate = today - numberOfDays * DAY_MS
log('Fetching data for stats update...') log('Fetching data for stats update...')
@ -139,73 +146,128 @@ export const updateStatsCore = async () => {
) )
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length) const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
const dailyActiveUsersWeeklyAvg = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(dailyActiveUsers.slice(start, end))
})
const weeklyActiveUsers = dailyUserIds.map((_, i) => { const weeklyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6) const start = Math.max(0, i - 6)
const end = i const end = i + 1
const uniques = new Set<string>() const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size return uniques.size
}) })
const monthlyActiveUsers = dailyUserIds.map((_, i) => { const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 29) const start = Math.max(0, i - 29)
const end = i const end = i + 1
const uniques = new Set<string>() const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size return uniques.size
}) })
const d1 = dailyUserIds.map((userIds, i) => {
if (i === 0) return 0
const uniques = new Set(userIds)
const yesterday = dailyUserIds[i - 1]
const retainedCount = sumBy(yesterday, (userId) =>
uniques.has(userId) ? 1 : 0
)
return retainedCount / uniques.size
})
const d1WeeklyAvg = d1.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(d1.slice(start, end))
})
const dailyNewUserIds = dailyNewUsers.map((users) => users.map((u) => u.id))
const nd1 = dailyUserIds.map((userIds, i) => {
if (i === 0) return 0
const uniques = new Set(userIds)
const yesterday = dailyNewUserIds[i - 1]
const retainedCount = sumBy(yesterday, (userId) =>
uniques.has(userId) ? 1 : 0
)
return retainedCount / uniques.size
})
const nd1WeeklyAvg = nd1.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i + 1
return average(nd1.slice(start, end))
})
const nw1 = dailyNewUserIds.map((_userIds, i) => {
if (i < 13) return 0
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 6),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i + 1,
}
const newTwoWeeksAgo = new Set<string>(
dailyNewUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
)
const activeLastWeek = new Set<string>(
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
)
const retainedCount = sumBy(Array.from(newTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
return retainedCount / newTwoWeeksAgo.size
})
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => { const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
const twoWeeksAgo = { const twoWeeksAgo = {
start: Math.max(0, i - 13), start: Math.max(0, i - 13),
end: Math.max(0, i - 7), end: Math.max(0, i - 6),
} }
const lastWeek = { const lastWeek = {
start: Math.max(0, i - 6), start: Math.max(0, i - 6),
end: i, end: i + 1,
} }
const activeTwoWeeksAgo = new Set<string>() const activeTwoWeeksAgo = new Set<string>(
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) { dailyUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId)) )
} const activeLastWeek = new Set<string>(
const activeLastWeek = new Set<string>() dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
for (let j = lastWeek.start; j <= lastWeek.end; j++) { )
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) => const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0 activeLastWeek.has(userId) ? 1 : 0
) )
const retainedFrac = retainedCount / activeTwoWeeksAgo.size return retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
}) })
const monthlyRetention = dailyUserIds.map((_userId, i) => { const monthlyRetention = dailyUserIds.map((_userId, i) => {
const twoMonthsAgo = { const twoMonthsAgo = {
start: Math.max(0, i - 60), start: Math.max(0, i - 59),
end: Math.max(0, i - 30), end: Math.max(0, i - 29),
} }
const lastMonth = { const lastMonth = {
start: Math.max(0, i - 30), start: Math.max(0, i - 29),
end: i, end: i + 1,
} }
const activeTwoMonthsAgo = new Set<string>() const activeTwoMonthsAgo = new Set<string>(
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) { dailyUserIds.slice(twoMonthsAgo.start, twoMonthsAgo.end).flat()
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId)) )
} const activeLastMonth = new Set<string>(
const activeLastMonth = new Set<string>() dailyUserIds.slice(lastMonth.start, lastMonth.end).flat()
for (let j = lastMonth.start; j <= lastMonth.end; j++) { )
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) => const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
activeLastMonth.has(userId) ? 1 : 0 activeLastMonth.has(userId) ? 1 : 0
) )
const retainedFrac = retainedCount / activeTwoMonthsAgo.size if (activeTwoMonthsAgo.size === 0) return 0
return Math.round(retainedFrac * 100 * 100) / 100 return retainedCount / activeTwoMonthsAgo.size
}) })
const firstBetDict: { [userId: string]: number } = {} const firstBetDict: { [userId: string]: number } = {}
@ -216,52 +278,20 @@ export const updateStatsCore = async () => {
firstBetDict[bet.userId] = i firstBetDict[bet.userId] = i
} }
} }
const weeklyActivationRate = dailyNewUsers.map((_, i) => { const dailyActivationRate = dailyNewUsers.map((newUsers, i) => {
const start = Math.max(0, i - 6) const activedCount = sumBy(newUsers, (user) => {
const end = i const firstBet = firstBetDict[user.id]
let activatedCount = 0 return firstBet === i ? 1 : 0
let newUsers = 0 })
for (let j = start; j <= end; j++) { return activedCount / newUsers.length
const userIds = dailyNewUsers[j].map((user) => user.id) })
newUsers += userIds.length const dailyActivationRateWeeklyAvg = dailyActivationRate.map((_, i) => {
for (const userId of userIds) { const start = Math.max(0, i - 6)
const dayIndex = firstBetDict[userId] const end = i + 1
if (dayIndex !== undefined && dayIndex <= end) { return average(dailyActivationRate.slice(start, end))
activatedCount++
}
}
}
const frac = activatedCount / (newUsers || 1)
return Math.round(frac * 100 * 100) / 100
}) })
const dailySignups = dailyNewUsers.map((users) => users.length)
const dailyTopTenthActions = zip( const dailySignups = dailyNewUsers.map((users) => users.length)
dailyContracts,
dailyBets,
dailyComments
).map(([contracts, bets, comments]) => {
const userIds = concat(
contracts?.map((c) => c.creatorId) ?? [],
bets?.map((b) => b.userId) ?? [],
comments?.map((c) => c.userId) ?? []
)
const counts = Object.values(countBy(userIds))
const sortedCounts = sortBy(counts, (count) => count).reverse()
if (sortedCounts.length === 0) return 0
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
return tenthPercentile
})
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
// Total mana divided by 100. // Total mana divided by 100.
const dailyManaBet = dailyBets.map((bets) => { const dailyManaBet = dailyBets.map((bets) => {
@ -269,37 +299,39 @@ export const updateStatsCore = async () => {
}) })
const weeklyManaBet = dailyManaBet.map((_, i) => { const weeklyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 6) const start = Math.max(0, i - 6)
const end = i const end = i + 1
const total = sum(dailyManaBet.slice(start, end)) const total = sum(dailyManaBet.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start) if (end - start < 7) return (total * 7) / (end - start)
return total return total
}) })
const monthlyManaBet = dailyManaBet.map((_, i) => { const monthlyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 29) const start = Math.max(0, i - 29)
const end = i const end = i + 1
const total = sum(dailyManaBet.slice(start, end)) const total = sum(dailyManaBet.slice(start, end))
const range = end - start + 1 const range = end - start
if (range < 30) return (total * 30) / range if (range < 30) return (total * 30) / range
return total return total
}) })
const statsData = { const statsData: Stats = {
startDate: startDate.valueOf(), startDate: startDate.valueOf(),
dailyActiveUsers, dailyActiveUsers,
dailyActiveUsersWeeklyAvg,
weeklyActiveUsers, weeklyActiveUsers,
monthlyActiveUsers, monthlyActiveUsers,
d1,
d1WeeklyAvg,
nd1,
nd1WeeklyAvg,
nw1,
dailyBetCounts, dailyBetCounts,
dailyContractCounts, dailyContractCounts,
dailyCommentCounts, dailyCommentCounts,
dailySignups, dailySignups,
weekOnWeekRetention, weekOnWeekRetention,
weeklyActivationRate, dailyActivationRate,
dailyActivationRateWeeklyAvg,
monthlyRetention, monthlyRetention,
topTenthActions: {
daily: dailyTopTenthActions,
weekly: weeklyTopTenthActions,
monthly: monthlyTopTenthActions,
},
manaBet: { manaBet: {
daily: dailyManaBet, daily: dailyManaBet,
weekly: weeklyManaBet, weekly: weeklyManaBet,

View File

@ -1,4 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import fetch from 'node-fetch'
import { chunk } from 'lodash' import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -17,6 +18,18 @@ export const logMemory = () => {
} }
} }
export const revalidateStaticProps = async (
// Path after domain: e.g. "/JamesGrugett/will-pete-buttigieg-ever-be-us-pres"
pathToRevalidate: string
) => {
if (isProd()) {
const apiSecret = process.env.API_SECRET as string
const queryStr = `?pathToRevalidate=${pathToRevalidate}&apiSecret=${apiSecret}`
await fetch('https://manifold.markets/api/v0/revalidate' + queryStr)
console.log('Revalidated', pathToRevalidate)
}
}
export type UpdateSpec = { export type UpdateSpec = {
doc: admin.firestore.DocumentReference doc: admin.firestore.DocumentReference
fields: { [k: string]: unknown } fields: { [k: string]: unknown }
@ -153,3 +166,7 @@ export const chargeUser = (
return updateUserBalance(userId, -charge, isAnte) return updateUserBalance(userId, -charge, isAnte)
} }
export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}`
}

View File

@ -16,7 +16,7 @@ import { DAY_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for an hour at 12pm PT (UTC -07:00) // every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 1') .pubsub.schedule('* 19 * * 1')
.timeZone('Etc/UTC') .timeZone('Etc/UTC')
@ -48,7 +48,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
// get all users that haven't unsubscribed from weekly emails // get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers.filter((user) => { const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
return ( return (
!user.unsubscribedFromWeeklyTrendingEmails && user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent !user.weeklyTrendingEmailSent
) )
}) })

View File

@ -1,5 +1,6 @@
import { Point, ResponsiveLine } from '@nivo/line' import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx' import clsx from 'clsx'
import { formatPercent } from 'common/util/format'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { zip } from 'lodash' import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
@ -63,18 +64,21 @@ export function DailyPercentChart(props: {
startDate: number startDate: number
dailyPercent: number[] dailyPercent: number[]
small?: boolean small?: boolean
excludeFirstDays?: number
}) { }) {
const { dailyPercent, startDate, small } = props const { dailyPercent, startDate, small, excludeFirstDays } = props
const { width } = useWindowSize() const { width } = useWindowSize()
const dates = dailyPercent.map((_, i) => const dates = dailyPercent.map((_, i) =>
dayjs(startDate).add(i, 'day').toDate() dayjs(startDate).add(i, 'day').toDate()
) )
const points = zip(dates, dailyPercent).map(([date, betCount]) => ({ const points = zip(dates, dailyPercent)
x: date, .map(([date, percent]) => ({
y: betCount, x: date,
})) y: percent,
}))
.slice(excludeFirstDays ?? 0)
const data = [{ id: 'Percent', data: points, color: '#11b981' }] const data = [{ id: 'Percent', data: points, color: '#11b981' }]
const bottomAxisTicks = width && width < 600 ? 6 : undefined const bottomAxisTicks = width && width < 600 ? 6 : undefined
@ -93,7 +97,7 @@ export function DailyPercentChart(props: {
type: 'time', type: 'time',
}} }}
axisLeft={{ axisLeft={{
format: (value) => `${value}%`, format: formatPercent,
}} }}
axisBottom={{ axisBottom={{
tickValues: bottomAxisTicks, tickValues: bottomAxisTicks,
@ -109,15 +113,15 @@ export function DailyPercentChart(props: {
margin={{ top: 20, right: 28, bottom: 22, left: 40 }} margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => { sliceTooltip={({ slice }) => {
const point = slice.points[0] const point = slice.points[0]
return <Tooltip point={point} /> return <Tooltip point={point} isPercent />
}} }}
/> />
</div> </div>
) )
} }
function Tooltip(props: { point: Point }) { function Tooltip(props: { point: Point; isPercent?: boolean }) {
const { point } = props const { point, isPercent } = props
return ( return (
<Col className="border border-gray-300 bg-white py-2 px-3"> <Col className="border border-gray-300 bg-white py-2 px-3">
<div <div
@ -126,7 +130,8 @@ function Tooltip(props: { point: Point }) {
color: point.serieColor, color: point.serieColor,
}} }}
> >
<strong>{point.serieId}</strong> {point.data.yFormatted} <strong>{point.serieId}</strong>{' '}
{isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)}
</div> </div>
<div>{dayjs(point.data.x).format('MMM DD')}</div> <div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col> </Col>

View File

@ -1,6 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import { sum } from 'lodash' import { sum } from 'lodash'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -9,6 +9,7 @@ import { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector' import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button' import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { BETTOR, PAST_BETS } from 'common/user'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
isAdmin: boolean isAdmin: boolean
@ -32,6 +33,18 @@ export function AnswerResolvePanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>(undefined) const [error, setError] = useState<string | undefined>(undefined)
const [warning, setWarning] = useState<string | undefined>(undefined)
useEffect(() => {
if (resolveOption === 'CANCEL') {
setWarning(
`All ${PAST_BETS} will be returned. Unique ${BETTOR} bonuses will be
withdrawn from your account.`
)
} else {
setWarning(undefined)
}
}, [resolveOption])
const onResolve = async () => { const onResolve = async () => {
if (resolveOption === 'CHOOSE' && answers.length !== 1) return if (resolveOption === 'CHOOSE' && answers.length !== 1) return
@ -126,6 +139,7 @@ export function AnswerResolvePanel(props: {
</Col> </Col>
{!!error && <div className="text-red-500">{error}</div>} {!!error && <div className="text-red-500">{error}</div>}
{!!warning && <div className="text-warning">{warning}</div>}
</Col> </Col>
) )
} }

View File

@ -1,14 +1,22 @@
import clsx from 'clsx' import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { MenuIcon } from '@heroicons/react/solid' import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { 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: { export function ArrangeHome(props: {
sections: { label: string; id: string }[] sections: { label: string; id: string; group?: Group }[]
setSectionIds: (sections: string[]) => void setSectionIds: (sections: string[]) => void
}) { }) {
const { sections, setSectionIds } = props const { sections, setSectionIds } = props
@ -40,8 +48,9 @@ export function ArrangeHome(props: {
function DraggableList(props: { function DraggableList(props: {
title: string title: string
items: { id: string; label: string }[] items: { id: string; label: string; group?: Group }[]
}) { }) {
const user = useUser()
const { title, items } = props const { title, items } = props
return ( return (
<Droppable droppableId={title.toLowerCase()}> <Droppable droppableId={title.toLowerCase()}>
@ -66,6 +75,7 @@ function DraggableList(props: {
snapshot.isDragging && 'z-[9000] bg-gray-200' snapshot.isDragging && 'z-[9000] bg-gray-200'
)} )}
item={item} item={item}
user={user}
/> />
</div> </div>
)} )}
@ -79,23 +89,53 @@ function DraggableList(props: {
} }
const SectionItem = (props: { const SectionItem = (props: {
item: { id: string; label: string } item: { id: string; label: string; group?: Group }
user: User | null | undefined
className?: string className?: string
}) => { }) => {
const { item, className } = props const { item, user, className } = props
const { group } = item
return ( return (
<div <Row
className={clsx( className={clsx(
className, className,
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2' 'items-center justify-between gap-4 rounded bg-gray-50 p-2'
)} )}
> >
<MenuIcon <Row className="items-center gap-4">
className="h-5 w-5 flex-shrink-0 text-gray-500" <MenuIcon
aria-hidden="true" className="h-5 w-5 flex-shrink-0 text-gray-500"
/>{' '} aria-hidden="true"
{item.label} />{' '}
</div> {item.label}
</Row>
{group && (
<Button
className="pt-1 pb-1"
color="gray-white"
onClick={() => {
if (user) {
const homeSections = (user.homeSections ?? []).filter(
(id) => id !== group.id
)
updateUser(user.id, { homeSections })
toast.promise(leaveGroup(group, user.id), {
loading: 'Unfollowing group...',
success: `Unfollowed ${group.name}`,
error: "Couldn't unfollow group, try again?",
})
}
}}
>
<XCircleIcon
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
</Button>
)}
</Row>
) )
} }

View File

@ -10,7 +10,6 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
import { PRESENT_BET } from 'common/user'
/** Button that opens BetPanel in a new modal */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -42,7 +41,7 @@ export default function BetButton(props: {
)} )}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
{PRESENT_BET} Predict
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -756,9 +756,10 @@ function SellButton(props: {
export function ProfitBadge(props: { export function ProfitBadge(props: {
profitPercent: number profitPercent: number
round?: boolean
className?: string className?: string
}) { }) {
const { profitPercent, className } = props const { profitPercent, round, className } = props
if (!profitPercent) return null if (!profitPercent) return null
const colors = const colors =
profitPercent > 0 profitPercent > 0
@ -773,7 +774,9 @@ export function ProfitBadge(props: {
className className
)} )}
> >
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'} {(profitPercent > 0 ? '+' : '') +
profitPercent.toFixed(round ? 0 : 1) +
'%'}
</span> </span>
) )
} }

View File

@ -1,5 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search' import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
@ -11,7 +10,7 @@ import {
import { ShowTime } from './contract/contract-details' import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row' import { Row } from './layout/row'
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react' import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import { import {
historyStore, historyStore,
@ -28,14 +27,11 @@ import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col' import { Col } from './layout/col'
import clsx from 'clsx' import clsx from 'clsx'
import { safeLocalStorage } from 'web/lib/util/local' import { safeLocalStorage } from 'web/lib/util/local'
import {
const searchClient = algoliasearch( getIndexName,
'GJQPAYENIF', searchClient,
'75c28fc084a80e1129d427d470cf41a3' searchIndexName,
) } from 'web/lib/service/algolia'
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
export const SORTS = [ export const SORTS = [
{ label: 'Newest', value: 'newest' }, { label: 'Newest', value: 'newest' },
@ -154,7 +150,7 @@ export function ContractSearch(props: {
if (freshQuery || requestedPage < state.numPages) { if (freshQuery || requestedPage < state.numPages) {
const index = query const index = query
? searchIndex ? searchIndex
: searchClient.initIndex(`${indexPrefix}contracts-${sort}`) : searchClient.initIndex(getIndexName(sort))
const numericFilters = query const numericFilters = query
? [] ? []
: [ : [

View File

@ -309,7 +309,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip <Tooltip
text={`${formatMoney( text={`${formatMoney(
volume volume
)} bet - ${uniqueBettors} unique predictors`} )} bet - ${uniqueBettors} unique traders`}
> >
{volumeTranslation} {volumeTranslation}
</Tooltip> </Tooltip>

View File

@ -106,7 +106,6 @@ export function ContractTopTrades(props: {
contract={contract} contract={contract}
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
tips={tips[topCommentId]} tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
/> />
</div> </div>
<Spacer h={16} /> <Spacer h={16} />

View File

@ -75,7 +75,9 @@ export function ContractTabs(props: {
<> <>
<FreeResponseContractCommentsActivity <FreeResponseContractCommentsActivity
contract={contract} contract={contract}
bets={visibleBets} betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments} comments={comments}
tips={tips} tips={tips}
user={user} user={user}
@ -85,7 +87,9 @@ export function ContractTabs(props: {
<div className={'mb-4 w-full border-b border-gray-200'} /> <div className={'mb-4 w-full border-b border-gray-200'} />
<ContractCommentsActivity <ContractCommentsActivity
contract={contract} contract={contract}
bets={generalBets} betsByCurrentUser={
user ? generalBets.filter((b) => b.userId === user.id) : []
}
comments={generalComments} comments={generalComments}
tips={tips} tips={tips}
user={user} user={user}
@ -95,7 +99,9 @@ export function ContractTabs(props: {
) : ( ) : (
<ContractCommentsActivity <ContractCommentsActivity
contract={contract} contract={contract}
bets={visibleBets} betsByCurrentUser={
user ? visibleBets.filter((b) => b.userId === user.id) : []
}
comments={comments} comments={comments}
tips={tips} tips={tips}
user={user} user={user}

View File

@ -19,19 +19,21 @@ export function ProbChangeTable(props: {
const { positiveChanges, negativeChanges } = changes const { positiveChanges, negativeChanges } = changes
const threshold = 0.075 const threshold = 0.01
const countOverThreshold = Math.max( const positiveAboveThreshold = positiveChanges.filter(
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1, (c) => c.probChanges.day > threshold
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
) )
const maxRows = Math.min(positiveChanges.length, negativeChanges.length) const negativeAboveThreshold = negativeChanges.filter(
const rows = Math.min( (c) => c.probChanges.day < threshold
full ? Infinity : 3,
Math.min(maxRows, countOverThreshold)
) )
const maxRows = Math.min(
positiveAboveThreshold.length,
negativeAboveThreshold.length
)
const rows = full ? maxRows : Math.min(3, maxRows)
const filteredPositiveChanges = positiveChanges.slice(0, rows) const filteredPositiveChanges = positiveAboveThreshold.slice(0, rows)
const filteredNegativeChanges = negativeChanges.slice(0, rows) const filteredNegativeChanges = negativeAboveThreshold.slice(0, rows)
if (rows === 0) return <div className="px-4 text-gray-500">None</div> if (rows === 0) return <div className="px-4 text-gray-500">None</div>
@ -54,14 +56,14 @@ export function ProbChangeTable(props: {
function ProbChangeRow(props: { contract: CPMMContract }) { function ProbChangeRow(props: { contract: CPMMContract }) {
const { contract } = props const { contract } = props
return ( return (
<Row className="items-center hover:bg-gray-100"> <Row className="items-center justify-between gap-4 hover:bg-gray-100">
<ProbChange className="p-4 text-right text-xl" contract={contract} />
<SiteLink <SiteLink
className="p-4 pl-2 font-semibold text-indigo-700" className="p-4 pr-0 font-semibold text-indigo-700"
href={contractPath(contract)} href={contractPath(contract)}
> >
<span className="line-clamp-2">{contract.question}</span> <span className="line-clamp-2">{contract.question}</span>
</SiteLink> </SiteLink>
<ProbChange className="py-2 pr-4 text-xl" contract={contract} />
</Row> </Row>
) )
} }
@ -72,19 +74,20 @@ export function ProbChange(props: {
}) { }) {
const { contract, className } = props const { contract, className } = props
const { const {
prob,
probChanges: { day: change }, probChanges: { day: change },
} = contract } = contract
const color = const color = change >= 0 ? 'text-green-500' : 'text-red-500'
change > 0
? 'text-green-500'
: change < 0
? 'text-red-500'
: 'text-gray-600'
const str = return (
change === 0 <Col className={clsx('flex flex-col items-end', className)}>
? '+0%' <div className="mb-0.5 mr-0.5 text-2xl">
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}` {formatPercent(Math.round(100 * prob) / 100)}
return <div className={clsx(className, color)}>{str}</div> </div>
<div className={clsx('text-base', color)}>
{(change > 0 ? '+' : '') + (change * 100).toFixed(0) + '%'}
</div>
</Col>
)
} }

View File

@ -9,7 +9,6 @@ import { Row } from '../layout/row'
import { ShareEmbedButton } from '../share-embed-button' import { ShareEmbedButton } from '../share-embed-button'
import { Title } from '../title' import { Title } from '../title'
import { TweetButton } from '../tweet-button' import { TweetButton } from '../tweet-button'
import { DuplicateContractButton } from '../copy-contract-button'
import { Button } from '../button' import { Button } from '../button'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { track, withTracking } from 'web/lib/service/analytics' import { track, withTracking } from 'web/lib/service/analytics'
@ -21,6 +20,7 @@ import { REFERRAL_AMOUNT } from 'common/economy'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { useState } from 'react' import { useState } from 'react'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ShareModal(props: { export function ShareModal(props: {
contract: Contract contract: Contract
@ -56,8 +56,8 @@ export function ShareModal(props: {
</p> </p>
<Button <Button
size="2xl" size="2xl"
color="gradient" color="indigo"
className={'flex max-w-xs self-center'} className={'mb-2 flex max-w-xs self-center'}
onClick={() => { onClick={() => {
copyToClipboard(shareUrl) copyToClipboard(shareUrl)
toast.success('Link copied!', { toast.success('Link copied!', {
@ -68,38 +68,39 @@ export function ShareModal(props: {
> >
{linkIcon} Copy link {linkIcon} Copy link
</Button> </Button>
<Row className={'justify-center'}>or</Row>
{showChallenge && (
<Button
size="2xl"
color="gradient"
className={'mb-2 flex max-w-xs self-center'}
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<span> Challenge</span>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={(open) => {
if (!open) {
setOpenCreateChallengeModal(false)
setOpen(false)
} else setOpenCreateChallengeModal(open)
}}
user={user}
contract={contract}
/>
</Button>
)}
<Row className="z-0 flex-wrap justify-center gap-4 self-center"> <Row className="z-0 flex-wrap justify-center gap-4 self-center">
<TweetButton <TweetButton
className="self-start" className="self-start"
tweetText={getTweetText(contract, shareUrl)} tweetText={getTweetText(contract, shareUrl)}
/> />
<ShareEmbedButton contract={contract} /> <ShareEmbedButton contract={contract} />
<DuplicateContractButton contract={contract} />
{showChallenge && (
<button
className={
'btn btn-xs flex-nowrap border-2 !border-indigo-500 !bg-white normal-case text-indigo-500'
}
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<ChallengeIcon className="mr-1 h-4 w-4" /> Challenge
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={(open) => {
if (!open) {
setOpenCreateChallengeModal(false)
setOpen(false)
} else setOpenCreateChallengeModal(open)
}}
user={user}
contract={contract}
/>
</button>
)}
</Row> </Row>
</Col> </Col>
</Modal> </Modal>

View File

@ -1,7 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Contract, FreeResponseContract } from 'common/contract' import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination' import { Pagination } from 'web/components/pagination'
@ -12,7 +11,7 @@ import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user' import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from 'common/liquidity-provision'
import { groupBy, sortBy, uniq } from 'lodash' import { groupBy, sortBy } from 'lodash'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
export function ContractBetsActivity(props: { export function ContractBetsActivity(props: {
@ -73,13 +72,12 @@ export function ContractBetsActivity(props: {
export function ContractCommentsActivity(props: { export function ContractCommentsActivity(props: {
contract: Contract contract: Contract
bets: Bet[] betsByCurrentUser: Bet[]
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
user: User | null | undefined user: User | null | undefined
}) { }) {
const { bets, contract, comments, user, tips } = props const { betsByCurrentUser, contract, comments, user, tips } = props
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_') const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy( const topLevelComments = sortBy(
@ -92,7 +90,7 @@ export function ContractCommentsActivity(props: {
<ContractCommentInput <ContractCommentInput
className="mb-5" className="mb-5"
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
/> />
{topLevelComments.map((parent) => ( {topLevelComments.map((parent) => (
@ -106,8 +104,7 @@ export function ContractCommentsActivity(props: {
(c) => c.createdTime (c) => c.createdTime
)} )}
tips={tips} tips={tips}
bets={bets} betsByCurrentUser={betsByCurrentUser}
betsByUserId={betsByUserId}
commentsByUserId={commentsByUserId} commentsByUserId={commentsByUserId}
/> />
))} ))}
@ -117,32 +114,26 @@ export function ContractCommentsActivity(props: {
export function FreeResponseContractCommentsActivity(props: { export function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract contract: FreeResponseContract
bets: Bet[] betsByCurrentUser: Bet[]
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
user: User | null | undefined user: User | null | undefined
}) { }) {
const { bets, contract, comments, user, tips } = props const { betsByCurrentUser, contract, comments, user, tips } = props
let outcomes = uniq(bets.map((bet) => bet.outcome)) const sortedAnswers = sortBy(
outcomes = sortBy( contract.answers,
outcomes, (answer) => -getOutcomeProbability(contract, answer.number.toString())
(outcome) => -getOutcomeProbability(contract, outcome)
) )
const answers = outcomes
.map((outcome) => {
return contract.answers.find((answer) => answer.id === outcome) as Answer
})
.filter((answer) => answer != null)
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId) const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_') const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return ( return (
<> <>
{answers.map((answer) => ( {sortedAnswers.map((answer) => (
<div key={answer.id} className={'relative pb-4'}> <div key={answer.id} className={'relative pb-4'}>
<span <span
className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200" className="absolute top-5 left-5 -ml-px h-[calc(100%-2rem)] w-0.5 bg-gray-200"
@ -157,7 +148,7 @@ export function FreeResponseContractCommentsActivity(props: {
(c) => c.createdTime (c) => c.createdTime
)} )}
tips={tips} tips={tips}
betsByUserId={betsByUserId} betsByCurrentUser={betsByCurrentUser}
commentsByUserId={commentsByUserId} commentsByUserId={commentsByUserId}
/> />
</div> </div>

View File

@ -27,7 +27,7 @@ export function FeedAnswerCommentGroup(props: {
answer: Answer answer: Answer
answerComments: ContractComment[] answerComments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
betsByUserId: Dictionary<Bet[]> betsByCurrentUser: Bet[]
commentsByUserId: Dictionary<ContractComment[]> commentsByUserId: Dictionary<ContractComment[]>
}) { }) {
const { const {
@ -35,7 +35,7 @@ export function FeedAnswerCommentGroup(props: {
contract, contract,
answerComments, answerComments,
tips, tips,
betsByUserId, betsByCurrentUser,
commentsByUserId, commentsByUserId,
user, user,
} = props } = props
@ -48,7 +48,6 @@ export function FeedAnswerCommentGroup(props: {
const router = useRouter() const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? [] const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const isFreeResponseContractPage = !!commentsByCurrentUser const isFreeResponseContractPage = !!commentsByCurrentUser
const mostRecentCommentableBet = getMostRecentCommentableBet( const mostRecentCommentableBet = getMostRecentCommentableBet(
@ -166,7 +165,6 @@ export function FeedAnswerCommentGroup(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id]} tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={scrollAndOpenReplyInput}
/> />
))} ))}

View File

@ -1,9 +1,9 @@
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { PRESENT_BET, User } from 'common/user' import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash' import { Dictionary } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -29,8 +29,7 @@ export function FeedCommentThread(props: {
threadComments: ContractComment[] threadComments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
parentComment: ContractComment parentComment: ContractComment
bets: Bet[] betsByCurrentUser: Bet[]
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]> commentsByUserId: Dictionary<ContractComment[]>
}) { }) {
const { const {
@ -38,8 +37,7 @@ export function FeedCommentThread(props: {
contract, contract,
threadComments, threadComments,
commentsByUserId, commentsByUserId,
bets, betsByCurrentUser,
betsByUserId,
tips, tips,
parentComment, parentComment,
} = props } = props
@ -64,17 +62,7 @@ export function FeedCommentThread(props: {
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id]} tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={scrollAndOpenReplyInput}
probAtCreatedTime={
contract.outcomeType === 'BINARY'
? minBy(bets, (bet) => {
return bet.createdTime < comment.createdTime
? comment.createdTime - bet.createdTime
: comment.createdTime
})?.probAfter
: undefined
}
/> />
))} ))}
{showReply && ( {showReply && (
@ -85,7 +73,7 @@ export function FeedCommentThread(props: {
/> />
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByCurrentUser) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
@ -104,22 +92,21 @@ export function FeedComment(props: {
contract: Contract contract: Contract
comment: ContractComment comment: ContractComment
tips: CommentTips tips: CommentTips
betsBySameUser: Bet[]
indent?: boolean indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: ContractComment) => void onReplyClick?: (comment: ContractComment) => void
}) { }) {
const { contract, comment, tips, indent, onReplyClick } = props
const { const {
contract, text,
comment, content,
tips, userUsername,
betsBySameUser, userName,
indent, userAvatarUrl,
probAtCreatedTime, commenterPositionProb,
onReplyClick, commenterPositionShares,
} = props commenterPositionOutcome,
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = createdTime,
comment } = comment
const betOutcome = comment.betOutcome const betOutcome = comment.betOutcome
let bought: string | undefined let bought: string | undefined
let money: string | undefined let money: string | undefined
@ -136,13 +123,6 @@ export function FeedComment(props: {
} }
}, [comment.id, router.asPath]) }, [comment.id, router.asPath])
// Only calculated if they don't have a matching bet
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
comment.createdTime,
comment.betId ? [] : betsBySameUser
)
return ( return (
<Row <Row
id={comment.id} id={comment.id}
@ -167,14 +147,17 @@ export function FeedComment(props: {
username={userUsername} username={userUsername}
name={userName} name={userName}
/>{' '} />{' '}
{!comment.betId != null && {comment.betId == null &&
userPosition > 0 && commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && ( contract.outcomeType !== 'NUMERIC' && (
<> <>
{'is '} {'is '}
<CommentStatus <CommentStatus
prob={probAtCreatedTime} prob={commenterPositionProb}
outcome={outcome} outcome={commenterPositionOutcome}
contract={contract} contract={contract}
/> />
</> </>
@ -255,7 +238,7 @@ function CommentStatus(props: {
const { contract, outcome, prob } = props const { contract, outcome, prob } = props
return ( return (
<> <>
{` ${PRESENT_BET}ing `} {` predicting `}
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> <OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
{prob && ' at ' + Math.round(prob * 100) + '%'} {prob && ' at ' + Math.round(prob * 100) + '%'}
</> </>
@ -310,56 +293,6 @@ export function ContractCommentInput(props: {
) )
} }
function getBettorsLargestPositionBeforeTime(
contract: Contract,
createdTime: number,
bets: Bet[]
) {
let yesFloorShares = 0,
yesShares = 0,
noShares = 0,
noFloorShares = 0
const previousBets = bets.filter(
(prevBet) => prevBet.createdTime < createdTime && !prevBet.isAnte
)
if (contract.outcomeType === 'FREE_RESPONSE') {
const answerCounts: { [outcome: string]: number } = {}
for (const bet of previousBets) {
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 {
userPosition: answerCounts[majorityAnswer] || 0,
outcome: majorityAnswer,
}
}
if (bets.length === 0) {
return { userPosition: 0, outcome: '' }
}
const [yesBets, noBets] = partition(
previousBets ?? [],
(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 userPosition = yesFloorShares || noFloorShares
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
return { userPosition, outcome }
}
function canCommentOnBet(bet: Bet, user?: User | null) { function canCommentOnBet(bet: Bet, user?: User | null) {
const { userId, createdTime, isRedemption } = bet const { userId, createdTime, isRedemption } = bet
const isSelf = user?.id === userId const isSelf = user?.id === userId

View File

@ -6,7 +6,6 @@ import { ConfirmationButton } from '../confirmation-button'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Title } from '../title' import { Title } from '../title'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user' import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group' import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api' import { createGroup } from 'web/lib/firebase/api'
@ -17,35 +16,30 @@ export function CreateGroupButton(props: {
label?: string label?: string
onOpenStateChange?: (isOpen: boolean) => void onOpenStateChange?: (isOpen: boolean) => void
goToGroupOnSubmit?: boolean goToGroupOnSubmit?: boolean
addGroupIdParamOnSubmit?: boolean
icon?: JSX.Element icon?: JSX.Element
}) { }) {
const { user, className, label, onOpenStateChange, goToGroupOnSubmit, icon } = const {
props user,
const [defaultName, setDefaultName] = useState(`${user.name}'s group`) className,
label,
onOpenStateChange,
goToGroupOnSubmit,
addGroupIdParamOnSubmit,
icon,
} = props
const [name, setName] = useState('') const [name, setName] = useState('')
const [memberUsers, setMemberUsers] = useState<User[]>([])
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [errorText, setErrorText] = useState('') const [errorText, setErrorText] = useState('')
const router = useRouter() const router = useRouter()
function updateMemberUsers(users: User[]) {
const usersFirstNames = users.map((user) => user.name.split(' ')[0])
const postFix =
usersFirstNames.length > 3 ? ` & ${usersFirstNames.length - 3} more` : ''
const newName = `${user.name.split(' ')[0]}${
users.length > 0 ? ', ' + usersFirstNames.slice(0, 3).join(', ') : ''
}${postFix}'s group`
setDefaultName(newName)
setMemberUsers(users)
}
const onSubmit = async () => { const onSubmit = async () => {
setIsSubmitting(true) setIsSubmitting(true)
const groupName = name !== '' ? name : defaultName
const newGroup = { const newGroup = {
name: groupName, name,
memberIds: memberUsers.map((user) => user.id), memberIds: [],
anyoneCanJoin: true, anyoneCanJoin: true,
} }
const result = await createGroup(newGroup).catch((e) => { const result = await createGroup(newGroup).catch((e) => {
@ -62,12 +56,17 @@ export function CreateGroupButton(props: {
console.log(result.details) console.log(result.details)
if (result.group) { if (result.group) {
updateMemberUsers([])
if (goToGroupOnSubmit) if (goToGroupOnSubmit)
router.push(groupPath(result.group.slug)).catch((e) => { router.push(groupPath(result.group.slug)).catch((e) => {
console.log(e) console.log(e)
setErrorText(e.message) setErrorText(e.message)
}) })
else if (addGroupIdParamOnSubmit) {
router.replace({
pathname: router.pathname,
query: { ...router.query, groupId: result.group.id },
})
}
setIsSubmitting(false) setIsSubmitting(false)
return true return true
} else { } else {
@ -99,41 +98,26 @@ export function CreateGroupButton(props: {
onSubmitWithSuccess={onSubmit} onSubmitWithSuccess={onSubmit}
onOpenChanged={(isOpen) => { onOpenChanged={(isOpen) => {
onOpenStateChange?.(isOpen) onOpenStateChange?.(isOpen)
updateMemberUsers([])
setName('') setName('')
}} }}
> >
<Title className="!my-0" text="Create a group" /> <Title className="!my-0" text="Create a group" />
<Col className="gap-1 text-gray-500"> <Col className="gap-1 text-gray-500">
<div>You can add markets and members to your group after creation.</div> <div>You can add markets to your group after creation.</div>
</Col> </Col>
<div className={'text-error'}>{errorText}</div> {errorText && <div className={'text-error'}>{errorText}</div>}
<div> <div className="form-control w-full">
<div className="form-control w-full"> <label className="mb-2 ml-1 mt-0">Group name</label>
<label className="label"> <input
<span className="mb-0">Add members (optional)</span> placeholder={'Your group name'}
</label> className="input input-bordered resize-none"
<FilterSelectUsers disabled={isSubmitting}
setSelectedUsers={updateMemberUsers} value={name}
selectedUsers={memberUsers} maxLength={MAX_GROUP_NAME_LENGTH}
ignoreUserIds={[user.id]} onChange={(e) => setName(e.target.value || '')}
/> />
</div>
<div className="form-control w-full">
<label className="label">
<span className="mt-1">Group name (optional)</span>
</label>
<input
placeholder={defaultName}
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={MAX_GROUP_NAME_LENGTH}
onChange={(e) => setName(e.target.value || '')}
/>
</div>
<Spacer h={4} /> <Spacer h={4} />
</div> </div>

View File

@ -16,7 +16,6 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
const router = useRouter() const router = useRouter()
const [name, setName] = useState(group.name) const [name, setName] = useState(group.name)
const [about, setAbout] = useState(group.about ?? '')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([]) const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
@ -26,8 +25,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
setOpen(newOpen) setOpen(newOpen)
} }
const saveDisabled = const saveDisabled = name === group.name && addMemberUsers.length === 0
name === group.name && about === group.about && addMemberUsers.length === 0
const onSubmit = async () => { const onSubmit = async () => {
setIsSubmitting(true) setIsSubmitting(true)
@ -66,23 +64,6 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
<Spacer h={4} /> <Spacer h={4} />
<div className="form-control w-full">
<label className="label">
<span className="mb-1">About</span>
</label>
<input
placeholder="Short description (140 characters max)"
className="input input-bordered resize-none"
disabled={isSubmitting}
value={about}
maxLength={140}
onChange={(e) => setAbout(e.target.value || '')}
/>
</div>
<Spacer h={4} />
<div className="form-control w-full"> <div className="form-control w-full">
<label className="label"> <label className="label">
<span className="mb-0">Add members</span> <span className="mb-0">Add members</span>

View File

@ -131,7 +131,7 @@ export function GroupSelector(props: {
)} )}
<span <span
className={clsx( className={clsx(
'ml-3 mt-1 block flex flex-row justify-between', 'ml-3 mt-1 flex flex-row justify-between',
selected && 'font-semibold' selected && 'font-semibold'
)} )}
> >
@ -166,7 +166,7 @@ export function GroupSelector(props: {
'w-full justify-start rounded-none border-0 bg-white pl-2 font-normal text-gray-900 hover:bg-indigo-500 hover:text-white' 'w-full justify-start rounded-none border-0 bg-white pl-2 font-normal text-gray-900 hover:bg-indigo-500 hover:text-white'
} }
label={'Create a new Group'} label={'Create a new Group'}
goToGroupOnSubmit={false} addGroupIdParamOnSubmit
icon={ icon={
<PlusCircleIcon className="text-primary mr-2 h-5 w-5" /> <PlusCircleIcon className="text-primary mr-2 h-5 w-5" />
} }

View File

@ -8,7 +8,8 @@ import {
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import { Transition, Dialog } from '@headlessui/react' import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react' import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar' import Sidebar from './sidebar'
import { Item } from './sidebar-item'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'

View File

@ -1,5 +1,5 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline' import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar' import { Item } from './sidebar-item'
import clsx from 'clsx' import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
@ -32,7 +32,7 @@ export function GroupNavBar(props: {
const user = useUser() const user = useUser()
return ( return (
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden"> <nav className="z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
{mobileGroupNavigation.map((item) => ( {mobileGroupNavigation.map((item) => (
<NavBarItem <NavBarItem
key={item.name} key={item.name}

View File

@ -7,7 +7,7 @@ import React from 'react'
import TrophyIcon from 'web/lib/icons/trophy-icon' import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button' import { SignInButton } from '../sign-in-button'
import NotificationsIcon from '../notifications-icon' import NotificationsIcon from '../notifications-icon'
import { SidebarItem } from './sidebar' import { SidebarItem } from './sidebar-item'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import { User } from 'common/user' import { User } from 'common/user'
import { Row } from '../layout/row' import { Row } from '../layout/row'

View File

@ -0,0 +1,23 @@
import { DotsHorizontalIcon } from '@heroicons/react/outline'
export function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
function SidebarButton(props: {
text: string
icon: React.ComponentType<{ className?: string }>
children?: React.ReactNode
}) {
const { text, children } = props
return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">{text}</span>
{children}
</a>
)
}

View File

@ -0,0 +1,63 @@
import React from 'react'
import clsx from 'clsx'
import Link from 'next/link'
import { trackCallback } from 'web/lib/service/analytics'
export type Item = {
name: string
trackingEventName?: string
href?: string
key?: string
icon?: React.ComponentType<{ className?: string }>
}
export function SidebarItem(props: {
item: Item
currentPage: string
onClick?: (key: string) => void
}) {
const { item, currentPage, onClick } = props
const isCurrentPage =
item.href != null ? item.href === currentPage : item.key === currentPage
const sidebarItem = (
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
isCurrentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
{item.icon && (
<item.icon
className={clsx(
isCurrentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
)
if (item.href) {
return (
<Link href={item.href} key={item.name}>
{sidebarItem}
</Link>
)
} else {
return onClick ? (
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
) : (
<> </>
)
}
}

View File

@ -1,29 +1,93 @@
import React from 'react'
import { import {
HomeIcon, HomeIcon,
SearchIcon, SearchIcon,
BookOpenIcon, BookOpenIcon,
DotsHorizontalIcon,
CashIcon, CashIcon,
HeartIcon, HeartIcon,
ChatIcon, ChatIcon,
ChartBarIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link'
import Router, { useRouter } from 'next/router' import Router, { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users' import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo' import { ManifoldLogo } from './manifold-logo'
import { MenuButton, MenuItem } from './menu' import { MenuButton, MenuItem } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import React from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import { trackCallback, withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon' import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button' import { SignInButton } from '../sign-in-button'
import { SidebarItem } from './sidebar-item'
import { MoreButton } from './more-button'
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const desktopNavOptions = !user
? signedOutDesktopNavigation
: getDesktopNavigation()
const mobileNavOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
const createMarketButton = user && !user.isBannedFromPosting && (
<CreateQuestionButton />
)
return (
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
{!user && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{user && (
<MenuButton
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}
{createMarketButton}
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{desktopNavOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreDesktopNavigation(user)}
buttonContent={<MoreButton />}
/>
{createMarketButton}
</div>
</nav>
)
}
const logout = async () => { const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out // log out, and then reload the page, in case SSR wants to boot them out
@ -32,7 +96,7 @@ const logout = async () => {
await Router.replace(Router.asPath) await Router.replace(Router.asPath)
} }
function getNavigation() { function getDesktopNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon }, { name: 'Search', href: '/search', icon: SearchIcon },
@ -51,10 +115,10 @@ function getNavigation() {
] ]
} }
function getMoreNavigation(user?: User | null) { function getMoreDesktopNavigation(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) { if (IS_PRIVATE_MANIFOLD) {
return [ return [
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
{ {
name: 'Sign out', name: 'Sign out',
href: '#', href: '#',
@ -99,7 +163,7 @@ function getMoreNavigation(user?: User | null) {
) )
} }
const signedOutNavigation = [ const signedOutDesktopNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/search', icon: SearchIcon }, { name: 'Explore', href: '/search', icon: SearchIcon },
{ {
@ -117,11 +181,14 @@ const signedOutMobileNavigation = [
}, },
{ name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
] ]
const signedInMobileNavigation = [ const signedInMobileNavigation = [
{ name: 'Search', href: '/search', icon: SearchIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
...(IS_PRIVATE_MANIFOLD ...(IS_PRIVATE_MANIFOLD
? [] ? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -145,7 +212,6 @@ function getMoreMobileNav() {
[ [
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Referrals', href: '/referrals' },
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -153,136 +219,3 @@ function getMoreMobileNav() {
signOut signOut
) )
} }
export type Item = {
name: string
trackingEventName?: string
href?: string
key?: string
icon?: React.ComponentType<{ className?: string }>
}
export function SidebarItem(props: {
item: Item
currentPage: string
onClick?: (key: string) => void
}) {
const { item, currentPage, onClick } = props
const isCurrentPage =
item.href != null ? item.href === currentPage : item.key === currentPage
const sidebarItem = (
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
isCurrentPage
? 'bg-gray-200 text-gray-900'
: 'text-gray-600 hover:bg-gray-100',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium'
)}
aria-current={item.href == currentPage ? 'page' : undefined}
>
{item.icon && (
<item.icon
className={clsx(
isCurrentPage
? 'text-gray-500'
: 'text-gray-400 group-hover:text-gray-500',
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
)}
aria-hidden="true"
/>
)}
<span className="truncate">{item.name}</span>
</a>
)
if (item.href) {
return (
<Link href={item.href} key={item.name}>
{sidebarItem}
</Link>
)
} else {
return onClick ? (
<button onClick={() => onClick(item.key ?? '#')}>{sidebarItem}</button>
) : (
<> </>
)
}
}
function SidebarButton(props: {
text: string
icon: React.ComponentType<{ className?: string }>
children?: React.ReactNode
}) {
const { text, children } = props
return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">{text}</span>
{children}
</a>
)
}
function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
return (
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
{!user && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{user && (
<MenuButton
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreNavigation(user)}
buttonContent={<MoreButton />}
/>
{user && !user.isBannedFromPosting && <CreateQuestionButton />}
</div>
</nav>
)
}

View File

@ -276,6 +276,7 @@ export function NotificationSettings(props: {
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}> <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
{subscriptionTypes.map((subType) => ( {subscriptionTypes.map((subType) => (
<NotificationSettingLine <NotificationSettingLine
key={subType}
subscriptionTypeKey={subType as notification_preference} subscriptionTypeKey={subType as notification_preference}
destinations={getUsersSavedPreference( destinations={getUsersSavedPreference(
subType as notification_preference subType as notification_preference

View File

@ -10,6 +10,8 @@ import { Modal } from 'web/components/layout/modal'
import { PillButton } from 'web/components/buttons/pill-button' import { PillButton } from 'web/components/buttons/pill-button'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Group } from 'common/group' import { Group } from 'common/group'
import { LoadingIndicator } from '../loading-indicator'
import { withTracking } from 'web/lib/service/analytics'
export default function GroupSelectorDialog(props: { export default function GroupSelectorDialog(props: {
open: boolean open: boolean
@ -65,20 +67,26 @@ export default function GroupSelectorDialog(props: {
</p> </p>
<div className="scrollbar-hide items-start gap-2 overflow-x-auto"> <div className="scrollbar-hide items-start gap-2 overflow-x-auto">
{user && {!user || displayedGroups.length === 0 ? (
<LoadingIndicator spinnerClassName="h-12 w-12" />
) : (
displayedGroups.map((group) => ( displayedGroups.map((group) => (
<PillButton <PillButton
selected={memberGroupIds.includes(group.id)} selected={memberGroupIds.includes(group.id)}
onSelect={() => onSelect={withTracking(
memberGroupIds.includes(group.id) () =>
? leaveGroup(group, user.id) memberGroupIds.includes(group.id)
: joinGroup(group, user.id) ? leaveGroup(group, user.id)
} : joinGroup(group, user.id),
'toggle group pill',
{ group: group.slug }
)}
className="mr-1 mb-2 max-w-[12rem] truncate" className="mr-1 mb-2 max-w-[12rem] truncate"
> >
{group.name} {group.name}
</PillButton> </PillButton>
))} ))
)}
</div> </div>
</Col> </Col>
<Col> <Col>

View File

@ -40,14 +40,7 @@ export function ShareEmbedButton(props: { contract: Contract }) {
track('copy embed code') track('copy embed code')
}} }}
> >
<Menu.Button <Menu.Button className="btn btn-xs border-2 !border-gray-500 !bg-white normal-case text-gray-500">
className="btn btn-xs normal-case"
style={{
backgroundColor: 'white',
border: '2px solid #9ca3af',
color: '#9ca3af', // text-gray-400
}}
>
{codeIcon} {codeIcon}
Embed Embed
</Menu.Button> </Menu.Button>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group' import { Group } from 'common/group'
import { User } from 'common/user' import { User } from 'common/user'
import { import {
getGroup,
getMemberGroups, getMemberGroups,
GroupMemberDoc, GroupMemberDoc,
groupMembers, groupMembers,
@ -102,6 +103,24 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
return memberGroupIds return memberGroupIds
} }
export function useMemberGroupsSubscription(user: User | null | undefined) {
const cachedGroups = useMemberGroups(user?.id) ?? []
const [groups, setGroups] = useState(cachedGroups)
const userId = user?.id
useEffect(() => {
if (userId) {
return listenForMemberGroupIds(userId, (groupIds) => {
Promise.all(groupIds.map((id) => getGroup(id))).then((groups) =>
setGroups(filterDefined(groups))
)
})
}
}, [userId])
return groups
}
export function useMembers(groupId: string | undefined) { export function useMembers(groupId: string | undefined) {
const [members, setMembers] = useState<User[]>([]) const [members, setMembers] = useState<User[]>([])
useEffect(() => { useEffect(() => {

View File

@ -1,6 +1,5 @@
import { usePrefetchUserBetContracts } from './use-contracts' import { usePrefetchUserBetContracts } from './use-contracts'
import { usePrefetchPortfolioHistory } from './use-portfolio-history' import { usePrefetchPortfolioHistory } from './use-portfolio-history'
import { usePrefetchProbChanges } from './use-prob-changes'
import { usePrefetchUserBets } from './use-user-bets' import { usePrefetchUserBets } from './use-user-bets'
export function usePrefetch(userId: string | undefined) { export function usePrefetch(userId: string | undefined) {
@ -9,6 +8,5 @@ export function usePrefetch(userId: string | undefined) {
usePrefetchUserBets(maybeUserId), usePrefetchUserBets(maybeUserId),
usePrefetchUserBetContracts(maybeUserId), usePrefetchUserBetContracts(maybeUserId),
usePrefetchPortfolioHistory(maybeUserId, 'weekly'), usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
usePrefetchProbChanges(userId),
]) ])
} }

View File

@ -1,11 +1,45 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { CPMMContract } from 'common/contract'
import { MINUTE_MS } from 'common/util/time' import { MINUTE_MS } from 'common/util/time'
import { useQueryClient } from 'react-query' import { useQuery, useQueryClient } from 'react-query'
import { import {
getProbChangesNegative, getProbChangesNegative,
getProbChangesPositive, getProbChangesPositive,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { getValues } from 'web/lib/firebase/utils' import { getValues } from 'web/lib/firebase/utils'
import { getIndexName, searchClient } from 'web/lib/service/algolia'
export const useProbChangesAlgolia = (userId: string) => {
const { data: positiveData } = useQuery(['prob-change-day', userId], () =>
searchClient
.initIndex(getIndexName('prob-change-day'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
const { data: negativeData } = useQuery(
['prob-change-day-ascending', userId],
() =>
searchClient
.initIndex(getIndexName('prob-change-day-ascending'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
if (!positiveData || !negativeData) {
return undefined
}
return {
positiveChanges: positiveData.hits
.filter((c) => c.probChanges && c.probChanges.day > 0)
.filter((c) => c.outcomeType === 'BINARY'),
negativeChanges: negativeData.hits
.filter((c) => c.probChanges && c.probChanges.day < 0)
.filter((c) => c.outcomeType === 'BINARY'),
}
}
export const useProbChanges = (userId: string) => { export const useProbChanges = (userId: string) => {
const { data: positiveChanges } = useFirestoreQueryData( const { data: positiveChanges } = useFirestoreQueryData(

View File

@ -13,7 +13,11 @@ export const useWarnUnsavedChanges = (hasUnsavedChanges: boolean) => {
return confirmationMessage return confirmationMessage
} }
const beforeRouteHandler = () => { const beforeRouteHandler = (href: string) => {
const pathname = href.split('?')[0]
// Don't warn if the user is navigating to the same page.
if (pathname === location.pathname) return
if (!confirm(confirmationMessage)) { if (!confirm(confirmationMessage)) {
Router.events.emit('routeChangeError') Router.events.emit('routeChangeError')
throw 'Abort route change. Please ignore this error.' throw 'Abort route change. Please ignore this error.'

View File

@ -8,9 +8,9 @@ export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>

View File

@ -8,9 +8,9 @@ export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<line x1="19" y1="4" x2="10" y2="4"></line> <line x1="19" y1="4" x2="10" y2="4"></line>

View File

@ -8,9 +8,9 @@ export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>

View File

@ -0,0 +1,15 @@
import algoliasearch from 'algoliasearch/lite'
import { ENV } from 'common/envs/constants'
export const searchClient = algoliasearch(
'GJQPAYENIF',
'75c28fc084a80e1129d427d470cf41a3'
)
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
export const searchIndexName =
ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
export const getIndexName = (sort: string) => {
return `${indexPrefix}contracts-${sort}`
}

View File

@ -1,7 +1,6 @@
import { PrivateUser, User } from 'common/user' import { PrivateUser, User } from 'common/user'
import { generateNewApiKey } from '../api/api-key' import { generateNewApiKey } from '../api/api-key'
import { ENV_CONFIG } from 'common/envs/constants'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
async function postToBot(url: string, body: unknown) { async function postToBot(url: string, body: unknown) {
const result = await fetch(url, { const result = await fetch(url, {
@ -21,13 +20,16 @@ export async function initLinkTwitchAccount(
manifoldUserID: string, manifoldUserID: string,
manifoldUserAPIKey: string manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { const response = await postToBot(
manifoldID: manifoldUserID, `${ENV_CONFIG.twitchBotEndpoint}/api/linkInit`,
apiKey: manifoldUserAPIKey, {
redirectURL: window.location.href, manifoldID: manifoldUserID,
}) apiKey: manifoldUserAPIKey,
redirectURL: window.location.href,
}
)
const responseFetch = fetch( const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` `${ENV_CONFIG.twitchBotEndpoint}/api/linkResult?userID=${manifoldUserID}`
) )
return [response.twitchAuthURL, responseFetch.then((r) => r.json())] return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
} }
@ -50,15 +52,18 @@ export async function updateBotEnabledForUser(
botEnabled: boolean botEnabled: boolean
) { ) {
if (botEnabled) { if (botEnabled) {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, { return postToBot(`${ENV_CONFIG.twitchBotEndpoint}/registerchanneltwitch`, {
apiKey: privateUser.apiKey, apiKey: privateUser.apiKey,
}).then((r) => { }).then((r) => {
if (!r.success) throw new Error(r.message) if (!r.success) throw new Error(r.message)
}) })
} else { } else {
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, { return postToBot(
apiKey: privateUser.apiKey, `${ENV_CONFIG.twitchBotEndpoint}/unregisterchanneltwitch`,
}).then((r) => { {
apiKey: privateUser.apiKey,
}
).then((r) => {
if (!r.success) throw new Error(r.message) if (!r.success) throw new Error(r.message)
}) })
} }
@ -66,10 +71,10 @@ export async function updateBotEnabledForUser(
export function getOverlayURLForUser(privateUser: PrivateUser) { export function getOverlayURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/overlay?t=${controlToken}` return `${ENV_CONFIG.twitchBotEndpoint}/overlay?t=${controlToken}`
} }
export function getDockURLForUser(privateUser: PrivateUser) { export function getDockURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken const controlToken = privateUser?.twitchInfo?.controlToken
return `${TWITCH_BOT_PUBLIC_URL}/dock?t=${controlToken}` return `${ENV_CONFIG.twitchBotEndpoint}/dock?t=${controlToken}`
} }

View File

@ -1,6 +1,6 @@
const API_DOCS_URL = 'https://docs.manifold.markets/api' const API_DOCS_URL = 'https://docs.manifold.markets/api'
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to' const ABOUT_PAGE_URL = 'https://help.manifold.markets/'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
module.exports = { module.exports = {

View File

@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
import { ValidationError } from './_types'
import { validate } from './_validate'
const queryParams = z
.object({
// This secret is stored in both Firebase and Vercel's environment variables, as API_SECRET.
apiSecret: z.string(),
// Path after domain: e.g. "/JamesGrugett/will-pete-buttigieg-ever-be-us-pres"
pathToRevalidate: z.string(),
})
.strict()
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
let params: z.infer<typeof queryParams>
try {
params = validate(queryParams, req.query)
} catch (e) {
if (e instanceof ValidationError) {
return res.status(400).json(e)
}
console.error(`Unknown error during validation: ${e}`)
return res.status(500).json({ error: 'Unknown error during validation' })
}
const { apiSecret, pathToRevalidate } = params
if (apiSecret !== process.env.API_SECRET) {
return res.status(401).json({ message: 'Invalid api secret' })
}
try {
await res.revalidate(pathToRevalidate)
return res.json({ revalidated: true })
} catch (err) {
return res.status(500).send('Error revalidating')
}
}

View File

@ -2,13 +2,13 @@ import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { useProbChanges } from 'web/hooks/use-prob-changes' import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
export default function DailyMovers() { export default function DailyMovers() {
const user = useUser() const user = useUser()
const changes = useProbChanges(user?.id ?? '') const changes = useProbChangesAlgolia(user?.id ?? '')
return ( return (
<Page> <Page>

View File

@ -140,7 +140,10 @@ export default function GroupPage(props: {
const user = useUser() const user = useUser()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
const [sidebarIndex, setSidebarIndex] = useState(0) // Note: Keep in sync with sidebarPages
const [sidebarIndex, setSidebarIndex] = useState(
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
)
useSaveReferral(user, { useSaveReferral(user, {
defaultReferrerUsername: creator.username, defaultReferrerUsername: creator.username,
@ -208,7 +211,7 @@ export default function GroupPage(props: {
<ContractSearch <ContractSearch
headerClassName="md:sticky" headerClassName="md:sticky"
user={user} user={user}
defaultSort={'newest'} defaultSort={'score'}
defaultFilter={suggestedFilter} defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }} additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`} persistPrefix={`group-${group.slug}`}
@ -241,6 +244,12 @@ export default function GroupPage(props: {
const onSidebarClick = (key: string) => { const onSidebarClick = (key: string) => {
const index = sidebarPages.findIndex((t) => t.key === key) const index = sidebarPages.findIndex((t) => t.key === key)
setSidebarIndex(index) setSidebarIndex(index)
// Append the page to the URL, e.g. /group/mexifold/markets
router.replace(
{ query: { ...router.query, slugs: [group.slug, key] } },
undefined,
{ shallow: true }
)
} }
const joinOrAddQuestionsButton = ( const joinOrAddQuestionsButton = (
@ -253,7 +262,11 @@ export default function GroupPage(props: {
return ( return (
<> <>
<TopGroupNavBar group={group} /> <TopGroupNavBar
group={group}
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
<div> <div>
<div <div
className={ className={
@ -278,19 +291,19 @@ export default function GroupPage(props: {
{pageContent} {pageContent}
</main> </main>
</div> </div>
<GroupNavBar
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
</div> </div>
</> </>
) )
} }
export function TopGroupNavBar(props: { group: Group }) { export function TopGroupNavBar(props: {
group: Group
currentPage: string
onClick: (key: string) => void
}) {
return ( return (
<header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12"> <header className="sticky top-0 z-50 w-full border-b border-gray-200 md:hidden lg:col-span-12">
<div className="flex items-center border-b border-gray-200 bg-white px-4"> <div className="flex items-center bg-white px-4">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link href="/"> <Link href="/">
<a className="text-indigo-700 hover:text-gray-500 "> <a className="text-indigo-700 hover:text-gray-500 ">
@ -304,6 +317,7 @@ export function TopGroupNavBar(props: { group: Group }) {
</h1> </h1>
</div> </div>
</div> </div>
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
</header> </header>
) )
} }

View File

@ -7,12 +7,12 @@ import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroupsSubscription } from 'web/hooks/use-group'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users' import { updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { getHomeItems } from '.' import { getHomeItems, TrendingGroupsSection } from '.'
export default function Home() { export default function Home() {
const user = useUser() const user = useUser()
@ -27,7 +27,7 @@ export default function Home() {
setHomeSections(newHomeSections) setHomeSections(newHomeSections)
} }
const groups = useMemberGroups(user?.id) ?? [] const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, homeSections) const { sections } = getHomeItems(groups, homeSections)
return ( return (
@ -38,7 +38,15 @@ export default function Home() {
<DoneButton /> <DoneButton />
</Row> </Row>
<ArrangeHome sections={sections} setSectionIds={updateHomeSections} /> <Col className="gap-8 md:flex-row">
<Col className="flex-1">
<ArrangeHome
sections={sections}
setSectionIds={updateHomeSections}
/>
</Col>
<TrendingGroupsSection className="flex-1" user={user} full />
</Col>
</Col> </Col>
</Page> </Page>
) )

View File

@ -1,11 +1,11 @@
import React, { ReactNode, useEffect, useState } from 'react' import React, { ReactNode, useEffect } from 'react'
import Router from 'next/router' import Router from 'next/router'
import { import {
AdjustmentsIcon, AdjustmentsIcon,
PencilAltIcon, PencilAltIcon,
ArrowSmRightIcon, ArrowSmRightIcon,
} from '@heroicons/react/solid' } from '@heroicons/react/solid'
import { XCircleIcon } from '@heroicons/react/outline' import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { toast, Toaster } from 'react-hot-toast' import { toast, Toaster } from 'react-hot-toast'
@ -22,21 +22,16 @@ import { SiteLink } from 'web/components/site-link'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { import {
useMemberGroupIds, useMemberGroupIds,
useMemberGroups, useMemberGroupsSubscription,
useTrendingGroups, useTrendingGroups,
} from 'web/hooks/use-group' } from 'web/hooks/use-group'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table' import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
getGroup,
groupPath,
joinGroup,
leaveGroup,
} from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes' import { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { ProfitBadge } from 'web/components/bets-list' import { ProfitBadge } from 'web/components/bets-list'
import { calculatePortfolioProfit } from 'common/calculate-metrics' import { calculatePortfolioProfit } from 'common/calculate-metrics'
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal' import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
@ -57,27 +52,32 @@ export default function Home() {
useSaveReferral() useSaveReferral()
usePrefetch(user?.id) usePrefetch(user?.id)
const cachedGroups = useMemberGroups(user?.id) ?? [] const groups = useMemberGroupsSubscription(user)
const groupIds = useMemberGroupIds(user)
const [groups, setGroups] = useState(cachedGroups)
useEffect(() => {
if (groupIds) {
Promise.all(groupIds.map((id) => getGroup(id))).then((groups) =>
setGroups(filterDefined(groups))
)
}
}, [groupIds])
const { sections } = getHomeItems(groups, user?.homeSections ?? []) const { sections } = getHomeItems(groups, user?.homeSections ?? [])
useEffect(() => {
if (
user &&
!user.homeSections &&
sections.length > 0 &&
groups.length > 0
) {
// Save initial home sections.
updateUser(user.id, { homeSections: sections.map((s) => s.id) })
}
}, [user, sections, groups])
return ( return (
<Page> <Page>
<Toaster /> <Toaster />
<Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0"> <Col className="pm:mx-10 gap-4 px-4 pb-12 pt-4 sm:pt-0">
<Row className={'mb-2 w-full items-center justify-between gap-8'}> <Row className={'mb-2 w-full items-center justify-between gap-8'}>
<Title className="!mt-0 !mb-0" text="Home" /> <Row className="items-center gap-2">
<Title className="!mt-0 !mb-0" text="Home" />
<CustomizeButton justIcon />
</Row>
<DailyStats user={user} /> <DailyStats user={user} />
</Row> </Row>
@ -102,7 +102,7 @@ export default function Home() {
const HOME_SECTIONS = [ const HOME_SECTIONS = [
{ label: 'Daily movers', id: 'daily-movers' }, { label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' }, { label: 'Trending', id: 'score' },
{ label: 'New for you', id: 'new-for-you' }, { label: 'New', id: 'newest' },
{ label: 'Recently updated', id: 'recently-updated-for-you' }, { label: 'Recently updated', id: 'recently-updated-for-you' },
] ]
@ -110,11 +110,12 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
// Accommodate old home sections. // Accommodate old home sections.
if (!isArray(sections)) sections = [] if (!isArray(sections)) sections = []
const items = [ const items: { id: string; label: string; group?: Group }[] = [
...HOME_SECTIONS, ...HOME_SECTIONS,
...groups.map((g) => ({ ...groups.map((g) => ({
label: g.name, label: g.name,
id: g.id, id: g.id,
group: g,
})), })),
] ]
const itemsById = keyBy(items, 'id') const itemsById = keyBy(items, 'id')
@ -139,16 +140,6 @@ function renderSection(
if (id === 'daily-movers') { if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} /> return <DailyMoversSection key={id} userId={user?.id} />
} }
if (id === 'new-for-you')
return (
<SearchSection
key={id}
label={label}
sort={'newest'}
pill="personal"
user={user}
/>
)
if (id === 'recently-updated-for-you') if (id === 'recently-updated-for-you')
return ( return (
<SearchSection <SearchSection
@ -235,7 +226,6 @@ function GroupSection(props: {
<Col> <Col>
<SectionHeader label={group.name} href={groupPath(group.slug)}> <SectionHeader label={group.name} href={groupPath(group.slug)}>
<Button <Button
className=""
color="gray-white" color="gray-white"
onClick={() => { onClick={() => {
if (user) { if (user) {
@ -252,10 +242,7 @@ function GroupSection(props: {
} }
}} }}
> >
<XCircleIcon <XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
</Button> </Button>
</SectionHeader> </SectionHeader>
<ContractsGrid contracts={contracts} /> <ContractsGrid contracts={contracts} />
@ -265,7 +252,16 @@ function GroupSection(props: {
function DailyMoversSection(props: { userId: string | null | undefined }) { function DailyMoversSection(props: { userId: string | null | undefined }) {
const { userId } = props const { userId } = props
const changes = useProbChanges(userId ?? '') const changes = useProbChangesAlgolia(userId ?? '')
if (changes) {
const { positiveChanges, negativeChanges } = changes
if (
!positiveChanges.find((c) => c.probChanges.day >= 0.01) ||
!negativeChanges.find((c) => c.probChanges.day <= -0.01)
)
return null
}
return ( return (
<Col className="gap-2"> <Col className="gap-2">
@ -285,8 +281,8 @@ function DailyStats(props: {
const [first, last] = [metrics[0], metrics[metrics.length - 1]] const [first, last] = [metrics[0], metrics[metrics.length - 1]]
const privateUser = usePrivateUser() const privateUser = usePrivateUser()
const streaksHidden = const streaks = privateUser?.notificationPreferences?.betting_streaks ?? []
privateUser?.notificationPreferences.betting_streaks.length === 0 const streaksHidden = streaks.length === 0
let profit = 0 let profit = 0
let profitPercent = 0 let profitPercent = 0
@ -322,24 +318,29 @@ function DailyStats(props: {
) )
} }
function TrendingGroupsSection(props: { user: User | null | undefined }) { export function TrendingGroupsSection(props: {
const { user } = props user: User | null | undefined
full?: boolean
className?: string
}) {
const { user, full, className } = props
const memberGroupIds = useMemberGroupIds(user) || [] const memberGroupIds = useMemberGroupIds(user) || []
const groups = useTrendingGroups().filter( const groups = useTrendingGroups().filter(
(g) => !memberGroupIds.includes(g.id) (g) => !memberGroupIds.includes(g.id)
) )
const count = 25 const count = full ? 100 : 25
const chosenGroups = groups.slice(0, count) const chosenGroups = groups.slice(0, count)
return ( return (
<Col> <Col className={className}>
<SectionHeader label="Trending groups" href="/explore-groups"> <SectionHeader label="Trending groups" href="/explore-groups">
<CustomizeButton /> {!full && <CustomizeButton className="mb-1" />}
</SectionHeader> </SectionHeader>
<Row className="flex-wrap gap-2"> <Row className="flex-wrap gap-2">
{chosenGroups.map((g) => ( {chosenGroups.map((g) => (
<PillButton <PillButton
className="flex flex-row items-center gap-1"
key={g.id} key={g.id}
selected={memberGroupIds.includes(g.id)} selected={memberGroupIds.includes(g.id)}
onSelect={() => { onSelect={() => {
@ -361,6 +362,11 @@ function TrendingGroupsSection(props: { user: User | null | undefined }) {
} }
}} }}
> >
<PlusCircleIcon
className={'h-5 w-5 flex-shrink-0 text-gray-500'}
aria-hidden="true"
/>
{g.name} {g.name}
</PillButton> </PillButton>
))} ))}
@ -369,10 +375,14 @@ function TrendingGroupsSection(props: { user: User | null | undefined }) {
) )
} }
function CustomizeButton() { function CustomizeButton(props: { justIcon?: boolean; className?: string }) {
const { justIcon, className } = props
return ( return (
<SiteLink <SiteLink
className="mb-2 flex flex-row items-center text-xl hover:no-underline" className={clsx(
className,
'flex flex-row items-center text-xl hover:no-underline'
)}
href="/home/edit" href="/home/edit"
> >
<Button size="lg" color="gray" className={clsx('flex gap-2')}> <Button size="lg" color="gray" className={clsx('flex gap-2')}>
@ -380,7 +390,7 @@ function CustomizeButton() {
className={clsx('h-[24px] w-5 text-gray-500')} className={clsx('h-[24px] w-5 text-gray-500')}
aria-hidden="true" aria-hidden="true"
/> />
Customize {!justIcon && 'Customize'}
</Button> </Button>
</SiteLink> </SiteLink>
) )

View File

@ -435,7 +435,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple reasonText = !simple
? `Bonus for ${ ? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new predictors on` } new traders on`
: 'bonus on' : 'bonus on'
} else if (sourceType === 'tip') { } else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
@ -556,7 +556,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && ( {(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink <MultiUserTransactionLink
userInfos={userLinks} userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'} modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
/> />
)} )}
<Row className={'line-clamp-2 flex max-w-xl'}> <Row className={'line-clamp-2 flex max-w-xl'}>
@ -971,13 +971,20 @@ function ContractResolvedNotification(props: {
const { sourceText, data } = notification const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {} const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market' const subtitle = 'resolved the market'
const resolutionDescription = () => { const resolutionDescription = () => {
if (!sourceText) return <div /> if (!sourceText) return <div />
if (sourceText === 'YES' || sourceText == 'NO') { if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} /> return <BinaryOutcomeLabel outcome={sourceText as any} />
} }
if (sourceText.includes('%')) if (sourceText.includes('%'))
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> return (
<ProbPercentLabel
prob={parseFloat(sourceText.replace('%', '')) / 100}
/>
)
if (sourceText === 'CANCEL') return <CancelLabel /> if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
@ -996,7 +1003,7 @@ function ContractResolvedNotification(props: {
const description = const description =
userInvestment && userPayout !== undefined ? ( userInvestment && userPayout !== undefined ? (
<Row className={'gap-1 '}> <Row className={'gap-1 '}>
{resolutionDescription()} Resolved: {resolutionDescription()}
Invested: Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span> <span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout: Payout:
@ -1013,7 +1020,7 @@ function ContractResolvedNotification(props: {
</span> </span>
</Row> </Row>
) : ( ) : (
<span>{resolutionDescription()}</span> <span>Resolved {resolutionDescription()}</span>
) )
if (justSummary) { if (justSummary) {

View File

@ -4,6 +4,7 @@ import { ContractSearch } from 'web/components/contract-search'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { usePrefetch } from 'web/hooks/use-prefetch' import { usePrefetch } from 'web/hooks/use-prefetch'
import { useRouter } from 'next/router'
export default function Search() { export default function Search() {
const user = useUser() const user = useUser()
@ -11,6 +12,10 @@ export default function Search() {
useTracking('view search') useTracking('view search')
const { query } = useRouter()
const { q, s, p } = query
const autoFocus = !q && !s && !p
return ( return (
<Page> <Page>
<Col className="mx-auto w-full p-2"> <Col className="mx-auto w-full p-2">
@ -18,7 +23,7 @@ export default function Search() {
user={user} user={user}
persistPrefix="search" persistPrefix="search"
useQueryUrlParam={true} useQueryUrlParam={true}
autoFocus autoFocus={autoFocus}
/> />
</Col> </Col>
</Page> </Page>

View File

@ -1,4 +1,3 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
DailyCountChart, DailyCountChart,
@ -47,65 +46,38 @@ export default function Analytics() {
) )
} }
export function CustomAnalytics(props: { export function CustomAnalytics(props: Stats) {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
manaBet: {
daily: number[]
weekly: number[]
monthly: number[]
}
}) {
const { const {
startDate,
dailyActiveUsers, dailyActiveUsers,
dailyActiveUsersWeeklyAvg,
weeklyActiveUsers,
monthlyActiveUsers,
d1,
d1WeeklyAvg,
nd1,
nd1WeeklyAvg,
nw1,
dailyBetCounts, dailyBetCounts,
dailyContractCounts, dailyContractCounts,
dailyCommentCounts, dailyCommentCounts,
dailySignups, dailySignups,
weeklyActiveUsers,
monthlyActiveUsers,
weekOnWeekRetention, weekOnWeekRetention,
monthlyRetention, monthlyRetention,
weeklyActivationRate, dailyActivationRate,
topTenthActions, dailyActivationRateWeeklyAvg,
manaBet, manaBet,
} = props } = props
const startDate = dayjs(props.startDate).add(12, 'hours').valueOf() const dailyDividedByWeekly = dailyActiveUsers.map(
(dailyActive, i) => dailyActive / weeklyActiveUsers[i]
const dailyDividedByWeekly = dailyActiveUsers )
.map((dailyActive, i) => const dailyDividedByMonthly = dailyActiveUsers.map(
Math.round((100 * dailyActive) / weeklyActiveUsers[i]) (dailyActive, i) => dailyActive / monthlyActiveUsers[i]
) )
.slice(7) const weeklyDividedByMonthly = weeklyActiveUsers.map(
(weeklyActive, i) => weeklyActive / monthlyActiveUsers[i]
const dailyDividedByMonthly = dailyActiveUsers )
.map((dailyActive, i) =>
Math.round((100 * dailyActive) / monthlyActiveUsers[i])
)
.slice(7)
const weeklyDividedByMonthly = weeklyActiveUsers
.map((weeklyActive, i) =>
Math.round((100 * weeklyActive) / monthlyActiveUsers[i])
)
.slice(7)
const oneWeekLaterDate = startDate + 7 * 24 * 60 * 60 * 1000
return ( return (
<Col className="px-2 sm:px-0"> <Col className="px-2 sm:px-0">
@ -129,6 +101,16 @@ export function CustomAnalytics(props: {
/> />
), ),
}, },
{
title: 'Daily (7d avg)',
content: (
<DailyCountChart
dailyCounts={dailyActiveUsersWeeklyAvg}
startDate={startDate}
small
/>
),
},
{ {
title: 'Weekly', title: 'Weekly',
content: ( content: (
@ -153,6 +135,108 @@ export function CustomAnalytics(props: {
/> />
<Spacer h={8} /> <Spacer h={8} />
<Title text="Retention" />
<p className="text-gray-500">
What fraction of active users are still active after the given time
period?
</p>
<Tabs
defaultIndex={1}
tabs={[
{
title: 'D1',
content: (
<DailyPercentChart
dailyPercent={d1}
startDate={startDate}
small
excludeFirstDays={1}
/>
),
},
{
title: 'D1 (7d avg)',
content: (
<DailyPercentChart
dailyPercent={d1WeeklyAvg}
startDate={startDate}
small
excludeFirstDays={7}
/>
),
},
{
title: 'W1',
content: (
<DailyPercentChart
dailyPercent={weekOnWeekRetention}
startDate={startDate}
small
excludeFirstDays={14}
/>
),
},
{
title: 'M1',
content: (
<DailyPercentChart
dailyPercent={monthlyRetention}
startDate={startDate}
small
excludeFirstDays={60}
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="New user retention" />
<p className="text-gray-500">
What fraction of new users are still active after the given time period?
</p>
<Spacer h={4} />
<Tabs
defaultIndex={2}
tabs={[
{
title: 'ND1',
content: (
<DailyPercentChart
dailyPercent={nd1}
startDate={startDate}
excludeFirstDays={1}
small
/>
),
},
{
title: 'ND1 (7d avg)',
content: (
<DailyPercentChart
dailyPercent={nd1WeeklyAvg}
startDate={startDate}
excludeFirstDays={7}
small
/>
),
},
{
title: 'NW1',
content: (
<DailyPercentChart
dailyPercent={nw1}
startDate={startDate}
excludeFirstDays={14}
small
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="Daily activity" /> <Title text="Daily activity" />
<Tabs <Tabs
defaultIndex={0} defaultIndex={0}
@ -202,30 +286,33 @@ export function CustomAnalytics(props: {
<Spacer h={8} /> <Spacer h={8} />
<Title text="Retention" /> <Title text="Activation rate" />
<p className="text-gray-500"> <p className="text-gray-500">
What fraction of active users are still active after the given time Out of all new users, how many placed at least one bet?
period?
</p> </p>
<Spacer h={4} />
<Tabs <Tabs
defaultIndex={0} defaultIndex={1}
tabs={[ tabs={[
{ {
title: 'Weekly', title: 'Daily',
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={weekOnWeekRetention.slice(7)} dailyPercent={dailyActivationRate}
startDate={oneWeekLaterDate} startDate={startDate}
excludeFirstDays={1}
small small
/> />
), ),
}, },
{ {
title: 'Monthly', title: 'Daily (7d avg)',
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={monthlyRetention.slice(7)} dailyPercent={dailyActivationRateWeeklyAvg}
startDate={oneWeekLaterDate} startDate={startDate}
excludeFirstDays={7}
small small
/> />
), ),
@ -234,17 +321,6 @@ export function CustomAnalytics(props: {
/> />
<Spacer h={8} /> <Spacer h={8} />
<Title text="Weekly activation rate" />
<p className="text-gray-500">
Out of all new users this week, how many placed at least one bet?
</p>
<DailyPercentChart
dailyPercent={weeklyActivationRate.slice(7)}
startDate={oneWeekLaterDate}
small
/>
<Spacer h={8} />
<Title text="Ratio of Active Users" /> <Title text="Ratio of Active Users" />
<Tabs <Tabs
defaultIndex={1} defaultIndex={1}
@ -254,8 +330,9 @@ export function CustomAnalytics(props: {
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={dailyDividedByWeekly} dailyPercent={dailyDividedByWeekly}
startDate={oneWeekLaterDate} startDate={startDate}
small small
excludeFirstDays={7}
/> />
), ),
}, },
@ -264,8 +341,9 @@ export function CustomAnalytics(props: {
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={dailyDividedByMonthly} dailyPercent={dailyDividedByMonthly}
startDate={oneWeekLaterDate} startDate={startDate}
small small
excludeFirstDays={30}
/> />
), ),
}, },
@ -274,8 +352,9 @@ export function CustomAnalytics(props: {
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={weeklyDividedByMonthly} dailyPercent={weeklyDividedByMonthly}
startDate={oneWeekLaterDate} startDate={startDate}
small small
excludeFirstDays={30}
/> />
), ),
}, },
@ -283,47 +362,6 @@ export function CustomAnalytics(props: {
/> />
<Spacer h={8} /> <Spacer h={8} />
<Title text="Action count of top tenth" />
<p className="text-gray-500">
Number of actions (bets, comments, markets created) taken by the tenth
percentile of top users.
</p>
<Tabs
defaultIndex={1}
tabs={[
{
title: 'Daily',
content: (
<DailyCountChart
dailyCounts={topTenthActions.daily}
startDate={startDate}
small
/>
),
},
{
title: 'Weekly',
content: (
<DailyCountChart
dailyCounts={topTenthActions.weekly}
startDate={startDate}
small
/>
),
},
{
title: 'Monthly',
content: (
<DailyCountChart
dailyCounts={topTenthActions.monthly}
startDate={startDate}
small
/>
),
},
]}
/>
<Title text="Total mana bet" /> <Title text="Total mana bet" />
<p className="text-gray-500"> <p className="text-gray-500">
Sum of bet amounts. (Divided by 100 to be more readable.) Sum of bet amounts. (Divided by 100 to be more readable.)
@ -363,6 +401,7 @@ export function CustomAnalytics(props: {
}, },
]} ]}
/> />
<Spacer h={8} />
</Col> </Col>
) )
} }

File diff suppressed because one or more lines are too long

View File

@ -3398,6 +3398,14 @@
resolved "https://registry.yarnpkg.com/@types/module-alias/-/module-alias-2.0.1.tgz#e5893236ce922152d57c5f3f978f764f4deeb45f" resolved "https://registry.yarnpkg.com/@types/module-alias/-/module-alias-2.0.1.tgz#e5893236ce922152d57c5f3f978f764f4deeb45f"
integrity sha512-DN/CCT1HQG6HquBNJdLkvV+4v5l/oEuwOHUPLxI+Eub0NED+lk0YUfba04WGH90EINiUrNgClkNnwGmbICeWMQ== integrity sha512-DN/CCT1HQG6HquBNJdLkvV+4v5l/oEuwOHUPLxI+Eub0NED+lk0YUfba04WGH90EINiUrNgClkNnwGmbICeWMQ==
"@types/node-fetch@2.6.2":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0": "@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0":
version "17.0.35" version "17.0.35"
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.35.tgz#635b7586086d51fb40de0a2ec9d1014a5283ba4a" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.35.tgz#635b7586086d51fb40de0a2ec9d1014a5283ba4a"
@ -4777,7 +4785,7 @@ combine-promises@^1.1.0:
resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.1.0.tgz#72db90743c0ca7aab7d0d8d2052fd7b0f674de71" resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.1.0.tgz#72db90743c0ca7aab7d0d8d2052fd7b0f674de71"
integrity sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg== integrity sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg==
combined-stream@^1.0.6: combined-stream@^1.0.6, combined-stream@^1.0.8:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -6536,6 +6544,15 @@ form-data@^2.3.3, form-data@^2.5.0:
combined-stream "^1.0.6" combined-stream "^1.0.6"
mime-types "^2.1.12" mime-types "^2.1.12"
form-data@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
formdata-polyfill@^4.0.10: formdata-polyfill@^4.0.10:
version "4.0.10" version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
@ -8666,7 +8683,7 @@ node-emoji@^1.10.0:
dependencies: dependencies:
lodash "^4.17.21" lodash "^4.17.21"
node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7: node-fetch@2, node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.6.7" version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==