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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,3 +46,10 @@ export const shuffle = (array: unknown[], rand: () => number) => {
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]] ;[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", "collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP", "queryScope": "COLLECTION_GROUP",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,12 +23,12 @@ export const onCreateUser = functions
await sendWelcomeEmail(user, privateUser) await sendWelcomeEmail(user, privateUser)
const guideSendTime = dayjs().add(28, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
const followupSendTime = dayjs().add(48, 'hours').toString() const followupSendTime = dayjs().add(48, 'hours').toString()
await sendPersonalFollowupEmail(user, privateUser, followupSendTime) await sendPersonalFollowupEmail(user, privateUser, followupSendTime)
const guideSendTime = dayjs().add(96, 'hours').toString()
await sendCreatorGuideEmail(user, privateUser, guideSendTime)
// skip email if weekly email is about to go out // skip email if weekly email is about to go out
const day = dayjs().utc().day() const day = dayjs().utc().day()
if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return 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') if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if ( if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question previousValue.question !== contract.question

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +1,27 @@
import clsx from 'clsx' import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
import { MenuIcon } from '@heroicons/react/solid' import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { useMemberGroups } from 'web/hooks/use-group' import { keyBy } from 'lodash'
import { filterDefined } from 'common/util/array' import { XCircleIcon } from '@heroicons/react/outline'
import { isArray, keyBy } from 'lodash' import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'
import { User } from 'common/user' import { User } from 'common/user'
import { useUser } from 'web/hooks/use-user'
import { Group } from 'common/group' import { Group } from 'common/group'
export function ArrangeHome(props: { export function ArrangeHome(props: {
user: User | null | undefined sections: { label: string; id: string; group?: Group }[]
homeSections: string[] setSectionIds: (sections: string[]) => void
setHomeSections: (sections: string[]) => void
}) { }) {
const { user, homeSections, setHomeSections } = props const { sections, setSectionIds } = props
const groups = useMemberGroups(user?.id) ?? [] const sectionsById = keyBy(sections, 'id')
const { itemsById, sections } = getHomeItems(groups, homeSections)
return ( return (
<DragDropContext <DragDropContext
@ -27,14 +29,14 @@ export function ArrangeHome(props: {
const { destination, source, draggableId } = e const { destination, source, draggableId } = e
if (!destination) return 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) newSectionIds.splice(source.index, 1)
newHomeSections.splice(destination.index, 0, item.id) newSectionIds.splice(destination.index, 0, section.id)
setHomeSections(newHomeSections) setSectionIds(newSectionIds)
}} }}
> >
<Row className="relative max-w-md gap-4"> <Row className="relative max-w-md gap-4">
@ -46,8 +48,9 @@ export function ArrangeHome(props: {
function DraggableList(props: { function DraggableList(props: {
title: string title: string
items: { id: string; label: string }[] items: { id: string; label: string; group?: Group }[]
}) { }) {
const user = useUser()
const { title, items } = props const { title, items } = props
return ( return (
<Droppable droppableId={title.toLowerCase()}> <Droppable droppableId={title.toLowerCase()}>
@ -72,6 +75,7 @@ function DraggableList(props: {
snapshot.isDragging && 'z-[9000] bg-gray-200' snapshot.isDragging && 'z-[9000] bg-gray-200'
)} )}
item={item} item={item}
user={user}
/> />
</div> </div>
)} )}
@ -85,49 +89,53 @@ function DraggableList(props: {
} }
const SectionItem = (props: { const SectionItem = (props: {
item: { id: string; label: string } item: { id: string; label: string; group?: Group }
user: User | null | undefined
className?: string className?: string
}) => { }) => {
const { item, className } = props const { item, user, className } = props
const { group } = item
return ( return (
<div <Row
className={clsx( className={clsx(
className, className,
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2' 'items-center justify-between gap-4 rounded bg-gray-50 p-2'
)} )}
> >
<MenuIcon <Row className="items-center gap-4">
className="h-5 w-5 flex-shrink-0 text-gray-500" <MenuIcon
aria-hidden="true" className="h-5 w-5 flex-shrink-0 text-gray-500"
/>{' '} aria-hidden="true"
{item.label} />{' '}
</div> {item.label}
</Row>
{group && (
<Button
className="pt-1 pb-1"
color="gray-white"
onClick={() => {
if (user) {
const homeSections = (user.homeSections ?? []).filter(
(id) => id !== group.id
)
updateUser(user.id, { homeSections })
toast.promise(leaveGroup(group, user.id), {
loading: 'Unfollowing group...',
success: `Unfollowed ${group.name}`,
error: "Couldn't unfollow group, try again?",
})
}
}}
>
<XCircleIcon
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
</Button>
)}
</Row>
) )
} }
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)} onClick={() => setOpen(true)}
> >
{PRESENT_BET} Predict
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash' import { groupBy, mapValues, sumBy, sortBy, keyBy } from 'lodash'
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { listUsers, User } from 'web/lib/firebase/users' import { listUsers, User } from 'web/lib/firebase/users'
import { FeedBet } from '../feed/feed-bets' import { FeedBet } from '../feed/feed-bets'
import { FeedComment } from '../feed/feed-comments' import { FeedComment } from '../feed/feed-comments'
@ -66,9 +65,8 @@ export function ContractTopTrades(props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap
}) { }) {
const { contract, bets, comments, tips } = props const { contract, bets, comments } = props
const commentsById = keyBy(comments, 'id') const commentsById = keyBy(comments, 'id')
const betsById = keyBy(bets, 'id') const betsById = keyBy(bets, 'id')
@ -105,8 +103,6 @@ export function ContractTopTrades(props: {
<FeedComment <FeedComment
contract={contract} contract={contract}
comment={commentsById[topCommentId]} comment={commentsById[topCommentId]}
tips={tips[topCommentId]}
betsBySameUser={[betsById[topCommentId]]}
/> />
</div> </div>
<Spacer h={16} /> <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 { Bet } from 'common/bet'
import { Contract, CPMMBinaryContract } from 'common/contract' import { Contract, FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { PAST_BETS, User } from 'common/user' import { PAST_BETS, User } from 'common/user'
import {
ContractCommentsActivity,
ContractBetsActivity,
FreeResponseContractCommentsActivity,
} from '../feed/contract-activity'
import { ContractBetsTable, BetsSummary } from '../bets-list' import { ContractBetsTable, BetsSummary } from '../bets-list'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs' import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' 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 { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt' import { useTipTxns } from 'web/hooks/use-tip-txns'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import BetButton from '../bet-button'
import { capitalize } from 'lodash' import { capitalize } from 'lodash'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes' } from 'common/antes'
import { useIsMobile } from 'web/hooks/use-is-mobile'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
bets: Bet[] bets: Bet[]
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap
}) { }) {
const { contract, user, bets, tips } = props const { contract, user, bets, comments } = props
const { outcomeType } = contract
const lps = useLiquidity(contract.id) const isMobile = useIsMobile()
const userBets = const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter( const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (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 = ( const yourTrades = (
<div> <div>
@ -115,39 +56,202 @@ export function ContractTabs(props: {
) )
return ( return (
<> <Tabs
<Tabs currentPageForAnalytics={'contract'}
currentPageForAnalytics={'contract'} tabs={[
tabs={[ {
{ title: 'Comments',
title: 'Comments', content: (
content: commentActivity, <CommentsTabContent contract={contract} comments={comments} />
badge: `${comments.length}`, ),
}, },
{ {
title: capitalize(PAST_BETS), title: capitalize(PAST_BETS),
content: betActivity, content: (
badge: `${visibleBets.length + visibleLps.length}`, <ContractBetsActivity contract={contract} bets={visibleBets} />
}, ),
...(!user || !userBets?.length },
? [] ...(!user || !userBets?.length
: [{ title: `Your ${PAST_BETS}`, content: yourTrades }]), ? []
]} : [
/> {
{!user ? ( title: isMobile ? `You` : `Your ${PAST_BETS}`,
<Col className="mt-4 max-w-sm items-center xl:hidden"> content: yourTrades,
<BetSignUpPrompt /> },
<PlayMoneyDisclaimer /> ]),
</Col> ]}
) : ( />
outcomeType === 'BINARY' && )
tradingAllowed(contract) && ( }
<BetButton
contract={contract as CPMMBinaryContract} const CommentsTabContent = memo(function CommentsTabContent(props: {
className="mb-2 !mt-0 xl:hidden" 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>
)} </>
)
} 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 ( return (
<ContractSearch <ContractSearch
headerClassName="sticky"
user={user} user={user}
defaultSort="newest" defaultSort="newest"
defaultFilter="all" defaultFilter="all"

View File

@ -11,23 +11,29 @@ export function ProbChangeTable(props: {
changes: changes:
| { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] } | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] }
| undefined | undefined
full?: boolean
}) { }) {
const { changes } = props const { changes, full } = props
if (!changes) return <LoadingIndicator /> if (!changes) return <LoadingIndicator />
const { positiveChanges, negativeChanges } = changes const { positiveChanges, negativeChanges } = changes
const threshold = 0.075 const threshold = 0.01
const countOverThreshold = Math.max( const positiveAboveThreshold = positiveChanges.filter(
positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1, (c) => c.probChanges.day > threshold
negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1
) )
const maxRows = Math.min(positiveChanges.length, negativeChanges.length) const negativeAboveThreshold = negativeChanges.filter(
const rows = Math.min(3, Math.min(maxRows, countOverThreshold)) (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 filteredPositiveChanges = positiveAboveThreshold.slice(0, rows)
const filteredNegativeChanges = negativeChanges.slice(0, rows) const filteredNegativeChanges = negativeAboveThreshold.slice(0, rows)
if (rows === 0) return <div className="px-4 text-gray-500">None</div> if (rows === 0) return <div className="px-4 text-gray-500">None</div>
@ -35,59 +41,53 @@ 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="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"> <Col className="flex-1 divide-y">
{filteredPositiveChanges.map((contract) => ( {filteredPositiveChanges.map((contract) => (
<Row className="items-center hover:bg-gray-100"> <ProbChangeRow key={contract.id} contract={contract} />
<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>
))} ))}
</Col> </Col>
<Col className="flex-1 divide-y"> <Col className="flex-1 divide-y">
{filteredNegativeChanges.map((contract) => ( {filteredNegativeChanges.map((contract) => (
<Row className="items-center hover:bg-gray-100"> <ProbChangeRow key={contract.id} contract={contract} />
<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>
))} ))}
</Col> </Col>
</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 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>
)
}
export function ProbChange(props: { export function ProbChange(props: {
contract: CPMMContract contract: CPMMContract
className?: string className?: string
}) { }) {
const { contract, className } = props const { contract, className } = props
const { const {
prob,
probChanges: { day: change }, probChanges: { day: change },
} = contract } = contract
const color = const color = change >= 0 ? 'text-green-500' : 'text-red-500'
change > 0
? 'text-green-500'
: change < 0
? 'text-red-500'
: 'text-gray-600'
const str = return (
change === 0 <Col className={clsx('flex flex-col items-end', className)}>
? '+0%' <div className="mb-0.5 mr-0.5 text-2xl">
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}` {formatPercent(Math.round(100 * prob) / 100)}
return <div className={clsx(className, color)}>{str}</div> </div>
<div className={clsx('text-base', color)}>
{(change > 0 ? '+' : '') + (change * 100).toFixed(0) + '%'}
</div>
</Col>
)
} }

View File

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

View File

@ -1,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 { Answer } from 'common/answer'
import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract } from 'common/contract'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
@ -11,11 +10,9 @@ import clsx from 'clsx'
import { import {
ContractCommentInput, ContractCommentInput,
FeedComment, FeedComment,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Dictionary } from 'lodash'
import { User } from 'common/user' import { User } from 'common/user'
import { useEvent } from 'web/hooks/use-event' import { useEvent } from 'web/hooks/use-event'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
@ -23,22 +20,11 @@ import { UserLink } from 'web/components/user-link'
export function FeedAnswerCommentGroup(props: { export function FeedAnswerCommentGroup(props: {
contract: FreeResponseContract contract: FreeResponseContract
user: User | undefined | null
answer: Answer answer: Answer
answerComments: ContractComment[] answerComments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]>
}) { }) {
const { const { answer, contract, answerComments, tips } = props
answer,
contract,
answerComments,
tips,
betsByUserId,
commentsByUserId,
user,
} = props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const [replyToUser, setReplyToUser] = const [replyToUser, setReplyToUser] =
@ -48,30 +34,6 @@ export function FeedAnswerCommentGroup(props: {
const router = useRouter() const router = useRouter()
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const betsByCurrentUser = (user && betsByUserId[user.id]) ?? []
const commentsByCurrentUser = (user && commentsByUserId[user.id]) ?? []
const 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( const scrollAndOpenReplyInput = useEvent(
(comment?: ContractComment, answer?: Answer) => { (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(() => { useEffect(() => {
if (router.asPath.endsWith(`#${answerElementId}`)) { if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true) setHighlighted(true)
@ -106,10 +55,7 @@ export function FeedAnswerCommentGroup(props: {
}, [answerElementId, router.asPath]) }, [answerElementId, router.asPath])
return ( return (
<Col <Col className="relative flex-1 items-stretch gap-3">
className={'relative flex-1 items-stretch gap-3'}
key={answer.id + 'comment'}
>
<Row <Row
className={clsx( className={clsx(
'gap-3 space-x-3 pt-4 transition-all duration-1000', '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"> <span className="whitespace-pre-line text-lg">
<Linkify text={text} /> <Linkify text={text} />
</span> </span>
<div className="sm:hidden">
{isFreeResponseContractPage && (
<div className={'sm:hidden'}>
<button
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'}>
<button <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)} onClick={() => scrollAndOpenReplyInput(undefined, answer)}
> >
Reply Reply
</button> </button>
</div> </div>
)} </Col>
<div className="justify-initial hidden sm:block">
<button
className="text-xs font-bold text-gray-500 hover:underline"
onClick={() => scrollAndOpenReplyInput(undefined, answer)}
>
Reply
</button>
</div>
</Col> </Col>
</Row> </Row>
<Col className="gap-3 pl-1"> <Col className="gap-3 pl-1">
@ -165,22 +106,19 @@ export function FeedAnswerCommentGroup(props: {
indent={true} indent={true}
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id]} tips={tips[comment.id] ?? {}}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={scrollAndOpenReplyInput}
/> />
))} ))}
</Col> </Col>
{showReply && ( {showReply && (
<div className={'relative ml-7'}> <div className="relative ml-7">
<span <span
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser}
parentAnswerOutcome={answer.number.toString()} parentAnswerOutcome={answer.number.toString()}
replyToUser={replyToUser} replyToUser={replyToUser}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => setShowReply(false)}

View File

@ -1,9 +1,6 @@
import { Bet } from 'common/bet'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
import { PRESENT_BET, User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { minBy, maxBy, partition, sumBy, Dictionary } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -24,25 +21,12 @@ import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input' import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
user: User | null | undefined
contract: Contract contract: Contract
threadComments: ContractComment[] threadComments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
parentComment: ContractComment parentComment: ContractComment
bets: Bet[]
betsByUserId: Dictionary<Bet[]>
commentsByUserId: Dictionary<ContractComment[]>
}) { }) {
const { const { contract, threadComments, tips, parentComment } = props
user,
contract,
threadComments,
commentsByUserId,
bets,
betsByUserId,
tips,
parentComment,
} = props
const [showReply, setShowReply] = useState(false) const [showReply, setShowReply] = useState(false)
const [replyTo, setReplyTo] = useState<{ id: string; username: string }>() const [replyTo, setReplyTo] = useState<{ id: string; username: string }>()
@ -63,18 +47,8 @@ export function FeedCommentThread(props: {
indent={commentIdx != 0} indent={commentIdx != 0}
contract={contract} contract={contract}
comment={comment} comment={comment}
tips={tips[comment.id]} tips={tips[comment.id] ?? {}}
betsBySameUser={betsByUserId[comment.userId] ?? []}
onReplyClick={scrollAndOpenReplyInput} onReplyClick={scrollAndOpenReplyInput}
probAtCreatedTime={
contract.outcomeType === 'BINARY'
? minBy(bets, (bet) => {
return bet.createdTime < comment.createdTime
? comment.createdTime - bet.createdTime
: comment.createdTime
})?.probAfter
: undefined
}
/> />
))} ))}
{showReply && ( {showReply && (
@ -85,11 +59,8 @@ export function FeedCommentThread(props: {
/> />
<ContractCommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => { onSubmitComment={() => {
setShowReply(false) setShowReply(false)
}} }}
@ -103,23 +74,22 @@ export function FeedCommentThread(props: {
export function FeedComment(props: { export function FeedComment(props: {
contract: Contract contract: Contract
comment: ContractComment comment: ContractComment
tips: CommentTips tips?: CommentTips
betsBySameUser: Bet[]
indent?: boolean indent?: boolean
probAtCreatedTime?: number
onReplyClick?: (comment: ContractComment) => void onReplyClick?: (comment: ContractComment) => void
}) { }) {
const { contract, comment, tips, indent, onReplyClick } = props
const { const {
contract, text,
comment, content,
tips, userUsername,
betsBySameUser, userName,
indent, userAvatarUrl,
probAtCreatedTime, commenterPositionProb,
onReplyClick, commenterPositionShares,
} = props commenterPositionOutcome,
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = createdTime,
comment } = comment
const betOutcome = comment.betOutcome const betOutcome = comment.betOutcome
let bought: string | undefined let bought: string | undefined
let money: string | undefined let money: string | undefined
@ -136,13 +106,6 @@ export function FeedComment(props: {
} }
}, [comment.id, router.asPath]) }, [comment.id, router.asPath])
// Only calculated if they don't have a matching bet
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
comment.createdTime,
comment.betId ? [] : betsBySameUser
)
return ( return (
<Row <Row
id={comment.id} id={comment.id}
@ -167,14 +130,17 @@ export function FeedComment(props: {
username={userUsername} username={userUsername}
name={userName} name={userName}
/>{' '} />{' '}
{!comment.betId != null && {comment.betId == null &&
userPosition > 0 && commenterPositionProb != null &&
commenterPositionOutcome != null &&
commenterPositionShares != null &&
commenterPositionShares > 0 &&
contract.outcomeType !== 'NUMERIC' && ( contract.outcomeType !== 'NUMERIC' && (
<> <>
{'is '} {'is '}
<CommentStatus <CommentStatus
prob={probAtCreatedTime} prob={commenterPositionProb}
outcome={outcome} outcome={commenterPositionOutcome}
contract={contract} contract={contract}
/> />
</> </>
@ -204,7 +170,7 @@ export function FeedComment(props: {
smallImage smallImage
/> />
<Row className="mt-2 items-center gap-6 text-xs text-gray-500"> <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 && ( {onReplyClick && (
<button <button
className="font-bold hover:underline" 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: { function CommentStatus(props: {
contract: Contract contract: Contract
outcome: string outcome: string
@ -255,7 +193,7 @@ function CommentStatus(props: {
const { contract, outcome, prob } = props const { contract, outcome, prob } = props
return ( return (
<> <>
{` ${PRESENT_BET}ing `} {` predicting `}
<OutcomeLabel outcome={outcome} contract={contract} truncate="short" /> <OutcomeLabel outcome={outcome} contract={contract} truncate="short" />
{prob && ' at ' + Math.round(prob * 100) + '%'} {prob && ' at ' + Math.round(prob * 100) + '%'}
</> </>
@ -264,8 +202,6 @@ function CommentStatus(props: {
export function ContractCommentInput(props: { export function ContractCommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[]
className?: string className?: string
parentAnswerOutcome?: string | undefined parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string } replyToUser?: { id: string; username: string }
@ -273,7 +209,7 @@ export function ContractCommentInput(props: {
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const user = useUser() const user = useUser()
async function onSubmitComment(editor: Editor, betId: string | undefined) { async function onSubmitComment(editor: Editor) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
@ -282,22 +218,12 @@ export function ContractCommentInput(props: {
props.contract.id, props.contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
betId,
props.parentAnswerOutcome, props.parentAnswerOutcome,
props.parentCommentId props.parentCommentId
) )
props.onSubmitComment?.() props.onSubmitComment?.()
} }
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return ( return (
<CommentInput <CommentInput
replyToUser={props.replyToUser} replyToUser={props.replyToUser}
@ -305,64 +231,6 @@ export function ContractCommentInput(props: {
parentCommentId={props.parentCommentId} parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment} onSubmitComment={onSubmitComment}
className={props.className} 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 { Col } from '../layout/col'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { Title } from '../title' import { Title } from '../title'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user' import { User } from 'common/user'
import { MAX_GROUP_NAME_LENGTH } from 'common/group' import { MAX_GROUP_NAME_LENGTH } from 'common/group'
import { createGroup } from 'web/lib/firebase/api' import { createGroup } from 'web/lib/firebase/api'
@ -17,35 +16,30 @@ export function CreateGroupButton(props: {
label?: string label?: string
onOpenStateChange?: (isOpen: boolean) => void onOpenStateChange?: (isOpen: boolean) => void
goToGroupOnSubmit?: boolean goToGroupOnSubmit?: boolean
addGroupIdParamOnSubmit?: boolean
icon?: JSX.Element icon?: JSX.Element
}) { }) {
const { user, className, label, onOpenStateChange, goToGroupOnSubmit, icon } = const {
props user,
const [defaultName, setDefaultName] = useState(`${user.name}'s group`) className,
label,
onOpenStateChange,
goToGroupOnSubmit,
addGroupIdParamOnSubmit,
icon,
} = props
const [name, setName] = useState('') const [name, setName] = useState('')
const [memberUsers, setMemberUsers] = useState<User[]>([])
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [errorText, setErrorText] = useState('') const [errorText, setErrorText] = useState('')
const router = useRouter() const router = useRouter()
function updateMemberUsers(users: User[]) {
const usersFirstNames = users.map((user) => user.name.split(' ')[0])
const postFix =
usersFirstNames.length > 3 ? ` & ${usersFirstNames.length - 3} more` : ''
const newName = `${user.name.split(' ')[0]}${
users.length > 0 ? ', ' + usersFirstNames.slice(0, 3).join(', ') : ''
}${postFix}'s group`
setDefaultName(newName)
setMemberUsers(users)
}
const onSubmit = async () => { const onSubmit = async () => {
setIsSubmitting(true) setIsSubmitting(true)
const groupName = name !== '' ? name : defaultName
const newGroup = { const newGroup = {
name: groupName, name,
memberIds: memberUsers.map((user) => user.id), memberIds: [],
anyoneCanJoin: true, anyoneCanJoin: true,
} }
const result = await createGroup(newGroup).catch((e) => { const result = await createGroup(newGroup).catch((e) => {
@ -62,12 +56,17 @@ export function CreateGroupButton(props: {
console.log(result.details) console.log(result.details)
if (result.group) { if (result.group) {
updateMemberUsers([])
if (goToGroupOnSubmit) if (goToGroupOnSubmit)
router.push(groupPath(result.group.slug)).catch((e) => { router.push(groupPath(result.group.slug)).catch((e) => {
console.log(e) console.log(e)
setErrorText(e.message) setErrorText(e.message)
}) })
else if (addGroupIdParamOnSubmit) {
router.replace({
pathname: router.pathname,
query: { ...router.query, groupId: result.group.id },
})
}
setIsSubmitting(false) setIsSubmitting(false)
return true return true
} else { } else {
@ -99,41 +98,26 @@ export function CreateGroupButton(props: {
onSubmitWithSuccess={onSubmit} onSubmitWithSuccess={onSubmit}
onOpenChanged={(isOpen) => { onOpenChanged={(isOpen) => {
onOpenStateChange?.(isOpen) onOpenStateChange?.(isOpen)
updateMemberUsers([])
setName('') setName('')
}} }}
> >
<Title className="!my-0" text="Create a group" /> <Title className="!my-0" text="Create a group" />
<Col className="gap-1 text-gray-500"> <Col className="gap-1 text-gray-500">
<div>You can add markets and members to your group after creation.</div> <div>You can add markets to your group after creation.</div>
</Col> </Col>
<div className={'text-error'}>{errorText}</div> {errorText && <div className={'text-error'}>{errorText}</div>}
<div> <div className="form-control w-full">
<div className="form-control w-full"> <label className="mb-2 ml-1 mt-0">Group name</label>
<label className="label"> <input
<span className="mb-0">Add members (optional)</span> placeholder={'Your group name'}
</label> className="input input-bordered resize-none"
<FilterSelectUsers disabled={isSubmitting}
setSelectedUsers={updateMemberUsers} value={name}
selectedUsers={memberUsers} maxLength={MAX_GROUP_NAME_LENGTH}
ignoreUserIds={[user.id]} onChange={(e) => setName(e.target.value || '')}
/> />
</div>
<div className="form-control w-full">
<label className="label">
<span className="mt-1">Group name (optional)</span>
</label>
<input
placeholder={defaultName}
className="input input-bordered resize-none"
disabled={isSubmitting}
value={name}
maxLength={MAX_GROUP_NAME_LENGTH}
onChange={(e) => setName(e.target.value || '')}
/>
</div>
<Spacer h={4} /> <Spacer h={4} />
</div> </div>

View File

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

View File

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

View File

@ -14,6 +14,8 @@ import { Col } from './layout/col'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from './info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user' 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 }) { export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props const { contract } = props
@ -28,31 +30,32 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
setShowWithdrawal(true) setShowWithdrawal(true)
}, [showWithdrawal, lpShares]) }, [showWithdrawal, lpShares])
const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin()
if (!showWithdrawal && !isAdmin && !isCreator) return <></>
return ( return (
<Tabs <Tabs
tabs={[ tabs={buildArray(
{ (isCreator || isAdmin) && {
title: 'Subsidize', title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',
content: <AddLiquidityPanel contract={contract} />, content: <AddLiquidityPanel contract={contract} />,
}, },
...(showWithdrawal showWithdrawal && {
? [ title: 'Withdraw',
{ content: (
title: 'Withdraw', <WithdrawLiquidityPanel
content: ( contract={contract}
<WithdrawLiquidityPanel lpShares={lpShares as { YES: number; NO: number }}
contract={contract} />
lpShares={lpShares as { YES: number; NO: number }} ),
/> },
),
},
]
: []),
{ {
title: 'Pool', title: 'Pool',
content: <ViewLiquidityPanel contract={contract} />, content: <ViewLiquidityPanel contract={contract} />,
}, }
]} )}
/> />
) )
} }

View File

@ -8,7 +8,8 @@ import {
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import { Transition, Dialog } from '@headlessui/react' import { Transition, Dialog } from '@headlessui/react'
import { useState, Fragment } from 'react' import { useState, Fragment } from 'react'
import Sidebar, { Item } from './sidebar' import Sidebar from './sidebar'
import { Item } from './sidebar-item'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
@ -17,11 +18,14 @@ import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics' import { trackCallback } from 'web/lib/service/analytics'
import { User } from 'common/user'
import { PAST_BETS } from 'common/user' import { PAST_BETS } from 'common/user'
function getNavigation() { function getNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
{ {
name: 'Notifications', name: 'Notifications',
href: `/notifications`, href: `/notifications`,
@ -32,9 +36,24 @@ function getNavigation() {
const signedOutNavigation = [ const signedOutNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { 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 // From https://codepen.io/chris__sev/pen/QWGvYbL
export function BottomNavBar() { export function BottomNavBar() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
@ -62,20 +81,7 @@ export function BottomNavBar() {
<NavBarItem <NavBarItem
key={'profile'} key={'profile'}
currentPage={currentPage} currentPage={currentPage}
item={{ item={userProfileItem(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
/>
),
}}
/> />
)} )}
<div <div
@ -99,7 +105,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`) const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
return ( return (
<Link href={item.href}> <Link href={item.href ?? '#'}>
<a <a
className={clsx( className={clsx(
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', '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 = { export type MenuItem = {
name: string name: string
href: string href?: string
onClick?: () => void onClick?: () => void
} }
@ -38,11 +38,11 @@ export function MenuButton(props: {
{({ active }) => ( {({ active }) => (
<a <a
href={item.href} href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined} target={item.href?.startsWith('http') ? '_blank' : undefined}
onClick={item.onClick} onClick={item.onClick}
className={clsx( className={clsx(
active ? 'bg-gray-100' : '', 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} {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 { import {
HomeIcon, HomeIcon,
SearchIcon, SearchIcon,
BookOpenIcon, BookOpenIcon,
DotsHorizontalIcon,
CashIcon, CashIcon,
HeartIcon, HeartIcon,
ChatIcon, ChatIcon,
ChartBarIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link'
import Router, { useRouter } from 'next/router' import Router, { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogout, User } from 'web/lib/firebase/users' import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo' import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton, MenuItem } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import React from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' import { CreateQuestionButton } from 'web/components/create-question-button'
import { trackCallback, withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon' import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button' import { SignInButton } from '../sign-in-button'
import { SidebarItem } from './sidebar-item'
import { MoreButton } from './more-button'
export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const desktopNavOptions = !user
? signedOutDesktopNavigation
: getDesktopNavigation()
const mobileNavOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
const createMarketButton = user && !user.isBannedFromPosting && (
<CreateQuestionButton />
)
return (
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
{!user && <SignInButton className="mb-4" />}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
{user && (
<MenuButton
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}
{createMarketButton}
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{desktopNavOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
<MenuButton
menuItems={getMoreDesktopNavigation(user)}
buttonContent={<MoreButton />}
/>
{createMarketButton}
</div>
</nav>
)
}
const logout = async () => { const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out // log out, and then reload the page, in case SSR wants to boot them out
@ -32,9 +96,10 @@ const logout = async () => {
await Router.replace(Router.asPath) await Router.replace(Router.asPath)
} }
function getNavigation() { function getDesktopNavigation() {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Search', href: '/search', icon: SearchIcon },
{ {
name: 'Notifications', name: 'Notifications',
href: `/notifications`, href: `/notifications`,
@ -50,10 +115,10 @@ function getNavigation() {
] ]
} }
function getMoreNavigation(user?: User | null) { function getMoreDesktopNavigation(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) { if (IS_PRIVATE_MANIFOLD) {
return [ return [
{ name: 'Leaderboards', href: '/leaderboards' }, { name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
{ {
name: 'Sign out', name: 'Sign out',
href: '#', href: '#',
@ -98,9 +163,9 @@ function getMoreNavigation(user?: User | null) {
) )
} }
const signedOutNavigation = [ const signedOutDesktopNavigation = [
{ name: 'Home', href: '/', icon: HomeIcon }, { name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Explore', href: '/home', icon: SearchIcon }, { name: 'Explore', href: '/search', icon: SearchIcon },
{ {
name: 'Help & About', name: 'Help & About',
href: 'https://help.manifold.markets/', href: 'https://help.manifold.markets/',
@ -116,11 +181,14 @@ const signedOutMobileNavigation = [
}, },
{ name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
] ]
const signedInMobileNavigation = [ const signedInMobileNavigation = [
{ name: 'Search', href: '/search', icon: SearchIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon }, { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartBarIcon },
...(IS_PRIVATE_MANIFOLD ...(IS_PRIVATE_MANIFOLD
? [] ? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -139,12 +207,11 @@ function getMoreMobileNav() {
} }
if (IS_PRIVATE_MANIFOLD) return [signOut] if (IS_PRIVATE_MANIFOLD) return [signOut]
return buildArray<Item>( return buildArray<MenuItem>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[ [
{ name: 'Groups', href: '/groups' }, { name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Referrals', href: '/referrals' },
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -152,116 +219,3 @@ function getMoreMobileNav() {
signOut 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', 'contract_from_followed_user',
'unique_bettors_on_your_contract', 'unique_bettors_on_your_contract',
// TODO: add these // 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 // 'profit_loss_updates', - changes in markets you have shares in
// biggest winner, here are the rest of your markets // 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')}> <Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
{subscriptionTypes.map((subType) => ( {subscriptionTypes.map((subType) => (
<NotificationSettingLine <NotificationSettingLine
key={subType}
subscriptionTypeKey={subType as notification_preference} subscriptionTypeKey={subType as notification_preference}
destinations={getUsersSavedPreference( destinations={getUsersSavedPreference(
subType as notification_preference subType as notification_preference

View File

@ -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 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 { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users' import { updateUser } from 'web/lib/firebase/users'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Title } from '../title' import { Title } from '../title'
import GroupSelectorDialog from './group-selector-dialog'
export default function Welcome() { export default function Welcome() {
const user = useUser() const user = useUser()
@ -26,23 +30,34 @@ export default function Welcome() {
} }
} }
async function setUserHasSeenWelcome() { const setUserHasSeenWelcome = async () => {
if (user) { if (user) await updateUser(user.id, { ['shouldShowWelcome']: false })
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 <></> return <></>
} else
return ( return (
<Modal <>
open={open} <GroupSelectorDialog
setOpen={(newOpen) => { open={groupSelectorOpen}
setUserHasSeenWelcome() setOpen={() => setGroupSelectorOpen(false)}
setOpen(newOpen) />
}}
> <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"> <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 === 0 && <Page0 />}
{page === 1 && <Page1 />} {page === 1 && <Page1 />}
@ -68,17 +83,30 @@ export default function Welcome() {
</Row> </Row>
<u <u
className="self-center text-xs text-gray-500" className="self-center text-xs text-gray-500"
onClick={() => { onClick={() => toggleOpen(false)}
setOpen(false)
setUserHasSeenWelcome()
}}
> >
I got the gist, exit welcome I got the gist, exit welcome
</u> </u>
</Col> </Col>
</Col> </Col>
</Modal> </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 }) { function PageIndicator(props: { page: number; totalpages: number }) {

View File

@ -1,6 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { BottomNavBar } from './nav/nav-bar' import { BottomNavBar } from './nav/bottom-nav-bar'
import Sidebar from './nav/sidebar' import Sidebar from './nav/sidebar'
import { Toaster } from 'react-hot-toast' 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') track('copy embed code')
}} }}
> >
<Menu.Button <Menu.Button className="btn btn-xs border-2 !border-gray-500 !bg-white normal-case text-gray-500">
className="btn btn-xs normal-case"
style={{
backgroundColor: 'white',
border: '2px solid #9ca3af',
color: '#9ca3af', // text-gray-400
}}
>
{codeIcon} {codeIcon}
Embed Embed
</Menu.Button> </Menu.Button>

View File

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

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group' import { Group } from 'common/group'
import { User } from 'common/user' import { User } from 'common/user'
import { import {
getGroup,
getMemberGroups, getMemberGroups,
GroupMemberDoc, GroupMemberDoc,
groupMembers, groupMembers,
@ -11,13 +12,17 @@ import {
listenForMemberGroupIds, listenForMemberGroupIds,
listenForOpenGroups, listenForOpenGroups,
listGroups, listGroups,
topFollowedGroupsQuery,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
import { getUser } from 'web/lib/firebase/users' import { getUser } from 'web/lib/firebase/users'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { uniq } from 'lodash' import { keyBy, uniq, uniqBy } from 'lodash'
import { listenForValues } from 'web/lib/firebase/utils' import { listenForValues } from 'web/lib/firebase/utils'
import { useQuery } from 'react-query' 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) => { export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>() const [group, setGroup] = useState<Group | null | undefined>()
@ -49,6 +54,30 @@ export const useOpenGroups = () => {
return groups 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) => { export const useMemberGroups = (userId: string | null | undefined) => {
const result = useQuery(['member-groups', userId ?? ''], () => const result = useQuery(['member-groups', userId ?? ''], () =>
getMemberGroups(userId ?? '') getMemberGroups(userId ?? '')
@ -56,10 +85,11 @@ export const useMemberGroups = (userId: string | null | undefined) => {
return result.data return result.data
} }
// Note: We cache member group ids in localstorage to speed up the initial load
export const useMemberGroupIds = (user: User | null | undefined) => { export const useMemberGroupIds = (user: User | null | undefined) => {
const cachedGroups = useMemberGroups(user?.id)
const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>( const [memberGroupIds, setMemberGroupIds] = useState<string[] | undefined>(
undefined cachedGroups?.map((g) => g.id)
) )
useEffect(() => { useEffect(() => {
@ -73,6 +103,24 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
return memberGroupIds return memberGroupIds
} }
export function useMemberGroupsSubscription(user: User | null | undefined) {
const cachedGroups = useMemberGroups(user?.id) ?? []
const [groups, setGroups] = useState(cachedGroups)
const userId = user?.id
useEffect(() => {
if (userId) {
return listenForMemberGroupIds(userId, (groupIds) => {
Promise.all(groupIds.map((id) => getGroup(id))).then((groups) =>
setGroups(filterDefined(groups))
)
})
}
}, [userId])
return groups
}
export function useMembers(groupId: string | undefined) { export function useMembers(groupId: string | undefined) {
const [members, setMembers] = useState<User[]>([]) const [members, setMembers] = useState<User[]>([])
useEffect(() => { useEffect(() => {

View File

@ -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 = { const notificationGroup: NotificationGroup = {
notifications: notificationsForContractId, notifications: notificationsForContractId,
groupedById: contractId, groupedById: contractId,
isSeen: notificationsForContractId[0].isSeen, isSeen: notificationsForContractId.some((n) => !n.isSeen),
timePeriod: day, timePeriod: day,
type: 'normal', type: 'normal',
} }

View File

@ -1,8 +1,45 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { CPMMContract } from 'common/contract'
import { MINUTE_MS } from 'common/util/time'
import { useQuery, useQueryClient } from 'react-query'
import { import {
getProbChangesNegative, getProbChangesNegative,
getProbChangesPositive, getProbChangesPositive,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { getValues } from 'web/lib/firebase/utils'
import { getIndexName, searchClient } from 'web/lib/service/algolia'
export const useProbChangesAlgolia = (userId: string) => {
const { data: positiveData } = useQuery(['prob-change-day', userId], () =>
searchClient
.initIndex(getIndexName('prob-change-day'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
const { data: negativeData } = useQuery(
['prob-change-day-ascending', userId],
() =>
searchClient
.initIndex(getIndexName('prob-change-day-ascending'))
.search<CPMMContract>('', {
facetFilters: ['uniqueBettorIds:' + userId, 'isResolved:false'],
})
)
if (!positiveData || !negativeData) {
return undefined
}
return {
positiveChanges: positiveData.hits
.filter((c) => c.probChanges && c.probChanges.day > 0)
.filter((c) => c.outcomeType === 'BINARY'),
negativeChanges: negativeData.hits
.filter((c) => c.probChanges && c.probChanges.day < 0)
.filter((c) => c.outcomeType === 'BINARY'),
}
}
export const useProbChanges = (userId: string) => { export const useProbChanges = (userId: string) => {
const { data: positiveChanges } = useFirestoreQueryData( const { data: positiveChanges } = useFirestoreQueryData(
@ -20,3 +57,19 @@ export const useProbChanges = (userId: string) => {
return { positiveChanges, negativeChanges } 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 return confirmationMessage
} }
const beforeRouteHandler = () => { const beforeRouteHandler = (href: string) => {
const pathname = href.split('?')[0]
// Don't warn if the user is navigating to the same page.
if (pathname === location.pathname) return
if (!confirm(confirmationMessage)) { if (!confirm(confirmationMessage)) {
Router.events.emit('routeChangeError') Router.events.emit('routeChangeError')
throw 'Abort route change. Please ignore this error.' throw 'Abort route change. Please ignore this error.'

View File

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

View File

@ -17,13 +17,14 @@ import { partition, sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils' import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract, CPMMContract } from 'common/contract' 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 { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time' import { DAY_MS } from 'common/util/time'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { getBinaryProb } from 'common/contract-details' import { getBinaryProb } from 'common/contract-details'
import { Sort } from 'web/components/contract-search'
export const contracts = coll<Contract>('contracts') export const contracts = coll<Contract>('contracts')
@ -176,23 +177,6 @@ export function getUserBetContractsQuery(userId: string) {
) as Query<Contract> ) 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( const inactiveContractsQuery = query(
contracts, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
@ -255,13 +239,6 @@ export async function unFollowContract(contractId: string, userId: string) {
await deleteDoc(followDoc) 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( const hotContractsQuery = query(
contracts, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
@ -282,16 +259,17 @@ export function listenForHotContracts(
}) })
} }
const trendingContractsQuery = query( export const trendingContractsQuery = query(
contracts, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
where('visibility', '==', 'public'), where('visibility', '==', 'public'),
orderBy('popularityScore', 'desc'), orderBy('popularityScore', 'desc')
limit(10)
) )
export async function getTrendingContracts() { export async function getTrendingContracts(maxContracts = 10) {
return await getValues<Contract>(trendingContractsQuery) return await getValues<Contract>(
query(trendingContractsQuery, limit(maxContracts))
)
} }
export async function getContractsBySlugs(slugs: string[]) { export async function getContractsBySlugs(slugs: string[]) {
@ -343,6 +321,51 @@ export const getTopGroupContracts = async (
return await getValues<Contract>(creatorContractsQuery) 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 ( export const getRecommendedContracts = async (
contract: Contract, contract: Contract,
excludeBettorId: string, excludeBettorId: string,

View File

@ -6,6 +6,7 @@ import {
doc, doc,
getDocs, getDocs,
onSnapshot, onSnapshot,
orderBy,
query, query,
setDoc, setDoc,
updateDoc, updateDoc,
@ -256,3 +257,9 @@ export async function listMemberIds(group: Group) {
const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
return members.map((m) => m.userId) 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" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>

View File

@ -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" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
{...props} {...props}
> >
<line x1="19" y1="4" x2="10" y2="4"></line> <line x1="19" y1="4" x2="10" y2="4"></line>

View File

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

View File

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

View File

@ -1,31 +1,37 @@
import { PrivateUser, User } from 'common/user' import { PrivateUser, User } from 'common/user'
import { generateNewApiKey } from '../api/api-key' import { generateNewApiKey } from '../api/api-key'
import { ENV_CONFIG } from 'common/envs/constants'
const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately async function postToBot(url: string, body: unknown) {
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( export async function initLinkTwitchAccount(
manifoldUserID: string, manifoldUserID: string,
manifoldUserAPIKey: string manifoldUserAPIKey: string
): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> {
const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { const response = await postToBot(
method: 'POST', `${ENV_CONFIG.twitchBotEndpoint}/api/linkInit`,
headers: { {
'Content-Type': 'application/json',
},
body: JSON.stringify({
manifoldID: manifoldUserID, manifoldID: manifoldUserID,
apiKey: manifoldUserAPIKey, apiKey: manifoldUserAPIKey,
redirectURL: window.location.href, 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( export async function linkTwitchAccountRedirect(
@ -38,4 +44,37 @@ export async function linkTwitchAccountRedirect(
const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey) const [twitchAuthURL] = await initLinkTwitchAccount(user.id, apiKey)
window.location.href = twitchAuthURL 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 API_DOCS_URL = 'https://docs.manifold.markets/api'
const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to' const ABOUT_PAGE_URL = 'https://help.manifold.markets/'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
module.exports = { module.exports = {

View File

@ -31,7 +31,6 @@ import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box' import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractComment } from 'common/comment' import { ContractComment } from 'common/comment'
@ -45,6 +44,10 @@ import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch' import { usePrefetch } from 'web/hooks/use-prefetch'
import { useAdmin } from 'web/hooks/use-admin' 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' import dayjs from 'dayjs'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
@ -192,8 +195,6 @@ export function ContractPageContent(
[bets] [bets]
) )
const tips = useTipTxns({ contractId: contract.id })
const [showConfetti, setShowConfetti] = useState(false) const [showConfetti, setShowConfetti] = useState(false)
useEffect(() => { useEffect(() => {
@ -205,18 +206,6 @@ export function ContractPageContent(
setShowConfetti(shouldSeeConfetti) setShowConfetti(shouldSeeConfetti)
}, [contract, user]) }, [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 { isResolved, question, outcomeType } = contract
const allowTrade = tradingAllowed(contract) const allowTrade = tradingAllowed(contract)
@ -286,7 +275,6 @@ export function ContractPageContent(
contract={contract} contract={contract}
bets={bets} bets={bets}
comments={comments} comments={comments}
tips={tips}
/> />
</div> </div>
<Spacer h={12} /> <Spacer h={12} />
@ -297,20 +285,48 @@ export function ContractPageContent(
contract={contract} contract={contract}
user={user} user={user}
bets={bets} bets={bets}
tips={tips}
comments={comments} comments={comments}
/> />
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
allowTrade && (
<BetButton
contract={contract as CPMMBinaryContract}
className="mb-2 !mt-0 xl:hidden"
/>
)
)}
</Col> </Col>
<RecommendedContractsWidget contract={contract} />
{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"
/>
</Col>
)}
</Page> </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 React, { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' 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 { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import { import {
addContractToGroup, addContractToGroup,
@ -30,7 +29,7 @@ import Custom404 from '../../404'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search' import { ContractSearch } from 'web/components/contract-search'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' 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 { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser' 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 { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user' import { BETTORS } from 'common/user'
@ -138,6 +140,10 @@ export default function GroupPage(props: {
const user = useUser() const user = useUser()
const isAdmin = useAdmin() const isAdmin = useAdmin()
const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds
// Note: Keep in sync with sidebarPages
const [sidebarIndex, setSidebarIndex] = useState(
['markets', 'leaderboards', 'about'].indexOf(page ?? 'markets')
)
useSaveReferral(user, { useSaveReferral(user, {
defaultReferrerUsername: creator.username, defaultReferrerUsername: creator.username,
@ -151,7 +157,7 @@ export default function GroupPage(props: {
const isMember = user && memberIds.includes(user.id) const isMember = user && memberIds.includes(user.id)
const maxLeaderboardSize = 50 const maxLeaderboardSize = 50
const leaderboard = ( const leaderboardPage = (
<Col> <Col>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
<GroupLeaderboard <GroupLeaderboard
@ -170,7 +176,7 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const aboutTab = ( const aboutPage = (
<Col> <Col>
{(group.aboutPostId != null || isCreator || isAdmin) && ( {(group.aboutPostId != null || isCreator || isAdmin) && (
<GroupAboutPost <GroupAboutPost
@ -190,73 +196,129 @@ export default function GroupPage(props: {
</Col> </Col>
) )
const questionsTab = ( const questionsPage = (
<ContractSearch <>
user={user} {/* align the divs to the right */}
defaultSort={'newest'} <div className={' flex justify-end px-2 pb-2 sm:hidden'}>
defaultFilter={suggestedFilter} <div>
additionalFilter={{ groupSlug: group.slug }} <JoinOrAddQuestionsButtons
persistPrefix={`group-${group.slug}`} group={group}
/> user={user}
isMember={!!isMember}
/>
</div>
</div>
<ContractSearch
headerClassName="md:sticky"
user={user}
defaultSort={'score'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
persistPrefix={`group-${group.slug}`}
/>
</>
) )
const tabs = [ const sidebarPages = [
{ {
title: 'Markets', title: 'Markets',
content: questionsTab, content: questionsPage,
href: groupPath(group.slug, 'markets'), href: groupPath(group.slug, 'markets'),
key: 'markets',
}, },
{ {
title: 'Leaderboards', title: 'Leaderboards',
content: leaderboard, content: leaderboardPage,
href: groupPath(group.slug, 'leaderboards'), href: groupPath(group.slug, 'leaderboards'),
key: 'leaderboards',
}, },
{ {
title: 'About', title: 'About',
content: aboutTab, content: aboutPage,
href: groupPath(group.slug, 'about'), href: groupPath(group.slug, 'about'),
key: 'about',
}, },
] ]
const tabIndex = tabs const pageContent = sidebarPages[sidebarIndex].content
.map((t) => t.title.toLowerCase()) const onSidebarClick = (key: string) => {
.indexOf(page ?? 'markets') 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 ( return (
<Page> <>
<SEO <TopGroupNavBar
title={group.name} group={group}
description={`Created by ${creator.name}. ${group.about}`} currentPage={sidebarPages[sidebarIndex].key}
url={groupPath(group.slug)} onClick={onSidebarClick}
/> />
<Col className="relative px-3"> <div>
<Row className={'items-center justify-between gap-4'}> <div
<div className={'sm:mb-1'}> className={
<div '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'
className={'line-clamp-1 my-2 text-2xl text-indigo-700 sm:my-3'} }
> >
{group.name} <Toaster />
</div> <GroupSidebar
<div className={'hidden sm:block'}> groupName={group.name}
<Linkify text={group.about} /> className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex"
</div> onClick={onSidebarClick}
</div> joinOrAddQuestionsButton={joinOrAddQuestionsButton}
<div className="mt-2"> currentKey={sidebarPages[sidebarIndex].key}
<JoinOrAddQuestionsButtons />
group={group}
user={user} <SEO
isMember={!!isMember} title={group.name}
/> description={`Created by ${creator.name}. ${group.about}`}
</div> url={groupPath(group.slug)}
</Row> />
</Col> <main className={'px-2 pt-1 lg:col-span-8 lg:pt-6 xl:col-span-8'}>
<Tabs {pageContent}
currentPageForAnalytics={groupPath(group.slug)} </main>
className={'mx-2 mb-0 sm:mb-2'} </div>
defaultIndex={tabIndex > 0 ? tabIndex : 0} </div>
tabs={tabs} </>
/> )
</Page> }
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 group: Group
user: User | null | undefined user: User | null | undefined
isMember: boolean isMember: boolean
className?: string
}) { }) {
const { group, user, isMember } = props const { group, user, isMember } = props
return user && isMember ? ( return user && isMember ? (
<Row className={'mt-0 justify-end'}> <Row className={'w-full self-start pt-4'}>
<AddContractButton group={group} user={user} /> <AddContractButton group={group} user={user} />
</Row> </Row>
) : group.anyoneCanJoin ? ( ) : group.anyoneCanJoin ? (
@ -411,9 +474,9 @@ function AddContractButton(props: { group: Group; user: User }) {
return ( return (
<> <>
<div className={'flex justify-center'}> <div className={'flex w-full justify-center'}>
<Button <Button
className="whitespace-nowrap" className="w-full whitespace-nowrap"
size="md" size="md"
color="indigo" color="indigo"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
@ -468,7 +531,9 @@ function JoinGroupButton(props: {
<div> <div>
<button <button
onClick={follow} 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 Follow
</button> </button>

View File

@ -171,26 +171,34 @@ export default function Groups(props: {
export function GroupCard(props: { export function GroupCard(props: {
group: Group group: Group
creator: User | undefined creator: User | null | undefined
user: User | undefined | null user: User | undefined | null
isMember: boolean isMember: boolean
className?: string
}) { }) {
const { group, creator, user, isMember } = props const { group, creator, user, isMember, className } = props
const { totalContracts } = group const { totalContracts } = group
return ( 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)}> <Link href={groupPath(group.slug)}>
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" /> <a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
</Link> </Link>
<div> {creator !== null && (
<Avatar <div>
className={'absolute top-2 right-2 z-10'} <Avatar
username={creator?.username} className={'absolute top-2 right-2 z-10'}
avatarUrl={creator?.avatarUrl} username={creator?.username}
noLink={false} avatarUrl={creator?.avatarUrl}
size={12} noLink={false}
/> size={12}
</div> />
</div>
)}
<Row className="items-center justify-between gap-2"> <Row className="items-center justify-between gap-2">
<span className="text-xl">{group.name}</span> <span className="text-xl">{group.name}</span>
</Row> </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 { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { useMemberGroupsSubscription } from 'web/hooks/use-group'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users' import { updateUser } from 'web/lib/firebase/users'
import { track } from 'web/lib/service/analytics'
import { getHomeItems, TrendingGroupsSection } from '.'
export default function Home() { export default function Home() {
const user = useUser() const user = useUser()
@ -24,6 +27,9 @@ export default function Home() {
setHomeSections(newHomeSections) setHomeSections(newHomeSections)
} }
const groups = useMemberGroupsSubscription(user)
const { sections } = getHomeItems(groups, homeSections)
return ( return (
<Page> <Page>
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2"> <Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
@ -32,11 +38,15 @@ export default function Home() {
<DoneButton /> <DoneButton />
</Row> </Row>
<ArrangeHome <Col className="gap-8 md:flex-row">
user={user} <Col className="flex-1">
homeSections={homeSections} <ArrangeHome
setHomeSections={updateHomeSections} sections={sections}
/> setSectionIds={updateHomeSections}
/>
</Col>
<TrendingGroupsSection className="flex-1" user={user} full />
</Col>
</Col> </Col>
</Page> </Page>
) )
@ -46,11 +56,12 @@ function DoneButton(props: { className?: string }) {
const { className } = props const { className } = props
return ( return (
<SiteLink href="/experimental/home"> <SiteLink href="/home">
<Button <Button
size="lg" size="lg"
color="blue" color="blue"
className={clsx(className, 'flex whitespace-nowrap')} className={clsx(className, 'flex whitespace-nowrap')}
onClick={() => track('done editing home')}
> >
Done Done
</Button> </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 reasonText = !simple
? `Bonus for ${ ? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} new predictors on` } new traders on`
: 'bonus on' : 'bonus on'
} else if (sourceType === 'tip') { } else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on` reasonText = !simple ? `tipped you on` : `in tips on`
@ -556,7 +556,7 @@ function IncomeNotificationItem(props: {
{(isTip || isUniqueBettorBonus) && ( {(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink <MultiUserTransactionLink
userInfos={userLinks} userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'} modalLabel={isTip ? 'Who tipped you' : 'Unique traders'}
/> />
)} )}
<Row className={'line-clamp-2 flex max-w-xl'}> <Row className={'line-clamp-2 flex max-w-xl'}>
@ -904,25 +904,30 @@ function BetFillNotification(props: {
}) { }) {
const { notification, isChildOfGroup, highlighted, justSummary } = props const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification const { sourceText, data } = notification
const { creatorOutcome, probability } = (data as BetFillData) ?? {} const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
(data as BetFillData) ?? {}
const subtitle = 'bet against you' const subtitle = 'bet against you'
const amount = formatMoney(parseInt(sourceText ?? '0')) const amount = formatMoney(parseInt(sourceText ?? '0'))
const description = const description =
creatorOutcome && probability ? ( creatorOutcome && probability ? (
<span> <span>
of your{' '} of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
<span <span
className={ className={clsx(
'mx-1',
creatorOutcome === 'YES' creatorOutcome === 'YES'
? 'text-primary' ? 'text-primary'
: creatorOutcome === 'NO' : creatorOutcome === 'NO'
? 'text-red-500' ? 'text-red-500'
: 'text-blue-500' : 'text-blue-500'
} )}
> >
{creatorOutcome}{' '} {creatorOutcome}
</span> </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>
) : ( ) : (
<span>of your limit order was filled</span> <span>of your limit order was filled</span>
@ -966,13 +971,20 @@ function ContractResolvedNotification(props: {
const { sourceText, data } = notification const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {} const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market' const subtitle = 'resolved the market'
const resolutionDescription = () => { const resolutionDescription = () => {
if (!sourceText) return <div /> if (!sourceText) return <div />
if (sourceText === 'YES' || sourceText == 'NO') { if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} /> return <BinaryOutcomeLabel outcome={sourceText as any} />
} }
if (sourceText.includes('%')) if (sourceText.includes('%'))
return <ProbPercentLabel prob={parseFloat(sourceText.replace('%', ''))} /> return (
<ProbPercentLabel
prob={parseFloat(sourceText.replace('%', '')) / 100}
/>
)
if (sourceText === 'CANCEL') return <CancelLabel /> if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel /> if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
@ -991,7 +1003,7 @@ function ContractResolvedNotification(props: {
const description = const description =
userInvestment && userPayout !== undefined ? ( userInvestment && userPayout !== undefined ? (
<Row className={'gap-1 '}> <Row className={'gap-1 '}>
{resolutionDescription()} Resolved: {resolutionDescription()}
Invested: Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span> <span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout: Payout:
@ -1008,7 +1020,7 @@ function ContractResolvedNotification(props: {
</span> </span>
</Row> </Row>
) : ( ) : (
<span>{resolutionDescription()}</span> <span>Resolved {resolutionDescription()}</span>
) )
if (justSummary) { if (justSummary) {

View File

@ -1,24 +1,28 @@
import React, { useState } from 'react'
import { RefreshIcon } from '@heroicons/react/outline' import { RefreshIcon } from '@heroicons/react/outline'
import { PrivateUser, User } from 'common/user'
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 { cleanDisplayName, cleanUsername } from 'common/util/clean-username' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { changeUserInfo } from 'web/lib/firebase/api' import { formatMoney } from 'common/util/format'
import { uploadImage } from 'web/lib/firebase/storage' 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 { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { User, PrivateUser } from 'common/user' import { Page } from 'web/components/page'
import { getUserAndPrivateUser, updateUser } from 'web/lib/firebase/users' import { SEO } from 'web/components/SEO'
import { defaultBannerUrl } from 'web/components/user-page'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import Textarea from 'react-expanding-textarea' import { Title } from 'web/components/title'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { defaultBannerUrl } from 'web/components/user-page'
import { generateNewApiKey } from 'web/lib/api/api-key' 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) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } } 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) const newApiKey = await generateNewApiKey(user.id)
setApiKey(newApiKey ?? '') setApiKey(newApiKey ?? '')
e.preventDefault() e?.preventDefault()
if (!privateUser.twitchInfo) return
await updatePrivateUser(privateUser.id, {
twitchInfo: { ...privateUser.twitchInfo, needsRelinking: true },
})
} }
const fileHandler = async (event: any) => { const fileHandler = async (event: any) => {
@ -229,16 +238,38 @@ export default function ProfilePage(props: {
value={apiKey} value={apiKey}
readOnly readOnly
/> />
<button <ConfirmationButton
className="btn btn-primary btn-square p-2" openModalBtn={{
onClick={updateApiKey} className: 'btn btn-primary btn-square p-2',
label: '',
icon: <RefreshIcon />,
}}
submitBtn={{
label: 'Update key',
className: 'btn-primary',
}}
onSubmitWithSuccess={async () => {
updateApiKey()
return true
}}
> >
<RefreshIcon /> <Col>
</button> <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>
</div> </div>
<TwitchPanel />
</Col> </Col>
</Col> </Col>
</Page> </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 { useEffect, useState } from 'react'
import { import {
DailyCountChart, DailyCountChart,
@ -47,65 +46,38 @@ export default function Analytics() {
) )
} }
export function CustomAnalytics(props: { export function CustomAnalytics(props: Stats) {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
manaBet: {
daily: number[]
weekly: number[]
monthly: number[]
}
}) {
const { const {
startDate,
dailyActiveUsers, dailyActiveUsers,
dailyActiveUsersWeeklyAvg,
weeklyActiveUsers,
monthlyActiveUsers,
d1,
d1WeeklyAvg,
nd1,
nd1WeeklyAvg,
nw1,
dailyBetCounts, dailyBetCounts,
dailyContractCounts, dailyContractCounts,
dailyCommentCounts, dailyCommentCounts,
dailySignups, dailySignups,
weeklyActiveUsers,
monthlyActiveUsers,
weekOnWeekRetention, weekOnWeekRetention,
monthlyRetention, monthlyRetention,
weeklyActivationRate, dailyActivationRate,
topTenthActions, dailyActivationRateWeeklyAvg,
manaBet, manaBet,
} = props } = props
const startDate = dayjs(props.startDate).add(12, 'hours').valueOf() const dailyDividedByWeekly = dailyActiveUsers.map(
(dailyActive, i) => dailyActive / weeklyActiveUsers[i]
const dailyDividedByWeekly = dailyActiveUsers )
.map((dailyActive, i) => const dailyDividedByMonthly = dailyActiveUsers.map(
Math.round((100 * dailyActive) / weeklyActiveUsers[i]) (dailyActive, i) => dailyActive / monthlyActiveUsers[i]
) )
.slice(7) const weeklyDividedByMonthly = weeklyActiveUsers.map(
(weeklyActive, i) => weeklyActive / monthlyActiveUsers[i]
const dailyDividedByMonthly = dailyActiveUsers )
.map((dailyActive, i) =>
Math.round((100 * dailyActive) / monthlyActiveUsers[i])
)
.slice(7)
const weeklyDividedByMonthly = weeklyActiveUsers
.map((weeklyActive, i) =>
Math.round((100 * weeklyActive) / monthlyActiveUsers[i])
)
.slice(7)
const oneWeekLaterDate = startDate + 7 * 24 * 60 * 60 * 1000
return ( return (
<Col className="px-2 sm:px-0"> <Col className="px-2 sm:px-0">
@ -129,6 +101,16 @@ export function CustomAnalytics(props: {
/> />
), ),
}, },
{
title: 'Daily (7d avg)',
content: (
<DailyCountChart
dailyCounts={dailyActiveUsersWeeklyAvg}
startDate={startDate}
small
/>
),
},
{ {
title: 'Weekly', title: 'Weekly',
content: ( content: (
@ -153,6 +135,108 @@ export function CustomAnalytics(props: {
/> />
<Spacer h={8} /> <Spacer h={8} />
<Title text="Retention" />
<p className="text-gray-500">
What fraction of active users are still active after the given time
period?
</p>
<Tabs
defaultIndex={1}
tabs={[
{
title: 'D1',
content: (
<DailyPercentChart
dailyPercent={d1}
startDate={startDate}
small
excludeFirstDays={1}
/>
),
},
{
title: 'D1 (7d avg)',
content: (
<DailyPercentChart
dailyPercent={d1WeeklyAvg}
startDate={startDate}
small
excludeFirstDays={7}
/>
),
},
{
title: 'W1',
content: (
<DailyPercentChart
dailyPercent={weekOnWeekRetention}
startDate={startDate}
small
excludeFirstDays={14}
/>
),
},
{
title: 'M1',
content: (
<DailyPercentChart
dailyPercent={monthlyRetention}
startDate={startDate}
small
excludeFirstDays={60}
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="New user retention" />
<p className="text-gray-500">
What fraction of new users are still active after the given time period?
</p>
<Spacer h={4} />
<Tabs
defaultIndex={2}
tabs={[
{
title: 'ND1',
content: (
<DailyPercentChart
dailyPercent={nd1}
startDate={startDate}
excludeFirstDays={1}
small
/>
),
},
{
title: 'ND1 (7d avg)',
content: (
<DailyPercentChart
dailyPercent={nd1WeeklyAvg}
startDate={startDate}
excludeFirstDays={7}
small
/>
),
},
{
title: 'NW1',
content: (
<DailyPercentChart
dailyPercent={nw1}
startDate={startDate}
excludeFirstDays={14}
small
/>
),
},
]}
/>
<Spacer h={8} />
<Title text="Daily activity" /> <Title text="Daily activity" />
<Tabs <Tabs
defaultIndex={0} defaultIndex={0}
@ -202,30 +286,33 @@ export function CustomAnalytics(props: {
<Spacer h={8} /> <Spacer h={8} />
<Title text="Retention" /> <Title text="Activation rate" />
<p className="text-gray-500"> <p className="text-gray-500">
What fraction of active users are still active after the given time Out of all new users, how many placed at least one bet?
period?
</p> </p>
<Spacer h={4} />
<Tabs <Tabs
defaultIndex={0} defaultIndex={1}
tabs={[ tabs={[
{ {
title: 'Weekly', title: 'Daily',
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={weekOnWeekRetention.slice(7)} dailyPercent={dailyActivationRate}
startDate={oneWeekLaterDate} startDate={startDate}
excludeFirstDays={1}
small small
/> />
), ),
}, },
{ {
title: 'Monthly', title: 'Daily (7d avg)',
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={monthlyRetention.slice(7)} dailyPercent={dailyActivationRateWeeklyAvg}
startDate={oneWeekLaterDate} startDate={startDate}
excludeFirstDays={7}
small small
/> />
), ),
@ -234,17 +321,6 @@ export function CustomAnalytics(props: {
/> />
<Spacer h={8} /> <Spacer h={8} />
<Title text="Weekly activation rate" />
<p className="text-gray-500">
Out of all new users this week, how many placed at least one bet?
</p>
<DailyPercentChart
dailyPercent={weeklyActivationRate.slice(7)}
startDate={oneWeekLaterDate}
small
/>
<Spacer h={8} />
<Title text="Ratio of Active Users" /> <Title text="Ratio of Active Users" />
<Tabs <Tabs
defaultIndex={1} defaultIndex={1}
@ -254,8 +330,9 @@ export function CustomAnalytics(props: {
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={dailyDividedByWeekly} dailyPercent={dailyDividedByWeekly}
startDate={oneWeekLaterDate} startDate={startDate}
small small
excludeFirstDays={7}
/> />
), ),
}, },
@ -264,8 +341,9 @@ export function CustomAnalytics(props: {
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={dailyDividedByMonthly} dailyPercent={dailyDividedByMonthly}
startDate={oneWeekLaterDate} startDate={startDate}
small small
excludeFirstDays={30}
/> />
), ),
}, },
@ -274,8 +352,9 @@ export function CustomAnalytics(props: {
content: ( content: (
<DailyPercentChart <DailyPercentChart
dailyPercent={weeklyDividedByMonthly} dailyPercent={weeklyDividedByMonthly}
startDate={oneWeekLaterDate} startDate={startDate}
small small
excludeFirstDays={30}
/> />
), ),
}, },
@ -283,47 +362,6 @@ export function CustomAnalytics(props: {
/> />
<Spacer h={8} /> <Spacer h={8} />
<Title text="Action count of top tenth" />
<p className="text-gray-500">
Number of actions (bets, comments, markets created) taken by the tenth
percentile of top users.
</p>
<Tabs
defaultIndex={1}
tabs={[
{
title: 'Daily',
content: (
<DailyCountChart
dailyCounts={topTenthActions.daily}
startDate={startDate}
small
/>
),
},
{
title: 'Weekly',
content: (
<DailyCountChart
dailyCounts={topTenthActions.weekly}
startDate={startDate}
small
/>
),
},
{
title: 'Monthly',
content: (
<DailyCountChart
dailyCounts={topTenthActions.monthly}
startDate={startDate}
small
/>
),
},
]}
/>
<Title text="Total mana bet" /> <Title text="Total mana bet" />
<p className="text-gray-500"> <p className="text-gray-500">
Sum of bet amounts. (Divided by 100 to be more readable.) Sum of bet amounts. (Divided by 100 to be more readable.)
@ -363,6 +401,7 @@ export function CustomAnalytics(props: {
}, },
]} ]}
/> />
<Spacer h={8} />
</Col> </Col>
) )
} }

View File

@ -80,7 +80,7 @@ const tourneys: Tourney[] = [
title: 'Clearer Thinking Regrant Project', title: 'Clearer Thinking Regrant Project',
blurb: 'Which projects will Clearer Thinking give a grant to?', blurb: 'Which projects will Clearer Thinking give a grant to?',
award: '$13,000', award: '$13,000',
endTime: toDate('Sep 22, 2022'), endTime: toDate('Sep 30, 2022'),
groupId: 'fhksfIgqyWf7OxsV9nkM', 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 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() { function ButtonGetStarted(props: {
useSaveReferral() user?: User | null
useTracking('view twitch landing page') privateUser?: PrivateUser | null
buttonClass?: string
spinnerClass?: string
}) {
const { user, privateUser, buttonClass, spinnerClass } = props
const user = useUser() const [isLoading, setLoading] = useState(false)
const privateUser = usePrivateUser()
const twitchUser = privateUser?.twitchInfo?.twitchName 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 = const callback =
user && privateUser user && privateUser
? () => linkTwitchAccountRedirect(user, privateUser) ? () => linkTwitchAccountRedirect(user, privateUser)
: async () => { : async () => {
const result = await firebaseLogin() await firebaseLogin()
setWaitingForUser(true)
const userId = result.user.uid
const { user, privateUser } = await getUserAndPrivateUser(userId)
if (!user || !privateUser) return
await linkTwitchAccountRedirect(user, privateUser)
} }
const [isLoading, setLoading] = useState(false)
const getStarted = async () => { const getStarted = async () => {
try { try {
setLoading(true) setLoading(true)
@ -49,9 +73,341 @@ export default function TwitchLandingPage() {
} catch (e) { } catch (e) {
console.error(e) console.error(e)
toast.error('Failed to sign up. Please try again later.') toast.error('Failed to sign up. Please try again later.')
} finally {
setLoading(false) 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 ( return (
<Page> <Page>
@ -62,58 +418,11 @@ export default function TwitchLandingPage() {
<div className="px-4 pt-2 md:mt-0 lg:hidden"> <div className="px-4 pt-2 md:mt-0 lg:hidden">
<ManifoldLogo /> <ManifoldLogo />
</div> </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} /> <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} />
{twitchUser ? ( <TwitchChatCommands />
<div className="mt-3 self-center rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 "> <SetUpBot user={user} privateUser={privateUser} />
<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> </Col>
</Page> </Page>
) )

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