Merge branch 'main' into inga/mobilebetting

This commit is contained in:
ingawei 2022-09-21 01:23:00 -05:00 committed by GitHub
commit 0aee3d2a29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
116 changed files with 4480 additions and 2505 deletions

View File

@ -1,4 +1,4 @@
import { maxBy, sortBy, sum, sumBy } from 'lodash'
import { maxBy, partition, sortBy, sum, sumBy } from 'lodash'
import { Bet, LimitBet } from './bet'
import {
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 }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -247,6 +247,8 @@ export type BetFillData = {
creatorOutcome: string
probability: number
fillAmount: number
limitOrderTotal?: number
limitOrderRemaining?: number
}
export type ContractResolutionData = {

View File

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

View File

@ -60,14 +60,6 @@ export const getDefaultNotificationPreferences = (
privateUser?: PrivateUser,
noEmails?: boolean
) => {
const {
unsubscribedFromCommentEmails,
unsubscribedFromAnswerEmails,
unsubscribedFromResolutionEmails,
unsubscribedFromWeeklyTrendingEmails,
unsubscribedFromGenericEmails,
} = privateUser || {}
const constructPref = (browserIf: boolean, emailIf: boolean) => {
const browser = browserIf ? 'browser' : undefined
const email = noEmails ? undefined : emailIf ? 'email' : undefined
@ -75,84 +67,48 @@ export const getDefaultNotificationPreferences = (
}
return {
// Watched Markets
all_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false),
// Comments
tips_on_your_comments: constructPref(true, !unsubscribedFromCommentEmails),
comments_by_followed_users_on_watched_markets: constructPref(true, false),
all_replies_to_my_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_replies_to_my_answers_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
tips_on_your_comments: constructPref(true, true),
comments_by_followed_users_on_watched_markets: constructPref(true, true),
all_replies_to_my_comments_on_watched_markets: constructPref(true, true),
all_replies_to_my_answers_on_watched_markets: constructPref(true, true),
all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
false
),
// Answers
answers_by_followed_users_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
answers_by_market_creator_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
answers_by_followed_users_on_watched_markets: constructPref(true, true),
answers_by_market_creator_on_watched_markets: constructPref(true, true),
all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
true
),
// On users' markets
your_contract_closed: constructPref(
true,
!unsubscribedFromResolutionEmails
), // High priority
all_comments_on_my_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
all_answers_on_my_markets: constructPref(
true,
!unsubscribedFromAnswerEmails
),
your_contract_closed: constructPref(true, true), // High priority
all_comments_on_my_markets: constructPref(true, true),
all_answers_on_my_markets: constructPref(true, true),
subsidized_your_market: constructPref(true, true),
// Market updates
resolutions_on_watched_markets: constructPref(
true,
!unsubscribedFromResolutionEmails
),
resolutions_on_watched_markets: constructPref(true, false),
market_updates_on_watched_markets: constructPref(true, false),
market_updates_on_watched_markets_with_shares_in: constructPref(
true,
false
),
resolutions_on_watched_markets_with_shares_in: constructPref(
true,
!unsubscribedFromResolutionEmails
),
resolutions_on_watched_markets_with_shares_in: constructPref(true, true),
//Balance Changes
loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, false),
tipped_comments_on_watched_markets: constructPref(
true,
!unsubscribedFromCommentEmails
),
tipped_comments_on_watched_markets: constructPref(true, true),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
@ -160,17 +116,11 @@ export const getDefaultNotificationPreferences = (
tagged_user: constructPref(true, true),
on_new_follow: constructPref(true, true),
contract_from_followed_user: constructPref(true, true),
trending_markets: constructPref(
false,
!unsubscribedFromWeeklyTrendingEmails
),
trending_markets: constructPref(false, true),
profit_loss_updates: constructPref(false, true),
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(
false,
!unsubscribedFromGenericEmails
),
onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails),
thank_you_for_purchases: constructPref(false, false),
onboarding_flow: constructPref(false, false),
} as notification_preferences
}

View File

@ -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
@ -71,6 +66,7 @@ export type PrivateUser = {
twitchName: string
controlToken: string
botEnabled?: boolean
needsRelinking?: boolean
}
}
@ -85,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'

View File

@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => {
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]
}
}
export function chooseRandomSubset<T>(items: T[], count: number) {
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
shuffle(items, createRNG(seed))
return items.slice(0, count)
}

View File

@ -100,6 +100,20 @@
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",

View File

@ -27,7 +27,7 @@ service cloud.firestore {
allow read;
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules
allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
@ -78,7 +78,7 @@ service cloud.firestore {
allow read: if userId == request.auth.uid || isAdmin();
allow update: if (userId == request.auth.uid || isAdmin())
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'unsubscribedFromWeeklyTrendingEmails', 'notificationPreferences', 'twitchInfo']);
.hasOnly(['apiKey', 'notificationPreferences', 'twitchInfo']);
}
match /private-users/{userId}/views/{viewId} {
@ -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} {

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { groupBy, uniq } from 'lodash'
import { groupBy, sum, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
@ -416,8 +416,9 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
)
}
//TODO: store all possible reasons why the user might be getting the notification and choose the most lenient that they
// have enabled so they will unsubscribe from the least important notifications
//TODO: store all possible reasons why the user might be getting the notification
// and choose the most lenient that they have enabled so they will unsubscribe
// from the least important notifications
await notifyRepliedUser()
await notifyTaggedUsers()
await notifyContractCreator()
@ -479,7 +480,7 @@ export const createBetFillNotification = async (
fromUser: User,
toUser: User,
bet: Bet,
userBet: LimitBet,
limitBet: LimitBet,
contract: Contract,
idempotencyKey: string
) => {
@ -491,8 +492,10 @@ export const createBetFillNotification = async (
)
if (!sendToBrowser) return
const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fill = limitBet.fills.find((fill) => fill.matchedBetId === bet.id)
const fillAmount = fill?.amount ?? 0
const remainingAmount =
limitBet.orderAmount - sum(limitBet.fills.map((f) => f.amount))
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
@ -503,7 +506,7 @@ export const createBetFillNotification = async (
reason: 'bet_fill',
createdTime: Date.now(),
isSeen: false,
sourceId: userBet.id,
sourceId: limitBet.id,
sourceType: 'bet',
sourceUpdateType: 'updated',
sourceUserName: fromUser.name,
@ -516,9 +519,11 @@ export const createBetFillNotification = async (
sourceContractId: contract.id,
data: {
betOutcome: bet.outcome,
creatorOutcome: userBet.outcome,
creatorOutcome: limitBet.outcome,
fillAmount,
probability: userBet.limitProb,
probability: limitBet.limitProb,
limitOrderTotal: limitBet.orderAmount,
limitOrderRemaining: remainingAmount,
} as BetFillData,
}
return await notificationRef.set(removeUndefinedProps(notification))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,18 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { compact } from 'lodash'
import { getContract, getUser, getValues } from './utils'
import {
getContract,
getContractPath,
getUser,
getValues,
revalidateStaticProps,
} from './utils'
import { ContractComment } from '../../common/comment'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
import { getLargestPosition } from '../../common/calculate'
import { maxBy } from 'lodash'
import {
createCommentOrAnswerOrUpdatedContractNotification,
replied_users_info,
@ -14,6 +22,60 @@ import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore()
function getMostRecentCommentableBet(
before: number,
betsByCurrentUser: Bet[],
commentsByCurrentUser: ContractComment[],
answerOutcome?: string
) {
let sortedBetsByCurrentUser = betsByCurrentUser.sort(
(a, b) => b.createdTime - a.createdTime
)
if (answerOutcome) {
sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1)
}
return sortedBetsByCurrentUser
.filter((bet) => {
const { createdTime, isRedemption } = bet
// You can comment on bets posted in the last hour
const commentable = !isRedemption && before - createdTime < 60 * 60 * 1000
const alreadyCommented = commentsByCurrentUser.some(
(comment) => comment.createdTime > bet.createdTime
)
if (commentable && !alreadyCommented) {
if (!answerOutcome) return true
return answerOutcome === bet.outcome
}
return false
})
.pop()
}
async function getPriorUserComments(
contractId: string,
userId: string,
before: number
) {
const priorCommentsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('comments')
.where('createdTime', '<', before)
.where('userId', '==', userId)
.get()
return priorCommentsQuery.docs.map((d) => d.data() as ContractComment)
}
async function getPriorContractBets(contractId: string, before: number) {
const priorBetsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.where('createdTime', '<', before)
.get()
return priorBetsQuery.docs.map((d) => d.data() as Bet)
}
export const onCreateCommentOnContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.firestore.document('contracts/{contractId}/comments/{commentId}')
@ -32,6 +94,8 @@ export const onCreateCommentOnContract = functions
contractQuestion: contract.question,
})
await revalidateStaticProps(getContractPath(contract))
const comment = change.data() as ContractComment
const lastCommentTime = comment.createdTime
@ -45,7 +109,48 @@ export const onCreateCommentOnContract = functions
.doc(contract.id)
.update({ lastCommentTime, lastUpdatedTime: Date.now() })
let bet: Bet | undefined
const priorBets = await getPriorContractBets(
contractId,
comment.createdTime
)
const priorUserBets = priorBets.filter(
(b) => b.userId === comment.userId && !b.isAnte
)
const priorUserComments = await getPriorUserComments(
contractId,
comment.userId,
comment.createdTime
)
const bet = getMostRecentCommentableBet(
comment.createdTime,
priorUserBets,
priorUserComments,
comment.answerOutcome
)
if (bet) {
await change.ref.update({
betId: bet.id,
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const position = getLargestPosition(contract, priorUserBets)
if (position) {
const fields: { [k: string]: unknown } = {
commenterPositionShares: position.shares,
commenterPositionOutcome: position.outcome,
}
const previousProb =
contract.outcomeType === 'BINARY'
? maxBy(priorBets, (bet) => bet.createdTime)?.probAfter
: undefined
if (previousProb != null) {
fields.commenterPositionProb = previousProb
}
await change.ref.update(fields)
}
let answer: Answer | undefined
if (comment.answerOutcome) {
answer =
@ -54,23 +159,6 @@ export const onCreateCommentOnContract = functions
(answer) => answer.id === comment.answerOutcome
)
: undefined
} else if (comment.betId) {
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.doc(comment.betId)
.get()
bet = betSnapshot.data() as Bet
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const comments = await getValues<ContractComment>(

View File

@ -23,12 +23,12 @@ export const onCreateUser = functions
await sendWelcomeEmail(user, privateUser)
const guideSendTime = dayjs().add(28, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
const followupSendTime = dayjs().add(48, 'hours').toString()
await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
const guideSendTime = dayjs().add(96, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
// skip email if weekly email is about to go out
const day = dayjs().utc().day()
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return

View File

@ -13,6 +13,10 @@ export const onUpdateContract = functions.firestore
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question

View File

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

View File

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

View File

@ -0,0 +1,92 @@
// Filling in historical bet positions on comments.
// Warning: This just recalculates all of them, rather than trying to
// figure out which ones are out of date, since I'm using it to fill them
// in once in the first place.
import { maxBy } from 'lodash'
import * as admin from 'firebase-admin'
import { filterDefined } from '../../../common/util/array'
import { Bet } from '../../../common/bet'
import { Comment } from '../../../common/comment'
import { Contract } from '../../../common/contract'
import { getLargestPosition } from '../../../common/calculate'
import { initAdmin } from './script-init'
import { DocumentSnapshot } from 'firebase-admin/firestore'
import { log, writeAsync } from '../utils'
initAdmin()
const firestore = admin.firestore()
async function getContractsById() {
const contracts = await firestore.collection('contracts').get()
const results = Object.fromEntries(
contracts.docs.map((doc) => [doc.id, doc.data() as Contract])
)
log(`Found ${contracts.size} contracts.`)
return results
}
async function getCommentsByContractId() {
const comments = await firestore
.collectionGroup('comments')
.where('contractId', '!=', null)
.get()
const results = new Map<string, DocumentSnapshot[]>()
comments.forEach((doc) => {
const contractId = doc.get('contractId')
const contractComments = results.get(contractId) || []
contractComments.push(doc)
results.set(contractId, contractComments)
})
log(`Found ${comments.size} comments on ${results.size} contracts.`)
return results
}
// not in a transaction for speed -- may need to be run more than once
async function denormalize() {
const contractsById = await getContractsById()
const commentsByContractId = await getCommentsByContractId()
for (const [contractId, comments] of commentsByContractId.entries()) {
const betsQuery = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.get()
log(`Loaded ${betsQuery.size} bets for contract ${contractId}.`)
const bets = betsQuery.docs.map((d) => d.data() as Bet)
const updates = comments.map((doc) => {
const comment = doc.data() as Comment
const contract = contractsById[contractId]
const previousBets = bets.filter(
(b) => b.createdTime < comment.createdTime
)
const position = getLargestPosition(
contract,
previousBets.filter((b) => b.userId === comment.userId && !b.isAnte)
)
if (position) {
const fields: { [k: string]: unknown } = {
commenterPositionShares: position.shares,
commenterPositionOutcome: position.outcome,
}
const previousProb =
contract.outcomeType === 'BINARY'
? maxBy(previousBets, (bet) => bet.createdTime)?.probAfter
: undefined
if (previousProb != null) {
fields.commenterPositionProb = previousProb
}
return { doc: doc.ref, fields }
} else {
return undefined
}
})
log(`Updating ${updates.length} comments.`)
await writeAsync(firestore, filterDefined(updates))
}
}
if (require.main === module) {
denormalize().catch((e) => console.error(e))
}

View File

@ -7,11 +7,12 @@ import { Contract } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { getLoanUpdates } from '../../common/loans'
import { createLoanIncomeNotification } from './create-notification'
import { filterDefined } from '../../common/util/array'
const firestore = admin.firestore()
export const updateLoans = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '8GB', timeoutSeconds: 540 })
// Run every day at midnight.
.pubsub.schedule('0 0 * * *')
.timeZone('America/Los_Angeles')
@ -30,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)

View File

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

View File

@ -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,
@ -311,6 +343,6 @@ export const updateStatsCore = async () => {
}
export const updateStats = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.runWith({ memory: '4GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

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

View File

@ -16,7 +16,7 @@ import { DAY_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
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
)
})

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { sortBy, partition, sum, uniq } from 'lodash'
import { sortBy, partition, sum } from 'lodash'
import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item'
import { CreateAnswerPanel } from './create-answer-panel'
import { AnswerResolvePanel } from './answer-resolve-panel'
import { Spacer } from '../layout/spacer'
import { User } from 'common/user'
import { getOutcomeProbability } from 'common/calculate'
import { Answer } from 'common/answer'
import clsx from 'clsx'
@ -56,6 +55,11 @@ export function AnswersPanel(props: {
),
]
const answerItems = sortBy(
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
(answer) => -getOutcomeProbability(contract, answer.id)
)
const user = useUser()
const [resolveOption, setResolveOption] = useState<
@ -67,12 +71,6 @@ export function AnswersPanel(props: {
const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems(
contract,
losingAnswers.length > 0 ? losingAnswers : sortedAnswers,
user
)
const onChoose = (answerId: string, prob: number) => {
if (resolveOption === 'CHOOSE') {
setChosenAnswers({ [answerId]: prob })
@ -123,28 +121,26 @@ export function AnswersPanel(props: {
))}
{!resolveOption && (
<div className={clsx('flow-root pr-2 md:pr-0')}>
<div className={clsx(tradingAllowed(contract) ? '' : '-mb-6')}>
<Col
className={clsx(
'gap-2 pr-2 md:pr-0',
tradingAllowed(contract) ? '' : '-mb-6'
)}
>
{answerItems.map((item) => (
<div key={item.id} className={'relative pb-2'}>
<div className="relative flex items-start space-x-3">
<OpenAnswer {...item} />
</div>
</div>
<OpenAnswer key={item.id} answer={item} contract={contract} />
))}
<Row className={'justify-end'}>
{hasZeroBetAnswers && !showAllAnswers && (
<Button
color={'gray-white'}
className="self-end"
color="gray-white"
onClick={() => setShowAllAnswers(true)}
size={'md'}
size="md"
>
Show More
</Button>
)}
</Row>
</div>
</div>
</Col>
)}
{answers.length <= 1 && (
@ -175,35 +171,9 @@ export function AnswersPanel(props: {
)
}
function getAnswerItems(
contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[],
user: User | undefined | null
) {
let outcomes = uniq(answers.map((answer) => answer.number.toString()))
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
).reverse()
return outcomes
.map((outcome) => {
const answer = answers.find((answer) => answer.id === outcome) as Answer
//unnecessary
return {
id: outcome,
type: 'answer' as const,
contract,
answer,
user,
}
})
.filter((group) => group.answer)
}
function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract
answer: Answer
type: string
}) {
const { answer, contract } = props
const { username, avatarUrl, name, text } = answer
@ -212,7 +182,7 @@ function OpenAnswer(props: {
const [open, setOpen] = useState(false)
return (
<Col className={'border-base-200 bg-base-200 flex-1 rounded-md px-2'}>
<Col className="border-base-200 bg-base-200 relative flex-1 rounded-md px-2">
<Modal open={open} setOpen={setOpen} position="center">
<AnswerBetPanel
answer={answer}
@ -229,21 +199,15 @@ function OpenAnswer(props: {
/>
<Row className="my-4 gap-3">
<div className="px-1">
<Avatar username={username} avatarUrl={avatarUrl} />
</div>
<Avatar className="mx-1" username={username} avatarUrl={avatarUrl} />
<Col className="min-w-0 flex-1 lg:gap-1">
<div className="text-sm text-gray-500">
<UserLink username={username} name={name} /> answered
</div>
<Col className="align-items justify-between gap-4 sm:flex-row">
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
<Row className="items-center justify-center gap-4">
<div className={'align-items flex w-full justify-end gap-4 '}>
<Linkify className="whitespace-pre-line text-lg" text={text} />
<Row className="align-items items-center justify-end gap-4">
<span
className={clsx(
'text-2xl',
@ -259,7 +223,6 @@ function OpenAnswer(props: {
)}
onClick={() => setOpen(true)}
/>
</div>
</Row>
</Col>
</Col>

View File

@ -1,25 +1,27 @@
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 { useMemberGroups } from 'web/hooks/use-group'
import { filterDefined } from 'common/util/array'
import { isArray, keyBy } from 'lodash'
import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { Group } from 'common/group'
export function ArrangeHome(props: {
user: User | null | undefined
homeSections: string[]
setHomeSections: (sections: string[]) => void
sections: { label: string; id: string; group?: Group }[]
setSectionIds: (sections: string[]) => void
}) {
const { user, homeSections, setHomeSections } = props
const { sections, setSectionIds } = props
const groups = useMemberGroups(user?.id) ?? []
const { itemsById, sections } = getHomeItems(groups, homeSections)
const sectionsById = keyBy(sections, 'id')
return (
<DragDropContext
@ -27,14 +29,14 @@ export function ArrangeHome(props: {
const { destination, source, draggableId } = e
if (!destination) return
const item = itemsById[draggableId]
const section = sectionsById[draggableId]
const newHomeSections = sections.map((section) => section.id)
const newSectionIds = sections.map((section) => section.id)
newHomeSections.splice(source.index, 1)
newHomeSections.splice(destination.index, 0, item.id)
newSectionIds.splice(source.index, 1)
newSectionIds.splice(destination.index, 0, section.id)
setHomeSections(newHomeSections)
setSectionIds(newSectionIds)
}}
>
<Row className="relative max-w-md gap-4">
@ -46,8 +48,9 @@ export function ArrangeHome(props: {
function DraggableList(props: {
title: string
items: { id: string; label: string }[]
items: { id: string; label: string; group?: Group }[]
}) {
const user = useUser()
const { title, items } = props
return (
<Droppable droppableId={title.toLowerCase()}>
@ -72,6 +75,7 @@ function DraggableList(props: {
snapshot.isDragging && 'z-[9000] bg-gray-200'
)}
item={item}
user={user}
/>
</div>
)}
@ -85,49 +89,53 @@ function DraggableList(props: {
}
const SectionItem = (props: {
item: { id: string; label: string }
item: { id: string; label: string; group?: Group }
user: User | null | undefined
className?: string
}) => {
const { item, className } = props
const { item, user, className } = props
const { group } = item
return (
<div
<Row
className={clsx(
className,
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
'items-center justify-between gap-4 rounded bg-gray-50 p-2'
)}
>
<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>
)
}
export const getHomeItems = (groups: Group[], sections: string[]) => {
// Accommodate old home sections.
if (!isArray(sections)) sections = []
const items = [
{ label: 'Trending', id: 'score' },
{ label: 'New for you', id: 'newest' },
{ label: 'Daily movers', id: 'daily-movers' },
...groups.map((g) => ({
label: g.name,
id: g.id,
})),
]
const itemsById = keyBy(items, 'id')
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
// Add unmentioned items to the end.
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
return {
sections: sectionItems,
itemsById,
}
}

View File

@ -49,7 +49,7 @@ export default function BetButton(props: {
)}
onClick={() => setOpen(true)}
>
{PRESENT_BET}
Predict
</Button>
) : (
<BetSignUpPrompt />

View File

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

View File

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

View File

@ -16,17 +16,11 @@ export function CommentInput(props: {
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
onSubmitComment?: (editor: Editor) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const { parentAnswerOutcome, parentCommentId, replyToUser, onSubmitComment } =
props
const user = useUser()
const { editor, upload } = useTextEditor({
@ -40,10 +34,10 @@ export function CommentInput(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
async function submitComment() {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
onSubmitComment?.(editor)
setIsSubmitting(false)
}
@ -65,7 +59,6 @@ export function CommentInput(props: {
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
@ -77,25 +70,17 @@ export function CommentInputTextArea(props: {
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
submitComment: () => void
isSubmitting: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
replyToUser,
} = props
const { user, editor, upload, submitComment, isSubmitting, replyToUser } =
props
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
submitComment()
editor?.commands?.clearContent()
}
@ -151,14 +136,14 @@ export function CommentInputTextArea(props: {
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
<LoadingIndicator spinnerClassName="border-gray-500" />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
className="btn btn-outline btn-sm mt-2 normal-case"
onClick={submitComment}
>
Add my comment
</button>

View File

@ -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,15 +10,13 @@ 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 {
storageStore,
historyStore,
urlParamStore,
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
@ -29,19 +26,17 @@ import { debounce, isEqual, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col'
import clsx from 'clsx'
const searchClient = algoliasearch(
'GJQPAYENIF',
'75c28fc084a80e1129d427d470cf41a3'
)
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
import { safeLocalStorage } from 'web/lib/util/local'
import {
getIndexName,
searchClient,
searchIndexName,
} from 'web/lib/service/algolia'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: `Most ${PAST_BETS}`, value: 'most-traded' },
{ label: `Most traded`, value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' },
@ -68,14 +63,13 @@ type AdditionalFilter = {
tag?: string
excludeContractIds?: string[]
groupSlug?: string
yourBets?: boolean
followed?: boolean
}
export function ContractSearch(props: {
user?: User | null
defaultSort?: Sort
defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter
highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void
@ -95,11 +89,13 @@ export function ContractSearch(props: {
contracts: Contract[] | undefined,
loadMore: () => void
) => ReactNode
autoFocus?: boolean
}) {
const {
user,
defaultSort,
defaultFilter,
defaultPill,
additionalFilter,
onContractClick,
hideOrderSelector,
@ -112,6 +108,7 @@ export function ContractSearch(props: {
noControls,
maxResults,
renderContracts,
autoFocus,
} = props
const [state, setState] = usePersistentState(
@ -153,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
? []
: [
@ -207,13 +204,15 @@ export function ContractSearch(props: {
className={headerClassName}
defaultSort={defaultSort}
defaultFilter={defaultFilter}
defaultPill={defaultPill}
additionalFilter={additionalFilter}
persistPrefix={persistPrefix}
hideOrderSelector={hideOrderSelector}
persistPrefix={persistPrefix ? `${persistPrefix}-controls` : undefined}
useQueryUrlParam={useQueryUrlParam}
user={user}
onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls}
autoFocus={autoFocus}
/>
{renderContracts ? (
renderContracts(renderedContracts, performQuery)
@ -235,25 +234,29 @@ function ContractSearchControls(props: {
className?: string
defaultSort?: Sort
defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter
persistPrefix?: string
hideOrderSelector?: boolean
onSearchParametersChanged: (params: SearchParameters) => void
persistPrefix?: string
useQueryUrlParam?: boolean
user?: User | null
noControls?: boolean
autoFocus?: boolean
}) {
const {
className,
defaultSort,
defaultFilter,
defaultPill,
additionalFilter,
persistPrefix,
hideOrderSelector,
onSearchParametersChanged,
persistPrefix,
useQueryUrlParam,
user,
noControls,
autoFocus,
} = props
const router = useRouter()
@ -267,19 +270,42 @@ function ContractSearchControls(props: {
}
)
const [state, setState] = usePersistentState(
{
sort: defaultSort ?? 'score',
filter: defaultFilter ?? 'open',
pillFilter: null as string | null,
},
!persistPrefix
const sortKey = `${persistPrefix}-search-sort`
const savedSort = safeLocalStorage()?.getItem(sortKey)
const [sort, setSort] = usePersistentState(
savedSort ?? defaultSort ?? 'score',
!useQueryUrlParam
? undefined
: {
key: `${persistPrefix}-params`,
store: storageStore(safeLocalStorage()),
key: 's',
store: urlParamStore(router),
}
)
const [filter, setFilter] = usePersistentState(
defaultFilter ?? 'open',
!useQueryUrlParam
? undefined
: {
key: 'f',
store: urlParamStore(router),
}
)
const [pill, setPill] = usePersistentState(
defaultPill ?? '',
!useQueryUrlParam
? undefined
: {
key: 'p',
store: urlParamStore(router),
}
)
useEffect(() => {
if (persistPrefix && sort) {
safeLocalStorage()?.setItem(sortKey, sort as string)
}
}, [persistPrefix, query, sort, sortKey])
const follows = useFollows(user?.id)
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
@ -319,11 +345,6 @@ function ContractSearchControls(props: {
additionalFilter?.groupSlug
? `groupLinks.slug:${additionalFilter.groupSlug}`
: '',
additionalFilter?.yourBets && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
...(additionalFilter?.followed ? personalFilters : []),
]
const facetFilters = query
? additionalFilters
@ -331,31 +352,25 @@ function ContractSearchControls(props: {
...additionalFilters,
additionalFilter ? '' : 'visibility:public',
state.filter === 'open' ? 'isResolved:false' : '',
state.filter === 'closed' ? 'isResolved:false' : '',
state.filter === 'resolved' ? 'isResolved:true' : '',
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
state.pillFilter &&
state.pillFilter !== 'personal' &&
state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}`
pill && pill !== 'personal' && pill !== 'your-bets'
? `groupLinks.slug:${pill}`
: '',
...(state.pillFilter === 'personal' ? personalFilters : []),
state.pillFilter === 'your-bets' && user
...(pill === 'personal' ? personalFilters : []),
pill === 'your-bets' && user
? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f)
const openClosedFilter =
state.filter === 'open'
? 'open'
: state.filter === 'closed'
? 'closed'
: undefined
filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined
const selectPill = (pill: string | null) => () => {
setState({ ...state, pillFilter: pill })
setPill(pill ?? '')
track('select search category', { category: pill ?? 'all' })
}
@ -364,34 +379,32 @@ function ContractSearchControls(props: {
}
const selectFilter = (newFilter: filter) => {
if (newFilter === state.filter) return
setState({ ...state, filter: newFilter })
if (newFilter === filter) return
setFilter(newFilter)
track('select search filter', { filter: newFilter })
}
const selectSort = (newSort: Sort) => {
if (newSort === state.sort) return
setState({ ...state, sort: newSort })
if (newSort === sort) return
setSort(newSort)
track('select search sort', { sort: newSort })
}
useEffect(() => {
onSearchParametersChanged({
query: query,
sort: state.sort,
sort: sort as Sort,
openClosedFilter: openClosedFilter,
facetFilters: facetFilters,
})
}, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)])
}, [query, sort, openClosedFilter, JSON.stringify(facetFilters)])
if (noControls) {
return <></>
}
return (
<Col
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
>
<Col className={clsx('bg-base-200 top-0 z-20 gap-3 pb-3', className)}>
<Row className="gap-1 sm:gap-2">
<input
type="text"
@ -400,11 +413,12 @@ function ContractSearchControls(props: {
onBlur={trackCallback('search', { query: query })}
placeholder={'Search'}
className="input input-bordered w-full"
autoFocus={autoFocus}
/>
{!query && (
<select
className="select select-bordered"
value={state.filter}
value={filter}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
@ -416,7 +430,7 @@ function ContractSearchControls(props: {
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={state.sort}
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{SORTS.map((option) => (
@ -430,16 +444,12 @@ function ContractSearchControls(props: {
{!additionalFilter && !query && (
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton
key={'all'}
selected={state.pillFilter === undefined}
onSelect={selectPill(null)}
>
<PillButton key={'all'} selected={!pill} onSelect={selectPill(null)}>
All
</PillButton>
<PillButton
key={'personal'}
selected={state.pillFilter === 'personal'}
selected={pill === 'personal'}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
@ -448,7 +458,7 @@ function ContractSearchControls(props: {
{user && (
<PillButton
key={'your-bets'}
selected={state.pillFilter === 'your-bets'}
selected={pill === 'your-bets'}
onSelect={selectPill('your-bets')}
>
Your {PAST_BETS}
@ -459,7 +469,7 @@ function ContractSearchControls(props: {
return (
<PillButton
key={slug}
selected={state.pillFilter === slug}
selected={pill === slug}
onSelect={selectPill(slug)}
>
{name}

View File

@ -81,7 +81,7 @@ export function SelectMarketsModal(props: {
</div>
)}
<div className="overflow-y-auto sm:px-8">
<div className="overflow-y-auto px-2 sm:px-8">
<ContractSearch
hideOrderSelector
onContractClick={addContract}
@ -96,7 +96,7 @@ export function SelectMarketsModal(props: {
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
additionalFilter={{}} /* hide pills */
headerClassName="bg-white"
headerClassName="bg-white sticky"
{...contractSearchOptions}
/>
</div>

View File

@ -27,10 +27,11 @@ import { contractMetrics } from 'common/contract-details'
import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip'
import { useWindowSize } from 'web/hooks/use-window-size'
import { ExtraContractActionsRow } from './extra-contract-actions-row'
import { PlusCircleIcon } from '@heroicons/react/solid'
import { GroupLink } from 'common/group'
import { Subtitle } from '../subtitle'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export type ShowTime = 'resolve-date' | 'close-date'
@ -108,11 +109,6 @@ export function AvatarDetails(props: {
)
}
export function useIsMobile() {
const { width } = useWindowSize()
return (width ?? 0) < 600
}
export function ContractDetails(props: {
contract: Contract
disabled?: boolean
@ -131,11 +127,7 @@ export function ContractDetails(props: {
{/* GROUPS */}
{isMobile && (
<div className="mt-2">
<MarketGroups
contract={contract}
isMobile={isMobile}
disabled={disabled}
/>
<MarketGroups contract={contract} disabled={disabled} />
</div>
)}
</Col>
@ -186,11 +178,7 @@ export function MarketSubheader(props: {
isCreator={isCreator}
/>
{!isMobile && (
<MarketGroups
contract={contract}
isMobile={isMobile}
disabled={disabled}
/>
<MarketGroups contract={contract} disabled={disabled} />
)}
</Row>
</Col>
@ -237,31 +225,26 @@ export function CloseOrResolveTime(props: {
export function MarketGroups(props: {
contract: Contract
isMobile: boolean | undefined
disabled: boolean | undefined
disabled?: boolean
}) {
const [open, setOpen] = useState(false)
const user = useUser()
const { contract, isMobile, disabled } = props
const { contract, disabled } = props
const groupToDisplay = getGroupLinkToDisplay(contract)
return (
<>
<Row className="align-middle">
<GroupDisplay groupToDisplay={groupToDisplay} isMobile={isMobile} />
{!disabled && (
<Row>
{user && (
<Row className="items-center gap-1">
<GroupDisplay groupToDisplay={groupToDisplay} />
{!disabled && user && (
<button
className="text-greyscale-4 hover:text-greyscale-3"
onClick={() => setOpen(!open)}
onClick={() => setOpen(true)}
>
<PlusCircleIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
<PlusCircleIcon className="h-[18px]" />
</button>
)}
</Row>
)}
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}>
<Col
className={
@ -326,7 +309,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip
text={`${formatMoney(
volume
)} bet - ${uniqueBettors} unique predictors`}
)} bet - ${uniqueBettors} unique traders`}
>
{volumeTranslation}
</Tooltip>
@ -337,42 +320,21 @@ export function ExtraMobileContractDetails(props: {
)
}
export function GroupDisplay(props: {
groupToDisplay?: GroupLink | null
isMobile?: boolean
}) {
const { groupToDisplay, isMobile } = props
export function GroupDisplay(props: { groupToDisplay?: GroupLink | null }) {
const { groupToDisplay } = props
if (groupToDisplay) {
return (
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
<a
className={clsx(
'flex flex-row items-center truncate pr-1',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<div className="bg-greyscale-4 hover:bg-greyscale-3 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs">
<a className="bg-greyscale-4 hover:bg-greyscale-3 max-w-[140px] truncate rounded-full px-2 text-xs text-white sm:max-w-[250px]">
{groupToDisplay.name}
</div>
</a>
</Link>
)
} else
return (
<Row
className={clsx(
'cursor-default select-none items-center truncate pr-1',
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
)}
>
<div
className={clsx(
'bg-greyscale-4 text-2xs items-center truncate rounded-full px-2 text-white sm:text-xs'
)}
>
<div className="bg-greyscale-4 truncate rounded-full px-2 text-xs text-white">
No Group
</div>
</Row>
)
}
@ -427,11 +389,18 @@ function EditableCloseDate(props: {
return (
<>
{isEditingCloseTime ? (
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
<Modal
size="sm"
open={isEditingCloseTime}
setOpen={setIsEditingCloseTime}
position="top"
>
<Col className="rounded bg-white px-8 pb-8">
<Subtitle text="Edit Close Date" />
<Row className="z-10 mr-2 w-full shrink-0 flex-wrap items-center gap-2">
<input
type="date"
className="input input-bordered shrink-0"
className="input input-bordered w-full shrink-0 sm:w-fit"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()}
@ -439,17 +408,23 @@ function EditableCloseDate(props: {
/>
<input
type="time"
className="input input-bordered shrink-0"
className="input input-bordered w-full shrink-0 sm:w-max"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"
value={closeHoursMinutes}
/>
<Button size={'xs'} color={'blue'} onClick={onSave}>
</Row>
<Button
className="mt-2"
size={'xs'}
color={'indigo'}
onClick={onSave}
>
Done
</Button>
</Row>
) : (
</Col>
</Modal>
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={closeTime}
@ -467,7 +442,6 @@ function EditableCloseDate(props: {
)}
</span>
</DateTimeTooltip>
)}
</>
)
}

View File

@ -5,7 +5,6 @@ import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { listUsers, User } from 'web/lib/firebase/users'
import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments'
@ -66,9 +65,8 @@ export function ContractTopTrades(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, bets, comments, tips } = props
const { contract, bets, comments } = props
const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id')
@ -105,8 +103,6 @@ export function ContractTopTrades(props: {
<FeedComment
contract={contract}
comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
/>
</div>
<Spacer h={16} />

View File

@ -1,104 +1,45 @@
import { memo, useState } from 'react'
import { getOutcomeProbability } from 'common/calculate'
import { Pagination } from 'web/components/pagination'
import { FeedBet } from '../feed/feed-bets'
import { FeedLiquidity } from '../feed/feed-liquidity'
import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group'
import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { groupBy, sortBy } from 'lodash'
import { Bet } from 'common/bet'
import { Contract, CPMMBinaryContract } from 'common/contract'
import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import { PAST_BETS, User } from 'common/user'
import {
ContractCommentsActivity,
ContractBetsActivity,
FreeResponseContractCommentsActivity,
} from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import BetButton from '../bet-button'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { capitalize } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export function ContractTabs(props: {
contract: Contract
user: User | null | undefined
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, user, bets, tips } = props
const { outcomeType } = contract
const { contract, user, bets, comments } = props
const lps = useLiquidity(contract.id)
const isMobile = useIsMobile()
const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
const visibleLps = (lps ?? []).filter(
(l) =>
!l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
l.amount > 0
)
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const betActivity = lps != null && (
<ContractBetsActivity
contract={contract}
bets={visibleBets}
lps={visibleLps}
/>
)
const generalBets = outcomeType === 'FREE_RESPONSE' ? [] : visibleBets
const generalComments = comments.filter(
(comment) =>
comment.answerOutcome === undefined &&
(outcomeType === 'FREE_RESPONSE' ? comment.betId === undefined : true)
)
const commentActivity =
outcomeType === 'FREE_RESPONSE' ? (
<>
<FreeResponseContractCommentsActivity
contract={contract}
bets={visibleBets}
comments={comments}
tips={tips}
user={user}
/>
<Col className={'mt-8 flex w-full '}>
<div className={'text-md mt-8 mb-2 text-left'}>General Comments</div>
<div className={'mb-4 w-full border-b border-gray-200'} />
<ContractCommentsActivity
contract={contract}
bets={generalBets}
comments={generalComments}
tips={tips}
user={user}
/>
</Col>
</>
) : (
<ContractCommentsActivity
contract={contract}
bets={visibleBets}
comments={comments}
tips={tips}
user={user}
/>
)
const yourTrades = (
<div>
@ -115,39 +56,202 @@ export function ContractTabs(props: {
)
return (
<>
<Tabs
currentPageForAnalytics={'contract'}
tabs={[
{
title: 'Comments',
content: commentActivity,
badge: `${comments.length}`,
content: (
<CommentsTabContent contract={contract} comments={comments} />
),
},
{
title: capitalize(PAST_BETS),
content: betActivity,
badge: `${visibleBets.length + visibleLps.length}`,
content: (
<ContractBetsActivity contract={contract} bets={visibleBets} />
),
},
...(!user || !userBets?.length
? []
: [{ title: `Your ${PAST_BETS}`, content: yourTrades }]),
: [
{
title: isMobile ? `You` : `Your ${PAST_BETS}`,
content: yourTrades,
},
]),
]}
/>
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
)
}
const CommentsTabContent = memo(function CommentsTabContent(props: {
contract: Contract
comments: ContractComment[]
}) {
const { contract, comments } = props
const tips = useTipTxns({ contractId: contract.id })
const updatedComments = useComments(contract.id) ?? comments
if (contract.outcomeType === 'FREE_RESPONSE') {
return (
<>
<FreeResponseContractCommentsActivity
contract={contract}
comments={updatedComments}
tips={tips}
/>
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
<ContractCommentsActivity
contract={contract}
comments={updatedComments.filter(
(comment) =>
comment.answerOutcome === undefined &&
comment.betId === undefined
)}
tips={tips}
/>
</Col>
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
</>
)
} else {
return (
<ContractCommentsActivity
contract={contract}
comments={comments}
tips={tips}
/>
)
}
})
function ContractBetsActivity(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const lps = useLiquidity(contract.id) ?? []
const visibleLps = lps.filter(
(l) =>
!l.isAnte &&
l.userId !== HOUSE_LIQUIDITY_PROVIDER_ID &&
l.userId !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID &&
l.amount > 0
)
const items = [
...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...visibleLps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const pageItems = sortBy(items, (item) =>
item.type === 'bet'
? -item.bet.createdTime
: item.type === 'liquidity'
? -item.lp.createdTime
: undefined
).slice(start, end)
return (
<>
<Col className="mb-4 gap-4">
{pageItems.map((item) =>
item.type === 'bet' ? (
<FeedBet key={item.id} contract={contract} bet={item.bet} />
) : (
<FeedLiquidity key={item.id} liquidity={item.lp} />
)
)}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
)
}
function ContractCommentsActivity(props: {
contract: Contract
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, comments, tips } = props
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<ContractCommentInput className="mb-5" contract={contract} />
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
))}
</>
)
}
function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
comments: ContractComment[]
tips: CommentTipMap
}) {
const { contract, comments, tips } = props
const sortedAnswers = sortBy(
contract.answers,
(answer) => -getOutcomeProbability(contract, answer.number.toString())
)
const commentsByOutcome = groupBy(
comments,
(c) => c.answerOutcome ?? c.betOutcome ?? '_'
)
return (
<>
{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"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
/>
</div>
))}
</>
)
}

View File

@ -110,6 +110,7 @@ export function CreatorContractsList(props: {
return (
<ContractSearch
headerClassName="sticky"
user={user}
defaultSort="newest"
defaultFilter="all"

View File

@ -11,23 +11,29 @@ export function ProbChangeTable(props: {
changes:
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined
full?: boolean
}) {
const { changes } = props
const { changes, full } = props
if (!changes) return <LoadingIndicator />
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(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>
@ -35,37 +41,30 @@ export function ProbChangeTable(props: {
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
<Col className="flex-1 divide-y">
{filteredPositiveChanges.map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
<ProbChangeRow key={contract.id} contract={contract} />
))}
</Col>
<Col className="flex-1 divide-y">
{filteredNegativeChanges.map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<ProbChangeRow key={contract.id} contract={contract} />
))}
</Col>
</Col>
)
}
function ProbChangeRow(props: { contract: CPMMContract }) {
const { contract } = props
return (
<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>
))}
</Col>
</Col>
)
}
@ -75,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>
)
}

View File

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

View File

@ -1,167 +0,0 @@
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'
import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
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 { Col } from 'web/components/layout/col'
export function ContractBetsActivity(props: {
contract: Contract
bets: Bet[]
lps: LiquidityProvision[]
}) {
const { contract, bets, lps } = props
const [page, setPage] = useState(0)
const ITEMS_PER_PAGE = 50
const start = page * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
const items = [
...bets.map((bet) => ({
type: 'bet' as const,
id: bet.id + '-' + bet.isSold,
bet,
})),
...lps.map((lp) => ({
type: 'liquidity' as const,
id: lp.id,
lp,
})),
]
const pageItems = sortBy(items, (item) =>
item.type === 'bet'
? -item.bet.createdTime
: item.type === 'liquidity'
? -item.lp.createdTime
: undefined
).slice(start, end)
return (
<>
<Col className="mb-4 gap-4">
{pageItems.map((item) =>
item.type === 'bet' ? (
<FeedBet key={item.id} contract={contract} bet={item.bet} />
) : (
<FeedLiquidity key={item.id} liquidity={item.lp} />
)
)}
</Col>
<Pagination
page={page}
itemsPerPage={50}
totalItems={items.length}
setPage={setPage}
scrollToTop
nextTitle={'Older'}
prevTitle={'Newer'}
/>
</>
)
}
export function ContractCommentsActivity(props: {
contract: Contract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { bets, contract, comments, user, tips } = props
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByParentId = groupBy(comments, (c) => c.replyToCommentId ?? '_')
const topLevelComments = sortBy(
commentsByParentId['_'] ?? [],
(c) => -c.createdTime
)
return (
<>
<ContractCommentInput
className="mb-5"
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
/>
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}
user={user}
contract={contract}
parentComment={parent}
threadComments={sortBy(
commentsByParentId[parent.id] ?? [],
(c) => c.createdTime
)}
tips={tips}
bets={bets}
betsByUserId={betsByUserId}
commentsByUserId={commentsByUserId}
/>
))}
</>
)
}
export function FreeResponseContractCommentsActivity(props: {
contract: FreeResponseContract
bets: Bet[]
comments: ContractComment[]
tips: CommentTipMap
user: User | null | undefined
}) {
const { bets, contract, comments, user, tips } = props
let outcomes = uniq(bets.map((bet) => bet.outcome))
outcomes = sortBy(
outcomes,
(outcome) => -getOutcomeProbability(contract, outcome)
)
const answers = outcomes
.map((outcome) => {
return contract.answers.find((answer) => answer.id === outcome) as Answer
})
.filter((answer) => answer != null)
const betsByUserId = groupBy(bets, (bet) => bet.userId)
const commentsByUserId = groupBy(comments, (c) => c.userId)
const commentsByOutcome = groupBy(comments, (c) => c.answerOutcome ?? '_')
return (
<>
{answers.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"
aria-hidden="true"
/>
<FeedAnswerCommentGroup
contract={contract}
user={user}
answer={answer}
answerComments={sortBy(
commentsByOutcome[answer.number.toString()] ?? [],
(c) => c.createdTime
)}
tips={tips}
betsByUserId={betsByUserId}
commentsByUserId={commentsByUserId}
/>
</div>
))}
</>
)
}

View File

@ -1,5 +1,4 @@
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react'
@ -11,11 +10,9 @@ import clsx from 'clsx'
import {
ContractCommentInput,
FeedComment,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router'
import { Dictionary } from 'lodash'
import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
@ -23,22 +20,11 @@ import { UserLink } from 'web/components/user-link'
export function FeedAnswerCommentGroup(props: {
contract: FreeResponseContract
user: User | undefined | null
answer: Answer
answerComments: ContractComment[]
tips: CommentTipMap
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]>
}) {
const {
answer,
contract,
answerComments,
tips,
betsByUserId,
commentsByUserId,
user,
} = props
const { answer, contract, answerComments, tips } = props
const { username, avatarUrl, name, text } = answer
const [replyToUser, setReplyToUser] =
@ -48,30 +34,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(
betsByCurrentUser,
commentsByCurrentUser,
user,
answer.number.toString()
)
const [usersMostRecentBetTimeAtLoad, setUsersMostRecentBetTimeAtLoad] =
useState<number | undefined>(
!user ? undefined : mostRecentCommentableBet?.createdTime ?? 0
)
useEffect(() => {
if (user && usersMostRecentBetTimeAtLoad === undefined)
setUsersMostRecentBetTimeAtLoad(
mostRecentCommentableBet?.createdTime ?? 0
)
}, [
mostRecentCommentableBet?.createdTime,
user,
usersMostRecentBetTimeAtLoad,
])
const scrollAndOpenReplyInput = useEvent(
(comment?: ContractComment, answer?: Answer) => {
@ -86,19 +48,6 @@ export function FeedAnswerCommentGroup(props: {
}
)
useEffect(() => {
// Only show one comment input for a bet at a time
if (
betsByCurrentUser.length > 1 &&
// inputRef?.textContent?.length === 0 && //TODO: editor.isEmpty
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
?.outcome !== answer.number.toString()
)
setShowReply(false)
// Even if we pass memoized bets this still runs on every render, which we don't want
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [betsByCurrentUser.length, user, answer.number])
useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true)
@ -106,10 +55,7 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath])
return (
<Col
className={'relative flex-1 items-stretch gap-3'}
key={answer.id + 'comment'}
>
<Col className="relative flex-1 items-stretch gap-3">
<Row
className={clsx(
'gap-3 space-x-3 pt-4 transition-all duration-1000',
@ -134,28 +80,23 @@ export function FeedAnswerCommentGroup(props: {
<span className="whitespace-pre-line text-lg">
<Linkify text={text} />
</span>
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<div className="sm:hidden">
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
</div>
)}
</Col>
{isFreeResponseContractPage && (
<div className={'justify-initial hidden sm:block'}>
<div className="justify-initial hidden sm:block">
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
</div>
)}
</Col>
</Row>
<Col className="gap-3 pl-1">
@ -165,22 +106,19 @@ export function FeedAnswerCommentGroup(props: {
indent={true}
contract={contract}
comment={comment}
tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
tips={tips[comment.id] ?? {}}
onReplyClick={scrollAndOpenReplyInput}
/>
))}
</Col>
{showReply && (
<div className={'relative ml-7'}>
<div className="relative ml-7">
<span
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true"
/>
<ContractCommentInput
contract={contract}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()}
replyToUser={replyToUser}
onSubmitComment={() => setShowReply(false)}

View File

@ -1,9 +1,6 @@
import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment'
import { PRESENT_BET, User } from 'common/user'
import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react'
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router'
@ -24,25 +21,12 @@ import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: {
user: User | null | undefined
contract: Contract
threadComments: ContractComment[]
tips: CommentTipMap
parentComment: ContractComment
bets: Bet[]
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]>
}) {
const {
user,
contract,
threadComments,
commentsByUserId,
bets,
betsByUserId,
tips,
parentComment,
} = props
const { contract, threadComments, tips, parentComment } = props
const [showReply, setShowReply] = useState(false)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
@ -63,18 +47,8 @@ export function FeedCommentThread(props: {
indent={commentIdx != 0}
contract={contract}
comment={comment}
tips={tips[comment.id]}
betsBySameUser={betsByUserId[comment.userId] ?? []}
tips={tips[comment.id] ?? {}}
onReplyClick={scrollAndOpenReplyInput}
probAtCreatedTime={
contract.outcomeType === 'BINARY'
? minBy(bets, (bet) => {
return bet.createdTime < comment.createdTime
? comment.createdTime - bet.createdTime
: comment.createdTime
})?.probAfter
: undefined
}
/>
))}
{showReply && (
@ -85,11 +59,8 @@ export function FeedCommentThread(props: {
/>
<ContractCommentInput
contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id}
replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => {
setShowReply(false)
}}
@ -103,23 +74,22 @@ export function FeedCommentThread(props: {
export function FeedComment(props: {
contract: Contract
comment: ContractComment
tips: CommentTips
betsBySameUser: Bet[]
tips?: CommentTips
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 +106,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 +130,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}
/>
</>
@ -204,7 +170,7 @@ export function FeedComment(props: {
smallImage
/>
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
<Tipper comment={comment} tips={tips ?? {}} />
{tips && <Tipper comment={comment} tips={tips} />}
{onReplyClick && (
<button
className="font-bold hover:underline"
@ -219,34 +185,6 @@ export function FeedComment(props: {
)
}
export function getMostRecentCommentableBet(
betsByCurrentUser: Bet[],
commentsByCurrentUser: ContractComment[],
user?: User | null,
answerOutcome?: string
) {
let sortedBetsByCurrentUser = betsByCurrentUser.sort(
(a, b) => b.createdTime - a.createdTime
)
if (answerOutcome) {
sortedBetsByCurrentUser = sortedBetsByCurrentUser.slice(0, 1)
}
return sortedBetsByCurrentUser
.filter((bet) => {
if (
canCommentOnBet(bet, user) &&
!commentsByCurrentUser.some(
(comment) => comment.createdTime > bet.createdTime
)
) {
if (!answerOutcome) return true
return answerOutcome === bet.outcome
}
return false
})
.pop()
}
function CommentStatus(props: {
contract: Contract
outcome: string
@ -255,7 +193,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) + '%'}
</>
@ -264,8 +202,6 @@ function CommentStatus(props: {
export function ContractCommentInput(props: {
contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string }
@ -273,7 +209,7 @@ export function ContractCommentInput(props: {
onSubmitComment?: () => void
}) {
const user = useUser()
async function onSubmitComment(editor: Editor, betId: string | undefined) {
async function onSubmitComment(editor: Editor) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
@ -282,22 +218,12 @@ export function ContractCommentInput(props: {
props.contract.id,
editor.getJSON(),
user,
betId,
props.parentAnswerOutcome,
props.parentCommentId
)
props.onSubmitComment?.()
}
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<CommentInput
replyToUser={props.replyToUser}
@ -305,64 +231,6 @@ export function ContractCommentInput(props: {
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
)
}
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
// You can comment if your bet was posted in the last hour
return !isRedemption && isSelf && Date.now() - createdTime < 60 * 60 * 1000
}

View File

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

View File

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

View File

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

View File

@ -14,6 +14,8 @@ import { Col } from './layout/col'
import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin'
export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
@ -28,16 +30,19 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
setShowWithdrawal(true)
}, [showWithdrawal, lpShares])
const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return (
<Tabs
tabs={[
{
title: 'Subsidize',
tabs={buildArray(
(isCreator || isAdmin) && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />,
},
...(showWithdrawal
? [
{
showWithdrawal && {
title: 'Withdraw',
content: (
<WithdrawLiquidityPanel
@ -46,13 +51,11 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
/>
),
},
]
: []),
{
title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />,
},
]}
}
)}
/>
)
}

View File

@ -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'
@ -17,11 +18,14 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
import { User } from 'common/user'
import { PAST_BETS } from 'common/user'
function getNavigation() {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
{
name: 'Notifications',
href: `/notifications`,
@ -32,9 +36,24 @@ function getNavigation() {
const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
{ name: 'Explore', href: '/search', icon: SearchIcon },
]
export const userProfileItem = (user: User) => ({
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=${PAST_BETS}`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
})
// From https://codepen.io/chris__sev/pen/QWGvYbL
export function BottomNavBar() {
const [sidebarOpen, setSidebarOpen] = useState(false)
@ -62,20 +81,7 @@ export function BottomNavBar() {
<NavBarItem
key={'profile'}
currentPage={currentPage}
item={{
name: formatMoney(user.balance),
trackingEventName: 'profile',
href: `/${user.username}?tab=${PAST_BETS}`,
icon: () => (
<Avatar
className="mx-auto my-1"
size="xs"
username={user.username}
avatarUrl={user.avatarUrl}
noLink
/>
),
}}
item={userProfileItem(user)}
/>
)}
<div
@ -99,7 +105,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
return (
<Link href={item.href}>
<Link href={item.href ?? '#'}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',

View File

@ -0,0 +1,94 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import { Item } from './sidebar-item'
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { useUser } from 'web/hooks/use-user'
import NotificationsIcon from '../notifications-icon'
import router from 'next/router'
import { userProfileItem } from './bottom-nav-bar'
const mobileGroupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
]
const mobileGeneralNavigation = [
{
name: 'Notifications',
key: 'notifications',
icon: NotificationsIcon,
href: '/notifications',
},
]
export function GroupNavBar(props: {
currentPage: string
onClick: (key: string) => void
}) {
const { currentPage } = props
const user = useUser()
return (
<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}
item={item}
currentPage={currentPage}
onClick={props.onClick}
/>
))}
{mobileGeneralNavigation.map((item) => (
<NavBarItem
key={item.name}
item={item}
currentPage={currentPage}
onClick={() => {
router.push(item.href)
}}
/>
))}
{user && (
<NavBarItem
key={'profile'}
currentPage={currentPage}
onClick={() => {
router.push(`/${user.username}?tab=trades`)
}}
item={userProfileItem(user)}
/>
)}
</nav>
)
}
function NavBarItem(props: {
item: Item
currentPage: string
onClick: (key: string) => void
}) {
const { item, currentPage } = props
const track = trackCallback(
`group navbar: ${item.trackingEventName ?? item.name}`
)
return (
<button onClick={() => props.onClick(item.key ?? '#')}>
<a
className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.key && 'bg-gray-200 text-indigo-700'
)}
onClick={track}
>
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
{item.name}
</a>
</button>
)
}

View File

@ -0,0 +1,82 @@
import { ClipboardIcon, HomeIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { useUser } from 'web/hooks/use-user'
import { ManifoldLogo } from './manifold-logo'
import { ProfileSummary } from './profile-menu'
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-item'
import { buildArray } from 'common/util/array'
import { User } from 'common/user'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
const groupNavigation = [
{ name: 'Markets', key: 'markets', icon: HomeIcon },
{ name: 'About', key: 'about', icon: ClipboardIcon },
{ name: 'Leaderboard', key: 'leaderboards', icon: TrophyIcon },
]
const generalNavigation = (user?: User | null) =>
buildArray(
user && {
name: 'Notifications',
href: `/notifications`,
key: 'notifications',
icon: NotificationsIcon,
}
)
export function GroupSidebar(props: {
groupName: string
className?: string
onClick: (key: string) => void
joinOrAddQuestionsButton: React.ReactNode
currentKey: string
}) {
const { className, groupName, currentKey } = props
const user = useUser()
return (
<nav
aria-label="Group Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="pt-6" twoLine />
<Row className="pl-2 text-xl text-indigo-700 sm:mt-3">{groupName}</Row>
<div className=" min-h-0 shrink flex-col items-stretch gap-1 pt-6 lg:flex ">
{user ? (
<ProfileSummary user={user} />
) : (
<SignInButton className="mb-4" />
)}
</div>
{/* Desktop navigation */}
{groupNavigation.map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
{generalNavigation(user).map((item) => (
<SidebarItem
key={item.key}
item={item}
currentPage={currentKey}
onClick={props.onClick}
/>
))}
<Spacer h={2} />
{props.joinOrAddQuestionsButton}
</nav>
)
}

View File

@ -4,7 +4,7 @@ import clsx from 'clsx'
export type MenuItem = {
name: string
href: string
href?: string
onClick?: () => void
}
@ -38,11 +38,11 @@ export function MenuButton(props: {
{({ active }) => (
<a
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
target={item.href?.startsWith('http') ? '_blank' : undefined}
onClick={item.onClick}
className={clsx(
active ? 'bg-gray-100' : '',
'line-clamp-3 block py-1.5 px-4 text-sm text-gray-700'
'line-clamp-3 block cursor-pointer py-1.5 px-4 text-sm text-gray-700'
)}
>
{item.name}

View File

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

View File

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

View File

@ -1,29 +1,93 @@
import React from 'react'
import {
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 } from './menu'
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,9 +96,10 @@ 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 },
{
name: 'Notifications',
href: `/notifications`,
@ -50,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: '#',
@ -98,9 +163,9 @@ function getMoreNavigation(user?: User | null) {
)
}
const signedOutNavigation = [
const signedOutDesktopNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon },
{ name: 'Explore', href: '/search', icon: SearchIcon },
{
name: 'Help & About',
href: 'https://help.manifold.markets/',
@ -116,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 }]),
@ -139,12 +207,11 @@ function getMoreMobileNav() {
}
if (IS_PRIVATE_MANIFOLD) return [signOut]
return buildArray<Item>(
return buildArray<MenuItem>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ 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' },
@ -152,116 +219,3 @@ function getMoreMobileNav() {
signOut
)
}
export type Item = {
name: string
trackingEventName?: string
href: string
icon?: React.ComponentType<{ className?: string }>
}
function SidebarItem(props: { item: Item; currentPage: string }) {
const { item, currentPage } = props
return (
<Link href={item.href} key={item.name}>
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
item.href == currentPage
? '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(
item.href == currentPage
? '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>
</Link>
)
}
function SidebarButton(props: {
text: string
icon: React.ComponentType<{ className?: string }>
children?: React.ReactNode
}) {
const { text, children } = props
return (
<a className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">{text}</span>
{children}
</a>
)
}
function MoreButton() {
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
}
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
return (
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
{!user && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{user && (
<MenuButton
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreNavigation(user)}
buttonContent={<MoreButton />}
/>
{user && !user.isBannedFromPosting && <CreateQuestionButton />}
</div>
</nav>
)
}

View File

@ -63,7 +63,6 @@ export function NotificationSettings(props: {
'contract_from_followed_user',
'unique_bettors_on_your_contract',
// TODO: add these
// one-click unsubscribe only unsubscribes them from that type only, (well highlighted), then a link to manage the rest of their notifications
// 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets
@ -277,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

View File

@ -0,0 +1,97 @@
import { sortBy } from 'lodash'
import React, { useRef } from 'react'
import { Col } from 'web/components/layout/col'
import { Title } from 'web/components/title'
import { useGroups, useMemberGroupIds } from 'web/hooks/use-group'
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { useUser } from 'web/hooks/use-user'
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
setOpen: (open: boolean) => void
}) {
const { open, setOpen } = props
const groups = useGroups()
const user = useUser()
const memberGroupIds = useMemberGroupIds(user) || []
const cachedGroups = useRef<Group[]>()
if (groups && !cachedGroups.current) {
cachedGroups.current = groups
}
const excludedGroups = [
'features',
'personal',
'private',
'nomic',
'proofnik',
'free money',
'motivation',
'sf events',
'please resolve',
'short-term',
'washifold',
]
const displayedGroups = sortBy(cachedGroups.current ?? [], [
(group) => -1 * group.totalMembers,
(group) => -1 * group.totalContracts,
])
.filter((group) => group.anyoneCanJoin)
.filter((group) =>
excludedGroups.every((name) => !group.name.toLowerCase().includes(name))
)
.filter(
(group) =>
(group.mostRecentContractAddedTime ?? 0) >
Date.now() - 1000 * 60 * 60 * 24 * 7
)
.slice(0, 30)
return (
<Modal open={open} setOpen={setOpen}>
<Col className="h-[32rem] rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
<Title text="What interests you?" />
<p className="mb-4">
Choose among the categories below to personalize your Manifold
experience.
</p>
<div className="scrollbar-hide items-start gap-2 overflow-x-auto">
{!user || displayedGroups.length === 0 ? (
<LoadingIndicator spinnerClassName="h-12 w-12" />
) : (
displayedGroups.map((group) => (
<PillButton
selected={memberGroupIds.includes(group.id)}
onSelect={withTracking(
() =>
memberGroupIds.includes(group.id)
? leaveGroup(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>
<Button onClick={() => setOpen(false)}>Done</Button>
</Col>
</Modal>
)
}

View File

@ -1,12 +1,16 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useState } from 'react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Title } from '../title'
import GroupSelectorDialog from './group-selector-dialog'
export default function Welcome() {
const user = useUser()
@ -26,23 +30,34 @@ export default function Welcome() {
}
}
async function setUserHasSeenWelcome() {
if (user) {
await updateUser(user.id, { ['shouldShowWelcome']: false })
const setUserHasSeenWelcome = async () => {
if (user) await updateUser(user.id, { ['shouldShowWelcome']: false })
}
const [groupSelectorOpen, setGroupSelectorOpen] = useState(false)
const toggleOpen = (isOpen: boolean) => {
setUserHasSeenWelcome()
setOpen(isOpen)
if (!isOpen) {
setGroupSelectorOpen(true)
}
}
if (!user || !user.shouldShowWelcome) {
const isTwitch = useIsTwitch(user)
if (isTwitch || !user || (!user.shouldShowWelcome && !groupSelectorOpen))
return <></>
} else
return (
<Modal
open={open}
setOpen={(newOpen) => {
setUserHasSeenWelcome()
setOpen(newOpen)
}}
>
<>
<GroupSelectorDialog
open={groupSelectorOpen}
setOpen={() => setGroupSelectorOpen(false)}
/>
<Modal open={open} setOpen={toggleOpen}>
<Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
{page === 0 && <Page0 />}
{page === 1 && <Page1 />}
@ -68,19 +83,32 @@ export default function Welcome() {
</Row>
<u
className="self-center text-xs text-gray-500"
onClick={() => {
setOpen(false)
setUserHasSeenWelcome()
}}
onClick={() => toggleOpen(false)}
>
I got the gist, exit welcome
</u>
</Col>
</Col>
</Modal>
</>
)
}
const useIsTwitch = (user: User | null | undefined) => {
const router = useRouter()
const isTwitch = router.pathname === '/twitch'
useEffect(() => {
console.log('twich?', isTwitch)
if (isTwitch && user?.shouldShowWelcome) {
updateUser(user.id, { ['shouldShowWelcome']: false })
}
}, [isTwitch, user])
return isTwitch
}
function PageIndicator(props: { page: number; totalpages: number }) {
const { page, totalpages } = props
return (

View File

@ -1,6 +1,6 @@
import clsx from 'clsx'
import { ReactNode } from 'react'
import { BottomNavBar } from './nav/nav-bar'
import { BottomNavBar } from './nav/bottom-nav-bar'
import Sidebar from './nav/sidebar'
import { Toaster } from 'react-hot-toast'

View File

@ -1,133 +0,0 @@
import clsx from 'clsx'
import { MouseEventHandler, ReactNode, useState } from 'react'
import toast from 'react-hot-toast'
import { LinkIcon } from '@heroicons/react/solid'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
import { Button, ColorType } from './../button'
import { Row } from './../layout/row'
import { LoadingIndicator } from './../loading-indicator'
function BouncyButton(props: {
children: ReactNode
onClick?: MouseEventHandler<any>
color?: ColorType
}) {
const { children, onClick, color } = props
return (
<Button
color={color}
size="lg"
onClick={onClick}
className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case"
>
{children}
</Button>
)
}
export function TwitchPanel() {
const user = useUser()
const privateUser = usePrivateUser()
const twitchInfo = privateUser?.twitchInfo
const twitchName = privateUser?.twitchInfo?.twitchName
const twitchToken = privateUser?.twitchInfo?.controlToken
const twitchBotConnected = privateUser?.twitchInfo?.botEnabled
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
const copyOverlayLink = async () => {
copyToClipboard(`http://localhost:1000/overlay?t=${twitchToken}`)
toast.success('Overlay link copied!', {
icon: linkIcon,
})
}
const copyDockLink = async () => {
copyToClipboard(`http://localhost:1000/dock?t=${twitchToken}`)
toast.success('Dock link copied!', {
icon: linkIcon,
})
}
const updateBotConnected = (connected: boolean) => async () => {
if (user && twitchInfo) {
twitchInfo.botEnabled = connected
await updatePrivateUser(user.id, { twitchInfo })
}
}
const [twitchLoading, setTwitchLoading] = useState(false)
const createLink = async () => {
if (!user || !privateUser) return
setTwitchLoading(true)
const promise = linkTwitchAccountRedirect(user, privateUser)
track('link twitch from profile')
await promise
setTwitchLoading(false)
}
return (
<>
<div>
<label className="label">Twitch</label>
{!twitchName ? (
<Row>
<Button
color="indigo"
onClick={createLink}
disabled={twitchLoading}
>
Link your Twitch account
</Button>
{twitchLoading && <LoadingIndicator className="ml-4" />}
</Row>
) : (
<Row>
<span className="mr-4 text-gray-500">Linked Twitch account</span>{' '}
{twitchName}
</Row>
)}
</div>
{twitchToken && (
<div>
<div className="flex w-full">
<div
className={clsx(
'flex grow gap-4',
twitchToken ? '' : 'tooltip tooltip-top'
)}
data-tip="You must link your Twitch account first"
>
<BouncyButton color="blue" onClick={copyOverlayLink}>
Copy overlay link
</BouncyButton>
<BouncyButton color="indigo" onClick={copyDockLink}>
Copy dock link
</BouncyButton>
{twitchBotConnected ? (
<BouncyButton color="red" onClick={updateBotConnected(false)}>
Remove bot from your channel
</BouncyButton>
) : (
<BouncyButton color="green" onClick={updateBotConnected(true)}>
Add bot to your channel
</BouncyButton>
)}
</div>
</div>
</div>
)}
</>
)
}

View File

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

View File

@ -2,17 +2,19 @@ import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { useEffect, useState } from 'react'
import {
Contract,
listenForActiveContracts,
listenForContracts,
listenForHotContracts,
listenForInactiveContracts,
listenForNewContracts,
getUserBetContracts,
getUserBetContractsQuery,
listAllContracts,
trendingContractsQuery,
getContractsQuery,
} from 'web/lib/firebase/contracts'
import { QueryClient, useQueryClient } from 'react-query'
import { MINUTE_MS } from 'common/util/time'
import { query, limit } from 'firebase/firestore'
import { Sort } from 'web/components/contract-search'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -30,23 +32,25 @@ export const getCachedContracts = async () =>
staleTime: Infinity,
})
export const useActiveContracts = () => {
const [activeContracts, setActiveContracts] = useState<
Contract[] | undefined
>()
const [newContracts, setNewContracts] = useState<Contract[] | undefined>()
export const useTrendingContracts = (maxContracts: number) => {
const result = useFirestoreQueryData(
['trending-contracts', maxContracts],
query(trendingContractsQuery, limit(maxContracts))
)
return result.data
}
useEffect(() => {
return listenForActiveContracts(setActiveContracts)
}, [])
useEffect(() => {
return listenForNewContracts(setNewContracts)
}, [])
if (!activeContracts || !newContracts) return undefined
return [...activeContracts, ...newContracts]
export const useContractsQuery = (
sort: Sort,
maxContracts: number,
filters: { groupSlug?: string } = {},
visibility?: 'public'
) => {
const result = useFirestoreQueryData(
['contracts-query', sort, maxContracts, filters],
getContractsQuery(sort, maxContracts, filters, visibility)
)
return result.data
}
export const useInactiveContracts = () => {

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getGroup,
getMemberGroups,
GroupMemberDoc,
groupMembers,
@ -11,13 +12,17 @@ import {
listenForMemberGroupIds,
listenForOpenGroups,
listGroups,
topFollowedGroupsQuery,
} from 'web/lib/firebase/groups'
import { getUser } from 'web/lib/firebase/users'
import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract'
import { uniq } from 'lodash'
import { keyBy, uniq, uniqBy } from 'lodash'
import { listenForValues } from 'web/lib/firebase/utils'
import { useQuery } from 'react-query'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { limit, query } from 'firebase/firestore'
import { useTrendingContracts } from './use-contracts'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
@ -49,6 +54,30 @@ export const useOpenGroups = () => {
return groups
}
export const useTopFollowedGroups = (count: number) => {
const result = useFirestoreQueryData(
['top-followed-contracts', count],
query(topFollowedGroupsQuery, limit(count))
)
return result.data
}
export const useTrendingGroups = () => {
const topGroups = useTopFollowedGroups(200)
const groupsById = keyBy(topGroups, 'id')
const trendingContracts = useTrendingContracts(200)
const groupLinks = uniqBy(
(trendingContracts ?? []).map((c) => c.groupLinks ?? []).flat(),
(link) => link.groupId
)
return filterDefined(
groupLinks.map((link) => groupsById[link.groupId])
).filter((group) => group.totalMembers >= 3)
}
export const useMemberGroups = (userId: string | null | undefined) => {
const result = useQuery(['member-groups', userId ?? ''], () =>
getMemberGroups(userId ?? '')
@ -56,10 +85,11 @@ export const useMemberGroups = (userId: string | null | undefined) => {
return result.data
}
// Note: We cache member group ids in localstorage to speed up the initial load
export const useMemberGroupIds = (user: User | null | undefined) => {
const cachedGroups = useMemberGroups(user?.id)
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
undefined
cachedGroups?.map((g) => g.id)
)
useEffect(() => {
@ -73,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(() => {

View File

@ -0,0 +1,7 @@
import { useWindowSize } from 'web/hooks/use-window-size'
// matches talwind sm breakpoint
export function useIsMobile() {
const { width } = useWindowSize()
return (width ?? 0) < 640
}

View File

@ -98,7 +98,7 @@ export function groupNotifications(notifications: Notification[]) {
const notificationGroup: NotificationGroup = {
notifications: notificationsForContractId,
groupedById: contractId,
isSeen: notificationsForContractId[0].isSeen,
isSeen: notificationsForContractId.some((n) => !n.isSeen),
timePeriod: day,
type: 'normal',
}

View File

@ -1,8 +1,45 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { CPMMContract } from 'common/contract'
import { MINUTE_MS } from 'common/util/time'
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(
@ -20,3 +57,19 @@ export const useProbChanges = (userId: string) => {
return { positiveChanges, negativeChanges }
}
export const usePrefetchProbChanges = (userId: string | undefined) => {
const queryClient = useQueryClient()
if (userId) {
queryClient.prefetchQuery(
['prob-changes-day-positive', userId],
() => getValues(getProbChangesPositive(userId)),
{ staleTime: MINUTE_MS }
)
queryClient.prefetchQuery(
['prob-changes-day-negative', userId],
() => getValues(getProbChangesNegative(userId)),
{ staleTime: MINUTE_MS }
)
}
}

View File

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

View File

@ -35,17 +35,13 @@ export async function createCommentOnContract(
contractId: string,
content: JSONContent,
user: User,
betId?: string,
answerOutcome?: string,
replyToCommentId?: string
) {
const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
const ref = doc(getCommentsCollection(contractId))
const onContract = {
commentType: 'contract',
contractId,
betId,
answerOutcome,
} as OnContract
return await createComment(

View File

@ -17,13 +17,14 @@ import { partition, sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
import { createRNG, shuffle } from 'common/util/random'
import { chooseRandomSubset } from 'common/util/random'
import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
import { Bet } from 'common/bet'
import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants'
import { getBinaryProb } from 'common/contract-details'
import { Sort } from 'web/components/contract-search'
export const contracts = coll<Contract>('contracts')
@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) {
) as Query<Contract>
}
const activeContractsQuery = query(
contracts,
where('isResolved', '==', false),
where('visibility', '==', 'public'),
where('volume7Days', '>', 0)
)
export function getActiveContracts() {
return getValues<Contract>(activeContractsQuery)
}
export function listenForActiveContracts(
setContracts: (contracts: Contract[]) => void
) {
return listenForValues<Contract>(activeContractsQuery, setContracts)
}
const inactiveContractsQuery = query(
contracts,
where('isResolved', '==', false),
@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) {
await deleteDoc(followDoc)
}
function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString()
shuffle(contracts, createRNG(seed))
return contracts.slice(0, count)
}
const hotContractsQuery = query(
contracts,
where('isResolved', '==', false),
@ -282,16 +259,17 @@ export function listenForHotContracts(
})
}
const trendingContractsQuery = query(
export const trendingContractsQuery = query(
contracts,
where('isResolved', '==', false),
where('visibility', '==', 'public'),
orderBy('popularityScore', 'desc'),
limit(10)
orderBy('popularityScore', 'desc')
)
export async function getTrendingContracts() {
return await getValues<Contract>(trendingContractsQuery)
export async function getTrendingContracts(maxContracts = 10) {
return await getValues<Contract>(
query(trendingContractsQuery, limit(maxContracts))
)
}
export async function getContractsBySlugs(slugs: string[]) {
@ -343,6 +321,51 @@ export const getTopGroupContracts = async (
return await getValues<Contract>(creatorContractsQuery)
}
const sortToField = {
newest: 'createdTime',
score: 'popularityScore',
'most-traded': 'volume',
'24-hour-vol': 'volume24Hours',
'prob-change-day': 'probChanges.day',
'last-updated': 'lastUpdated',
liquidity: 'totalLiquidity',
'close-date': 'closeTime',
'resolve-date': 'resolutionTime',
'prob-descending': 'prob',
'prob-ascending': 'prob',
} as const
const sortToDirection = {
newest: 'desc',
score: 'desc',
'most-traded': 'desc',
'24-hour-vol': 'desc',
'prob-change-day': 'desc',
'last-updated': 'desc',
liquidity: 'desc',
'close-date': 'asc',
'resolve-date': 'desc',
'prob-ascending': 'asc',
'prob-descending': 'desc',
} as const
export const getContractsQuery = (
sort: Sort,
maxItems: number,
filters: { groupSlug?: string } = {},
visibility?: 'public'
) => {
const { groupSlug } = filters
return query(
contracts,
where('isResolved', '==', false),
...(visibility ? [where('visibility', '==', visibility)] : []),
...(groupSlug ? [where('groupSlugs', 'array-contains', groupSlug)] : []),
orderBy(sortToField[sort], sortToDirection[sort]),
limit(maxItems)
)
}
export const getRecommendedContracts = async (
contract: Contract,
excludeBettorId: string,

View File

@ -6,6 +6,7 @@ import {
doc,
getDocs,
onSnapshot,
orderBy,
query,
setDoc,
updateDoc,
@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) {
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
return members.map((m) => m.userId)
}
export const topFollowedGroupsQuery = query(
groups,
where('anyoneCanJoin', '==', true),
orderBy('totalMembers', 'desc')
)

View File

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

View File

@ -0,0 +1,19 @@
export default function CornerDownRightIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className={props.className}
>
<polyline points="15 10 20 15 15 20"></polyline>
<path d="M4 4v7a4 4 0 0 0 4 4h12"></path>
</svg>
)
}

View File

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

View File

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

View File

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

View File

@ -1,31 +1,37 @@
import { PrivateUser, User } from 'common/user'
import { generateNewApiKey } from '../api/api-key'
import { ENV_CONFIG } from 'common/envs/constants'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately
async function postToBot(url: string, body: unknown) {
const result = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await result.json()
if (!result.ok) {
throw new Error(json.message)
} else {
return json
}
}
export async function initLinkTwitchAccount(
manifoldUserID: string,
manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
const response = await postToBot(
`${ENV_CONFIG.twitchBotEndpoint}/api/linkInit`,
{
manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey,
redirectURL: window.location.href,
}),
})
const responseData = await response.json()
if (!response.ok) {
throw new Error(responseData.message)
}
const responseFetch = fetch(
`${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}`
)
return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())]
const responseFetch = fetch(
`${ENV_CONFIG.twitchBotEndpoint}/api/linkResult?userID=${manifoldUserID}`
)
return [response.twitchAuthURL, responseFetch.then((r) => r.json())]
}
export async function linkTwitchAccountRedirect(
@ -38,4 +44,37 @@ export async function linkTwitchAccountRedirect(
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
window.location.href = twitchAuthURL
await new Promise((r) => setTimeout(r, 1e10)) // Wait "forever" for the page to change location
}
export async function updateBotEnabledForUser(
privateUser: PrivateUser,
botEnabled: boolean
) {
if (botEnabled) {
return postToBot(`${ENV_CONFIG.twitchBotEndpoint}/registerchanneltwitch`, {
apiKey: privateUser.apiKey,
}).then((r) => {
if (!r.success) throw new Error(r.message)
})
} else {
return postToBot(
`${ENV_CONFIG.twitchBotEndpoint}/unregisterchanneltwitch`,
{
apiKey: privateUser.apiKey,
}
).then((r) => {
if (!r.success) throw new Error(r.message)
})
}
}
export function getOverlayURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${ENV_CONFIG.twitchBotEndpoint}/overlay?t=${controlToken}`
}
export function getDockURLForUser(privateUser: PrivateUser) {
const controlToken = privateUser?.twitchInfo?.controlToken
return `${ENV_CONFIG.twitchBotEndpoint}/dock?t=${controlToken}`
}

View File

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

View File

@ -31,7 +31,6 @@ import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
@ -45,6 +44,10 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin'
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { PlayMoneyDisclaimer } from 'web/components/play-money-disclaimer'
import BetButton from 'web/components/bet-button'
import dayjs from 'dayjs'
export const getStaticProps = fromPropz(getStaticPropz)
@ -192,8 +195,6 @@ export function ContractPageContent(
[bets]
)
const tips = useTipTxns({ contractId: contract.id })
const [showConfetti, setShowConfetti] = useState(false)
useEffect(() => {
@ -205,18 +206,6 @@ export function ContractPageContent(
setShowConfetti(shouldSeeConfetti)
}, [contract, user])
const [recommendedContracts, setRecommendedContracts] = useState<Contract[]>(
[]
)
useEffect(() => {
if (contract && user) {
getRecommendedContracts(contract, user.id, 6).then(
setRecommendedContracts
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contract.id, user?.id])
const { isResolved, question, outcomeType } = contract
const allowTrade = tradingAllowed(contract)
@ -286,7 +275,6 @@ export function ContractPageContent(
contract={contract}
bets={bets}
comments={comments}
tips={tips}
/>
</div>
<Spacer h={12} />
@ -297,20 +285,48 @@ export function ContractPageContent(
contract={contract}
user={user}
bets={bets}
tips={tips}
comments={comments}
/>
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
{recommendedContracts.length > 0 && (
<Col className="mt-2 gap-2 px-2 sm:px-0">
<Title className="text-gray-700" text="Recommended" />
<ContractsGrid
contracts={recommendedContracts}
trackingPostfix=" recommended"
) : (
outcomeType === 'BINARY' &&
allowTrade && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
</Col>
)
)}
</Col>
<RecommendedContractsWidget contract={contract} />
</Page>
)
}
function RecommendedContractsWidget(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const [recommendations, setRecommendations] = useState<Contract[]>([])
useEffect(() => {
if (user) {
getRecommendedContracts(contract, user.id, 6).then(setRecommendations)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contract.id, user?.id])
if (recommendations.length === 0) {
return null
}
return (
<Col className="mt-2 gap-2 px-2 sm:px-0">
<Title className="text-gray-700" text="Recommended" />
<ContractsGrid
contracts={recommendations}
trackingPostfix=" recommended"
/>
</Col>
)
}

View File

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

View File

@ -0,0 +1,24 @@
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 { useProbChangesAlgolia } from 'web/hooks/use-prob-changes'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
export default function DailyMovers() {
const user = useUser()
const changes = useProbChangesAlgolia(user?.id ?? '')
useTracking('view daily movers')
return (
<Page>
<Col className="pm:mx-10 gap-4 sm:px-4 sm:pb-4">
<Title className="mx-4 !mb-0 sm:mx-0" text="Daily movers" />
<ProbChangeTable changes={changes} full />
</Col>
</Page>
)
}

View File

@ -1,215 +0,0 @@
import React from 'react'
import Router from 'next/router'
import {
AdjustmentsIcon,
PlusSmIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group'
import { SiteLink } from 'web/components/site-link'
import { useUser } from 'web/hooks/use-user'
import { useMemberGroups } from 'web/hooks/use-group'
import { Button } from 'web/components/button'
import { getHomeItems } from '../../../components/arrange-home'
import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
import { groupPath } 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 { ProfitBadge } from 'web/components/bets-list'
import { calculatePortfolioProfit } from 'common/calculate-metrics'
export default function Home() {
const user = useUser()
useTracking('view home')
useSaveReferral()
const groups = useMemberGroups(user?.id) ?? []
const { sections } = getHomeItems(groups, user?.homeSections ?? [])
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12">
<Row className={'mt-4 w-full items-start justify-between'}>
<Row className="items-end gap-4">
<Title className="!mb-1 !mt-0" text="Home" />
<EditButton />
</Row>
<DailyProfitAndBalance className="" user={user} />
</Row>
{sections.map((item) => {
const { id } = item
if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} />
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.value === 'newest' ? 'New for you' : sort.label}
sort={sort.value}
followed={sort.value === 'newest'}
user={user}
/>
)
const group = groups.find((g) => g.id === id)
if (group) return <GroupSection key={id} group={group} user={user} />
return null
})}
</Col>
<button
type="button"
className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
onClick={() => {
Router.push('/create')
track('mobile create button')
}}
>
<PlusSmIcon className="h-8 w-8" aria-hidden="true" />
</button>
</Page>
)
}
function SearchSection(props: {
label: string
user: User | null | undefined | undefined
sort: Sort
yourBets?: boolean
followed?: boolean
}) {
const { label, user, sort, yourBets, followed } = props
return (
<Col>
<SectionHeader label={label} href={`/home?s=${sort}`} />
<ContractSearch
user={user}
defaultSort={sort}
additionalFilter={
yourBets
? { yourBets: true }
: followed
? { followed: true }
: undefined
}
noControls
maxResults={6}
persistPrefix={`experimental-home-${sort}`}
/>
</Col>
)
}
function GroupSection(props: {
group: Group
user: User | null | undefined | undefined
}) {
const { group, user } = props
return (
<Col>
<SectionHeader label={group.name} href={groupPath(group.slug)} />
<ContractSearch
user={user}
defaultSort={'score'}
additionalFilter={{ groupSlug: group.slug }}
noControls
maxResults={6}
persistPrefix={`experimental-home-${group.slug}`}
/>
</Col>
)
}
function DailyMoversSection(props: { userId: string | null | undefined }) {
const { userId } = props
const changes = useProbChanges(userId ?? '')
return (
<Col className="gap-2">
<SectionHeader label="Daily movers" href="daily-movers" />
<ProbChangeTable changes={changes} />
</Col>
)
}
function SectionHeader(props: { label: string; href: string }) {
const { label, href } = props
return (
<Row className="mb-3 items-center justify-between">
<SiteLink className="text-xl" href={href}>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
</Row>
)
}
function EditButton(props: { className?: string }) {
const { className } = props
return (
<SiteLink href="/experimental/home/edit">
<Button size="sm" color="gray-white" className={clsx(className, 'flex')}>
<AdjustmentsIcon className={clsx('h-[24px] w-5')} aria-hidden="true" />
</Button>
</SiteLink>
)
}
function DailyProfitAndBalance(props: {
user: User | null | undefined
className?: string
}) {
const { user, className } = props
const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
if (first === undefined || last === undefined) return null
const profit =
calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
const profitPercent = profit / first.investmentValue
return (
<Row className={'gap-4'}>
<Col>
<div className="text-gray-500">Daily profit</div>
<Row className={clsx(className, 'items-center text-lg')}>
<span>{formatMoney(profit)}</span>{' '}
<ProfitBadge profitPercent={profitPercent * 100} />
</Row>
</Col>
<Col>
<div className="text-gray-500">Streak</div>
<Row className={clsx(className, 'items-center text-lg')}>
<span>🔥 {user?.currentBettingStreak ?? 0}</span>
</Row>
</Col>
</Row>
)
}

View File

@ -0,0 +1,41 @@
import Masonry from 'react-masonry-css'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { useMemberGroupIds, useTrendingGroups } from 'web/hooks/use-group'
import { useUser } from 'web/hooks/use-user'
import { GroupCard } from './groups'
export default function Explore() {
const user = useUser()
const groups = useTrendingGroups()
const memberGroupIds = useMemberGroupIds(user) || []
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-12 xl:w-[115%]">
<Row className={'w-full items-center justify-between'}>
<Title className="!mb-0" text="Trending groups" />
</Row>
<Masonry
breakpointCols={{ default: 3, 1200: 2, 570: 1 }}
className="-ml-4 flex w-auto self-center"
columnClassName="pl-4 bg-clip-padding"
>
{groups.map((g) => (
<GroupCard
key={g.id}
className="mb-4 !min-w-[250px]"
group={g}
creator={null}
user={user}
isMember={memberGroupIds.includes(g.id)}
/>
))}
</Masonry>
</Col>
</Page>
)
}

View File

@ -1,10 +1,9 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { toast } from 'react-hot-toast'
import { toast, Toaster } from 'react-hot-toast'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import {
addContractToGroup,
@ -30,7 +29,7 @@ import Custom404 from '../../404'
import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
@ -49,6 +48,9 @@ import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
import { GroupNavBar } from 'web/components/nav/group-nav-bar'
import { ArrowLeftIcon } from '@heroicons/react/solid'
import { GroupSidebar } from 'web/components/nav/group-sidebar'
import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user'
@ -138,6 +140,10 @@ export default function GroupPage(props: {
const user = useUser()
const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
// Note: Keep in sync with sidebarPages
const [sidebarIndex, setSidebarIndex] = useState(
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
)
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -151,7 +157,7 @@ export default function GroupPage(props: {
const isMember = user && memberIds.includes(user.id)
const maxLeaderboardSize = 50
const leaderboard = (
const leaderboardPage = (
<Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard
@ -170,7 +176,7 @@ export default function GroupPage(props: {
</Col>
)
const aboutTab = (
const aboutPage = (
<Col>
{(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost
@ -190,73 +196,129 @@ export default function GroupPage(props: {
</Col>
)
const questionsTab = (
<ContractSearch
user={user}
defaultSort={'newest'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
)
const tabs = [
{
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
},
{
title: 'Leaderboards',
content: leaderboard,
href: groupPath(group.slug, 'leaderboards'),
},
{
title: 'About',
content: aboutTab,
href: groupPath(group.slug, 'about'),
},
]
const tabIndex = tabs
.map((t) => t.title.toLowerCase())
.indexOf(page ?? 'markets')
return (
<Page>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<Col className="relative px-3">
<Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}>
<div
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'}
>
{group.name}
</div>
<div className={'hidden sm:block'}>
<Linkify text={group.about} />
</div>
</div>
<div className="mt-2">
const questionsPage = (
<>
{/* align the divs to the right */}
<div className={' flex justify-end px-2 pb-2 sm:hidden'}>
<div>
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
</div>
</Row>
</Col>
<Tabs
currentPageForAnalytics={groupPath(group.slug)}
className={'mx-2 mb-0 sm:mb-2'}
defaultIndex={tabIndex > 0 ? tabIndex : 0}
tabs={tabs}
</div>
<ContractSearch
headerClassName="md:sticky"
user={user}
defaultSort={'score'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
</Page>
</>
)
const sidebarPages = [
{
title: 'Markets',
content: questionsPage,
href: groupPath(group.slug, 'markets'),
key: 'markets',
},
{
title: 'Leaderboards',
content: leaderboardPage,
href: groupPath(group.slug, 'leaderboards'),
key: 'leaderboards',
},
{
title: 'About',
content: aboutPage,
href: groupPath(group.slug, 'about'),
key: 'about',
},
]
const pageContent = sidebarPages[sidebarIndex].content
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 = (
<JoinOrAddQuestionsButtons
group={group}
user={user}
isMember={!!isMember}
/>
)
return (
<>
<TopGroupNavBar
group={group}
currentPage={sidebarPages[sidebarIndex].key}
onClick={onSidebarClick}
/>
<div>
<div
className={
'mx-auto w-full pb-[58px] lg:grid lg:grid-cols-12 lg:gap-x-2 lg:pb-0 xl:max-w-7xl xl:gap-x-8'
}
>
<Toaster />
<GroupSidebar
groupName={group.name}
className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
onClick={onSidebarClick}
joinOrAddQuestionsButton={joinOrAddQuestionsButton}
currentKey={sidebarPages[sidebarIndex].key}
/>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
{pageContent}
</main>
</div>
</div>
</>
)
}
export function TopGroupNavBar(props: {
group: Group
currentPage: string
onClick: (key: string) => void
}) {
return (
<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 ">
<ArrowLeftIcon className="h-5 w-5" aria-hidden="true" />
</a>
</Link>
</div>
<div className="ml-3">
<h1 className="text-lg font-medium text-indigo-700">
{props.group.name}
</h1>
</div>
</div>
<GroupNavBar currentPage={props.currentPage} onClick={props.onClick} />
</header>
)
}
@ -264,10 +326,11 @@ function JoinOrAddQuestionsButtons(props: {
group: Group
user: User | null | undefined
isMember: boolean
className?: string
}) {
const { group, user, isMember } = props
return user && isMember ? (
<Row className={'mt-0 justify-end'}>
<Row className={'w-full self-start pt-4'}>
<AddContractButton group={group} user={user} />
</Row>
) : group.anyoneCanJoin ? (
@ -411,9 +474,9 @@ function AddContractButton(props: { group: Group; user: User }) {
return (
<>
<div className={'flex justify-center'}>
<div className={'flex w-full justify-center'}>
<Button
className="whitespace-nowrap"
className="w-full whitespace-nowrap"
size="md"
color="indigo"
onClick={() => setOpen(true)}
@ -468,7 +531,9 @@ function JoinGroupButton(props: {
<div>
<button
onClick={follow}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
className={
'btn-md btn-outline btn w-full whitespace-nowrap normal-case'
}
>
Follow
</button>

View File

@ -171,17 +171,24 @@ export default function Groups(props: {
export function GroupCard(props: {
group: Group
creator: User | undefined
creator: User | null | undefined
user: User | undefined | null
isMember: boolean
className?: string
}) {
const { group, creator, user, isMember } = props
const { group, creator, user, isMember, className } = props
const { totalContracts } = group
return (
<Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100">
<Col
className={clsx(
'relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-6 shadow-md hover:bg-gray-100',
className
)}
>
<Link href={groupPath(group.slug)}>
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
</Link>
{creator !== null && (
<div>
<Avatar
className={'absolute top-2 right-2 z-10'}
@ -191,6 +198,7 @@ export function GroupCard(props: {
size={12}
/>
</div>
)}
<Row className="items-center justify-between gap-2">
<span className="text-xl">{group.name}</span>
</Row>

View File

@ -1,46 +0,0 @@
import { useRouter } from 'next/router'
import { PencilAltIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch } from 'web/components/contract-search'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { track } from 'web/lib/service/analytics'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { usePrefetch } from 'web/hooks/use-prefetch'
const Home = () => {
const user = useUser()
const router = useRouter()
useTracking('view home')
useSaveReferral()
usePrefetch(user?.id)
return (
<>
<Page>
<Col className="mx-auto w-full p-2">
<ContractSearch
user={user}
persistPrefix="home-search"
useQueryUrlParam={true}
/>
</Col>
<button
type="button"
className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
onClick={() => {
router.push('/create')
track('mobile create button')
}}
>
<PencilAltIcon className="h-7 w-7" aria-hidden="true" />
</button>
</Page>
</>
)
}
export default Home

View File

@ -7,9 +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 { 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, TrendingGroupsSection } from '.'
export default function Home() {
const user = useUser()
@ -24,6 +27,9 @@ export default function Home() {
setHomeSections(newHomeSections)
}
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, homeSections)
return (
<Page>
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
@ -32,12 +38,16 @@ export default function Home() {
<DoneButton />
</Row>
<Col className="gap-8 md:flex-row">
<Col className="flex-1">
<ArrangeHome
user={user}
homeSections={homeSections}
setHomeSections={updateHomeSections}
sections={sections}
setSectionIds={updateHomeSections}
/>
</Col>
<TrendingGroupsSection className="flex-1" user={user} full />
</Col>
</Col>
</Page>
)
}
@ -46,11 +56,12 @@ function DoneButton(props: { className?: string }) {
const { className } = props
return (
<SiteLink href="/experimental/home">
<SiteLink href="/home">
<Button
size="lg"
color="blue"
className={clsx(className, 'flex whitespace-nowrap')}
onClick={() => track('done editing home')}
>
Done
</Button>

397
web/pages/home/index.tsx Normal file
View File

@ -0,0 +1,397 @@
import React, { ReactNode, useEffect } from 'react'
import Router from 'next/router'
import {
AdjustmentsIcon,
PencilAltIcon,
ArrowSmRightIcon,
} from '@heroicons/react/solid'
import { PlusCircleIcon, XCircleIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { toast, Toaster } from 'react-hot-toast'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch, SORTS } from 'web/components/contract-search'
import { User } from 'common/user'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Sort } from 'web/components/contract-search'
import { Group } from 'common/group'
import { SiteLink } from 'web/components/site-link'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
useMemberGroupIds,
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 { groupPath, joinGroup, leaveGroup } from 'web/lib/firebase/groups'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { formatMoney } from 'common/util/format'
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'
import { useContractsQuery } from 'web/hooks/use-contracts'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { PillButton } from 'web/components/buttons/pill-button'
import { filterDefined } from 'common/util/array'
import { updateUser } from 'web/lib/firebase/users'
import { isArray, keyBy } from 'lodash'
import { usePrefetch } from 'web/hooks/use-prefetch'
import { Title } from 'web/components/title'
export default function Home() {
const user = useUser()
useTracking('view home')
useSaveReferral()
usePrefetch(user?.id)
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>
{sections.map((section) => renderSection(section, user, groups))}
<TrendingGroupsSection user={user} />
</Col>
<button
type="button"
className="fixed bottom-[70px] right-3 z-20 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-4 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
onClick={() => {
Router.push('/create')
track('mobile create button')
}}
>
<PencilAltIcon className="h-7 w-7" aria-hidden="true" />
</button>
</Page>
)
}
const HOME_SECTIONS = [
{ label: 'Daily movers', id: 'daily-movers' },
{ label: 'Trending', id: 'score' },
{ label: 'New', id: 'newest' },
{ label: 'Recently updated', id: 'recently-updated-for-you' },
]
export const getHomeItems = (groups: Group[], sections: string[]) => {
// Accommodate old home sections.
if (!isArray(sections)) sections = []
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')
const sectionItems = filterDefined(sections.map((id) => itemsById[id]))
// Add unmentioned items to the end.
sectionItems.push(...items.filter((item) => !sectionItems.includes(item)))
return {
sections: sectionItems,
itemsById,
}
}
function renderSection(
section: { id: string; label: string },
user: User | null | undefined,
groups: Group[]
) {
const { id, label } = section
if (id === 'daily-movers') {
return <DailyMoversSection key={id} userId={user?.id} />
}
if (id === 'recently-updated-for-you')
return (
<SearchSection
key={id}
label={label}
sort={'last-updated'}
pill="personal"
user={user}
/>
)
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection key={id} label={label} sort={sort.value} user={user} />
)
const group = groups.find((g) => g.id === id)
if (group) return <GroupSection key={id} group={group} user={user} />
return null
}
function SectionHeader(props: {
label: string
href: string
children?: ReactNode
}) {
const { label, href, children } = props
return (
<Row className="mb-3 items-center justify-between">
<SiteLink
className="text-xl"
href={href}
onClick={() => track('home click section header', { section: href })}
>
{label}{' '}
<ArrowSmRightIcon
className="mb-0.5 inline h-6 w-6 text-gray-500"
aria-hidden="true"
/>
</SiteLink>
{children}
</Row>
)
}
function SearchSection(props: {
label: string
user: User | null | undefined | undefined
sort: Sort
pill?: string
}) {
const { label, user, sort, pill } = props
return (
<Col>
<SectionHeader
label={label}
href={`/search?s=${sort}${pill ? `&p=${pill}` : ''}`}
/>
<ContractSearch
user={user}
defaultSort={sort}
defaultPill={pill}
noControls
maxResults={6}
headerClassName="sticky"
persistPrefix={`home-${sort}`}
/>
</Col>
)
}
function GroupSection(props: {
group: Group
user: User | null | undefined | undefined
}) {
const { group, user } = props
const contracts = useContractsQuery('score', 4, { groupSlug: group.slug })
return (
<Col>
<SectionHeader label={group.name} href={groupPath(group.slug)}>
<Button
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={'h-5 w-5 flex-shrink-0'} aria-hidden="true" />
</Button>
</SectionHeader>
<ContractsGrid contracts={contracts} />
</Col>
)
}
function DailyMoversSection(props: { userId: string | null | undefined }) {
const { userId } = props
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">
<SectionHeader label="Daily movers" href="/daily-movers" />
<ProbChangeTable changes={changes} />
</Col>
)
}
function DailyStats(props: {
user: User | null | undefined
className?: string
}) {
const { user, className } = props
const metrics = usePortfolioHistory(user?.id ?? '', 'daily') ?? []
const [first, last] = [metrics[0], metrics[metrics.length - 1]]
const privateUser = usePrivateUser()
const streaks = privateUser?.notificationPreferences?.betting_streaks ?? []
const streaksHidden = streaks.length === 0
let profit = 0
let profitPercent = 0
if (first && last) {
profit = calculatePortfolioProfit(last) - calculatePortfolioProfit(first)
profitPercent = profit / first.investmentValue
}
return (
<Row className={'gap-4'}>
<Col>
<div className="text-gray-500">Daily profit</div>
<Row className={clsx(className, 'items-center text-lg')}>
<span>{formatMoney(profit)}</span>{' '}
<ProfitBadge profitPercent={profitPercent * 100} />
</Row>
</Col>
{!streaksHidden && (
<Col>
<div className="text-gray-500">Streak</div>
<Row
className={clsx(
className,
'items-center text-lg',
user && !hasCompletedStreakToday(user) && 'grayscale'
)}
>
<span>🔥 {user?.currentBettingStreak ?? 0}</span>
</Row>
</Col>
)}
</Row>
)
}
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 = full ? 100 : 25
const chosenGroups = groups.slice(0, count)
return (
<Col className={className}>
<SectionHeader label="Trending groups" href="/explore-groups">
{!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={() => {
if (!user) return
if (memberGroupIds.includes(g.id)) leaveGroup(g, user?.id)
else {
const homeSections = (user.homeSections ?? [])
.filter((id) => id !== g.id)
.concat(g.id)
updateUser(user.id, { homeSections })
toast.promise(joinGroup(g, user.id), {
loading: 'Following group...',
success: `Followed ${g.name}`,
error: "Couldn't follow group, try again?",
})
track('home follow group', { group: g.slug })
}
}}
>
<PlusCircleIcon
className={'h-5 w-5 flex-shrink-0 text-gray-500'}
aria-hidden="true"
/>
{g.name}
</PillButton>
))}
</Row>
</Col>
)
}
function CustomizeButton(props: { justIcon?: boolean; className?: string }) {
const { justIcon, className } = props
return (
<SiteLink
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')}>
<AdjustmentsIcon
className={clsx('h-[24px] w-5 text-gray-500')}
aria-hidden="true"
/>
{!justIcon && 'Customize'}
</Button>
</SiteLink>
)
}

View File

@ -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'}>
@ -904,25 +904,30 @@ function BetFillNotification(props: {
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { creatorOutcome, probability } = (data as BetFillData) ?? {}
const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
(data as BetFillData) ?? {}
const subtitle = 'bet against you'
const amount = formatMoney(parseInt(sourceText ?? '0'))
const description =
creatorOutcome && probability ? (
<span>
of your{' '}
of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
<span
className={
className={clsx(
'mx-1',
creatorOutcome === 'YES'
? 'text-primary'
: creatorOutcome === 'NO'
? 'text-red-500'
: 'text-blue-500'
}
)}
>
{creatorOutcome}{' '}
{creatorOutcome}
</span>
limit order at {Math.round(probability * 100)}% was filled
limit order at {Math.round(probability * 100)}% was filled{' '}
{limitOrderRemaining
? `(${formatMoney(limitOrderRemaining)} remaining)`
: ''}
</span>
) : (
<span>of your limit order was filled</span>
@ -966,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 />
@ -991,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:
@ -1008,7 +1020,7 @@ function ContractResolvedNotification(props: {
</span>
</Row>
) : (
<span>{resolutionDescription()}</span>
<span>Resolved {resolutionDescription()}</span>
)
if (justSummary) {

View File

@ -1,24 +1,28 @@
import React, { useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline'
import { AddFundsButton } from 'web/components/add-funds-button'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { formatMoney } from 'common/util/format'
import { PrivateUser, User } from 'common/user'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { changeUserInfo } from 'web/lib/firebase/api'
import { uploadImage } from 'web/lib/firebase/storage'
import { formatMoney } from 'common/util/format'
import Link from 'next/link'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { AddFundsButton } from 'web/components/add-funds-button'
import { ConfirmationButton } from 'web/components/confirmation-button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { User, PrivateUser } from 'common/user'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users'
import { defaultBannerUrl } from 'web/components/user-page'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Title } from 'web/components/title'
import { defaultBannerUrl } from 'web/components/user-page'
import { generateNewApiKey } from 'web/lib/api/api-key'
import { TwitchPanel } from 'web/components/profile/twitch-panel'
import { changeUserInfo } from 'web/lib/firebase/api'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { uploadImage } from 'web/lib/firebase/storage'
import {
getUserAndPrivateUser,
updatePrivateUser,
updateUser,
} from 'web/lib/firebase/users'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -93,10 +97,15 @@ export default function ProfilePage(props: {
}
}
const updateApiKey = async (e: React.MouseEvent) => {
const updateApiKey = async (e?: React.MouseEvent) => {
const newApiKey = await generateNewApiKey(user.id)
setApiKey(newApiKey ?? '')
e.preventDefault()
e?.preventDefault()
if (!privateUser.twitchInfo) return
await updatePrivateUser(privateUser.id, {
twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true },
})
}
const fileHandler = async (event: any) => {
@ -229,16 +238,38 @@ export default function ProfilePage(props: {
value={apiKey}
readOnly
/>
<button
className="btn btn-primary btn-square p-2"
onClick={updateApiKey}
<ConfirmationButton
openModalBtn={{
className: 'btn btn-primary btn-square p-2',
label: '',
icon: <RefreshIcon />,
}}
submitBtn={{
label: 'Update key',
className: 'btn-primary',
}}
onSubmitWithSuccess={async () => {
updateApiKey()
return true
}}
>
<RefreshIcon />
</button>
<Col>
<Title text={'Are you sure?'} />
<div>
Updating your API key will break any existing applications
connected to your account, <b>including the Twitch bot</b>.
You will need to go to the{' '}
<Link href="/twitch">
<a className="underline focus:outline-none">
Twitch page
</a>
</Link>{' '}
to relink your account.
</div>
</Col>
</ConfirmationButton>
</div>
</div>
<TwitchPanel />
</Col>
</Col>
</Page>

31
web/pages/search.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
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()
usePrefetch(user?.id)
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">
<ContractSearch
user={user}
persistPrefix="search"
useQueryUrlParam={true}
autoFocus={autoFocus}
/>
</Col>
</Page>
)
}

View File

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

View File

@ -80,7 +80,7 @@ const tourneys: Tourney[] = [
title: 'Clearer Thinking Regrant Project',
blurb: 'Which projects will Clearer Thinking give a grant to?',
award: '$13,000',
endTime: toDate('Sep 22, 2022'),
endTime: toDate('Sep 30, 2022'),
groupId: 'fhksfIgqyWf7OxsV9nkM',
},
{

View File

@ -1,44 +1,68 @@
import { useState } from 'react'
import { LinkIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { PrivateUser, User } from 'common/user'
import { MouseEventHandler, ReactNode, useEffect, useState } from 'react'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { SEO } from 'web/components/SEO'
import { Spacer } from 'web/components/layout/spacer'
import { firebaseLogin, getUserAndPrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
import { useTracking } from 'web/hooks/use-tracking'
import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
import toast from 'react-hot-toast'
import { Button } from 'web/components/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Spacer } from 'web/components/layout/spacer'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { useTracking } from 'web/hooks/use-tracking'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { firebaseLogin, updatePrivateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import {
getDockURLForUser,
getOverlayURLForUser,
linkTwitchAccountRedirect,
updateBotEnabledForUser,
} from 'web/lib/twitch/link-twitch-account'
import { copyToClipboard } from 'web/lib/util/copy'
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
function ButtonGetStarted(props: {
user?: User | null
privateUser?: PrivateUser | null
buttonClass?: string
spinnerClass?: string
}) {
const { user, privateUser, buttonClass, spinnerClass } = props
const user = useUser()
const privateUser = usePrivateUser()
const twitchUser = privateUser?.twitchInfo?.twitchName
const [isLoading, setLoading] = useState(false)
const needsRelink =
privateUser?.twitchInfo?.twitchName &&
privateUser?.twitchInfo?.needsRelinking
const [waitingForUser, setWaitingForUser] = useState(false)
useEffect(() => {
if (waitingForUser && user && privateUser) {
setWaitingForUser(false)
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
setLoading(true)
linkTwitchAccountRedirect(user, privateUser).then(() => {
setLoading(false)
})
}
}, [user, privateUser, waitingForUser])
const callback =
user && privateUser
? () => linkTwitchAccountRedirect(user, privateUser)
: async () => {
const result = await firebaseLogin()
const userId = result.user.uid
const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return
await linkTwitchAccountRedirect(user, privateUser)
await firebaseLogin()
setWaitingForUser(true)
}
const [isLoading, setLoading] = useState(false)
const getStarted = async () => {
try {
setLoading(true)
@ -49,9 +73,341 @@ export default function TwitchLandingPage() {
} catch (e) {
console.error(e)
toast.error('Failed to sign up. Please try again later.')
} finally {
setLoading(false)
}
}
return isLoading ? (
<LoadingIndicator
spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)}
/>
) : (
<Button
size="xl"
color={needsRelink ? 'red' : 'gradient'}
className={clsx('my-4 self-center !px-16', buttonClass)}
onClick={getStarted}
>
{needsRelink ? 'API key updated: relink Twitch' : 'Start playing'}
</Button>
)
}
function TwitchPlaysManifoldMarkets(props: {
user?: User | null
privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const twitchInfo = privateUser?.twitchInfo
const twitchUser = twitchInfo?.twitchName
return (
<div>
<Row className="mb-4">
<img
src="/twitch-glitch.svg"
className="mb-[0.4rem] mr-4 inline h-10 w-10"
></img>
<Title
text={'Twitch plays Manifold Markets'}
className={'!-my-0 md:block'}
/>
</Row>
<Col className="mb-4 gap-4">
Start betting on Twitch now by linking your account and typing commands
in chat!
{twitchUser && !twitchInfo.needsRelinking ? (
<Button
size="xl"
color="green"
className="btn-disabled my-4 self-center !border-none"
>
Account connected: {twitchUser}
</Button>
) : (
<ButtonGetStarted user={user} privateUser={privateUser} />
)}
</Col>
<Col className="gap-4">
<Subtitle text="How it works" />
<div>
Similar to Twitch channel point predictions, Manifold Markets allows
you to create a play-money betting market on any question you like and
feature it in your stream.
</div>
<div>
The key difference is that Manifold's questions function more like a
stock market and viewers can buy and sell shares over the course of
the event and not just at the start. The market will eventually
resolve to yes or no at which point the winning shareholders will
receive their profit.
</div>
<div>
Instead of Twitch channel points we use our own play money, mana (M$).
All viewers start with M$1,000 and can earn more for free by betting
well.
</div>
</Col>
</div>
)
}
function Subtitle(props: { text: string }) {
const { text } = props
return <div className="text-2xl">{text}</div>
}
function Command(props: { command: string; desc: string }) {
const { command, desc } = props
return (
<div>
<p className="inline font-bold">{'!' + command}</p>
{' - '}
<p className="inline">{desc}</p>
</div>
)
}
function TwitchChatCommands() {
return (
<div>
<Title text="Twitch Chat Commands" className="md:block" />
<Col className="gap-4">
<Subtitle text="For Chat" />
<Command
command="bet yes #"
desc="Bets an amount of M$ on yes, for example !bet yes 20"
/>
<Command command="bet no #" desc="Bets an amount of M$ on no." />
<Command
command="sell"
desc="Sells all shares you own. Using this command causes you to
cash out early before the market resolves. This could be profitable
(if the probability has moved towards the direction you bet) or cause
a loss, although at least you keep some mana. For maximum profit (but
also risk) it is better to not sell and wait for a favourable
resolution."
/>
<Command command="balance" desc="Shows how much M$ you have." />
<Command command="allin yes" desc="Bets your entire balance on yes." />
<Command command="allin no" desc="Bets your entire balance on no." />
<div className="mb-4" />
<Subtitle text="For Mods/Streamer" />
<Command
command="create <question>"
desc="Creates and features the question. Be careful... this will override any question that is currently featured."
/>
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
<Command command="resolve no" desc="Resolves the market as 'No'." />
<Command
command="resolve n/a"
desc="Resolves the market as 'N/A' and refunds everyone their mana."
/>
</Col>
</div>
)
}
function BotSetupStep(props: {
stepNum: number
buttonName?: string
buttonOnClick?: MouseEventHandler
overrideButton?: ReactNode
children: ReactNode
}) {
const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props
return (
<Col className="flex-1">
{(overrideButton || buttonName) && (
<>
{overrideButton ?? (
<Button
size={'md'}
color={'green'}
className="!border-none"
onClick={buttonOnClick}
>
{buttonName}
</Button>
)}
<Spacer h={4} />
</>
)}
<div>
<p className="inline font-bold">Step {stepNum}. </p>
{children}
</div>
</Col>
)
}
function BotConnectButton(props: {
privateUser: PrivateUser | null | undefined
}) {
const { privateUser } = props
const [loading, setLoading] = useState(false)
const updateBotConnected = (connected: boolean) => async () => {
if (!privateUser) return
const twitchInfo = privateUser.twitchInfo
if (!twitchInfo) return
const error = connected
? 'Failed to add bot to your channel'
: 'Failed to remove bot from your channel'
const success = connected
? 'Added bot to your channel'
: 'Removed bot from your channel'
setLoading(true)
toast.promise(
updateBotEnabledForUser(privateUser, connected)
.then(() =>
updatePrivateUser(privateUser.id, {
twitchInfo: { ...twitchInfo, botEnabled: connected },
})
)
.finally(() => setLoading(false)),
{ loading: 'Updating bot settings...', error, success },
{
loading: {
className: '!max-w-sm',
},
success: {
className:
'!bg-primary !transition-all !duration-500 !text-white !max-w-sm',
},
error: {
className:
'!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm',
},
}
)
}
return (
<>
{privateUser?.twitchInfo?.botEnabled ? (
<Button
color="red"
onClick={updateBotConnected(false)}
className={clsx(loading && '!btn-disabled', 'border-none')}
>
{loading ? (
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
) : (
'Remove bot from channel'
)}
</Button>
) : (
<Button
color="green"
onClick={updateBotConnected(true)}
className={clsx(loading && '!btn-disabled', 'border-none')}
>
{loading ? (
<LoadingIndicator spinnerClassName="!h-5 !w-5 border-white !border-2" />
) : (
'Add bot to your channel'
)}
</Button>
)}
</>
)
}
function SetUpBot(props: {
user?: User | null
privateUser?: PrivateUser | null
}) {
const { user, privateUser } = props
const twitchLinked =
privateUser?.twitchInfo?.twitchName &&
!privateUser?.twitchInfo?.needsRelinking
? true
: undefined
const toastTheme = {
className: '!bg-primary !text-white',
icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />,
}
const copyOverlayLink = async () => {
if (!privateUser) return
copyToClipboard(getOverlayURLForUser(privateUser))
toast.success('Overlay link copied!', toastTheme)
}
const copyDockLink = async () => {
if (!privateUser) return
copyToClipboard(getDockURLForUser(privateUser))
toast.success('Dock link copied!', toastTheme)
}
return (
<>
<Title
text={'Set up the bot for your own stream'}
className={'!mb-4 md:block'}
/>
<Col className="gap-4">
<img
src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" // TODO: Copy this into the Manifold codebase public folder
className="!-my-2"
></img>
To add the bot to your stream make sure you have logged in then follow
the steps below.
{!twitchLinked && (
<ButtonGetStarted
user={user}
privateUser={privateUser}
buttonClass={'!my-0'}
spinnerClass={'!my-0'}
/>
)}
<div className="flex flex-col gap-6 sm:flex-row">
<BotSetupStep
stepNum={1}
overrideButton={
twitchLinked && <BotConnectButton privateUser={privateUser} />
}
>
Use the button above to add the bot to your channel. Then mod it by
typing in your Twitch chat: <b>/mod ManifoldBot</b>
<br />
If the bot is not modded it will not be able to respond to commands
properly.
</BotSetupStep>
<BotSetupStep
stepNum={2}
buttonName={twitchLinked && 'Overlay link'}
buttonOnClick={copyOverlayLink}
>
Create a new browser source in your streaming software such as OBS.
Paste in the above link and resize it to your liking. We recommend
setting the size to 400x400.
</BotSetupStep>
<BotSetupStep
stepNum={3}
buttonName={twitchLinked && 'Control dock link'}
buttonOnClick={copyDockLink}
>
The bot can be controlled entirely through chat. But we made an easy
to use control panel. Share the link with your mods or embed it into
your OBS as a custom dock.
</BotSetupStep>
</div>
</Col>
</>
)
}
export default function TwitchLandingPage() {
useSaveReferral()
useTracking('view twitch landing page')
const user = useUser()
const privateUser = usePrivateUser()
return (
<Page>
@ -62,58 +418,11 @@ export default function TwitchLandingPage() {
<div className="px-4 pt-2 md:mt-0 lg:hidden">
<ManifoldLogo />
</div>
<Col className="items-center">
<Col className="max-w-3xl">
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
<Row className="self-center">
<img height={200} width={200} src="/twitch-logo.png" />
<img height={200} width={200} src="/flappy-logo.gif" />
</Row>
<div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2">
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
Bet
</span>{' '}
on your favorite streams
</div>
</h1>
<Spacer h={6} />
<div className="mb-4 px-2 ">
Get more out of Twitch with play-money betting markets.{' '}
{!twitchUser &&
'Click the button below to link your Twitch account.'}
<br />
</div>
</div>
<Spacer h={6} />
{twitchUser ? (
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 ">
<div className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<div className="truncate text-sm font-medium text-gray-500">
Twitch account linked
</div>
<div className="mt-1 text-2xl font-semibold text-gray-900">
{twitchUser}
</div>
</div>
</div>
) : isLoading ? (
<LoadingIndicator spinnerClassName="!w-16 !h-16" />
) : (
<Button
size="2xl"
color="gradient"
className="self-center"
onClick={getStarted}
>
Get started
</Button>
)}
</Col>
</Col>
<Col className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10">
<TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} />
<TwitchChatCommands />
<SetUpBot user={user} privateUser={privateUser} />
</Col>
</Page>
)

Some files were not shown because too many files have changed in this diff Show More