Merge branch 'main' of https://github.com/marsteralex/manifold
This commit is contained in:
commit
b1ac37ea87
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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[]
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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} {
|
||||||
|
@ -196,7 +196,6 @@ service cloud.firestore {
|
||||||
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} {
|
||||||
|
|
|
@ -65,5 +65,6 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
||||||
|
|
||||||
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows:
|
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords). We store these using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them. Some useful workflows:
|
||||||
|
|
||||||
- Set a secret: `$ firebase functions:secrets:set stripe.test_secret="THE-API-KEY"`
|
- Set a secret: `$ firebase functions:secrets:set STRIPE_APIKEY`
|
||||||
|
- Then, enter the secret in the prompt.
|
||||||
- Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY`
|
- Read a secret: `$ firebase functions:secrets:access STRIPE_APIKEY`
|
||||||
|
|
|
@ -39,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
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<title>New unique predictors on your market</title>
|
<title>New unique traders on your market</title>
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
img {
|
img {
|
||||||
|
@ -214,14 +214,14 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user!
|
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first trade from a user!
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
|
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
|
||||||
creating a market that appeals to others, and we'll do so for each new predictor.
|
creating a market that appeals to others, and we'll do so for each new trader.
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
Keep up the good work and check out your newest predictor below!
|
Keep up the good work and check out your newest trader below!
|
||||||
</span></p>
|
</span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
<title>New unique predictors on your market</title>
|
<title>New unique traders on your market</title>
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
img {
|
img {
|
||||||
|
@ -214,14 +214,14 @@
|
||||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||||
data-testid="4XoHRGw1Y"><span
|
data-testid="4XoHRGw1Y"><span
|
||||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users!
|
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> has attracted {{totalPredictors}} total traders!
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
|
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new traders,
|
||||||
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market).
|
and we'll continue to do so for each new trader, (although we won't send you any more emails about it for this market).
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
Keep up the good work and check out your newest predictors below!
|
Keep up the good work and check out your newest traders below!
|
||||||
</span></p>
|
</span></p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -192,7 +192,7 @@
|
||||||
tips on comments and markets</span></li>
|
tips on comments and markets</span></li>
|
||||||
<li style="line-height:23px;"><span
|
<li style="line-height:23px;"><span
|
||||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||||
predictor bonus for each user who predicts on your
|
trader bonus for each user who trades on your
|
||||||
markets</span></li>
|
markets</span></li>
|
||||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||||
|
|
|
@ -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;"
|
||||||
|
|
|
@ -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,23 +87,30 @@ const updateBettingStreak = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
eventId: string
|
eventId: string
|
||||||
) => {
|
) => {
|
||||||
|
const { newBettingStreak } = await firestore.runTransaction(async (trans) => {
|
||||||
|
const userDoc = firestore.collection('users').doc(user.id)
|
||||||
|
const bettor = (await trans.get(userDoc)).data() as User
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const currentDateResetTime = currentDateBettingStreakResetTime()
|
const currentDateResetTime = currentDateBettingStreakResetTime()
|
||||||
// if now is before reset time, use yesterday's reset time
|
// if now is before reset time, use yesterday's reset time
|
||||||
const lastDateResetTime = currentDateResetTime - DAY_MS
|
const lastDateResetTime = currentDateResetTime - DAY_MS
|
||||||
const betStreakResetTime =
|
const betStreakResetTime =
|
||||||
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
|
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
|
||||||
const lastBetTime = user?.lastBetTime ?? 0
|
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
|
||||||
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
// Send them the bonus times their streak
|
// Send them the bonus times their streak
|
||||||
const bonusAmount = Math.min(
|
const bonusAmount = Math.min(
|
||||||
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
||||||
|
@ -110,8 +122,7 @@ const updateBettingStreak = async (
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
currentBettingStreak: newBettingStreak,
|
currentBettingStreak: newBettingStreak,
|
||||||
}
|
}
|
||||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
|
||||||
const bonusTxn: TxnData = {
|
const bonusTxn: TxnData = {
|
||||||
fromId: fromUserId,
|
fromId: fromUserId,
|
||||||
fromType: 'BANK',
|
fromType: 'BANK',
|
||||||
|
@ -123,41 +134,46 @@ 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
|
||||||
) => {
|
) => {
|
||||||
|
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
|
let previousUniqueBettorIds = contract.uniqueBettorIds
|
||||||
|
|
||||||
|
const betsSnap = await trans.get(
|
||||||
|
firestore.collection(`contracts/${contract.id}/bets`)
|
||||||
|
)
|
||||||
if (!previousUniqueBettorIds) {
|
if (!previousUniqueBettorIds) {
|
||||||
const contractBets = (
|
const contractBets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
await firestore.collection(`contracts/${contract.id}/bets`).get()
|
|
||||||
).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(
|
previousUniqueBettorIds = uniq(
|
||||||
|
@ -175,22 +191,23 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||||
|
|
||||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
await trans.update(contractDoc, {
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to give a bonus for the creator's bet
|
// No need to give a bonus for the creator's bet
|
||||||
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
|
if (!isNewUniqueBettor || bettor.id == contract.creatorId)
|
||||||
|
return { newUniqueBettorIds: undefined }
|
||||||
|
|
||||||
if (contract.mechanism === 'cpmm-1') {
|
return { newUniqueBettorIds }
|
||||||
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
if (!newUniqueBettorIds) return
|
||||||
|
|
||||||
// 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'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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(
|
||||||
|
|
92
functions/src/scripts/backfill-comment-position-data.ts
Normal file
92
functions/src/scripts/backfill-comment-position-data.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
// Filling in historical bet positions on comments.
|
||||||
|
|
||||||
|
// Warning: This just recalculates all of them, rather than trying to
|
||||||
|
// figure out which ones are out of date, since I'm using it to fill them
|
||||||
|
// in once in the first place.
|
||||||
|
|
||||||
|
import { maxBy } from 'lodash'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { filterDefined } from '../../../common/util/array'
|
||||||
|
import { Bet } from '../../../common/bet'
|
||||||
|
import { Comment } from '../../../common/comment'
|
||||||
|
import { Contract } from '../../../common/contract'
|
||||||
|
import { getLargestPosition } from '../../../common/calculate'
|
||||||
|
import { initAdmin } from './script-init'
|
||||||
|
import { DocumentSnapshot } from 'firebase-admin/firestore'
|
||||||
|
import { log, writeAsync } from '../utils'
|
||||||
|
|
||||||
|
initAdmin()
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
async function getContractsById() {
|
||||||
|
const contracts = await firestore.collection('contracts').get()
|
||||||
|
const results = Object.fromEntries(
|
||||||
|
contracts.docs.map((doc) => [doc.id, doc.data() as Contract])
|
||||||
|
)
|
||||||
|
log(`Found ${contracts.size} contracts.`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCommentsByContractId() {
|
||||||
|
const comments = await firestore
|
||||||
|
.collectionGroup('comments')
|
||||||
|
.where('contractId', '!=', null)
|
||||||
|
.get()
|
||||||
|
const results = new Map<string, DocumentSnapshot[]>()
|
||||||
|
comments.forEach((doc) => {
|
||||||
|
const contractId = doc.get('contractId')
|
||||||
|
const contractComments = results.get(contractId) || []
|
||||||
|
contractComments.push(doc)
|
||||||
|
results.set(contractId, contractComments)
|
||||||
|
})
|
||||||
|
log(`Found ${comments.size} comments on ${results.size} contracts.`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// not in a transaction for speed -- may need to be run more than once
|
||||||
|
async function denormalize() {
|
||||||
|
const contractsById = await getContractsById()
|
||||||
|
const commentsByContractId = await getCommentsByContractId()
|
||||||
|
for (const [contractId, comments] of commentsByContractId.entries()) {
|
||||||
|
const betsQuery = await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(contractId)
|
||||||
|
.collection('bets')
|
||||||
|
.get()
|
||||||
|
log(`Loaded ${betsQuery.size} bets for contract ${contractId}.`)
|
||||||
|
const bets = betsQuery.docs.map((d) => d.data() as Bet)
|
||||||
|
const updates = comments.map((doc) => {
|
||||||
|
const comment = doc.data() as Comment
|
||||||
|
const contract = contractsById[contractId]
|
||||||
|
const previousBets = bets.filter(
|
||||||
|
(b) => b.createdTime < comment.createdTime
|
||||||
|
)
|
||||||
|
const position = getLargestPosition(
|
||||||
|
contract,
|
||||||
|
previousBets.filter((b) => b.userId === comment.userId && !b.isAnte)
|
||||||
|
)
|
||||||
|
if (position) {
|
||||||
|
const fields: { [k: string]: unknown } = {
|
||||||
|
commenterPositionShares: position.shares,
|
||||||
|
commenterPositionOutcome: position.outcome,
|
||||||
|
}
|
||||||
|
const previousProb =
|
||||||
|
contract.outcomeType === 'BINARY'
|
||||||
|
? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter
|
||||||
|
: undefined
|
||||||
|
if (previousProb != null) {
|
||||||
|
fields.commenterPositionProb = previousProb
|
||||||
|
}
|
||||||
|
return { doc: doc.ref, fields }
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
log(`Updating ${updates.length} comments.`)
|
||||||
|
await writeAsync(firestore, filterDefined(updates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
denormalize().catch((e) => console.error(e))
|
||||||
|
}
|
|
@ -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,7 +31,8 @@ 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(
|
||||||
|
await Promise.all(
|
||||||
users.map(async (user) => {
|
users.map(async (user) => {
|
||||||
const portfolio = await getValues<PortfolioMetrics>(
|
const portfolio = await getValues<PortfolioMetrics>(
|
||||||
firestore
|
firestore
|
||||||
|
@ -41,6 +43,7 @@ async function updateLoansCore() {
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
.map(([date, percent]) => ({
|
||||||
x: date,
|
x: date,
|
||||||
y: betCount,
|
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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Row className="items-center gap-4">
|
||||||
<MenuIcon
|
<MenuIcon
|
||||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>{' '}
|
/>{' '}
|
||||||
{item.label}
|
{item.label}
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,18 +68,26 @@ export function ShareModal(props: {
|
||||||
>
|
>
|
||||||
{linkIcon} Copy link
|
{linkIcon} Copy link
|
||||||
</Button>
|
</Button>
|
||||||
<Row className={'justify-center'}>or</Row>
|
|
||||||
|
<Row className="z-0 flex-wrap justify-center gap-4 self-center">
|
||||||
|
<TweetButton
|
||||||
|
className="self-start"
|
||||||
|
tweetText={getTweetText(contract, shareUrl)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareEmbedButton contract={contract} />
|
||||||
|
|
||||||
{showChallenge && (
|
{showChallenge && (
|
||||||
<Button
|
<button
|
||||||
size="2xl"
|
className={
|
||||||
color="gradient"
|
'btn btn-xs flex-nowrap border-2 !border-indigo-500 !bg-white normal-case text-indigo-500'
|
||||||
className={'mb-2 flex max-w-xs self-center'}
|
}
|
||||||
onClick={withTracking(
|
onClick={withTracking(
|
||||||
() => setOpenCreateChallengeModal(true),
|
() => setOpenCreateChallengeModal(true),
|
||||||
'click challenge button'
|
'click challenge button'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>⚔️ Challenge</span>
|
️<ChallengeIcon className="mr-1 h-4 w-4" /> Challenge
|
||||||
<CreateChallengeModal
|
<CreateChallengeModal
|
||||||
isOpen={openCreateChallengeModal}
|
isOpen={openCreateChallengeModal}
|
||||||
setOpen={(open) => {
|
setOpen={(open) => {
|
||||||
|
@ -91,15 +99,8 @@ export function ShareModal(props: {
|
||||||
user={user}
|
user={user}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Row className="z-0 flex-wrap justify-center gap-4 self-center">
|
|
||||||
<TweetButton
|
|
||||||
className="self-start"
|
|
||||||
tweetText={getTweetText(contract, shareUrl)}
|
|
||||||
/>
|
|
||||||
<ShareEmbedButton contract={contract} />
|
|
||||||
<DuplicateContractButton contract={contract} />
|
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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="label">
|
<label className="mb-2 ml-1 mt-0">Group name</label>
|
||||||
<span className="mb-0">Add members (optional)</span>
|
|
||||||
</label>
|
|
||||||
<FilterSelectUsers
|
|
||||||
setSelectedUsers={updateMemberUsers}
|
|
||||||
selectedUsers={memberUsers}
|
|
||||||
ignoreUserIds={[user.id]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-control w-full">
|
|
||||||
<label className="label">
|
|
||||||
<span className="mt-1">Group name (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
placeholder={defaultName}
|
placeholder={'Your group name'}
|
||||||
className="input input-bordered resize-none"
|
className="input input-bordered resize-none"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
value={name}
|
value={name}
|
||||||
maxLength={MAX_GROUP_NAME_LENGTH}
|
maxLength={MAX_GROUP_NAME_LENGTH}
|
||||||
onChange={(e) => setName(e.target.value || '')}
|
onChange={(e) => setName(e.target.value || '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" />
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
23
web/components/nav/more-button.tsx
Normal file
23
web/components/nav/more-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
63
web/components/nav/sidebar-item.tsx
Normal file
63
web/components/nav/sidebar-item.tsx
Normal 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>
|
||||||
|
) : (
|
||||||
|
<> </>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
memberGroupIds.includes(group.id)
|
||||||
? leaveGroup(group, user.id)
|
? leaveGroup(group, user.id)
|
||||||
: joinGroup(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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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.'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
15
web/lib/service/algolia.ts
Normal file
15
web/lib/service/algolia.ts
Normal 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}`
|
||||||
|
}
|
|
@ -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(
|
||||||
|
`${ENV_CONFIG.twitchBotEndpoint}/api/linkInit`,
|
||||||
|
{
|
||||||
manifoldID: manifoldUserID,
|
manifoldID: manifoldUserID,
|
||||||
apiKey: manifoldUserAPIKey,
|
apiKey: manifoldUserAPIKey,
|
||||||
redirectURL: window.location.href,
|
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(
|
||||||
|
`${ENV_CONFIG.twitchBotEndpoint}/unregisterchanneltwitch`,
|
||||||
|
{
|
||||||
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
42
web/pages/api/v0/revalidate.ts
Normal file
42
web/pages/api/v0/revalidate.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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'}>
|
||||||
|
<Row className="items-center gap-2">
|
||||||
<Title className="!mt-0 !mb-0" text="Home" />
|
<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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) =>
|
|
||||||
Math.round((100 * dailyActive) / weeklyActiveUsers[i])
|
|
||||||
)
|
)
|
||||||
.slice(7)
|
const dailyDividedByMonthly = dailyActiveUsers.map(
|
||||||
|
(dailyActive, i) => dailyActive / monthlyActiveUsers[i]
|
||||||
const dailyDividedByMonthly = dailyActiveUsers
|
|
||||||
.map((dailyActive, i) =>
|
|
||||||
Math.round((100 * dailyActive) / monthlyActiveUsers[i])
|
|
||||||
)
|
)
|
||||||
.slice(7)
|
const weeklyDividedByMonthly = weeklyActiveUsers.map(
|
||||||
|
(weeklyActive, i) => weeklyActive / monthlyActiveUsers[i]
|
||||||
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
21
yarn.lock
21
yarn.lock
|
@ -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==
|
||||||
|
|
Loading…
Reference in New Issue
Block a user