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 {
|
||||
calculateCpmmSale,
|
||||
|
@ -255,3 +255,43 @@ export function getTopAnswer(
|
|||
)
|
||||
return top?.answer
|
||||
}
|
||||
|
||||
export function getLargestPosition(contract: Contract, userBets: Bet[]) {
|
||||
let yesFloorShares = 0,
|
||||
yesShares = 0,
|
||||
noShares = 0,
|
||||
noFloorShares = 0
|
||||
|
||||
if (userBets.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
const answerCounts: { [outcome: string]: number } = {}
|
||||
for (const bet of userBets) {
|
||||
if (bet.outcome) {
|
||||
if (!answerCounts[bet.outcome]) {
|
||||
answerCounts[bet.outcome] = bet.amount
|
||||
} else {
|
||||
answerCounts[bet.outcome] += bet.amount
|
||||
}
|
||||
}
|
||||
}
|
||||
const majorityAnswer =
|
||||
maxBy(Object.keys(answerCounts), (outcome) => answerCounts[outcome]) ?? ''
|
||||
return {
|
||||
prob: undefined,
|
||||
shares: answerCounts[majorityAnswer] || 0,
|
||||
outcome: majorityAnswer,
|
||||
}
|
||||
}
|
||||
|
||||
const [yesBets, noBets] = partition(userBets, (bet) => bet.outcome === 'YES')
|
||||
yesShares = sumBy(yesBets, (bet) => bet.shares)
|
||||
noShares = sumBy(noBets, (bet) => bet.shares)
|
||||
yesFloorShares = Math.floor(yesShares)
|
||||
noFloorShares = Math.floor(noShares)
|
||||
|
||||
const shares = yesFloorShares || noFloorShares
|
||||
const outcome = yesFloorShares > noFloorShares ? 'YES' : 'NO'
|
||||
return { shares, outcome }
|
||||
}
|
||||
|
|
|
@ -33,6 +33,11 @@ export type OnContract = {
|
|||
// denormalized from bet
|
||||
betAmount?: number
|
||||
betOutcome?: string
|
||||
|
||||
// denormalized based on betting history
|
||||
commenterPositionProb?: number // binary only
|
||||
commenterPositionShares?: number
|
||||
commenterPositionOutcome?: string
|
||||
}
|
||||
|
||||
export type OnGroup = {
|
||||
|
|
|
@ -148,7 +148,7 @@ export const OUTCOME_TYPES = [
|
|||
'NUMERIC',
|
||||
] as const
|
||||
|
||||
export const MAX_QUESTION_LENGTH = 480
|
||||
export const MAX_QUESTION_LENGTH = 240
|
||||
export const MAX_DESCRIPTION_LENGTH = 16000
|
||||
export const MAX_TAG_LENGTH = 60
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
|
|||
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
|
||||
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
|
||||
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
|
||||
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||
|
|
|
@ -16,4 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
|
|||
cloudRunId: 'w3txbmd3ba',
|
||||
cloudRunRegion: 'uc',
|
||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||
// this is Phil's deployment
|
||||
twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ export type EnvConfig = {
|
|||
domain: string
|
||||
firebaseConfig: FirebaseConfig
|
||||
amplitudeApiKey?: string
|
||||
twitchBotEndpoint?: string
|
||||
|
||||
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
||||
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
||||
|
@ -66,6 +67,7 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
|
||||
measurementId: 'G-SSFK1Q138D',
|
||||
},
|
||||
twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
|
||||
cloudRunId: 'nggbo3neva',
|
||||
cloudRunRegion: 'uc',
|
||||
adminEmails: [
|
||||
|
@ -82,9 +84,9 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
visibility: 'PUBLIC',
|
||||
|
||||
moneyMoniker: 'M$',
|
||||
bettor: 'predictor',
|
||||
pastBet: 'prediction',
|
||||
presentBet: 'predict',
|
||||
bettor: 'trader',
|
||||
pastBet: 'trade',
|
||||
presentBet: 'trade',
|
||||
navbarLogoPath: '',
|
||||
faviconPath: '/favicon.ico',
|
||||
newQuestionPlaceholders: [
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
export type Stats = {
|
||||
startDate: number
|
||||
dailyActiveUsers: number[]
|
||||
dailyActiveUsersWeeklyAvg: number[]
|
||||
weeklyActiveUsers: number[]
|
||||
monthlyActiveUsers: number[]
|
||||
d1: number[]
|
||||
d1WeeklyAvg: number[]
|
||||
nd1: number[]
|
||||
nd1WeeklyAvg: number[]
|
||||
nw1: number[]
|
||||
dailyBetCounts: number[]
|
||||
dailyContractCounts: number[]
|
||||
dailyCommentCounts: number[]
|
||||
dailySignups: number[]
|
||||
weekOnWeekRetention: number[]
|
||||
monthlyRetention: number[]
|
||||
weeklyActivationRate: number[]
|
||||
topTenthActions: {
|
||||
daily: number[]
|
||||
weekly: number[]
|
||||
monthly: number[]
|
||||
}
|
||||
dailyActivationRate: number[]
|
||||
dailyActivationRateWeeklyAvg: number[]
|
||||
manaBet: {
|
||||
daily: number[]
|
||||
weekly: number[]
|
||||
|
|
|
@ -56,11 +56,6 @@ export type PrivateUser = {
|
|||
username: string // denormalized from User
|
||||
|
||||
email?: string
|
||||
unsubscribedFromResolutionEmails?: boolean
|
||||
unsubscribedFromCommentEmails?: boolean
|
||||
unsubscribedFromAnswerEmails?: boolean
|
||||
unsubscribedFromGenericEmails?: boolean
|
||||
unsubscribedFromWeeklyTrendingEmails?: boolean
|
||||
weeklyTrendingEmailSent?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
|
@ -86,9 +81,11 @@ export type PortfolioMetrics = {
|
|||
export const MANIFOLD_USERNAME = 'ManifoldMarkets'
|
||||
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
|
||||
|
||||
export const BETTOR = ENV_CONFIG.bettor ?? 'bettor' // aka predictor
|
||||
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'bettors'
|
||||
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'bet' // aka predict
|
||||
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'bets'
|
||||
export const PAST_BET = ENV_CONFIG.pastBet ?? 'bet' // aka prediction
|
||||
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'bets' // aka predictions
|
||||
// TODO: remove. Hardcoding the strings would be better.
|
||||
// Different views require different language.
|
||||
export const BETTOR = ENV_CONFIG.bettor ?? 'trader'
|
||||
export const BETTORS = ENV_CONFIG.bettor + 's' ?? 'traders'
|
||||
export const PRESENT_BET = ENV_CONFIG.presentBet ?? 'trade'
|
||||
export const PRESENT_BETS = ENV_CONFIG.presentBet + 's' ?? 'trades'
|
||||
export const PAST_BET = ENV_CONFIG.pastBet ?? 'trade'
|
||||
export const PAST_BETS = ENV_CONFIG.pastBet + 's' ?? 'trades'
|
||||
|
|
|
@ -27,7 +27,7 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
|
||||
// User referral rules
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -78,7 +78,7 @@ service cloud.firestore {
|
|||
allow read: if userId == request.auth.uid || isAdmin();
|
||||
allow update: if (userId == request.auth.uid || isAdmin())
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
|
||||
.hasOnly(['apiKey', 'notificationPreferences', 'twitchInfo']);
|
||||
}
|
||||
|
||||
match /private-users/{userId}/views/{viewId} {
|
||||
|
@ -196,7 +196,6 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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:
|
||||
|
||||
- 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`
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
"lodash": "4.17.21",
|
||||
"mailgun-js": "0.22.0",
|
||||
"module-alias": "2.2.2",
|
||||
"node-fetch": "2",
|
||||
"react-masonry-css": "1.0.16",
|
||||
"stripe": "8.194.0",
|
||||
"zod": "3.17.2"
|
||||
|
@ -46,6 +47,7 @@
|
|||
"devDependencies": {
|
||||
"@types/mailgun-js": "0.22.12",
|
||||
"@types/module-alias": "2.0.1",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"firebase-functions-test": "0.3.3"
|
||||
},
|
||||
"private": true
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>New unique predictors on your market</title>
|
||||
<title>New unique traders on your market</title>
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
|
@ -214,14 +214,14 @@
|
|||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first prediction from a user!
|
||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> just got its first trade from a user!
|
||||
<br/>
|
||||
<br/>
|
||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for
|
||||
creating a market that appeals to others, and we'll do so for each new predictor.
|
||||
creating a market that appeals to others, and we'll do so for each new trader.
|
||||
<br/>
|
||||
<br/>
|
||||
Keep up the good work and check out your newest predictor below!
|
||||
Keep up the good work and check out your newest trader below!
|
||||
</span></p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>New unique predictors on your market</title>
|
||||
<title>New unique traders on your market</title>
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
|
@ -214,14 +214,14 @@
|
|||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> got predictions from a total of {{totalPredictors}} users!
|
||||
Your market <a href='{{marketUrl}}'>{{marketTitle}}</a> has attracted {{totalPredictors}} total traders!
|
||||
<br/>
|
||||
<br/>
|
||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new predictors,
|
||||
and we'll continue to do so for each new predictor, (although we won't send you any more emails about it for this market).
|
||||
We sent you a <span style='color: #11b981'>{{bonusString}}</span> bonus for getting {{newPredictors}} new traders,
|
||||
and we'll continue to do so for each new trader, (although we won't send you any more emails about it for this market).
|
||||
<br/>
|
||||
<br/>
|
||||
Keep up the good work and check out your newest predictors below!
|
||||
Keep up the good work and check out your newest traders below!
|
||||
</span></p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -192,7 +192,7 @@
|
|||
tips on comments and markets</span></li>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||
predictor bonus for each user who predicts on your
|
||||
trader bonus for each user who trades on your
|
||||
markets</span></li>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
|
|
|
@ -210,7 +210,7 @@
|
|||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Refer
|
||||
your friends</u></span></a> and earn M$500 for each signup!</span></li>
|
||||
your friends</u></span></a> and earn M$250 for each signup!</span></li>
|
||||
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
|
|
|
@ -3,7 +3,14 @@ import * as admin from 'firebase-admin'
|
|||
import { keyBy, uniq } from 'lodash'
|
||||
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
import { getUser, getValues, isProd, log } from './utils'
|
||||
import {
|
||||
getContractPath,
|
||||
getUser,
|
||||
getValues,
|
||||
isProd,
|
||||
log,
|
||||
revalidateStaticProps,
|
||||
} from './utils'
|
||||
import {
|
||||
createBetFillNotification,
|
||||
createBettingStreakBonusNotification,
|
||||
|
@ -24,8 +31,6 @@ import {
|
|||
} from '../../common/antes'
|
||||
import { APIError } from '../../common/api'
|
||||
import { User } from '../../common/user'
|
||||
import { UNIQUE_BETTOR_LIQUIDITY_AMOUNT } from '../../common/antes'
|
||||
import { addHouseLiquidity } from './add-liquidity'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
|
||||
|
||||
|
@ -33,7 +38,7 @@ const firestore = admin.firestore()
|
|||
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
|
||||
|
||||
export const onCreateBet = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
.runWith({ secrets: ['MAILGUN_KEY', 'API_SECRET'] })
|
||||
.firestore.document('contracts/{contractId}/bets/{betId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const { contractId } = context.params as {
|
||||
|
@ -73,7 +78,7 @@ export const onCreateBet = functions
|
|||
await notifyFills(bet, contract, eventId, bettor)
|
||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||
|
||||
await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
|
||||
await revalidateStaticProps(getContractPath(contract))
|
||||
})
|
||||
|
||||
const updateBettingStreak = async (
|
||||
|
@ -82,23 +87,30 @@ const updateBettingStreak = async (
|
|||
contract: Contract,
|
||||
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 currentDateResetTime = currentDateBettingStreakResetTime()
|
||||
// if now is before reset time, use yesterday's reset time
|
||||
const lastDateResetTime = currentDateResetTime - DAY_MS
|
||||
const betStreakResetTime =
|
||||
now < currentDateResetTime ? lastDateResetTime : currentDateResetTime
|
||||
const lastBetTime = user?.lastBetTime ?? 0
|
||||
const lastBetTime = bettor?.lastBetTime ?? 0
|
||||
|
||||
// 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
|
||||
await firestore.collection('users').doc(user.id).update({
|
||||
await trans.update(userDoc, {
|
||||
currentBettingStreak: newBettingStreak,
|
||||
lastBetTime: bet.createdTime,
|
||||
})
|
||||
|
||||
return { newBettingStreak }
|
||||
})
|
||||
if (!newBettingStreak) return
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
// Send them the bonus times their streak
|
||||
const bonusAmount = Math.min(
|
||||
BETTING_STREAK_BONUS_AMOUNT * newBettingStreak,
|
||||
|
@ -110,8 +122,7 @@ const updateBettingStreak = async (
|
|||
const bonusTxnDetails = {
|
||||
currentBettingStreak: newBettingStreak,
|
||||
}
|
||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUserId,
|
||||
fromType: 'BANK',
|
||||
|
@ -123,41 +134,46 @@ const updateBettingStreak = async (
|
|||
description: JSON.stringify(bonusTxnDetails),
|
||||
data: bonusTxnDetails,
|
||||
} as Omit<BettingStreakBonusTxn, 'id' | 'createdTime'>
|
||||
return await runTxn(trans, bonusTxn)
|
||||
const { message, txn, status } = await runTxn(trans, bonusTxn)
|
||||
return { message, txn, status, bonusAmount }
|
||||
})
|
||||
if (!result.txn) {
|
||||
if (result.status != 'success') {
|
||||
log("betting streak bonus txn couldn't be made")
|
||||
log('status:', result.status)
|
||||
log('message:', result.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.txn)
|
||||
await createBettingStreakBonusNotification(
|
||||
user,
|
||||
result.txn.id,
|
||||
bet,
|
||||
contract,
|
||||
bonusAmount,
|
||||
result.bonusAmount,
|
||||
newBettingStreak,
|
||||
eventId
|
||||
)
|
||||
}
|
||||
|
||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||
contract: Contract,
|
||||
oldContract: Contract,
|
||||
eventId: string,
|
||||
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
|
||||
|
||||
const betsSnap = await trans.get(
|
||||
firestore.collection(`contracts/${contract.id}/bets`)
|
||||
)
|
||||
if (!previousUniqueBettorIds) {
|
||||
const contractBets = (
|
||||
await firestore.collection(`contracts/${contract.id}/bets`).get()
|
||||
).docs.map((doc) => doc.data() as Bet)
|
||||
const contractBets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
|
||||
if (contractBets.length === 0) {
|
||||
log(`No bets for contract ${contract.id}`)
|
||||
return
|
||||
return { newUniqueBettorIds: undefined }
|
||||
}
|
||||
|
||||
previousUniqueBettorIds = uniq(
|
||||
|
@ -175,22 +191,23 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||
|
||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
||||
await trans.update(contractDoc, {
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
})
|
||||
}
|
||||
|
||||
// No need to give a bonus for the creator's bet
|
||||
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
|
||||
if (!isNewUniqueBettor || bettor.id == contract.creatorId)
|
||||
return { newUniqueBettorIds: undefined }
|
||||
|
||||
if (contract.mechanism === 'cpmm-1') {
|
||||
await addHouseLiquidity(contract, UNIQUE_BETTOR_LIQUIDITY_AMOUNT)
|
||||
return { newUniqueBettorIds }
|
||||
}
|
||||
)
|
||||
if (!newUniqueBettorIds) return
|
||||
|
||||
// Create combined txn for all new unique bettors
|
||||
const bonusTxnDetails = {
|
||||
contractId: contract.id,
|
||||
contractId: oldContract.id,
|
||||
uniqueNewBettorId: bettor.id,
|
||||
}
|
||||
const fromUserId = isProd()
|
||||
|
@ -199,12 +216,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
|
||||
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
|
||||
const fromUser = fromSnap.data() as User
|
||||
// TODO: set the id of the txn to the eventId to prevent duplicates
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const bonusTxn: TxnData = {
|
||||
fromId: fromUser.id,
|
||||
fromType: 'BANK',
|
||||
toId: contract.creatorId,
|
||||
toId: oldContract.creatorId,
|
||||
toType: 'USER',
|
||||
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
token: 'M$',
|
||||
|
@ -212,21 +228,25 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
description: JSON.stringify(bonusTxnDetails),
|
||||
data: bonusTxnDetails,
|
||||
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
|
||||
return await runTxn(trans, bonusTxn)
|
||||
const { status, message, txn } = await runTxn(trans, bonusTxn)
|
||||
return { status, newUniqueBettorIds, message, txn }
|
||||
})
|
||||
|
||||
if (result.status != 'success' || !result.txn) {
|
||||
log(`No bonus for user: ${contract.creatorId} - status:`, result.status)
|
||||
log(`No bonus for user: ${oldContract.creatorId} - status:`, result.status)
|
||||
log('message:', result.message)
|
||||
} else {
|
||||
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
||||
log(
|
||||
`Bonus txn for user: ${oldContract.creatorId} completed:`,
|
||||
result.txn?.id
|
||||
)
|
||||
await createUniqueBettorBonusNotification(
|
||||
contract.creatorId,
|
||||
oldContract.creatorId,
|
||||
bettor,
|
||||
result.txn.id,
|
||||
contract,
|
||||
oldContract,
|
||||
result.txn.amount,
|
||||
newUniqueBettorIds,
|
||||
result.newUniqueBettorIds,
|
||||
eventId + '-unique-bettor-bonus'
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { compact } from 'lodash'
|
||||
import { getContract, getUser, getValues } from './utils'
|
||||
import {
|
||||
getContract,
|
||||
getContractPath,
|
||||
getUser,
|
||||
getValues,
|
||||
revalidateStaticProps,
|
||||
} from './utils'
|
||||
import { ContractComment } from '../../common/comment'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { getLargestPosition } from '../../common/calculate'
|
||||
import { maxBy } from 'lodash'
|
||||
import {
|
||||
createCommentOrAnswerOrUpdatedContractNotification,
|
||||
replied_users_info,
|
||||
|
@ -32,6 +40,8 @@ export const onCreateCommentOnContract = functions
|
|||
contractQuestion: contract.question,
|
||||
})
|
||||
|
||||
await revalidateStaticProps(getContractPath(contract))
|
||||
|
||||
const comment = change.data() as ContractComment
|
||||
const lastCommentTime = comment.createdTime
|
||||
|
||||
|
@ -45,6 +55,32 @@ export const onCreateCommentOnContract = functions
|
|||
.doc(contract.id)
|
||||
.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 answer: Answer | undefined
|
||||
if (comment.answerOutcome) {
|
||||
|
|
|
@ -4,14 +4,8 @@ import * as utc from 'dayjs/plugin/utc'
|
|||
dayjs.extend(utc)
|
||||
|
||||
import { getPrivateUser } from './utils'
|
||||
import { User } from 'common/user'
|
||||
import {
|
||||
sendCreatorGuideEmail,
|
||||
sendInterestingMarketsEmail,
|
||||
sendPersonalFollowupEmail,
|
||||
sendWelcomeEmail,
|
||||
} from './emails'
|
||||
import { getTrendingContracts } from './weekly-markets-emails'
|
||||
import { User } from '../../common/user'
|
||||
import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails'
|
||||
|
||||
export const onCreateUser = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -23,23 +17,6 @@ export const onCreateUser = functions
|
|||
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
|
||||
const guideSendTime = dayjs().add(28, 'hours').toString()
|
||||
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
|
||||
|
||||
const followupSendTime = dayjs().add(48, 'hours').toString()
|
||||
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
|
||||
|
||||
// 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'
|
||||
|
||||
export const resetWeeklyEmailsFlag = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
|
||||
.pubsub.schedule('0 7 * * 1')
|
||||
.runWith({
|
||||
timeoutSeconds: 300,
|
||||
memory: '4GB',
|
||||
})
|
||||
.pubsub // every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
|
||||
.schedule('0 7 * * 1')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
const privateUsers = await getAllPrivateUsers()
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return !user.unsubscribedFromWeeklyTrendingEmails
|
||||
})
|
||||
const firestore = admin.firestore()
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
privateUsers.map(async (user) => {
|
||||
return firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: false,
|
||||
})
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getUser, getValues, isProd, log, payUser } from './utils'
|
||||
import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
|
||||
import {
|
||||
getLoanPayouts,
|
||||
getPayouts,
|
||||
|
@ -171,6 +171,8 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
await processPayouts([...payouts, ...loanPayouts])
|
||||
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
|
||||
|
||||
await revalidateStaticProps(getContractPath(contract))
|
||||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
|
||||
const userInvestments = mapValues(
|
||||
|
|
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 { getLoanUpdates } from '../../common/loans'
|
||||
import { createLoanIncomeNotification } from './create-notification'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -30,7 +31,8 @@ async function updateLoansCore() {
|
|||
log(
|
||||
`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) => {
|
||||
const portfolio = await getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
|
@ -41,6 +43,7 @@ async function updateLoansCore() {
|
|||
return portfolio[0]
|
||||
})
|
||||
)
|
||||
)
|
||||
log(`Loaded ${userPortfolios.length} portfolios`)
|
||||
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import { Group } from 'common/group'
|
|||
const firestore = admin.firestore()
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '4GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash'
|
||||
import * as dayjs from 'dayjs'
|
||||
import * as utc from 'dayjs/plugin/utc'
|
||||
import * as timezone from 'dayjs/plugin/timezone'
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
import { range, zip, uniq, sum, sumBy } from 'lodash'
|
||||
import { getValues, log, logMemory } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { User } from '../../common/user'
|
||||
import { Stats } from '../../common/stats'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { average } from '../../common/util/math'
|
||||
|
||||
|
@ -103,7 +110,7 @@ export async function getDailyNewUsers(
|
|||
}
|
||||
|
||||
export const updateStatsCore = async () => {
|
||||
const today = Date.now()
|
||||
const today = dayjs().tz('America/Los_Angeles').startOf('day').valueOf()
|
||||
const startDate = today - numberOfDays * DAY_MS
|
||||
|
||||
log('Fetching data for stats update...')
|
||||
|
@ -139,73 +146,128 @@ export const updateStatsCore = async () => {
|
|||
)
|
||||
|
||||
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
|
||||
const dailyActiveUsersWeeklyAvg = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i + 1
|
||||
return average(dailyActiveUsers.slice(start, end))
|
||||
})
|
||||
|
||||
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
||||
const end = i + 1
|
||||
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
|
||||
return uniques.size
|
||||
})
|
||||
|
||||
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
||||
const end = i + 1
|
||||
const uniques = new Set<string>(dailyUserIds.slice(start, end).flat())
|
||||
return uniques.size
|
||||
})
|
||||
|
||||
const d1 = dailyUserIds.map((userIds, i) => {
|
||||
if (i === 0) return 0
|
||||
|
||||
const uniques = new Set(userIds)
|
||||
const yesterday = dailyUserIds[i - 1]
|
||||
|
||||
const retainedCount = sumBy(yesterday, (userId) =>
|
||||
uniques.has(userId) ? 1 : 0
|
||||
)
|
||||
return retainedCount / uniques.size
|
||||
})
|
||||
|
||||
const d1WeeklyAvg = d1.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i + 1
|
||||
return average(d1.slice(start, end))
|
||||
})
|
||||
|
||||
const dailyNewUserIds = dailyNewUsers.map((users) => users.map((u) => u.id))
|
||||
const nd1 = dailyUserIds.map((userIds, i) => {
|
||||
if (i === 0) return 0
|
||||
|
||||
const uniques = new Set(userIds)
|
||||
const yesterday = dailyNewUserIds[i - 1]
|
||||
|
||||
const retainedCount = sumBy(yesterday, (userId) =>
|
||||
uniques.has(userId) ? 1 : 0
|
||||
)
|
||||
return retainedCount / uniques.size
|
||||
})
|
||||
|
||||
const nd1WeeklyAvg = nd1.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i + 1
|
||||
return average(nd1.slice(start, end))
|
||||
})
|
||||
const nw1 = dailyNewUserIds.map((_userIds, i) => {
|
||||
if (i < 13) return 0
|
||||
|
||||
const twoWeeksAgo = {
|
||||
start: Math.max(0, i - 13),
|
||||
end: Math.max(0, i - 6),
|
||||
}
|
||||
const lastWeek = {
|
||||
start: Math.max(0, i - 6),
|
||||
end: i + 1,
|
||||
}
|
||||
const newTwoWeeksAgo = new Set<string>(
|
||||
dailyNewUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
|
||||
)
|
||||
const activeLastWeek = new Set<string>(
|
||||
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
|
||||
)
|
||||
const retainedCount = sumBy(Array.from(newTwoWeeksAgo), (userId) =>
|
||||
activeLastWeek.has(userId) ? 1 : 0
|
||||
)
|
||||
return retainedCount / newTwoWeeksAgo.size
|
||||
})
|
||||
|
||||
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
|
||||
const twoWeeksAgo = {
|
||||
start: Math.max(0, i - 13),
|
||||
end: Math.max(0, i - 7),
|
||||
end: Math.max(0, i - 6),
|
||||
}
|
||||
const lastWeek = {
|
||||
start: Math.max(0, i - 6),
|
||||
end: i,
|
||||
end: i + 1,
|
||||
}
|
||||
|
||||
const activeTwoWeeksAgo = new Set<string>()
|
||||
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
|
||||
}
|
||||
const activeLastWeek = new Set<string>()
|
||||
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
|
||||
}
|
||||
const activeTwoWeeksAgo = new Set<string>(
|
||||
dailyUserIds.slice(twoWeeksAgo.start, twoWeeksAgo.end).flat()
|
||||
)
|
||||
const activeLastWeek = new Set<string>(
|
||||
dailyUserIds.slice(lastWeek.start, lastWeek.end).flat()
|
||||
)
|
||||
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
|
||||
activeLastWeek.has(userId) ? 1 : 0
|
||||
)
|
||||
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
|
||||
return Math.round(retainedFrac * 100 * 100) / 100
|
||||
return retainedCount / activeTwoWeeksAgo.size
|
||||
})
|
||||
|
||||
const monthlyRetention = dailyUserIds.map((_userId, i) => {
|
||||
const twoMonthsAgo = {
|
||||
start: Math.max(0, i - 60),
|
||||
end: Math.max(0, i - 30),
|
||||
start: Math.max(0, i - 59),
|
||||
end: Math.max(0, i - 29),
|
||||
}
|
||||
const lastMonth = {
|
||||
start: Math.max(0, i - 30),
|
||||
end: i,
|
||||
start: Math.max(0, i - 29),
|
||||
end: i + 1,
|
||||
}
|
||||
|
||||
const activeTwoMonthsAgo = new Set<string>()
|
||||
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
|
||||
}
|
||||
const activeLastMonth = new Set<string>()
|
||||
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
|
||||
}
|
||||
const activeTwoMonthsAgo = new Set<string>(
|
||||
dailyUserIds.slice(twoMonthsAgo.start, twoMonthsAgo.end).flat()
|
||||
)
|
||||
const activeLastMonth = new Set<string>(
|
||||
dailyUserIds.slice(lastMonth.start, lastMonth.end).flat()
|
||||
)
|
||||
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
|
||||
activeLastMonth.has(userId) ? 1 : 0
|
||||
)
|
||||
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
|
||||
return Math.round(retainedFrac * 100 * 100) / 100
|
||||
if (activeTwoMonthsAgo.size === 0) return 0
|
||||
return retainedCount / activeTwoMonthsAgo.size
|
||||
})
|
||||
|
||||
const firstBetDict: { [userId: string]: number } = {}
|
||||
|
@ -216,52 +278,20 @@ export const updateStatsCore = async () => {
|
|||
firstBetDict[bet.userId] = i
|
||||
}
|
||||
}
|
||||
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
let activatedCount = 0
|
||||
let newUsers = 0
|
||||
for (let j = start; j <= end; j++) {
|
||||
const userIds = dailyNewUsers[j].map((user) => user.id)
|
||||
newUsers += userIds.length
|
||||
for (const userId of userIds) {
|
||||
const dayIndex = firstBetDict[userId]
|
||||
if (dayIndex !== undefined && dayIndex <= end) {
|
||||
activatedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
const frac = activatedCount / (newUsers || 1)
|
||||
return Math.round(frac * 100 * 100) / 100
|
||||
const dailyActivationRate = dailyNewUsers.map((newUsers, i) => {
|
||||
const activedCount = sumBy(newUsers, (user) => {
|
||||
const firstBet = firstBetDict[user.id]
|
||||
return firstBet === i ? 1 : 0
|
||||
})
|
||||
return activedCount / newUsers.length
|
||||
})
|
||||
const dailyActivationRateWeeklyAvg = dailyActivationRate.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i + 1
|
||||
return average(dailyActivationRate.slice(start, end))
|
||||
})
|
||||
const dailySignups = dailyNewUsers.map((users) => users.length)
|
||||
|
||||
const dailyTopTenthActions = zip(
|
||||
dailyContracts,
|
||||
dailyBets,
|
||||
dailyComments
|
||||
).map(([contracts, bets, comments]) => {
|
||||
const userIds = concat(
|
||||
contracts?.map((c) => c.creatorId) ?? [],
|
||||
bets?.map((b) => b.userId) ?? [],
|
||||
comments?.map((c) => c.userId) ?? []
|
||||
)
|
||||
const counts = Object.values(countBy(userIds))
|
||||
const sortedCounts = sortBy(counts, (count) => count).reverse()
|
||||
if (sortedCounts.length === 0) return 0
|
||||
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
|
||||
return tenthPercentile
|
||||
})
|
||||
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
const dailySignups = dailyNewUsers.map((users) => users.length)
|
||||
|
||||
// Total mana divided by 100.
|
||||
const dailyManaBet = dailyBets.map((bets) => {
|
||||
|
@ -269,37 +299,39 @@ export const updateStatsCore = async () => {
|
|||
})
|
||||
const weeklyManaBet = dailyManaBet.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const end = i + 1
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
if (end - start < 7) return (total * 7) / (end - start)
|
||||
return total
|
||||
})
|
||||
const monthlyManaBet = dailyManaBet.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const end = i + 1
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
const range = end - start + 1
|
||||
const range = end - start
|
||||
if (range < 30) return (total * 30) / range
|
||||
return total
|
||||
})
|
||||
|
||||
const statsData = {
|
||||
const statsData: Stats = {
|
||||
startDate: startDate.valueOf(),
|
||||
dailyActiveUsers,
|
||||
dailyActiveUsersWeeklyAvg,
|
||||
weeklyActiveUsers,
|
||||
monthlyActiveUsers,
|
||||
d1,
|
||||
d1WeeklyAvg,
|
||||
nd1,
|
||||
nd1WeeklyAvg,
|
||||
nw1,
|
||||
dailyBetCounts,
|
||||
dailyContractCounts,
|
||||
dailyCommentCounts,
|
||||
dailySignups,
|
||||
weekOnWeekRetention,
|
||||
weeklyActivationRate,
|
||||
dailyActivationRate,
|
||||
dailyActivationRateWeeklyAvg,
|
||||
monthlyRetention,
|
||||
topTenthActions: {
|
||||
daily: dailyTopTenthActions,
|
||||
weekly: weeklyTopTenthActions,
|
||||
monthly: monthlyTopTenthActions,
|
||||
},
|
||||
manaBet: {
|
||||
daily: dailyManaBet,
|
||||
weekly: weeklyManaBet,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { chunk } from 'lodash'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
@ -17,6 +18,18 @@ export const logMemory = () => {
|
|||
}
|
||||
}
|
||||
|
||||
export const revalidateStaticProps = async (
|
||||
// Path after domain: e.g. "/JamesGrugett/will-pete-buttigieg-ever-be-us-pres"
|
||||
pathToRevalidate: string
|
||||
) => {
|
||||
if (isProd()) {
|
||||
const apiSecret = process.env.API_SECRET as string
|
||||
const queryStr = `?pathToRevalidate=${pathToRevalidate}&apiSecret=${apiSecret}`
|
||||
await fetch('https://manifold.markets/api/v0/revalidate' + queryStr)
|
||||
console.log('Revalidated', pathToRevalidate)
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdateSpec = {
|
||||
doc: admin.firestore.DocumentReference
|
||||
fields: { [k: string]: unknown }
|
||||
|
@ -153,3 +166,7 @@ export const chargeUser = (
|
|||
|
||||
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'
|
||||
|
||||
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)
|
||||
.pubsub.schedule('* 19 * * 1')
|
||||
.timeZone('Etc/UTC')
|
||||
|
@ -48,7 +48,7 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return (
|
||||
!user.unsubscribedFromWeeklyTrendingEmails &&
|
||||
user.notificationPreferences.trending_markets.includes('email') &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Point, ResponsiveLine } from '@nivo/line'
|
||||
import clsx from 'clsx'
|
||||
import { formatPercent } from 'common/util/format'
|
||||
import dayjs from 'dayjs'
|
||||
import { zip } from 'lodash'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
@ -63,18 +64,21 @@ export function DailyPercentChart(props: {
|
|||
startDate: number
|
||||
dailyPercent: number[]
|
||||
small?: boolean
|
||||
excludeFirstDays?: number
|
||||
}) {
|
||||
const { dailyPercent, startDate, small } = props
|
||||
const { dailyPercent, startDate, small, excludeFirstDays } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const dates = dailyPercent.map((_, i) =>
|
||||
dayjs(startDate).add(i, 'day').toDate()
|
||||
)
|
||||
|
||||
const points = zip(dates, dailyPercent).map(([date, betCount]) => ({
|
||||
const points = zip(dates, dailyPercent)
|
||||
.map(([date, percent]) => ({
|
||||
x: date,
|
||||
y: betCount,
|
||||
y: percent,
|
||||
}))
|
||||
.slice(excludeFirstDays ?? 0)
|
||||
const data = [{ id: 'Percent', data: points, color: '#11b981' }]
|
||||
|
||||
const bottomAxisTicks = width && width < 600 ? 6 : undefined
|
||||
|
@ -93,7 +97,7 @@ export function DailyPercentChart(props: {
|
|||
type: 'time',
|
||||
}}
|
||||
axisLeft={{
|
||||
format: (value) => `${value}%`,
|
||||
format: formatPercent,
|
||||
}}
|
||||
axisBottom={{
|
||||
tickValues: bottomAxisTicks,
|
||||
|
@ -109,15 +113,15 @@ export function DailyPercentChart(props: {
|
|||
margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
|
||||
sliceTooltip={({ slice }) => {
|
||||
const point = slice.points[0]
|
||||
return <Tooltip point={point} />
|
||||
return <Tooltip point={point} isPercent />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip(props: { point: Point }) {
|
||||
const { point } = props
|
||||
function Tooltip(props: { point: Point; isPercent?: boolean }) {
|
||||
const { point, isPercent } = props
|
||||
return (
|
||||
<Col className="border border-gray-300 bg-white py-2 px-3">
|
||||
<div
|
||||
|
@ -126,7 +130,8 @@ function Tooltip(props: { point: Point }) {
|
|||
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>{dayjs(point.data.x).format('MMM DD')}</div>
|
||||
</Col>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import { sum } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
|
@ -9,6 +9,7 @@ import { Row } from '../layout/row'
|
|||
import { ChooseCancelSelector } from '../yes-no-selector'
|
||||
import { ResolveConfirmationButton } from '../confirmation-button'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { BETTOR, PAST_BETS } from 'common/user'
|
||||
|
||||
export function AnswerResolvePanel(props: {
|
||||
isAdmin: boolean
|
||||
|
@ -32,6 +33,18 @@ export function AnswerResolvePanel(props: {
|
|||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [warning, setWarning] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (resolveOption === 'CANCEL') {
|
||||
setWarning(
|
||||
`All ${PAST_BETS} will be returned. Unique ${BETTOR} bonuses will be
|
||||
withdrawn from your account.`
|
||||
)
|
||||
} else {
|
||||
setWarning(undefined)
|
||||
}
|
||||
}, [resolveOption])
|
||||
|
||||
const onResolve = async () => {
|
||||
if (resolveOption === 'CHOOSE' && answers.length !== 1) return
|
||||
|
@ -126,6 +139,7 @@ export function AnswerResolvePanel(props: {
|
|||
</Col>
|
||||
|
||||
{!!error && <div className="text-red-500">{error}</div>}
|
||||
{!!warning && <div className="text-warning">{warning}</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
import clsx from 'clsx'
|
||||
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
|
||||
import { MenuIcon } from '@heroicons/react/solid'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Subtitle } from 'web/components/subtitle'
|
||||
import { 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: {
|
||||
sections: { label: string; id: string }[]
|
||||
sections: { label: string; id: string; group?: Group }[]
|
||||
setSectionIds: (sections: string[]) => void
|
||||
}) {
|
||||
const { sections, setSectionIds } = props
|
||||
|
@ -40,8 +48,9 @@ export function ArrangeHome(props: {
|
|||
|
||||
function DraggableList(props: {
|
||||
title: string
|
||||
items: { id: string; label: string }[]
|
||||
items: { id: string; label: string; group?: Group }[]
|
||||
}) {
|
||||
const user = useUser()
|
||||
const { title, items } = props
|
||||
return (
|
||||
<Droppable droppableId={title.toLowerCase()}>
|
||||
|
@ -66,6 +75,7 @@ function DraggableList(props: {
|
|||
snapshot.isDragging && 'z-[9000] bg-gray-200'
|
||||
)}
|
||||
item={item}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -79,23 +89,53 @@ function DraggableList(props: {
|
|||
}
|
||||
|
||||
const SectionItem = (props: {
|
||||
item: { id: string; label: string }
|
||||
item: { id: string; label: string; group?: Group }
|
||||
user: User | null | undefined
|
||||
className?: string
|
||||
}) => {
|
||||
const { item, className } = props
|
||||
const { item, user, className } = props
|
||||
const { group } = item
|
||||
|
||||
return (
|
||||
<div
|
||||
<Row
|
||||
className={clsx(
|
||||
className,
|
||||
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
|
||||
'items-center justify-between gap-4 rounded bg-gray-50 p-2'
|
||||
)}
|
||||
>
|
||||
<Row className="items-center gap-4">
|
||||
<MenuIcon
|
||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{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 { Button } from 'web/components/button'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { PRESENT_BET } from 'common/user'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
|
@ -42,7 +41,7 @@ export default function BetButton(props: {
|
|||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{PRESENT_BET}
|
||||
Predict
|
||||
</Button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
|
|
|
@ -756,9 +756,10 @@ function SellButton(props: {
|
|||
|
||||
export function ProfitBadge(props: {
|
||||
profitPercent: number
|
||||
round?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { profitPercent, className } = props
|
||||
const { profitPercent, round, className } = props
|
||||
if (!profitPercent) return null
|
||||
const colors =
|
||||
profitPercent > 0
|
||||
|
@ -773,7 +774,9 @@ export function ProfitBadge(props: {
|
|||
className
|
||||
)}
|
||||
>
|
||||
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
|
||||
{(profitPercent > 0 ? '+' : '') +
|
||||
profitPercent.toFixed(round ? 0 : 1) +
|
||||
'%'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import algoliasearch from 'algoliasearch/lite'
|
||||
import { SearchOptions } from '@algolia/client-search'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Contract } from 'common/contract'
|
||||
|
@ -11,7 +10,7 @@ import {
|
|||
import { ShowTime } from './contract/contract-details'
|
||||
import { Row } from './layout/row'
|
||||
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 {
|
||||
historyStore,
|
||||
|
@ -28,14 +27,11 @@ import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
|||
import { Col } from './layout/col'
|
||||
import clsx from 'clsx'
|
||||
import { safeLocalStorage } from 'web/lib/util/local'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
'75c28fc084a80e1129d427d470cf41a3'
|
||||
)
|
||||
|
||||
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||
import {
|
||||
getIndexName,
|
||||
searchClient,
|
||||
searchIndexName,
|
||||
} from 'web/lib/service/algolia'
|
||||
|
||||
export const SORTS = [
|
||||
{ label: 'Newest', value: 'newest' },
|
||||
|
@ -154,7 +150,7 @@ export function ContractSearch(props: {
|
|||
if (freshQuery || requestedPage < state.numPages) {
|
||||
const index = query
|
||||
? searchIndex
|
||||
: searchClient.initIndex(`${indexPrefix}contracts-${sort}`)
|
||||
: searchClient.initIndex(getIndexName(sort))
|
||||
const numericFilters = query
|
||||
? []
|
||||
: [
|
||||
|
|
|
@ -309,7 +309,7 @@ export function ExtraMobileContractDetails(props: {
|
|||
<Tooltip
|
||||
text={`${formatMoney(
|
||||
volume
|
||||
)} bet - ${uniqueBettors} unique predictors`}
|
||||
)} bet - ${uniqueBettors} unique traders`}
|
||||
>
|
||||
{volumeTranslation}
|
||||
</Tooltip>
|
||||
|
|
|
@ -106,7 +106,6 @@ export function ContractTopTrades(props: {
|
|||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
tips={tips[topCommentId]}
|
||||
betsBySameUser={[betsById[topCommentId]]}
|
||||
/>
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
|
|
|
@ -75,7 +75,9 @@ export function ContractTabs(props: {
|
|||
<>
|
||||
<FreeResponseContractCommentsActivity
|
||||
contract={contract}
|
||||
bets={visibleBets}
|
||||
betsByCurrentUser={
|
||||
user ? visibleBets.filter((b) => b.userId === user.id) : []
|
||||
}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
@ -85,7 +87,9 @@ export function ContractTabs(props: {
|
|||
<div className={'mb-4 w-full border-b border-gray-200'} />
|
||||
<ContractCommentsActivity
|
||||
contract={contract}
|
||||
bets={generalBets}
|
||||
betsByCurrentUser={
|
||||
user ? generalBets.filter((b) => b.userId === user.id) : []
|
||||
}
|
||||
comments={generalComments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
@ -95,7 +99,9 @@ export function ContractTabs(props: {
|
|||
) : (
|
||||
<ContractCommentsActivity
|
||||
contract={contract}
|
||||
bets={visibleBets}
|
||||
betsByCurrentUser={
|
||||
user ? visibleBets.filter((b) => b.userId === user.id) : []
|
||||
}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
|
|
@ -19,19 +19,21 @@ export function ProbChangeTable(props: {
|
|||
|
||||
const { positiveChanges, negativeChanges } = changes
|
||||
|
||||
const threshold = 0.075
|
||||
const countOverThreshold = Math.max(
|
||||
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1,
|
||||
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
|
||||
const threshold = 0.01
|
||||
const positiveAboveThreshold = positiveChanges.filter(
|
||||
(c) => c.probChanges.day > threshold
|
||||
)
|
||||
const maxRows = Math.min(positiveChanges.length, negativeChanges.length)
|
||||
const rows = Math.min(
|
||||
full ? Infinity : 3,
|
||||
Math.min(maxRows, countOverThreshold)
|
||||
const negativeAboveThreshold = negativeChanges.filter(
|
||||
(c) => c.probChanges.day < threshold
|
||||
)
|
||||
const maxRows = Math.min(
|
||||
positiveAboveThreshold.length,
|
||||
negativeAboveThreshold.length
|
||||
)
|
||||
const rows = full ? maxRows : Math.min(3, maxRows)
|
||||
|
||||
const filteredPositiveChanges = positiveChanges.slice(0, rows)
|
||||
const filteredNegativeChanges = negativeChanges.slice(0, rows)
|
||||
const filteredPositiveChanges = positiveAboveThreshold.slice(0, rows)
|
||||
const filteredNegativeChanges = negativeAboveThreshold.slice(0, rows)
|
||||
|
||||
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 }) {
|
||||
const { contract } = props
|
||||
return (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange className="p-4 text-right text-xl" contract={contract} />
|
||||
<Row className="items-center justify-between gap-4 hover:bg-gray-100">
|
||||
<SiteLink
|
||||
className="p-4 pl-2 font-semibold text-indigo-700"
|
||||
className="p-4 pr-0 font-semibold text-indigo-700"
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
<span className="line-clamp-2">{contract.question}</span>
|
||||
</SiteLink>
|
||||
<ProbChange className="py-2 pr-4 text-xl" contract={contract} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -72,19 +74,20 @@ export function ProbChange(props: {
|
|||
}) {
|
||||
const { contract, className } = props
|
||||
const {
|
||||
prob,
|
||||
probChanges: { day: change },
|
||||
} = contract
|
||||
|
||||
const color =
|
||||
change > 0
|
||||
? 'text-green-500'
|
||||
: change < 0
|
||||
? 'text-red-500'
|
||||
: 'text-gray-600'
|
||||
const color = change >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
|
||||
const str =
|
||||
change === 0
|
||||
? '+0%'
|
||||
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}`
|
||||
return <div className={clsx(className, color)}>{str}</div>
|
||||
return (
|
||||
<Col className={clsx('flex flex-col items-end', className)}>
|
||||
<div className="mb-0.5 mr-0.5 text-2xl">
|
||||
{formatPercent(Math.round(100 * prob) / 100)}
|
||||
</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 { Title } from '../title'
|
||||
import { TweetButton } from '../tweet-button'
|
||||
import { DuplicateContractButton } from '../copy-contract-button'
|
||||
import { Button } from '../button'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
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 { useState } from 'react'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import ChallengeIcon from 'web/lib/icons/challenge-icon'
|
||||
|
||||
export function ShareModal(props: {
|
||||
contract: Contract
|
||||
|
@ -56,8 +56,8 @@ export function ShareModal(props: {
|
|||
</p>
|
||||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'flex max-w-xs self-center'}
|
||||
color="indigo"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
onClick={() => {
|
||||
copyToClipboard(shareUrl)
|
||||
toast.success('Link copied!', {
|
||||
|
@ -68,18 +68,26 @@ export function ShareModal(props: {
|
|||
>
|
||||
{linkIcon} Copy link
|
||||
</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 && (
|
||||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
<button
|
||||
className={
|
||||
'btn btn-xs flex-nowrap border-2 !border-indigo-500 !bg-white normal-case text-indigo-500'
|
||||
}
|
||||
onClick={withTracking(
|
||||
() => setOpenCreateChallengeModal(true),
|
||||
'click challenge button'
|
||||
)}
|
||||
>
|
||||
<span>⚔️ Challenge</span>
|
||||
️<ChallengeIcon className="mr-1 h-4 w-4" /> Challenge
|
||||
<CreateChallengeModal
|
||||
isOpen={openCreateChallengeModal}
|
||||
setOpen={(open) => {
|
||||
|
@ -91,15 +99,8 @@ export function ShareModal(props: {
|
|||
user={user}
|
||||
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>
|
||||
</Col>
|
||||
</Modal>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
import { getOutcomeProbability } from 'common/calculate'
|
||||
import { Pagination } from 'web/components/pagination'
|
||||
|
@ -12,7 +11,7 @@ import { FeedCommentThread, ContractCommentInput } from './feed-comments'
|
|||
import { User } from 'common/user'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { groupBy, sortBy, uniq } from 'lodash'
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
||||
export function ContractBetsActivity(props: {
|
||||
|
@ -73,13 +72,12 @@ export function ContractBetsActivity(props: {
|
|||
|
||||
export function ContractCommentsActivity(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
betsByCurrentUser: Bet[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { bets, contract, comments, user, tips } = props
|
||||
const betsByUserId = groupBy(bets, (bet) => bet.userId)
|
||||
const { betsByCurrentUser, contract, comments, user, tips } = props
|
||||
const commentsByUserId = groupBy(comments, (c) => c.userId)
|
||||
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
|
||||
const topLevelComments = sortBy(
|
||||
|
@ -92,7 +90,7 @@ export function ContractCommentsActivity(props: {
|
|||
<ContractCommentInput
|
||||
className="mb-5"
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
||||
/>
|
||||
{topLevelComments.map((parent) => (
|
||||
|
@ -106,8 +104,7 @@ export function ContractCommentsActivity(props: {
|
|||
(c) => c.createdTime
|
||||
)}
|
||||
tips={tips}
|
||||
bets={bets}
|
||||
betsByUserId={betsByUserId}
|
||||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByUserId={commentsByUserId}
|
||||
/>
|
||||
))}
|
||||
|
@ -117,32 +114,26 @@ export function ContractCommentsActivity(props: {
|
|||
|
||||
export function FreeResponseContractCommentsActivity(props: {
|
||||
contract: FreeResponseContract
|
||||
bets: Bet[]
|
||||
betsByCurrentUser: Bet[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
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))
|
||||
outcomes = sortBy(
|
||||
outcomes,
|
||||
(outcome) => -getOutcomeProbability(contract, outcome)
|
||||
const sortedAnswers = sortBy(
|
||||
contract.answers,
|
||||
(answer) => -getOutcomeProbability(contract, answer.number.toString())
|
||||
)
|
||||
|
||||
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 commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_')
|
||||
const commentsByOutcome = groupBy(
|
||||
comments,
|
||||
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{answers.map((answer) => (
|
||||
{sortedAnswers.map((answer) => (
|
||||
<div key={answer.id} className={'relative pb-4'}>
|
||||
<span
|
||||
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
|
||||
)}
|
||||
tips={tips}
|
||||
betsByUserId={betsByUserId}
|
||||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByUserId={commentsByUserId}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -27,7 +27,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
answer: Answer
|
||||
answerComments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
betsByUserId: Dictionary<Bet[]>
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByUserId: Dictionary<ContractComment[]>
|
||||
}) {
|
||||
const {
|
||||
|
@ -35,7 +35,7 @@ export function FeedAnswerCommentGroup(props: {
|
|||
contract,
|
||||
answerComments,
|
||||
tips,
|
||||
betsByUserId,
|
||||
betsByCurrentUser,
|
||||
commentsByUserId,
|
||||
user,
|
||||
} = props
|
||||
|
@ -48,7 +48,6 @@ export function FeedAnswerCommentGroup(props: {
|
|||
const router = useRouter()
|
||||
|
||||
const answerElementId = `answer-${answer.id}`
|
||||
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
|
||||
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
|
||||
const isFreeResponseContractPage = !!commentsByCurrentUser
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
|
@ -166,7 +165,6 @@ export function FeedAnswerCommentGroup(props: {
|
|||
contract={contract}
|
||||
comment={comment}
|
||||
tips={tips[comment.id]}
|
||||
betsBySameUser={betsByUserId[comment.userId] ?? []}
|
||||
onReplyClick={scrollAndOpenReplyInput}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { Bet } from 'common/bet'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { PRESENT_BET, User } from 'common/user'
|
||||
import { User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
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 { formatMoney } from 'common/util/format'
|
||||
import { useRouter } from 'next/router'
|
||||
|
@ -29,8 +29,7 @@ export function FeedCommentThread(props: {
|
|||
threadComments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
parentComment: ContractComment
|
||||
bets: Bet[]
|
||||
betsByUserId: Dictionary<Bet[]>
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByUserId: Dictionary<ContractComment[]>
|
||||
}) {
|
||||
const {
|
||||
|
@ -38,8 +37,7 @@ export function FeedCommentThread(props: {
|
|||
contract,
|
||||
threadComments,
|
||||
commentsByUserId,
|
||||
bets,
|
||||
betsByUserId,
|
||||
betsByCurrentUser,
|
||||
tips,
|
||||
parentComment,
|
||||
} = props
|
||||
|
@ -64,17 +62,7 @@ export function FeedCommentThread(props: {
|
|||
contract={contract}
|
||||
comment={comment}
|
||||
tips={tips[comment.id]}
|
||||
betsBySameUser={betsByUserId[comment.userId] ?? []}
|
||||
onReplyClick={scrollAndOpenReplyInput}
|
||||
probAtCreatedTime={
|
||||
contract.outcomeType === 'BINARY'
|
||||
? minBy(bets, (bet) => {
|
||||
return bet.createdTime < comment.createdTime
|
||||
? comment.createdTime - bet.createdTime
|
||||
: comment.createdTime
|
||||
})?.probAfter
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{showReply && (
|
||||
|
@ -85,7 +73,7 @@ export function FeedCommentThread(props: {
|
|||
/>
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
betsByCurrentUser={(user && betsByCurrentUser) ?? []}
|
||||
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUser={replyTo}
|
||||
|
@ -104,22 +92,21 @@ export function FeedComment(props: {
|
|||
contract: Contract
|
||||
comment: ContractComment
|
||||
tips: CommentTips
|
||||
betsBySameUser: Bet[]
|
||||
indent?: boolean
|
||||
probAtCreatedTime?: number
|
||||
onReplyClick?: (comment: ContractComment) => void
|
||||
}) {
|
||||
const { contract, comment, tips, indent, onReplyClick } = props
|
||||
const {
|
||||
contract,
|
||||
comment,
|
||||
tips,
|
||||
betsBySameUser,
|
||||
indent,
|
||||
probAtCreatedTime,
|
||||
onReplyClick,
|
||||
} = props
|
||||
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||
comment
|
||||
text,
|
||||
content,
|
||||
userUsername,
|
||||
userName,
|
||||
userAvatarUrl,
|
||||
commenterPositionProb,
|
||||
commenterPositionShares,
|
||||
commenterPositionOutcome,
|
||||
createdTime,
|
||||
} = comment
|
||||
const betOutcome = comment.betOutcome
|
||||
let bought: string | undefined
|
||||
let money: string | undefined
|
||||
|
@ -136,13 +123,6 @@ export function FeedComment(props: {
|
|||
}
|
||||
}, [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 (
|
||||
<Row
|
||||
id={comment.id}
|
||||
|
@ -167,14 +147,17 @@ export function FeedComment(props: {
|
|||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
{!comment.betId != null &&
|
||||
userPosition > 0 &&
|
||||
{comment.betId == null &&
|
||||
commenterPositionProb != null &&
|
||||
commenterPositionOutcome != null &&
|
||||
commenterPositionShares != null &&
|
||||
commenterPositionShares > 0 &&
|
||||
contract.outcomeType !== 'NUMERIC' && (
|
||||
<>
|
||||
{'is '}
|
||||
<CommentStatus
|
||||
prob={probAtCreatedTime}
|
||||
outcome={outcome}
|
||||
prob={commenterPositionProb}
|
||||
outcome={commenterPositionOutcome}
|
||||
contract={contract}
|
||||
/>
|
||||
</>
|
||||
|
@ -255,7 +238,7 @@ function CommentStatus(props: {
|
|||
const { contract, outcome, prob } = props
|
||||
return (
|
||||
<>
|
||||
{` ${PRESENT_BET}ing `}
|
||||
{` predicting `}
|
||||
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
|
||||
{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) {
|
||||
const { userId, createdTime, isRedemption } = bet
|
||||
const isSelf = user?.id === userId
|
||||
|
|
|
@ -6,7 +6,6 @@ import { ConfirmationButton } from '../confirmation-button'
|
|||
import { Col } from '../layout/col'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { Title } from '../title'
|
||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { User } from 'common/user'
|
||||
import { MAX_GROUP_NAME_LENGTH } from 'common/group'
|
||||
import { createGroup } from 'web/lib/firebase/api'
|
||||
|
@ -17,35 +16,30 @@ export function CreateGroupButton(props: {
|
|||
label?: string
|
||||
onOpenStateChange?: (isOpen: boolean) => void
|
||||
goToGroupOnSubmit?: boolean
|
||||
addGroupIdParamOnSubmit?: boolean
|
||||
icon?: JSX.Element
|
||||
}) {
|
||||
const { user, className, label, onOpenStateChange, goToGroupOnSubmit, icon } =
|
||||
props
|
||||
const [defaultName, setDefaultName] = useState(`${user.name}'s group`)
|
||||
const {
|
||||
user,
|
||||
className,
|
||||
label,
|
||||
onOpenStateChange,
|
||||
goToGroupOnSubmit,
|
||||
addGroupIdParamOnSubmit,
|
||||
icon,
|
||||
} = props
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [memberUsers, setMemberUsers] = useState<User[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
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 () => {
|
||||
setIsSubmitting(true)
|
||||
const groupName = name !== '' ? name : defaultName
|
||||
const newGroup = {
|
||||
name: groupName,
|
||||
memberIds: memberUsers.map((user) => user.id),
|
||||
name,
|
||||
memberIds: [],
|
||||
anyoneCanJoin: true,
|
||||
}
|
||||
const result = await createGroup(newGroup).catch((e) => {
|
||||
|
@ -62,12 +56,17 @@ export function CreateGroupButton(props: {
|
|||
console.log(result.details)
|
||||
|
||||
if (result.group) {
|
||||
updateMemberUsers([])
|
||||
if (goToGroupOnSubmit)
|
||||
router.push(groupPath(result.group.slug)).catch((e) => {
|
||||
console.log(e)
|
||||
setErrorText(e.message)
|
||||
})
|
||||
else if (addGroupIdParamOnSubmit) {
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, groupId: result.group.id },
|
||||
})
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
return true
|
||||
} else {
|
||||
|
@ -99,41 +98,26 @@ export function CreateGroupButton(props: {
|
|||
onSubmitWithSuccess={onSubmit}
|
||||
onOpenChanged={(isOpen) => {
|
||||
onOpenStateChange?.(isOpen)
|
||||
updateMemberUsers([])
|
||||
setName('')
|
||||
}}
|
||||
>
|
||||
<Title className="!my-0" text="Create a group" />
|
||||
|
||||
<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>
|
||||
<div className={'text-error'}>{errorText}</div>
|
||||
{errorText && <div className={'text-error'}>{errorText}</div>}
|
||||
|
||||
<div>
|
||||
<div className="form-control w-full">
|
||||
<label className="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>
|
||||
<label className="mb-2 ml-1 mt-0">Group name</label>
|
||||
<input
|
||||
placeholder={defaultName}
|
||||
placeholder={'Your group name'}
|
||||
className="input input-bordered resize-none"
|
||||
disabled={isSubmitting}
|
||||
value={name}
|
||||
maxLength={MAX_GROUP_NAME_LENGTH}
|
||||
onChange={(e) => setName(e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spacer h={4} />
|
||||
</div>
|
||||
|
|
|
@ -16,7 +16,6 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
const router = useRouter()
|
||||
|
||||
const [name, setName] = useState(group.name)
|
||||
const [about, setAbout] = useState(group.about ?? '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
||||
|
@ -26,8 +25,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
setOpen(newOpen)
|
||||
}
|
||||
|
||||
const saveDisabled =
|
||||
name === group.name && about === group.about && addMemberUsers.length === 0
|
||||
const saveDisabled = name === group.name && addMemberUsers.length === 0
|
||||
|
||||
const onSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
|
@ -66,23 +64,6 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
|
||||
<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">
|
||||
<label className="label">
|
||||
<span className="mb-0">Add members</span>
|
||||
|
|
|
@ -131,7 +131,7 @@ export function GroupSelector(props: {
|
|||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-3 mt-1 block flex flex-row justify-between',
|
||||
'ml-3 mt-1 flex flex-row justify-between',
|
||||
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'
|
||||
}
|
||||
label={'Create a new Group'}
|
||||
goToGroupOnSubmit={false}
|
||||
addGroupIdParamOnSubmit
|
||||
icon={
|
||||
<PlusCircleIcon className="text-primary mr-2 h-5 w-5" />
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@ import {
|
|||
} from '@heroicons/react/outline'
|
||||
import { Transition, Dialog } from '@headlessui/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 { formatMoney } from 'common/util/format'
|
||||
import { Avatar } from '../avatar'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
|
||||
import { Item } from './sidebar'
|
||||
import { Item } from './sidebar-item'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
@ -32,7 +32,7 @@ export function GroupNavBar(props: {
|
|||
const user = useUser()
|
||||
|
||||
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) => (
|
||||
<NavBarItem
|
||||
key={item.name}
|
||||
|
|
|
@ -7,7 +7,7 @@ import React from 'react'
|
|||
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||
import { SignInButton } from '../sign-in-button'
|
||||
import NotificationsIcon from '../notifications-icon'
|
||||
import { SidebarItem } from './sidebar'
|
||||
import { SidebarItem } from './sidebar-item'
|
||||
import { buildArray } from 'common/util/array'
|
||||
import { User } from 'common/user'
|
||||
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 {
|
||||
HomeIcon,
|
||||
SearchIcon,
|
||||
BookOpenIcon,
|
||||
DotsHorizontalIcon,
|
||||
CashIcon,
|
||||
HeartIcon,
|
||||
ChatIcon,
|
||||
ChartBarIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import Router, { useRouter } from 'next/router'
|
||||
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||
import { ManifoldLogo } from './manifold-logo'
|
||||
import { MenuButton, MenuItem } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
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 { buildArray } from 'common/util/array'
|
||||
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||
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 () => {
|
||||
// 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)
|
||||
}
|
||||
|
||||
function getNavigation() {
|
||||
function getDesktopNavigation() {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{ 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) {
|
||||
return [
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
|
@ -99,7 +163,7 @@ function getMoreNavigation(user?: User | null) {
|
|||
)
|
||||
}
|
||||
|
||||
const signedOutNavigation = [
|
||||
const signedOutDesktopNavigation = [
|
||||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/search', icon: SearchIcon },
|
||||
{
|
||||
|
@ -117,11 +181,14 @@ const signedOutMobileNavigation = [
|
|||
},
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
||||
]
|
||||
|
||||
const signedInMobileNavigation = [
|
||||
{ name: 'Search', href: '/search', icon: SearchIcon },
|
||||
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
|
@ -145,7 +212,6 @@ function getMoreMobileNav() {
|
|||
[
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
|
@ -153,136 +219,3 @@ function getMoreMobileNav() {
|
|||
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')}>
|
||||
{subscriptionTypes.map((subType) => (
|
||||
<NotificationSettingLine
|
||||
key={subType}
|
||||
subscriptionTypeKey={subType as notification_preference}
|
||||
destinations={getUsersSavedPreference(
|
||||
subType as notification_preference
|
||||
|
|
|
@ -10,6 +10,8 @@ import { Modal } from 'web/components/layout/modal'
|
|||
import { PillButton } from 'web/components/buttons/pill-button'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Group } from 'common/group'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
|
||||
export default function GroupSelectorDialog(props: {
|
||||
open: boolean
|
||||
|
@ -65,20 +67,26 @@ export default function GroupSelectorDialog(props: {
|
|||
</p>
|
||||
|
||||
<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) => (
|
||||
<PillButton
|
||||
selected={memberGroupIds.includes(group.id)}
|
||||
onSelect={() =>
|
||||
onSelect={withTracking(
|
||||
() =>
|
||||
memberGroupIds.includes(group.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"
|
||||
>
|
||||
{group.name}
|
||||
</PillButton>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
|
|
|
@ -40,14 +40,7 @@ export function ShareEmbedButton(props: { contract: Contract }) {
|
|||
track('copy embed code')
|
||||
}}
|
||||
>
|
||||
<Menu.Button
|
||||
className="btn btn-xs normal-case"
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #9ca3af',
|
||||
color: '#9ca3af', // text-gray-400
|
||||
}}
|
||||
>
|
||||
<Menu.Button className="btn btn-xs border-2 !border-gray-500 !bg-white normal-case text-gray-500">
|
||||
{codeIcon}
|
||||
Embed
|
||||
</Menu.Button>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { Group } from 'common/group'
|
||||
import { User } from 'common/user'
|
||||
import {
|
||||
getGroup,
|
||||
getMemberGroups,
|
||||
GroupMemberDoc,
|
||||
groupMembers,
|
||||
|
@ -102,6 +103,24 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
|||
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) {
|
||||
const [members, setMembers] = useState<User[]>([])
|
||||
useEffect(() => {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { usePrefetchUserBetContracts } from './use-contracts'
|
||||
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
|
||||
import { usePrefetchProbChanges } from './use-prob-changes'
|
||||
import { usePrefetchUserBets } from './use-user-bets'
|
||||
|
||||
export function usePrefetch(userId: string | undefined) {
|
||||
|
@ -9,6 +8,5 @@ export function usePrefetch(userId: string | undefined) {
|
|||
usePrefetchUserBets(maybeUserId),
|
||||
usePrefetchUserBetContracts(maybeUserId),
|
||||
usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
|
||||
usePrefetchProbChanges(userId),
|
||||
])
|
||||
}
|
||||
|
|
|
@ -1,11 +1,45 @@
|
|||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
import { useQueryClient } from 'react-query'
|
||||
import { useQuery, useQueryClient } from 'react-query'
|
||||
import {
|
||||
getProbChangesNegative,
|
||||
getProbChangesPositive,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
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) => {
|
||||
const { data: positiveChanges } = useFirestoreQueryData(
|
||||
|
|
|
@ -13,7 +13,11 @@ export const useWarnUnsavedChanges = (hasUnsavedChanges: boolean) => {
|
|||
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)) {
|
||||
Router.events.emit('routeChangeError')
|
||||
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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...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>
|
||||
|
|
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 { generateNewApiKey } from '../api/api-key'
|
||||
|
||||
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
async function postToBot(url: string, body: unknown) {
|
||||
const result = await fetch(url, {
|
||||
|
@ -21,13 +20,16 @@ export async function initLinkTwitchAccount(
|
|||
manifoldUserID: string,
|
||||
manifoldUserAPIKey: 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,
|
||||
apiKey: manifoldUserAPIKey,
|
||||
redirectURL: window.location.href,
|
||||
})
|
||||
}
|
||||
)
|
||||
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())]
|
||||
}
|
||||
|
@ -50,15 +52,18 @@ export async function updateBotEnabledForUser(
|
|||
botEnabled: boolean
|
||||
) {
|
||||
if (botEnabled) {
|
||||
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, {
|
||||
return postToBot(`${ENV_CONFIG.twitchBotEndpoint}/registerchanneltwitch`, {
|
||||
apiKey: privateUser.apiKey,
|
||||
}).then((r) => {
|
||||
if (!r.success) throw new Error(r.message)
|
||||
})
|
||||
} else {
|
||||
return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, {
|
||||
return postToBot(
|
||||
`${ENV_CONFIG.twitchBotEndpoint}/unregisterchanneltwitch`,
|
||||
{
|
||||
apiKey: privateUser.apiKey,
|
||||
}).then((r) => {
|
||||
}
|
||||
).then((r) => {
|
||||
if (!r.success) throw new Error(r.message)
|
||||
})
|
||||
}
|
||||
|
@ -66,10 +71,10 @@ export async function updateBotEnabledForUser(
|
|||
|
||||
export function getOverlayURLForUser(privateUser: PrivateUser) {
|
||||
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) {
|
||||
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 ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
|
||||
const ABOUT_PAGE_URL = 'https://help.manifold.markets/'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
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 { Page } from 'web/components/page'
|
||||
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'
|
||||
|
||||
export default function DailyMovers() {
|
||||
const user = useUser()
|
||||
|
||||
const changes = useProbChanges(user?.id ?? '')
|
||||
const changes = useProbChangesAlgolia(user?.id ?? '')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
|
|
@ -140,7 +140,10 @@ export default function GroupPage(props: {
|
|||
const user = useUser()
|
||||
const isAdmin = useAdmin()
|
||||
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, {
|
||||
defaultReferrerUsername: creator.username,
|
||||
|
@ -208,7 +211,7 @@ export default function GroupPage(props: {
|
|||
<ContractSearch
|
||||
headerClassName="md:sticky"
|
||||
user={user}
|
||||
defaultSort={'newest'}
|
||||
defaultSort={'score'}
|
||||
defaultFilter={suggestedFilter}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
persistPrefix={`group-${group.slug}`}
|
||||
|
@ -241,6 +244,12 @@ export default function GroupPage(props: {
|
|||
const onSidebarClick = (key: string) => {
|
||||
const index = sidebarPages.findIndex((t) => t.key === key)
|
||||
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 = (
|
||||
|
@ -253,7 +262,11 @@ export default function GroupPage(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<TopGroupNavBar group={group} />
|
||||
<TopGroupNavBar
|
||||
group={group}
|
||||
currentPage={sidebarPages[sidebarIndex].key}
|
||||
onClick={onSidebarClick}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
|
@ -278,19 +291,19 @@ export default function GroupPage(props: {
|
|||
{pageContent}
|
||||
</main>
|
||||
</div>
|
||||
<GroupNavBar
|
||||
currentPage={sidebarPages[sidebarIndex].key}
|
||||
onClick={onSidebarClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TopGroupNavBar(props: { group: Group }) {
|
||||
export function TopGroupNavBar(props: {
|
||||
group: Group
|
||||
currentPage: string
|
||||
onClick: (key: string) => void
|
||||
}) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full pb-2 md:hidden lg:col-span-12">
|
||||
<div className="flex items-center border-b border-gray-200 bg-white px-4">
|
||||
<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 bg-white px-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Link href="/">
|
||||
<a className="text-indigo-700 hover:text-gray-500 ">
|
||||
|
@ -304,6 +317,7 @@ export function TopGroupNavBar(props: { group: Group }) {
|
|||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,12 +7,12 @@ import { Row } from 'web/components/layout/row'
|
|||
import { Page } from 'web/components/page'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
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 { useUser } from 'web/hooks/use-user'
|
||||
import { updateUser } from 'web/lib/firebase/users'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { getHomeItems } from '.'
|
||||
import { getHomeItems, TrendingGroupsSection } from '.'
|
||||
|
||||
export default function Home() {
|
||||
const user = useUser()
|
||||
|
@ -27,7 +27,7 @@ export default function Home() {
|
|||
setHomeSections(newHomeSections)
|
||||
}
|
||||
|
||||
const groups = useMemberGroups(user?.id) ?? []
|
||||
const groups = useMemberGroupsSubscription(user)
|
||||
const { sections } = getHomeItems(groups, homeSections)
|
||||
|
||||
return (
|
||||
|
@ -38,7 +38,15 @@ export default function Home() {
|
|||
<DoneButton />
|
||||
</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>
|
||||
</Page>
|
||||
)
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import Router from 'next/router'
|
||||
import {
|
||||
AdjustmentsIcon,
|
||||
PencilAltIcon,
|
||||
ArrowSmRightIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import { XCircleIcon } from '@heroicons/react/outline'
|
||||
import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
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 {
|
||||
useMemberGroupIds,
|
||||
useMemberGroups,
|
||||
useMemberGroupsSubscription,
|
||||
useTrendingGroups,
|
||||
} from 'web/hooks/use-group'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
|
||||
import {
|
||||
getGroup,
|
||||
groupPath,
|
||||
joinGroup,
|
||||
leaveGroup,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
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 { calculatePortfolioProfit } from 'common/calculate-metrics'
|
||||
import { hasCompletedStreakToday } from 'web/components/profile/betting-streak-modal'
|
||||
|
@ -57,27 +52,32 @@ export default function Home() {
|
|||
useSaveReferral()
|
||||
usePrefetch(user?.id)
|
||||
|
||||
const cachedGroups = useMemberGroups(user?.id) ?? []
|
||||
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 groups = useMemberGroupsSubscription(user)
|
||||
|
||||
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 (
|
||||
<Page>
|
||||
<Toaster />
|
||||
|
||||
<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="items-center gap-2">
|
||||
<Title className="!mt-0 !mb-0" text="Home" />
|
||||
<CustomizeButton justIcon />
|
||||
</Row>
|
||||
<DailyStats user={user} />
|
||||
</Row>
|
||||
|
||||
|
@ -102,7 +102,7 @@ export default function Home() {
|
|||
const HOME_SECTIONS = [
|
||||
{ label: 'Daily movers', id: 'daily-movers' },
|
||||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'New for you', id: 'new-for-you' },
|
||||
{ label: 'New', id: 'newest' },
|
||||
{ label: 'Recently updated', id: 'recently-updated-for-you' },
|
||||
]
|
||||
|
||||
|
@ -110,11 +110,12 @@ export const getHomeItems = (groups: Group[], sections: string[]) => {
|
|||
// Accommodate old home sections.
|
||||
if (!isArray(sections)) sections = []
|
||||
|
||||
const items = [
|
||||
const items: { id: string; label: string; group?: Group }[] = [
|
||||
...HOME_SECTIONS,
|
||||
...groups.map((g) => ({
|
||||
label: g.name,
|
||||
id: g.id,
|
||||
group: g,
|
||||
})),
|
||||
]
|
||||
const itemsById = keyBy(items, 'id')
|
||||
|
@ -139,16 +140,6 @@ function renderSection(
|
|||
if (id === 'daily-movers') {
|
||||
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')
|
||||
return (
|
||||
<SearchSection
|
||||
|
@ -235,7 +226,6 @@ function GroupSection(props: {
|
|||
<Col>
|
||||
<SectionHeader label={group.name} href={groupPath(group.slug)}>
|
||||
<Button
|
||||
className=""
|
||||
color="gray-white"
|
||||
onClick={() => {
|
||||
if (user) {
|
||||
|
@ -252,10 +242,7 @@ function GroupSection(props: {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<XCircleIcon
|
||||
className={clsx('h-5 w-5 flex-shrink-0')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<XCircleIcon className={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
|
||||
</Button>
|
||||
</SectionHeader>
|
||||
<ContractsGrid contracts={contracts} />
|
||||
|
@ -265,7 +252,16 @@ function GroupSection(props: {
|
|||
|
||||
function DailyMoversSection(props: { userId: string | null | undefined }) {
|
||||
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 (
|
||||
<Col className="gap-2">
|
||||
|
@ -285,8 +281,8 @@ function DailyStats(props: {
|
|||
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
|
||||
|
||||
const privateUser = usePrivateUser()
|
||||
const streaksHidden =
|
||||
privateUser?.notificationPreferences.betting_streaks.length === 0
|
||||
const streaks = privateUser?.notificationPreferences?.betting_streaks ?? []
|
||||
const streaksHidden = streaks.length === 0
|
||||
|
||||
let profit = 0
|
||||
let profitPercent = 0
|
||||
|
@ -322,24 +318,29 @@ function DailyStats(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function TrendingGroupsSection(props: { user: User | null | undefined }) {
|
||||
const { user } = props
|
||||
export function TrendingGroupsSection(props: {
|
||||
user: User | null | undefined
|
||||
full?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { user, full, className } = props
|
||||
const memberGroupIds = useMemberGroupIds(user) || []
|
||||
|
||||
const groups = useTrendingGroups().filter(
|
||||
(g) => !memberGroupIds.includes(g.id)
|
||||
)
|
||||
const count = 25
|
||||
const count = full ? 100 : 25
|
||||
const chosenGroups = groups.slice(0, count)
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Col className={className}>
|
||||
<SectionHeader label="Trending groups" href="/explore-groups">
|
||||
<CustomizeButton />
|
||||
{!full && <CustomizeButton className="mb-1" />}
|
||||
</SectionHeader>
|
||||
<Row className="flex-wrap gap-2">
|
||||
{chosenGroups.map((g) => (
|
||||
<PillButton
|
||||
className="flex flex-row items-center gap-1"
|
||||
key={g.id}
|
||||
selected={memberGroupIds.includes(g.id)}
|
||||
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}
|
||||
</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 (
|
||||
<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"
|
||||
>
|
||||
<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')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Customize
|
||||
{!justIcon && 'Customize'}
|
||||
</Button>
|
||||
</SiteLink>
|
||||
)
|
||||
|
|
|
@ -435,7 +435,7 @@ function IncomeNotificationItem(props: {
|
|||
reasonText = !simple
|
||||
? `Bonus for ${
|
||||
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
|
||||
} new predictors on`
|
||||
} new traders on`
|
||||
: 'bonus on'
|
||||
} else if (sourceType === 'tip') {
|
||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
|
@ -556,7 +556,7 @@ function IncomeNotificationItem(props: {
|
|||
{(isTip || isUniqueBettorBonus) && (
|
||||
<MultiUserTransactionLink
|
||||
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'}>
|
||||
|
@ -971,13 +971,20 @@ function ContractResolvedNotification(props: {
|
|||
const { sourceText, data } = notification
|
||||
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
|
||||
const subtitle = 'resolved the market'
|
||||
|
||||
const resolutionDescription = () => {
|
||||
if (!sourceText) return <div />
|
||||
|
||||
if (sourceText === 'YES' || sourceText == 'NO') {
|
||||
return <BinaryOutcomeLabel outcome={sourceText as any} />
|
||||
}
|
||||
|
||||
if (sourceText.includes('%'))
|
||||
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} />
|
||||
return (
|
||||
<ProbPercentLabel
|
||||
prob={parseFloat(sourceText.replace('%', '')) / 100}
|
||||
/>
|
||||
)
|
||||
if (sourceText === 'CANCEL') return <CancelLabel />
|
||||
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
|
||||
|
||||
|
@ -996,7 +1003,7 @@ function ContractResolvedNotification(props: {
|
|||
const description =
|
||||
userInvestment && userPayout !== undefined ? (
|
||||
<Row className={'gap-1 '}>
|
||||
{resolutionDescription()}
|
||||
Resolved: {resolutionDescription()}
|
||||
Invested:
|
||||
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
|
||||
Payout:
|
||||
|
@ -1013,7 +1020,7 @@ function ContractResolvedNotification(props: {
|
|||
</span>
|
||||
</Row>
|
||||
) : (
|
||||
<span>{resolutionDescription()}</span>
|
||||
<span>Resolved {resolutionDescription()}</span>
|
||||
)
|
||||
|
||||
if (justSummary) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ContractSearch } from 'web/components/contract-search'
|
|||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { usePrefetch } from 'web/hooks/use-prefetch'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export default function Search() {
|
||||
const user = useUser()
|
||||
|
@ -11,6 +12,10 @@ export default function Search() {
|
|||
|
||||
useTracking('view search')
|
||||
|
||||
const { query } = useRouter()
|
||||
const { q, s, p } = query
|
||||
const autoFocus = !q && !s && !p
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="mx-auto w-full p-2">
|
||||
|
@ -18,7 +23,7 @@ export default function Search() {
|
|||
user={user}
|
||||
persistPrefix="search"
|
||||
useQueryUrlParam={true}
|
||||
autoFocus
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
DailyCountChart,
|
||||
|
@ -47,65 +46,38 @@ export default function Analytics() {
|
|||
)
|
||||
}
|
||||
|
||||
export function CustomAnalytics(props: {
|
||||
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[]
|
||||
}
|
||||
}) {
|
||||
export function CustomAnalytics(props: Stats) {
|
||||
const {
|
||||
startDate,
|
||||
dailyActiveUsers,
|
||||
dailyActiveUsersWeeklyAvg,
|
||||
weeklyActiveUsers,
|
||||
monthlyActiveUsers,
|
||||
d1,
|
||||
d1WeeklyAvg,
|
||||
nd1,
|
||||
nd1WeeklyAvg,
|
||||
nw1,
|
||||
dailyBetCounts,
|
||||
dailyContractCounts,
|
||||
dailyCommentCounts,
|
||||
dailySignups,
|
||||
weeklyActiveUsers,
|
||||
monthlyActiveUsers,
|
||||
weekOnWeekRetention,
|
||||
monthlyRetention,
|
||||
weeklyActivationRate,
|
||||
topTenthActions,
|
||||
dailyActivationRate,
|
||||
dailyActivationRateWeeklyAvg,
|
||||
manaBet,
|
||||
} = props
|
||||
|
||||
const startDate = dayjs(props.startDate).add(12, 'hours').valueOf()
|
||||
|
||||
const dailyDividedByWeekly = dailyActiveUsers
|
||||
.map((dailyActive, i) =>
|
||||
Math.round((100 * dailyActive) / weeklyActiveUsers[i])
|
||||
const dailyDividedByWeekly = dailyActiveUsers.map(
|
||||
(dailyActive, i) => dailyActive / weeklyActiveUsers[i]
|
||||
)
|
||||
.slice(7)
|
||||
|
||||
const dailyDividedByMonthly = dailyActiveUsers
|
||||
.map((dailyActive, i) =>
|
||||
Math.round((100 * dailyActive) / monthlyActiveUsers[i])
|
||||
const dailyDividedByMonthly = dailyActiveUsers.map(
|
||||
(dailyActive, i) => dailyActive / monthlyActiveUsers[i]
|
||||
)
|
||||
.slice(7)
|
||||
|
||||
const weeklyDividedByMonthly = weeklyActiveUsers
|
||||
.map((weeklyActive, i) =>
|
||||
Math.round((100 * weeklyActive) / monthlyActiveUsers[i])
|
||||
const weeklyDividedByMonthly = weeklyActiveUsers.map(
|
||||
(weeklyActive, i) => weeklyActive / monthlyActiveUsers[i]
|
||||
)
|
||||
.slice(7)
|
||||
|
||||
const oneWeekLaterDate = startDate + 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
return (
|
||||
<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',
|
||||
content: (
|
||||
|
@ -153,6 +135,108 @@ export function CustomAnalytics(props: {
|
|||
/>
|
||||
<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" />
|
||||
<Tabs
|
||||
defaultIndex={0}
|
||||
|
@ -202,30 +286,33 @@ export function CustomAnalytics(props: {
|
|||
|
||||
<Spacer h={8} />
|
||||
|
||||
<Title text="Retention" />
|
||||
<Title text="Activation rate" />
|
||||
<p className="text-gray-500">
|
||||
What fraction of active users are still active after the given time
|
||||
period?
|
||||
Out of all new users, how many placed at least one bet?
|
||||
</p>
|
||||
<Spacer h={4} />
|
||||
|
||||
<Tabs
|
||||
defaultIndex={0}
|
||||
defaultIndex={1}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Weekly',
|
||||
title: 'Daily',
|
||||
content: (
|
||||
<DailyPercentChart
|
||||
dailyPercent={weekOnWeekRetention.slice(7)}
|
||||
startDate={oneWeekLaterDate}
|
||||
dailyPercent={dailyActivationRate}
|
||||
startDate={startDate}
|
||||
excludeFirstDays={1}
|
||||
small
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Monthly',
|
||||
title: 'Daily (7d avg)',
|
||||
content: (
|
||||
<DailyPercentChart
|
||||
dailyPercent={monthlyRetention.slice(7)}
|
||||
startDate={oneWeekLaterDate}
|
||||
dailyPercent={dailyActivationRateWeeklyAvg}
|
||||
startDate={startDate}
|
||||
excludeFirstDays={7}
|
||||
small
|
||||
/>
|
||||
),
|
||||
|
@ -234,17 +321,6 @@ export function CustomAnalytics(props: {
|
|||
/>
|
||||
<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" />
|
||||
<Tabs
|
||||
defaultIndex={1}
|
||||
|
@ -254,8 +330,9 @@ export function CustomAnalytics(props: {
|
|||
content: (
|
||||
<DailyPercentChart
|
||||
dailyPercent={dailyDividedByWeekly}
|
||||
startDate={oneWeekLaterDate}
|
||||
startDate={startDate}
|
||||
small
|
||||
excludeFirstDays={7}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -264,8 +341,9 @@ export function CustomAnalytics(props: {
|
|||
content: (
|
||||
<DailyPercentChart
|
||||
dailyPercent={dailyDividedByMonthly}
|
||||
startDate={oneWeekLaterDate}
|
||||
startDate={startDate}
|
||||
small
|
||||
excludeFirstDays={30}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -274,8 +352,9 @@ export function CustomAnalytics(props: {
|
|||
content: (
|
||||
<DailyPercentChart
|
||||
dailyPercent={weeklyDividedByMonthly}
|
||||
startDate={oneWeekLaterDate}
|
||||
startDate={startDate}
|
||||
small
|
||||
excludeFirstDays={30}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -283,47 +362,6 @@ export function CustomAnalytics(props: {
|
|||
/>
|
||||
<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" />
|
||||
<p className="text-gray-500">
|
||||
Sum of bet amounts. (Divided by 100 to be more readable.)
|
||||
|
@ -363,6 +401,7 @@ export function CustomAnalytics(props: {
|
|||
},
|
||||
]}
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
</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"
|
||||
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":
|
||||
version "17.0.35"
|
||||
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"
|
||||
integrity sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg==
|
||||
|
||||
combined-stream@^1.0.6:
|
||||
combined-stream@^1.0.6, combined-stream@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
|
@ -6536,6 +6544,15 @@ form-data@^2.3.3, form-data@^2.5.0:
|
|||
combined-stream "^1.0.6"
|
||||
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:
|
||||
version "4.0.10"
|
||||
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
|
||||
|
@ -8666,7 +8683,7 @@ node-emoji@^1.10.0:
|
|||
dependencies:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
|
||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||
|
|
Loading…
Reference in New Issue
Block a user