Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
127e6930f9
|
@ -11,11 +11,8 @@ import {
|
|||
import { User } from './user'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { noFees } from './fees'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
import { Answer } from './answer'
|
||||
|
||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||
|
||||
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
|
||||
export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
|
||||
|
||||
|
|
|
@ -61,5 +61,3 @@ export type fill = {
|
|||
// I.e. -fill.shares === matchedBet.shares
|
||||
isSale?: boolean
|
||||
}
|
||||
|
||||
export const MAX_LOAN_PER_CONTRACT = 20
|
||||
|
|
|
@ -57,6 +57,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
uniqueBettorIds?: string[]
|
||||
uniqueBettorCount?: number
|
||||
popularityScore?: number
|
||||
followerCount?: number
|
||||
featuredOnHomeRank?: number
|
||||
} & T
|
||||
|
||||
export type BinaryContract = Contract & Binary
|
||||
|
|
17
common/economy.ts
Normal file
17
common/economy.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { ENV_CONFIG } from './envs/constants'
|
||||
|
||||
const econ = ENV_CONFIG.economy
|
||||
|
||||
export const FIXED_ANTE = econ?.FIXED_ANTE ?? 100
|
||||
|
||||
export const STARTING_BALANCE = econ?.STARTING_BALANCE ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = econ?.SUS_STARTING_BALANCE ?? 10
|
||||
export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 500
|
||||
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT =
|
||||
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
|
||||
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
|
||||
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 0
|
||||
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
|
|
@ -44,3 +44,7 @@ export const CORS_ORIGIN_VERCEL = new RegExp(
|
|||
)
|
||||
// Any localhost server on any port
|
||||
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
|
||||
|
||||
export function firestoreConsolePath(contractId: string) {
|
||||
return `https://console.firebase.google.com/project/${PROJECT_ID}/firestore/data/~2Fcontracts~2F${contractId}`
|
||||
}
|
||||
|
|
|
@ -19,10 +19,23 @@ export type EnvConfig = {
|
|||
navbarLogoPath?: string
|
||||
newQuestionPlaceholders: string[]
|
||||
|
||||
// Currency controls
|
||||
fixedAnte?: number
|
||||
startingBalance?: number
|
||||
referralBonus?: number
|
||||
economy?: Economy
|
||||
}
|
||||
|
||||
export type Economy = {
|
||||
FIXED_ANTE?: number
|
||||
|
||||
STARTING_BALANCE?: number
|
||||
SUS_STARTING_BALANCE?: number
|
||||
|
||||
REFERRAL_AMOUNT?: number
|
||||
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT?: number
|
||||
|
||||
BETTING_STREAK_BONUS_AMOUNT?: number
|
||||
BETTING_STREAK_BONUS_MAX?: number
|
||||
BETTING_STREAK_RESET_HOUR?: number
|
||||
FREE_MARKETS_PER_USER_MAX?: number
|
||||
}
|
||||
|
||||
type FirebaseConfig = {
|
||||
|
@ -58,6 +71,8 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
'taowell@gmail.com', // Stephen
|
||||
'abc.sinclair@gmail.com', // Sinclair
|
||||
'manticmarkets@gmail.com', // Manifold
|
||||
'iansphilips@gmail.com', // Ian
|
||||
'd4vidchee@gmail.com', // D4vid
|
||||
],
|
||||
visibility: 'PUBLIC',
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
export const PLATFORM_FEE = 0
|
||||
export const CREATOR_FEE = 0.1
|
||||
export const CREATOR_FEE = 0
|
||||
export const LIQUIDITY_FEE = 0
|
||||
|
||||
export const DPM_PLATFORM_FEE = 0.01
|
||||
export const DPM_CREATOR_FEE = 0.04
|
||||
export const DPM_PLATFORM_FEE = 0.0
|
||||
export const DPM_CREATOR_FEE = 0.0
|
||||
export const DPM_FEES = DPM_PLATFORM_FEE + DPM_CREATOR_FEE
|
||||
|
||||
export type Fees = {
|
||||
|
|
138
common/loans.ts
Normal file
138
common/loans.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { Dictionary, groupBy, sumBy, minBy } from 'lodash'
|
||||
import { Bet } from './bet'
|
||||
import { getContractBetMetrics } from './calculate'
|
||||
import {
|
||||
Contract,
|
||||
CPMMContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
} from './contract'
|
||||
import { PortfolioMetrics, User } from './user'
|
||||
import { filterDefined } from './util/array'
|
||||
|
||||
const LOAN_DAILY_RATE = 0.01
|
||||
|
||||
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
|
||||
const netValue = investedValue - loanTotal
|
||||
return netValue * LOAN_DAILY_RATE
|
||||
}
|
||||
|
||||
export const getLoanUpdates = (
|
||||
users: User[],
|
||||
contractsById: { [contractId: string]: Contract },
|
||||
portfolioByUser: { [userId: string]: PortfolioMetrics | undefined },
|
||||
betsByUser: { [userId: string]: Bet[] }
|
||||
) => {
|
||||
const eligibleUsers = filterDefined(
|
||||
users.map((user) =>
|
||||
isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined
|
||||
)
|
||||
)
|
||||
|
||||
const betUpdates = eligibleUsers
|
||||
.map((user) => {
|
||||
const updates = calculateLoanBetUpdates(
|
||||
betsByUser[user.id] ?? [],
|
||||
contractsById
|
||||
).betUpdates
|
||||
return updates.map((update) => ({ ...update, user }))
|
||||
})
|
||||
.flat()
|
||||
|
||||
const updatesByUser = groupBy(betUpdates, (update) => update.userId)
|
||||
const userPayouts = Object.values(updatesByUser).map((updates) => {
|
||||
return {
|
||||
user: updates[0].user,
|
||||
payout: sumBy(updates, (update) => update.newLoan),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
betUpdates,
|
||||
userPayouts,
|
||||
}
|
||||
}
|
||||
|
||||
const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => {
|
||||
if (!portfolio) return true
|
||||
|
||||
const { balance, investmentValue } = portfolio
|
||||
return balance + investmentValue > 0
|
||||
}
|
||||
|
||||
const calculateLoanBetUpdates = (
|
||||
bets: Bet[],
|
||||
contractsById: Dictionary<Contract>
|
||||
) => {
|
||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||
const contracts = filterDefined(
|
||||
Object.keys(betsByContract).map((contractId) => contractsById[contractId])
|
||||
).filter((c) => !c.isResolved)
|
||||
|
||||
const betUpdates = filterDefined(
|
||||
contracts
|
||||
.map((c) => {
|
||||
if (c.mechanism === 'cpmm-1') {
|
||||
return getBinaryContractLoanUpdate(c, betsByContract[c.id])
|
||||
} else if (
|
||||
c.outcomeType === 'FREE_RESPONSE' ||
|
||||
c.outcomeType === 'MULTIPLE_CHOICE'
|
||||
)
|
||||
return getFreeResponseContractLoanUpdate(c, betsByContract[c.id])
|
||||
else {
|
||||
// Unsupported contract / mechanism for loans.
|
||||
return []
|
||||
}
|
||||
})
|
||||
.flat()
|
||||
)
|
||||
|
||||
const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal)
|
||||
|
||||
return {
|
||||
totalNewLoan,
|
||||
betUpdates,
|
||||
}
|
||||
}
|
||||
|
||||
const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
|
||||
const { invested } = getContractBetMetrics(contract, bets)
|
||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const oldestBet = minBy(bets, (bet) => bet.createdTime)
|
||||
|
||||
const newLoan = calculateNewLoan(invested, loanAmount)
|
||||
if (!isFinite(newLoan) || newLoan <= 0 || !oldestBet) return undefined
|
||||
|
||||
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
|
||||
|
||||
return {
|
||||
userId: oldestBet.userId,
|
||||
contractId: contract.id,
|
||||
betId: oldestBet.id,
|
||||
newLoan,
|
||||
loanTotal,
|
||||
}
|
||||
}
|
||||
|
||||
const getFreeResponseContractLoanUpdate = (
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
|
||||
|
||||
return openBets.map((bet) => {
|
||||
const loanAmount = bet.loanAmount ?? 0
|
||||
const newLoan = calculateNewLoan(bet.amount, loanAmount)
|
||||
const loanTotal = loanAmount + newLoan
|
||||
|
||||
if (!isFinite(newLoan) || newLoan <= 0) return undefined
|
||||
|
||||
return {
|
||||
userId: bet.userId,
|
||||
contractId: contract.id,
|
||||
betId: bet.id,
|
||||
newLoan,
|
||||
loanTotal,
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { sortBy, sum, sumBy } from 'lodash'
|
||||
|
||||
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
|
||||
import { Bet, fill, LimitBet, NumericBet } from './bet'
|
||||
import {
|
||||
calculateDpmShares,
|
||||
getDpmProbability,
|
||||
|
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
|
|||
export const getNewBinaryDpmBetInfo = (
|
||||
outcome: 'YES' | 'NO',
|
||||
amount: number,
|
||||
contract: DPMBinaryContract,
|
||||
loanAmount: number
|
||||
contract: DPMBinaryContract
|
||||
) => {
|
||||
const { YES: yesPool, NO: noPool } = contract.pool
|
||||
|
||||
|
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
loanAmount: 0,
|
||||
shares,
|
||||
outcome,
|
||||
probBefore,
|
||||
|
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
|
|||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
loanAmount: number
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
||||
|
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
|
|||
const newBet: CandidateBet = {
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
loanAmount,
|
||||
loanAmount: 0,
|
||||
shares,
|
||||
outcome,
|
||||
probBefore,
|
||||
|
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
|
|||
|
||||
return { newBet, newPool, newTotalShares, newTotalBets }
|
||||
}
|
||||
|
||||
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
|
||||
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanAmount = Math.min(
|
||||
newBetAmount,
|
||||
MAX_LOAN_PER_CONTRACT - prevLoanAmount
|
||||
)
|
||||
return loanAmount
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ export type notification_source_types =
|
|||
| 'bonus'
|
||||
| 'challenge'
|
||||
| 'betting_streak_bonus'
|
||||
| 'loan'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -68,3 +69,5 @@ export type notification_reason_types =
|
|||
| 'user_joined_from_your_group_invite'
|
||||
| 'challenge_accepted'
|
||||
| 'betting_streak_incremented'
|
||||
| 'loan_income'
|
||||
| 'you_follow_contract'
|
||||
|
|
|
@ -3,7 +3,3 @@ export const NUMERIC_FIXED_VAR = 0.005
|
|||
|
||||
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
|
||||
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
|
||||
export const UNIQUE_BETTOR_BONUS_AMOUNT = 10
|
||||
export const BETTING_STREAK_BONUS_AMOUNT = 5
|
||||
export const BETTING_STREAK_BONUS_MAX = 100
|
||||
export const BETTING_STREAK_RESET_HOUR = 0
|
||||
|
|
|
@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
|||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
||||
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
|
||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanPayment = Math.min(loanAmount, shares)
|
||||
const loanPayment = loanAmount * soldFrac
|
||||
const netAmount = shares - loanPayment
|
||||
return { shares, loanPayment, netAmount }
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
|||
|
||||
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
const { id: betId, amount, shares, outcome } = bet
|
||||
const { id: betId, amount, shares, outcome, loanAmount } = bet
|
||||
|
||||
const adjShareValue = calculateDpmShareValue(contract, bet)
|
||||
|
||||
|
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
|||
betId,
|
||||
},
|
||||
fees,
|
||||
loanAmount: -(loanAmount ?? 0),
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
|
|||
shares: number,
|
||||
outcome: 'YES' | 'NO',
|
||||
contract: CPMMContract,
|
||||
prevLoanAmount: number,
|
||||
unfilledBets: LimitBet[]
|
||||
unfilledBets: LimitBet[],
|
||||
loanPaid: number
|
||||
) => {
|
||||
const { pool, p } = contract
|
||||
|
||||
|
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
|
|||
unfilledBets
|
||||
)
|
||||
|
||||
const loanPaid = Math.min(prevLoanAmount, saleValue)
|
||||
const probBefore = getCpmmProbability(pool, p)
|
||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { ENV_CONFIG } from './envs/constants'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
@ -32,6 +30,7 @@ export type User = {
|
|||
allTime: number
|
||||
}
|
||||
|
||||
nextLoanCached: number
|
||||
followerCountCached: number
|
||||
|
||||
followedCategories?: string[]
|
||||
|
@ -43,13 +42,10 @@ export type User = {
|
|||
shouldShowWelcome?: boolean
|
||||
lastBetTime?: number
|
||||
currentBettingStreak?: number
|
||||
hasSeenContractFollowModal?: boolean
|
||||
freeMarketsCreated?: number
|
||||
}
|
||||
|
||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||
// for sus users, i.e. multiple sign ups for same person
|
||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
||||
|
||||
export type PrivateUser = {
|
||||
id: string // same as User.id
|
||||
username: string // denormalized from User
|
||||
|
@ -60,6 +56,7 @@ export type PrivateUser = {
|
|||
unsubscribedFromAnswerEmails?: boolean
|
||||
unsubscribedFromGenericEmails?: boolean
|
||||
unsubscribedFromWeeklyTrendingEmails?: boolean
|
||||
weeklyTrendingEmailSent?: boolean
|
||||
manaBonusEmailSent?: boolean
|
||||
initialDeviceToken?: string
|
||||
initialIpAddress?: string
|
||||
|
|
2
dev.sh
2
dev.sh
|
@ -24,7 +24,7 @@ then
|
|||
npx concurrently \
|
||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||
-c green,white,magenta,cyan \
|
||||
"yarn --cwd=functions firestore" \
|
||||
"yarn --cwd=functions localDbScript" \
|
||||
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||
|
|
|
@ -97,7 +97,6 @@ Requires no authorization.
|
|||
"creatorAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
||||
"closeTime":1653893940000,
|
||||
"question":"Will I write a new blog post today?",
|
||||
"description":"I'm supposed to, or else Beeminder charges me $90.\nTentative topic ideas:\n- \"Manifold funding, a history\"\n- \"Markets and bounties allow trades through time\"\n- \"equity vs money vs time\"\n\nClose date updated to 2022-05-29 11:59 pm",
|
||||
"tags":[
|
||||
"personal",
|
||||
"commitments"
|
||||
|
@ -135,8 +134,6 @@ Requires no authorization.
|
|||
// Market attributes. All times are in milliseconds since epoch
|
||||
closeTime?: number // Min of creator's chosen date, and resolutionTime
|
||||
question: string
|
||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||
textDescription: string // string description without formatting, images, or embeds
|
||||
|
||||
// A list of tags on each market. Any user can add tags to any market.
|
||||
// This list also includes the predefined categories shown as filters on the home page.
|
||||
|
@ -398,6 +395,8 @@ Requires no authorization.
|
|||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers?: Answer[]
|
||||
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
|
||||
textDescription: string // string description without formatting, images, or embeds
|
||||
}
|
||||
|
||||
type Bet = {
|
||||
|
|
|
@ -10,7 +10,9 @@ service cloud.firestore {
|
|||
'akrolsmir@gmail.com',
|
||||
'jahooma@gmail.com',
|
||||
'taowell@gmail.com',
|
||||
'manticmarkets@gmail.com'
|
||||
'abc.sinclair@gmail.com',
|
||||
'manticmarkets@gmail.com',
|
||||
'iansphilips@gmail.com'
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -22,7 +24,7 @@ service cloud.firestore {
|
|||
allow read;
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']);
|
||||
// User referral rules
|
||||
allow update: if userId == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
|
@ -43,6 +45,11 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/follows/{userId} {
|
||||
allow read;
|
||||
allow create, delete: if userId == request.auth.uid;
|
||||
}
|
||||
|
||||
match /contracts/{contractId}/challenges/{challengeId}{
|
||||
allow read;
|
||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log",
|
||||
"dev": "nodemon src/serve.ts",
|
||||
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
||||
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||
|
|
|
@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
|||
}
|
||||
transaction.create(newAnswerDoc, answer)
|
||||
|
||||
const loanAmount = 0
|
||||
|
||||
const { newBet, newPool, newTotalShares, newTotalBets } =
|
||||
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
|
||||
getNewMultiBetInfo(answerId, amount, contract)
|
||||
|
||||
const newBalance = user.balance - amount
|
||||
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||
|
|
|
@ -15,15 +15,17 @@ import {
|
|||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
|
||||
import { chargeUser, getContract } from './utils'
|
||||
import { chargeUser, getContract, isProd } from './utils'
|
||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||
|
||||
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
|
||||
import {
|
||||
FIXED_ANTE,
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getMultipleChoiceAntes,
|
||||
getNumericAnte,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
|
@ -34,6 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric'
|
|||
import { JSONContent } from '@tiptap/core'
|
||||
import { uniq, zip } from 'lodash'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
|
@ -137,9 +140,10 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
const user = userDoc.data() as User
|
||||
|
||||
const ante = FIXED_ANTE
|
||||
|
||||
const deservesFreeMarket =
|
||||
(user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||
// TODO: this is broken because it's not in a transaction
|
||||
if (ante > user.balance)
|
||||
if (ante > user.balance && !deservesFreeMarket)
|
||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||
|
||||
let group: Group | null = null
|
||||
|
@ -207,7 +211,18 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
visibility
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
const providerId = deservesFreeMarket
|
||||
? isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: user.id
|
||||
|
||||
if (ante) await chargeUser(providerId, ante, true)
|
||||
if (deservesFreeMarket)
|
||||
await firestore
|
||||
.collection('users')
|
||||
.doc(user.id)
|
||||
.update({ freeMarketsCreated: FieldValue.increment(1) })
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
|
@ -221,8 +236,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
}
|
||||
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const liquidityDoc = firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '../../common/notification'
|
||||
import { User } from '../../common/user'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getValues } from './utils'
|
||||
import { getValues, log } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
|
@ -33,19 +33,12 @@ export const createNotification = async (
|
|||
sourceText: string,
|
||||
miscData?: {
|
||||
contract?: Contract
|
||||
relatedSourceType?: notification_source_types
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
) => {
|
||||
const {
|
||||
contract: sourceContract,
|
||||
relatedSourceType,
|
||||
recipients,
|
||||
slug,
|
||||
title,
|
||||
} = miscData ?? {}
|
||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
|
@ -90,24 +83,6 @@ export const createNotification = async (
|
|||
)
|
||||
}
|
||||
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
contract: Contract
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (!shouldGetNotification(userId, userToReasonTexts)) return
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUsersFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
|
@ -129,23 +104,6 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
}
|
||||
} else if (relatedSourceType === 'answer') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_answer',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyFollowedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
followedUserId: string
|
||||
|
@ -182,71 +140,6 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const answers = await getValues<Answer>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('answers')
|
||||
)
|
||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_answer',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyOtherCommentersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('comments')
|
||||
)
|
||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_comment',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
||||
(userId) => {
|
||||
return (
|
||||
getContractBetMetrics(
|
||||
sourceContract,
|
||||
bets.filter((bet) => bet.userId === userId)
|
||||
).invested > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUserAddedToGroup = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
|
@ -266,9 +159,9 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
|
||||
if (sourceType === 'follow' && recipients?.[0]) {
|
||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||
} else if (
|
||||
|
@ -277,47 +170,278 @@ export const createNotification = async (
|
|||
recipients
|
||||
) {
|
||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||
}
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
if (!sourceContract) return userToReasonTexts
|
||||
|
||||
if (
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
(sourceType === 'contract' &&
|
||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (recipients?.[0] && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'closed' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
} else if (
|
||||
sourceType === 'liquidity' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
||||
} else if (
|
||||
sourceType === 'bonus' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
}
|
||||
return userToReasonTexts
|
||||
|
||||
await createUsersNotifications(userToReasonTexts)
|
||||
}
|
||||
|
||||
const userToReasonTexts = await getUsersToNotify()
|
||||
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||
sourceId: string,
|
||||
sourceType: notification_source_types,
|
||||
sourceUpdateType: notification_source_update_types,
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
sourceContract: Contract,
|
||||
miscData?: {
|
||||
relatedSourceType?: notification_source_types
|
||||
repliedUserId?: string
|
||||
taggedUserIds?: string[]
|
||||
}
|
||||
) => {
|
||||
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
|
||||
|
||||
const createUsersNotifications = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
await Promise.all(
|
||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${userId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId,
|
||||
reason: userToReasonTexts[userId].reason,
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId,
|
||||
sourceType,
|
||||
sourceUpdateType,
|
||||
sourceContractId: sourceContract.id,
|
||||
sourceUserName: sourceUser.name,
|
||||
sourceUserUsername: sourceUser.username,
|
||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||
sourceText,
|
||||
sourceContractCreatorUsername: sourceContract.creatorUsername,
|
||||
sourceContractTitle: sourceContract.question,
|
||||
sourceContractSlug: sourceContract.slug,
|
||||
sourceSlug: sourceContract.slug,
|
||||
sourceTitle: sourceContract.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// get contract follower documents and check here if they're a follower
|
||||
const contractFollowersSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/follows`)
|
||||
.get()
|
||||
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||
(doc) => doc.data().id
|
||||
)
|
||||
log('contractFollowerIds', contractFollowersIds)
|
||||
|
||||
const stillFollowingContract = (userId: string) => {
|
||||
return contractFollowersIds.includes(userId)
|
||||
}
|
||||
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
return (
|
||||
sourceUser.id != userId &&
|
||||
!Object.keys(userToReasonTexts).includes(userId)
|
||||
)
|
||||
}
|
||||
|
||||
const notifyContractFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
for (const userId of contractFollowersIds) {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'you_follow_contract',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContractCreator = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
||||
stillFollowingContract(sourceContract.creatorId)
|
||||
)
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'on_users_contract',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const answers = await getValues<Answer>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('answers')
|
||||
)
|
||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_answer',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyOtherCommentersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('comments')
|
||||
)
|
||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_comment',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
||||
(userId) => {
|
||||
return (
|
||||
getContractBetMetrics(
|
||||
sourceContract,
|
||||
bets.filter((bet) => bet.userId === userId)
|
||||
).invested > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(relatedUserId, userToReasonTexts) &&
|
||||
stillFollowingContract(relatedUserId)
|
||||
) {
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
}
|
||||
} else if (relatedSourceType === 'answer') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_answer',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
userIds.forEach((id) => {
|
||||
console.log('tagged user: ', id)
|
||||
// Allowing non-following users to get tagged
|
||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||
userToReasonTexts[id] = {
|
||||
reason: 'tagged_user',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
) {
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
|
||||
if (sourceType === 'comment') {
|
||||
if (repliedUserId && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
||||
await notifyLiquidityProviders(userToReasonTexts)
|
||||
await notifyBettorsOnContract(userToReasonTexts)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts)
|
||||
// if they weren't added previously, add them now
|
||||
await notifyContractFollowers(userToReasonTexts)
|
||||
|
||||
await createUsersNotifications(userToReasonTexts)
|
||||
}
|
||||
|
||||
|
@ -471,6 +595,32 @@ export const createReferralNotification = async (
|
|||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
export const createLoanIncomeNotification = async (
|
||||
toUser: User,
|
||||
idempotencyKey: string,
|
||||
income: number
|
||||
) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${toUser.id}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId: toUser.id,
|
||||
reason: 'loan_income',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: idempotencyKey,
|
||||
sourceType: 'loan',
|
||||
sourceUpdateType: 'updated',
|
||||
sourceUserName: toUser.name,
|
||||
sourceUserUsername: toUser.username,
|
||||
sourceUserAvatarUrl: toUser.avatarUrl,
|
||||
sourceText: income.toString(),
|
||||
sourceTitle: 'Loan',
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||
|
||||
export const createChallengeAcceptedNotification = async (
|
||||
|
|
|
@ -2,15 +2,8 @@ import * as admin from 'firebase-admin'
|
|||
import { z } from 'zod'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import {
|
||||
MANIFOLD_AVATAR_URL,
|
||||
MANIFOLD_USERNAME,
|
||||
PrivateUser,
|
||||
STARTING_BALANCE,
|
||||
SUS_STARTING_BALANCE,
|
||||
User,
|
||||
} from '../../common/user'
|
||||
import { getUser, getUserByUsername, getValues, isProd } from './utils'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { getUser, getUserByUsername, getValues } from './utils'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import {
|
||||
cleanDisplayName,
|
||||
|
@ -25,10 +18,7 @@ import {
|
|||
import { track } from './analytics'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||
|
||||
const bodySchema = z.object({
|
||||
deviceToken: z.string().optional(),
|
||||
|
@ -75,6 +65,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
createdTime: Date.now(),
|
||||
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||
nextLoanCached: 0,
|
||||
followerCountCached: 0,
|
||||
followedCategories: DEFAULT_CATEGORIES,
|
||||
shouldShowWelcome: true,
|
||||
|
@ -144,24 +135,5 @@ const addUserToDefaultGroups = async (user: User) => {
|
|||
.update({
|
||||
memberIds: uniq(group.memberIds.concat(user.id)),
|
||||
})
|
||||
const manifoldAccount = isProd()
|
||||
? HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
|
||||
if (slug === 'welcome') {
|
||||
const welcomeCommentDoc = firestore
|
||||
.collection(`groups/${group.id}/comments`)
|
||||
.doc()
|
||||
await welcomeCommentDoc.create({
|
||||
id: welcomeCommentDoc.id,
|
||||
groupId: group.id,
|
||||
userId: manifoldAccount,
|
||||
text: `Welcome, @${user.username} aka ${user.name}!`,
|
||||
createdTime: Date.now(),
|
||||
userName: 'Manifold Markets',
|
||||
userUsername: MANIFOLD_USERNAME,
|
||||
userAvatarUrl: MANIFOLD_AVATAR_URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -444,7 +444,7 @@
|
|||
style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to unsubscribe</a>.
|
||||
" target="_blank">click here to unsubscribe</a> from future recommended markets.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -53,10 +53,10 @@ export const sendMarketResolutionEmail = async (
|
|||
|
||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||
|
||||
const creatorPayoutText =
|
||||
userId === creator.id
|
||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
: ''
|
||||
// const creatorPayoutText =
|
||||
// userId === creator.id
|
||||
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
// : ''
|
||||
|
||||
const emailType = 'market-resolved'
|
||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
@ -68,7 +68,7 @@ export const sendMarketResolutionEmail = async (
|
|||
question: contract.question,
|
||||
outcome,
|
||||
investment: `${Math.floor(investment)}`,
|
||||
payout: `${Math.floor(payout)}${creatorPayoutText}`,
|
||||
payout: `${Math.floor(payout)}`,
|
||||
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
||||
unsubscribeUrl,
|
||||
}
|
||||
|
@ -116,7 +116,9 @@ const toDisplayResolution = (
|
|||
}
|
||||
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
const { resolutionValue } = contract
|
||||
const { resolution, resolutionValue } = contract
|
||||
|
||||
if (resolution === 'CANCEL') return 'N/A'
|
||||
|
||||
return resolutionValue
|
||||
? formatLargeNumber(resolutionValue)
|
||||
|
|
36
functions/src/follow-market.ts
Normal file
36
functions/src/follow-market.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const addUserToContractFollowers = async (
|
||||
contractId: string,
|
||||
userId: string
|
||||
) => {
|
||||
const followerDoc = await firestore
|
||||
.collection(`contracts/${contractId}/follows`)
|
||||
.doc(userId)
|
||||
.get()
|
||||
if (followerDoc.exists) return
|
||||
await firestore
|
||||
.collection(`contracts/${contractId}/follows`)
|
||||
.doc(userId)
|
||||
.set({
|
||||
id: userId,
|
||||
createdTime: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export const removeUserFromContractFollowers = async (
|
||||
contractId: string,
|
||||
userId: string
|
||||
) => {
|
||||
const followerDoc = await firestore
|
||||
.collection(`contracts/${contractId}/follows`)
|
||||
.doc(userId)
|
||||
.get()
|
||||
if (!followerDoc.exists) return
|
||||
await firestore
|
||||
.collection(`contracts/${contractId}/follows`)
|
||||
.doc(userId)
|
||||
.delete()
|
||||
}
|
|
@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
|
|||
export * from './on-view'
|
||||
export * from './update-metrics'
|
||||
export * from './update-stats'
|
||||
export * from './update-loans'
|
||||
export * from './backup-db'
|
||||
export * from './market-close-notifications'
|
||||
export * from './on-create-answer'
|
||||
|
@ -28,6 +29,8 @@ export * from './on-delete-group'
|
|||
export * from './score-contracts'
|
||||
export * from './weekly-markets-emails'
|
||||
export * from './reset-betting-streaks'
|
||||
export * from './reset-weekly-emails-flag'
|
||||
export * from './on-update-contract-follow'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { Answer } from '../../common/answer'
|
||||
|
||||
export const onCreateAnswer = functions.firestore
|
||||
|
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
|
|||
|
||||
const answerCreator = await getUser(answer.userId)
|
||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
answer.id,
|
||||
'answer',
|
||||
'created',
|
||||
answerCreator,
|
||||
eventId,
|
||||
answer.text,
|
||||
{ contract }
|
||||
contract
|
||||
)
|
||||
})
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
BETTING_STREAK_BONUS_MAX,
|
||||
BETTING_STREAK_RESET_HOUR,
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
} from '../../common/numeric-constants'
|
||||
} from '../../common/economy'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
|
|
|
@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
|
|||
import { sendNewCommentEmail } from './emails'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Answer } from '../../common/answer'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
|
|||
const commentCreator = await getUser(comment.userId)
|
||||
if (!commentCreator) throw new Error('Could not find comment creator')
|
||||
|
||||
await addUserToContractFollowers(contract.id, commentCreator.id)
|
||||
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(contract.id)
|
||||
|
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
|
|||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||
: answer?.userId
|
||||
|
||||
const recipients = uniq(
|
||||
compact([...parseMentions(comment.content), repliedUserId])
|
||||
)
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
comment.id,
|
||||
'comment',
|
||||
'created',
|
||||
commentCreator,
|
||||
eventId,
|
||||
richTextToString(comment.content),
|
||||
{ contract, relatedSourceType, recipients }
|
||||
contract,
|
||||
{
|
||||
relatedSourceType,
|
||||
repliedUserId,
|
||||
taggedUserIds: compact(parseMentions(comment.content)),
|
||||
}
|
||||
)
|
||||
|
||||
const recipientUserIds = uniq([
|
||||
|
|
|
@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
export const onCreateContract = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
|
@ -18,6 +19,7 @@ export const onCreateContract = functions
|
|||
|
||||
const desc = contract.description as JSONContent
|
||||
const mentioned = parseMentions(desc)
|
||||
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getContract, getUser } from './utils'
|
||||
import { getContract, getUser, log } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
import { FIXED_ANTE } from '../../common/economy'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
|
||||
export const onCreateLiquidityProvision = functions.firestore
|
||||
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
||||
|
@ -10,7 +16,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
const { eventId } = context
|
||||
|
||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
||||
if (
|
||||
(liquidity.userId === HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||
liquidity.userId === DEV_HOUSE_LIQUIDITY_PROVIDER_ID) &&
|
||||
liquidity.amount === FIXED_ANTE
|
||||
)
|
||||
return
|
||||
|
||||
log(`onCreateLiquidityProvision: ${JSON.stringify(liquidity)}`)
|
||||
|
||||
const contract = await getContract(liquidity.contractId)
|
||||
if (!contract)
|
||||
|
@ -18,6 +31,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
|
||||
const liquidityProvider = await getUser(liquidity.userId)
|
||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||
|
||||
await createNotification(
|
||||
contract.id,
|
||||
|
|
45
functions/src/on-update-contract-follow.ts
Normal file
45
functions/src/on-update-contract-follow.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
|
||||
export const onDeleteContractFollow = functions.firestore
|
||||
.document('contracts/{contractId}/follows/{userId}')
|
||||
.onDelete(async (change, context) => {
|
||||
const { contractId } = context.params as {
|
||||
contractId: string
|
||||
}
|
||||
const firestore = admin.firestore()
|
||||
const contract = await firestore
|
||||
.collection(`contracts`)
|
||||
.doc(contractId)
|
||||
.get()
|
||||
if (!contract.exists) throw new Error('Could not find contract')
|
||||
|
||||
await firestore
|
||||
.collection(`contracts`)
|
||||
.doc(contractId)
|
||||
.update({
|
||||
followerCount: FieldValue.increment(-1),
|
||||
})
|
||||
})
|
||||
|
||||
export const onCreateContractFollow = functions.firestore
|
||||
.document('contracts/{contractId}/follows/{userId}')
|
||||
.onCreate(async (change, context) => {
|
||||
const { contractId } = context.params as {
|
||||
contractId: string
|
||||
}
|
||||
const firestore = admin.firestore()
|
||||
const contract = await firestore
|
||||
.collection(`contracts`)
|
||||
.doc(contractId)
|
||||
.get()
|
||||
if (!contract.exists) throw new Error('Could not find contract')
|
||||
|
||||
await firestore
|
||||
.collection(`contracts`)
|
||||
.doc(contractId)
|
||||
.update({
|
||||
followerCount: FieldValue.increment(1),
|
||||
})
|
||||
})
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import { getUser } from './utils'
|
||||
import { createNotification } from './create-notification'
|
||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
||||
export const onUpdateContract = functions.firestore
|
||||
|
@ -29,40 +29,37 @@ export const onUpdateContract = functions.firestore
|
|||
resolutionText = `${contract.resolutionValue}`
|
||||
}
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'resolved',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
resolutionText,
|
||||
{ contract }
|
||||
contract
|
||||
)
|
||||
} else if (
|
||||
previousValue.closeTime !== contract.closeTime ||
|
||||
previousValue.description !== contract.description
|
||||
previousValue.question !== contract.question
|
||||
) {
|
||||
let sourceText = ''
|
||||
if (previousValue.closeTime !== contract.closeTime && contract.closeTime)
|
||||
if (
|
||||
previousValue.closeTime !== contract.closeTime &&
|
||||
contract.closeTime
|
||||
) {
|
||||
sourceText = contract.closeTime.toString()
|
||||
else {
|
||||
const oldTrimmedDescription = previousValue.description.trim()
|
||||
const newTrimmedDescription = contract.description.trim()
|
||||
if (oldTrimmedDescription === '') sourceText = newTrimmedDescription
|
||||
else
|
||||
sourceText = newTrimmedDescription
|
||||
.split(oldTrimmedDescription)[1]
|
||||
.trim()
|
||||
} else if (previousValue.question !== contract.question) {
|
||||
sourceText = contract.question
|
||||
}
|
||||
|
||||
await createNotification(
|
||||
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||
contract.id,
|
||||
'contract',
|
||||
'updated',
|
||||
contractUpdater,
|
||||
eventId,
|
||||
sourceText,
|
||||
{ contract }
|
||||
contract
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
||||
import { User } from '../../common/user'
|
||||
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||
import { createReferralNotification } from './create-notification'
|
||||
import { ReferralTxn } from '../../common/txn'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { LimitBet } from 'common/bet'
|
||||
import { LimitBet } from '../../common/bet'
|
||||
import { QuerySnapshot } from 'firebase-admin/firestore'
|
||||
import { Group } from 'common/group'
|
||||
import { Group } from '../../common/group'
|
||||
import { REFERRAL_AMOUNT } from '../../common/economy'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const onUpdateUser = functions.firestore
|
||||
|
|
|
@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
|
|||
import { floatingEqual } from '../../common/util/math'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { log } from './utils'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -59,7 +60,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
const user = userSnap.data() as User
|
||||
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
||||
|
||||
const loanAmount = 0
|
||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||
contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
|
@ -119,7 +119,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||
const answerSnap = await trans.get(answerDoc)
|
||||
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
|
||||
return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
|
||||
return getNewMultiBetInfo(outcome, amount, contract)
|
||||
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
|
||||
const { outcome, value } = validate(numericSchema, req.body)
|
||||
return getNumericBetsInfo(value, outcome, amount, contract)
|
||||
|
@ -168,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
return { betId: betDoc.id, makers, newBet }
|
||||
})
|
||||
|
||||
await addUserToContractFollowers(contractId, auth.uid)
|
||||
|
||||
log('Main transaction finished.')
|
||||
|
||||
if (result.newBet.amount !== 0) {
|
||||
|
|
|
@ -4,12 +4,12 @@ import * as functions from 'firebase-functions'
|
|||
import * as admin from 'firebase-admin'
|
||||
import { User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants'
|
||||
import { BETTING_STREAK_RESET_HOUR } from '../../common/economy'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const resetBettingStreaksForUsers = functions.pubsub
|
||||
.schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`)
|
||||
.timeZone('utc')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
await resetBettingStreaksInternal()
|
||||
})
|
||||
|
|
24
functions/src/reset-weekly-emails-flag.ts
Normal file
24
functions/src/reset-weekly-emails-flag.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { getAllPrivateUsers } from './utils'
|
||||
|
||||
export const resetWeeklyEmailsFlag = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
// every Monday at 12 am PT (UTC -07:00) ( 12 hours before the emails will be sent)
|
||||
.pubsub.schedule('0 7 * * 1')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
const privateUsers = await getAllPrivateUsers()
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return !user.unsubscribedFromWeeklyTrendingEmails
|
||||
})
|
||||
const firestore = admin.firestore()
|
||||
await Promise.all(
|
||||
privateUsersToSendEmailsTo.map(async (user) => {
|
||||
return firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: false,
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { getValues } from '../utils'
|
||||
import { Contract } from 'common/lib/contract'
|
||||
import { Comment } from 'common/lib/comment'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet } from 'common/lib/bet'
|
||||
import {
|
||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from 'common/lib/antes'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
async function backfillContractFollowers() {
|
||||
console.log('Backfilling contract followers')
|
||||
const contracts = await getValues<Contract>(
|
||||
firestore.collection('contracts').where('isResolved', '==', false)
|
||||
)
|
||||
let count = 0
|
||||
for (const contract of contracts) {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore.collection('contracts').doc(contract.id).collection('comments')
|
||||
)
|
||||
const commenterIds = uniq(comments.map((comment) => comment.userId))
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const bettorIds = uniq(bets.map((bet) => bet.userId))
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
// exclude free market liquidity provider
|
||||
).filter(
|
||||
(id) =>
|
||||
id !== HOUSE_LIQUIDITY_PROVIDER_ID ||
|
||||
id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID
|
||||
)
|
||||
const followerIds = uniq([
|
||||
...commenterIds,
|
||||
...bettorIds,
|
||||
...liquidityProvidersIds,
|
||||
contract.creatorId,
|
||||
])
|
||||
for (const followerId of followerIds) {
|
||||
await firestore
|
||||
.collection(`contracts/${contract.id}/follows`)
|
||||
.doc(followerId)
|
||||
.set({ id: followerId, createdTime: Date.now() })
|
||||
}
|
||||
// Perhaps handled by the trigger?
|
||||
// const followerCount = followerIds.length
|
||||
// await firestore
|
||||
// .collection(`contracts`)
|
||||
// .doc(contract.id)
|
||||
// .update({ followerCount: followerCount })
|
||||
count += 1
|
||||
if (count % 100 === 0) {
|
||||
console.log(`${count} contracts processed`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
backfillContractFollowers()
|
||||
.then(() => process.exit())
|
||||
.catch(console.log)
|
||||
}
|
|
@ -3,7 +3,8 @@ import * as admin from 'firebase-admin'
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import { STARTING_BALANCE } from 'common/economy'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
|
|
@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
const saleAmount = newBet.sale!.amount
|
||||
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0)
|
||||
const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0)
|
||||
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
transaction.update(betDoc, { isSold: true })
|
||||
// Note: id should have been newBetDoc.id! But leaving it for now so it's consistent.
|
||||
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
||||
transaction.update(
|
||||
contractDoc,
|
||||
|
|
|
@ -7,12 +7,13 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
|||
import { User } from '../../common/user'
|
||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
import { getValues, log } from './utils'
|
||||
import { log } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||
import { FieldValue } from 'firebase-admin/firestore'
|
||||
import { redeemShares } from './redeem-shares'
|
||||
import { removeUserFromContractFollowers } from './follow-market'
|
||||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
|
@ -28,12 +29,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||
const [[contractSnap, userSnap], userBets] = await Promise.all([
|
||||
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
|
||||
await Promise.all([
|
||||
transaction.getAll(contractDoc, userDoc),
|
||||
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
||||
transaction.get(betsQ),
|
||||
transaction.get(getUnfilledBetsQuery(contractDoc)),
|
||||
])
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
|
||||
const contract = contractSnap.data() as Contract
|
||||
const user = userSnap.data() as User
|
||||
|
@ -45,7 +50,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
if (closeTime && Date.now() > closeTime)
|
||||
throw new APIError(400, 'Trading is closed.')
|
||||
|
||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
||||
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||
sumBy(bets, (b) => b.shares)
|
||||
|
@ -77,18 +82,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||
|
||||
const soldShares = Math.min(sharesToSell, maxShares)
|
||||
|
||||
const unfilledBetsSnap = await transaction.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
)
|
||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||
const saleFrac = soldShares / maxShares
|
||||
let loanPaid = saleFrac * loanAmount
|
||||
if (!isFinite(loanPaid)) loanPaid = 0
|
||||
|
||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||
soldShares,
|
||||
chosenOutcome,
|
||||
contract,
|
||||
prevLoanAmount,
|
||||
unfilledBets
|
||||
unfilledBets,
|
||||
loanPaid
|
||||
)
|
||||
|
||||
if (
|
||||
|
@ -104,7 +107,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
|
||||
|
||||
transaction.update(userDoc, {
|
||||
balance: FieldValue.increment(-newBet.amount),
|
||||
balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
|
||||
})
|
||||
transaction.create(newBetDoc, {
|
||||
id: newBetDoc.id,
|
||||
|
@ -121,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
})
|
||||
)
|
||||
|
||||
return { newBet, makers }
|
||||
return { newBet, makers, maxShares, soldShares }
|
||||
})
|
||||
|
||||
if (result.maxShares === result.soldShares) {
|
||||
await removeUserFromContractFollowers(contractId, auth.uid)
|
||||
}
|
||||
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
||||
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||
log('Share redemption transaction finished.')
|
||||
|
|
|
@ -69,6 +69,10 @@ export const unsubscribe: EndpointDefinition = {
|
|||
res.send(
|
||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'weekly-trending')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from weekly trending emails on Manifold Markets.`
|
||||
)
|
||||
else res.send(`${name}, you have been unsubscribed.`)
|
||||
},
|
||||
}
|
||||
|
|
92
functions/src/update-loans.ts
Normal file
92
functions/src/update-loans.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, keyBy } from 'lodash'
|
||||
import { getValues, log, payUser, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
import { createLoanIncomeNotification } from './create-notification'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
export const updateLoans = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
// Run every day at midnight.
|
||||
.pubsub.schedule('0 0 * * *')
|
||||
.timeZone('America/Los_Angeles')
|
||||
.onRun(updateLoansCore)
|
||||
|
||||
async function updateLoansCore() {
|
||||
log('Updating loans...')
|
||||
|
||||
const [users, contracts, bets] = await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(
|
||||
firestore.collection('contracts').where('isResolved', '==', false)
|
||||
),
|
||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||
])
|
||||
log(
|
||||
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||
)
|
||||
const userPortfolios = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const portfolio = await getValues<PortfolioMetrics>(
|
||||
firestore
|
||||
.collection(`users/${user.id}/portfolioHistory`)
|
||||
.orderBy('timestamp', 'desc')
|
||||
.limit(1)
|
||||
)
|
||||
return portfolio[0]
|
||||
})
|
||||
)
|
||||
log(`Loaded ${userPortfolios.length} portfolios`)
|
||||
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
|
||||
|
||||
const contractsById = Object.fromEntries(
|
||||
contracts.map((contract) => [contract.id, contract])
|
||||
)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const { betUpdates, userPayouts } = getLoanUpdates(
|
||||
users,
|
||||
contractsById,
|
||||
portfolioByUser,
|
||||
betsByUser
|
||||
)
|
||||
|
||||
log(`${betUpdates.length} bet updates.`)
|
||||
|
||||
const betDocUpdates = betUpdates.map((update) => ({
|
||||
doc: firestore
|
||||
.collection('contracts')
|
||||
.doc(update.contractId)
|
||||
.collection('bets')
|
||||
.doc(update.betId),
|
||||
fields: {
|
||||
loanAmount: update.loanTotal,
|
||||
},
|
||||
}))
|
||||
|
||||
await writeAsync(firestore, betDocUpdates)
|
||||
|
||||
log(`${userPayouts.length} user payouts`)
|
||||
|
||||
await Promise.all(
|
||||
userPayouts.map(({ user, payout }) => payUser(user.id, payout))
|
||||
)
|
||||
|
||||
const today = new Date().toDateString().replace(' ', '-')
|
||||
const key = `loan-notifications-${today}`
|
||||
await Promise.all(
|
||||
userPayouts
|
||||
// Don't send a notification if the payout is < M$1,
|
||||
// because a M$0 loan is confusing.
|
||||
.filter(({ payout }) => payout >= 1)
|
||||
.map(({ user, payout }) =>
|
||||
createLoanIncomeNotification(user, key, payout)
|
||||
)
|
||||
)
|
||||
|
||||
log('Notifications sent!')
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, sum, sumBy } from 'lodash'
|
||||
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
|
|||
import { calculatePayout } from '../../common/calculate'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { last } from 'lodash'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
|
@ -21,7 +22,9 @@ const computeInvestmentValue = (
|
|||
if (bet.sale || bet.isSold) return 0
|
||||
|
||||
const payout = calculatePayout(contract, bet, 'MKT')
|
||||
return payout - (bet.loanAmount ?? 0)
|
||||
const value = payout - (bet.loanAmount ?? 0)
|
||||
if (isNaN(value)) return 0
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -71,7 +74,8 @@ export const updateMetricsCore = async () => {
|
|||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||
const userUpdates = users.map((user) => {
|
||||
|
||||
const userMetrics = users.map((user) => {
|
||||
const currentBets = betsByUser[user.id] ?? []
|
||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||
const userContracts = contractsByUser[user.id] ?? []
|
||||
|
@ -93,7 +97,29 @@ export const updateMetricsCore = async () => {
|
|||
newPortfolio,
|
||||
didProfitChange
|
||||
)
|
||||
return {
|
||||
user,
|
||||
newCreatorVolume,
|
||||
newPortfolio,
|
||||
newProfit,
|
||||
didProfitChange,
|
||||
}
|
||||
})
|
||||
|
||||
const portfolioByUser = Object.fromEntries(
|
||||
userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio])
|
||||
)
|
||||
const { userPayouts } = getLoanUpdates(
|
||||
users,
|
||||
contractsById,
|
||||
portfolioByUser,
|
||||
betsByUser
|
||||
)
|
||||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||
|
||||
const userUpdates = userMetrics.map(
|
||||
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
|
||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||
return {
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
|
@ -102,6 +128,7 @@ export const updateMetricsCore = async () => {
|
|||
...(didProfitChange && {
|
||||
profitCached: newProfit,
|
||||
}),
|
||||
nextLoanCached,
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -118,7 +145,8 @@ export const updateMetricsCore = async () => {
|
|||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
await writeAsync(
|
||||
firestore,
|
||||
userUpdates.map((u) => u.fieldUpdates)
|
||||
|
@ -234,6 +262,6 @@ const calculateNewProfit = (
|
|||
}
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
|
|
|
@ -311,6 +311,6 @@ export const updateStatsCore = async () => {
|
|||
}
|
||||
|
||||
export const updateStats = functions
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 60 minutes')
|
||||
.onRun(updateStatsCore)
|
||||
|
|
|
@ -9,9 +9,9 @@ import { DAY_MS } from '../../common/util/time'
|
|||
|
||||
export const weeklyMarketsEmails = functions
|
||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||
// every Monday at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('0 19 * * 1')
|
||||
.timeZone('utc')
|
||||
// every minute on Monday for an hour at 12pm PT (UTC -07:00)
|
||||
.pubsub.schedule('* 19 * * 1')
|
||||
.timeZone('Etc/UTC')
|
||||
.onRun(async () => {
|
||||
await sendTrendingMarketsEmailsToAllUsers()
|
||||
})
|
||||
|
@ -37,17 +37,33 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
const privateUsers = await getAllPrivateUsers()
|
||||
// get all users that haven't unsubscribed from weekly emails
|
||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||
return !user.unsubscribedFromWeeklyTrendingEmails
|
||||
return (
|
||||
!user.unsubscribedFromWeeklyTrendingEmails &&
|
||||
!user.weeklyTrendingEmailSent
|
||||
)
|
||||
})
|
||||
log(
|
||||
'Sending weekly trending emails to',
|
||||
privateUsersToSendEmailsTo.length,
|
||||
'users'
|
||||
)
|
||||
const trendingContracts = (await getTrendingContracts())
|
||||
.filter(
|
||||
(contract) =>
|
||||
!(
|
||||
contract.question.toLowerCase().includes('trump') &&
|
||||
contract.question.toLowerCase().includes('president')
|
||||
) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS
|
||||
) &&
|
||||
(contract?.closeTime ?? 0) > Date.now() + DAY_MS &&
|
||||
!contract.groupSlugs?.includes('manifold-features') &&
|
||||
!contract.groupSlugs?.includes('manifold-6748e065087e')
|
||||
)
|
||||
.slice(0, 20)
|
||||
log(
|
||||
`Found ${trendingContracts.length} trending contracts:\n`,
|
||||
trendingContracts.map((c) => c.question).join('\n ')
|
||||
)
|
||||
|
||||
for (const privateUser of privateUsersToSendEmailsTo) {
|
||||
if (!privateUser.email) {
|
||||
log(`No email for ${privateUser.username}`)
|
||||
|
@ -70,12 +86,17 @@ async function sendTrendingMarketsEmailsToAllUsers() {
|
|||
if (!user) continue
|
||||
|
||||
await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
|
||||
await firestore.collection('private-users').doc(user.id).update({
|
||||
weeklyTrendingEmailSent: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||
shuffle(contracts, createRNG(seed))
|
||||
const rng = createRNG(seed)
|
||||
|
||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||
shuffle(contracts, rng)
|
||||
return contracts.slice(0, count)
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
|
|||
import clsx from 'clsx'
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
||||
|
||||
export function NotificationSettings() {
|
||||
const user = useUser()
|
||||
|
@ -17,6 +19,7 @@ export function NotificationSettings() {
|
|||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||
useState<notification_subscribe_types>('all')
|
||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||
|
@ -121,12 +124,20 @@ export function NotificationSettings() {
|
|||
}
|
||||
|
||||
function NotificationSettingLine(props: {
|
||||
label: string
|
||||
label: string | React.ReactNode
|
||||
highlight: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { label, highlight } = props
|
||||
const { label, highlight, onClick } = props
|
||||
return (
|
||||
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
|
||||
<Row
|
||||
className={clsx(
|
||||
'my-1 gap-1 text-gray-300',
|
||||
highlight && '!text-black',
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||
{label}
|
||||
</Row>
|
||||
|
@ -148,31 +159,45 @@ export function NotificationSettings() {
|
|||
toggleClassName={'w-24'}
|
||||
/>
|
||||
<div className={'mt-4 text-sm'}>
|
||||
<div>
|
||||
<div className={''}>
|
||||
You will receive notifications for:
|
||||
<NotificationSettingLine
|
||||
label={"Resolution of questions you've interacted with"}
|
||||
highlight={notificationSettings !== 'none'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={'Activity on your own questions, comments, & answers'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Activity on questions you're betting on"}
|
||||
/>
|
||||
<Col className={''}>
|
||||
<Row className={'my-1'}>
|
||||
You will receive notifications for these general events:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<Row className={'my-1'}>
|
||||
You will receive new comment, answer, & resolution notifications on
|
||||
questions:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
label={"Activity on questions you've ever bet or commented on"}
|
||||
highlight={notificationSettings === 'all'}
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={
|
||||
<span>
|
||||
That <span className={'font-bold'}>you watch </span>- you
|
||||
auto-watch questions if:
|
||||
</span>
|
||||
}
|
||||
onClick={() => setShowModal(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Col
|
||||
className={clsx(
|
||||
'mb-2 ml-8',
|
||||
'gap-1 text-gray-300',
|
||||
notificationSettings !== 'none' && '!text-black'
|
||||
)}
|
||||
>
|
||||
<Row>• You create it</Row>
|
||||
<Row>• You bet, comment on, or answer it</Row>
|
||||
<Row>• You add liquidity to it</Row>
|
||||
<Row>
|
||||
• If you select 'Less' and you've commented on or answered a
|
||||
question, you'll only receive notification on direct replies to
|
||||
your comments or answers
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
</div>
|
||||
<div className={'mt-4'}>Email Notifications</div>
|
||||
<ChoicesToggleGroup
|
||||
|
@ -205,6 +230,7 @@ export function NotificationSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { formatMoney } from 'common/util/format'
|
|||
import { Col } from './layout/col'
|
||||
import { SiteLink } from './site-link'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
export function AmountInput(props: {
|
||||
amount: number | undefined
|
||||
|
@ -33,7 +34,8 @@ export function AmountInput(props: {
|
|||
const isInvalid = !str || isNaN(amount)
|
||||
onChange(isInvalid ? undefined : amount)
|
||||
}
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 768
|
||||
return (
|
||||
<Col className={className}>
|
||||
<label className="input-group mb-4">
|
||||
|
@ -50,6 +52,7 @@ export function AmountInput(props: {
|
|||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
maxLength={6}
|
||||
autoFocus={!isMobile}
|
||||
value={amount ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onAmountChange(e.target.value)}
|
||||
|
|
|
@ -8,6 +8,8 @@ import { useUser } from 'web/hooks/use-user'
|
|||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { Col } from './layout/col'
|
||||
import { Button } from 'web/components/button'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
|
@ -30,23 +32,27 @@ export default function BetButton(props: {
|
|||
return (
|
||||
<>
|
||||
<Col className={clsx('items-center', className)}>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-lg btn-outline my-auto inline-flex h-10 min-h-0 w-24',
|
||||
btnClassName
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||
onClick={() => {
|
||||
!user ? firebaseLogin() : setOpen(true)
|
||||
}}
|
||||
>
|
||||
Bet
|
||||
</button>
|
||||
{user ? 'Bet' : 'Sign up to Bet'}
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||
{hasYesShares
|
||||
? `(${Math.floor(yesShares)} ${isPseudoNumeric ? 'HIGHER' : 'YES'})`
|
||||
? `(${Math.floor(yesShares)} ${
|
||||
isPseudoNumeric ? 'HIGHER' : 'YES'
|
||||
})`
|
||||
: hasNoShares
|
||||
? `(${Math.floor(noShares)} ${isPseudoNumeric ? 'LOWER' : 'NO'})`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
|
|
|
@ -22,7 +22,7 @@ import { formatMoney } from 'common/util/format'
|
|||
export function BetInline(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
className?: string
|
||||
setProbAfter: (probAfter: number) => void
|
||||
setProbAfter: (probAfter: number | undefined) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { contract, className, setProbAfter, onClose } = props
|
||||
|
@ -82,7 +82,7 @@ export function BetInline(props: {
|
|||
<div className="text-xl">Bet</div>
|
||||
<YesNoSelector
|
||||
className="space-x-0"
|
||||
btnClassName="rounded-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
btnClassName="rounded-l-none rounded-r-none first:rounded-l-2xl last:rounded-r-2xl"
|
||||
selected={outcome}
|
||||
onSelect={setOutcome}
|
||||
isPseudoNumeric={isPseudoNumeric}
|
||||
|
@ -113,7 +113,12 @@ export function BetInline(props: {
|
|||
</Button>
|
||||
)}
|
||||
<SignUpPrompt size="xs" />
|
||||
<button onClick={onClose}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProbAfter(undefined)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<XIcon className="ml-1 h-6 w-6" />
|
||||
</button>
|
||||
</Row>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { clamp, partition, sum, sumBy } from 'lodash'
|
||||
import { clamp, partition, sumBy } from 'lodash'
|
||||
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract'
|
||||
|
@ -9,7 +9,6 @@ import { Row } from './layout/row'
|
|||
import { Spacer } from './layout/spacer'
|
||||
import {
|
||||
formatMoney,
|
||||
formatMoneyWithDecimals,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
} from 'common/util/format'
|
||||
|
@ -18,7 +17,6 @@ import { User } from 'web/lib/firebase/users'
|
|||
import { Bet, LimitBet } from 'common/bet'
|
||||
import { APIError, placeBet, sellShares } from 'web/lib/firebase/api'
|
||||
import { AmountInput, BuyAmountInput } from './amount-input'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import {
|
||||
BinaryOutcomeLabel,
|
||||
HigherLabel,
|
||||
|
@ -261,8 +259,6 @@ function BuyPanel(props: {
|
|||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = formatPercent(currentReturn)
|
||||
|
||||
const totalFees = sum(Object.values(newBet.fees))
|
||||
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
@ -346,9 +342,9 @@ function BuyPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<InfoTooltip
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
|
||||
/>
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
@ -665,9 +661,9 @@ function LimitOrderPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<InfoTooltip
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(yesFees)} in fees`}
|
||||
/>
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
@ -689,9 +685,9 @@ function LimitOrderPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<InfoTooltip
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(noFees)} in fees`}
|
||||
/>
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
|
|
@ -21,7 +21,7 @@ import { AmountInput } from '../amount-input'
|
|||
import { getProbability } from 'common/calculate'
|
||||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { FIXED_ANTE } from 'common/antes'
|
||||
import { FIXED_ANTE } from 'common/economy'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { useTextEditor } from 'web/components/editor'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
|
|
|
@ -91,6 +91,8 @@ export function ContractSearch(props: {
|
|||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
isWholePage?: boolean
|
||||
maxItems?: number
|
||||
noControls?: boolean
|
||||
}) {
|
||||
const {
|
||||
user,
|
||||
|
@ -105,6 +107,8 @@ export function ContractSearch(props: {
|
|||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
isWholePage,
|
||||
maxItems,
|
||||
noControls,
|
||||
} = props
|
||||
|
||||
const [numPages, setNumPages] = useState(1)
|
||||
|
@ -158,6 +162,8 @@ export function ContractSearch(props: {
|
|||
const contracts = pages
|
||||
.flat()
|
||||
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
||||
const renderedContracts =
|
||||
pages.length === 0 ? undefined : contracts.slice(0, maxItems)
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||
|
@ -175,10 +181,11 @@ export function ContractSearch(props: {
|
|||
useQuerySortUrlParams={useQuerySortUrlParams}
|
||||
user={user}
|
||||
onSearchParametersChanged={onSearchParametersChanged}
|
||||
noControls={noControls}
|
||||
/>
|
||||
<ContractsGrid
|
||||
contracts={pages.length === 0 ? undefined : contracts}
|
||||
loadMore={performQuery}
|
||||
contracts={renderedContracts}
|
||||
loadMore={noControls ? undefined : performQuery}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
highlightOptions={highlightOptions}
|
||||
|
@ -198,6 +205,7 @@ function ContractSearchControls(props: {
|
|||
useQuerySortLocalStorage?: boolean
|
||||
useQuerySortUrlParams?: boolean
|
||||
user?: User | null
|
||||
noControls?: boolean
|
||||
}) {
|
||||
const {
|
||||
className,
|
||||
|
@ -209,6 +217,7 @@ function ContractSearchControls(props: {
|
|||
useQuerySortLocalStorage,
|
||||
useQuerySortUrlParams,
|
||||
user,
|
||||
noControls,
|
||||
} = props
|
||||
|
||||
const savedSort = useQuerySortLocalStorage ? getSavedSort() : null
|
||||
|
@ -329,6 +338,10 @@ function ContractSearchControls(props: {
|
|||
})
|
||||
}, [query, index, searchIndex, filter, JSON.stringify(facetFilters)])
|
||||
|
||||
if (noControls) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
className={clsx('bg-base-200 sticky top-0 z-20 gap-3 pb-3', className)}
|
||||
|
|
9
web/components/contract/FeaturedContractBadge.tsx
Normal file
9
web/components/contract/FeaturedContractBadge.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { SparklesIcon } from '@heroicons/react/solid'
|
||||
|
||||
export function FeaturedContractBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800">
|
||||
<SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -32,6 +32,8 @@ import { groupPath } from 'web/lib/firebase/groups'
|
|||
import { insertContent } from '../editor/utils'
|
||||
import clsx from 'clsx'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
import { User } from 'common/user'
|
||||
import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -72,6 +74,8 @@ export function MiscDetails(props: {
|
|||
{'Resolved '}
|
||||
{fromNow(resolutionTime || 0)}
|
||||
</Row>
|
||||
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
|
||||
<FeaturedContractBadge />
|
||||
) : volume > 0 || !isNew ? (
|
||||
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
||||
) : (
|
||||
|
@ -134,6 +138,7 @@ export function AbbrContractDetails(props: {
|
|||
export function ContractDetails(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
user: User | null | undefined
|
||||
isCreator?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
|
@ -157,7 +162,7 @@ export function ContractDetails(props: {
|
|||
)
|
||||
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
<Row className="flex-1 flex-wrap items-center gap-2 text-sm text-gray-500 md:gap-x-4 md:gap-y-2">
|
||||
<Row className="items-center gap-2">
|
||||
<Avatar
|
||||
username={creatorUsername}
|
||||
|
@ -179,6 +184,8 @@ export function ContractDetails(props: {
|
|||
<Row>
|
||||
{disabled ? (
|
||||
groupInfo
|
||||
) : !groupToDisplay && !user ? (
|
||||
<div />
|
||||
) : (
|
||||
<Button
|
||||
size={'xs'}
|
||||
|
@ -206,10 +213,9 @@ export function ContractDetails(props: {
|
|||
|
||||
{(!!closeTime || !!resolvedDate) && (
|
||||
<Row className="items-center gap-1">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
|
||||
{resolvedDate && contract.resolutionTime ? (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
<DateTimeTooltip
|
||||
text="Market resolved:"
|
||||
time={dayjs(contract.resolutionTime)}
|
||||
|
@ -219,8 +225,9 @@ export function ContractDetails(props: {
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{!resolvedDate && closeTime && (
|
||||
{!resolvedDate && closeTime && user && (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
<EditableCloseDate
|
||||
closeTime={closeTime}
|
||||
contract={contract}
|
||||
|
@ -230,14 +237,15 @@ export function ContractDetails(props: {
|
|||
)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<Row className="items-center gap-1">
|
||||
<DatabaseIcon className="h-5 w-5" />
|
||||
|
||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||
</Row>
|
||||
|
||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,12 +7,16 @@ import { Bet } from 'common/bet'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { contractPool } from 'web/lib/firebase/contracts'
|
||||
import { contractPool, updateContract } from 'web/lib/firebase/contracts'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
import { Title } from '../title'
|
||||
import { InfoTooltip } from '../info-tooltip'
|
||||
import { useAdmin, useDev } from 'web/hooks/use-admin'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { firestoreConsolePath } from 'common/envs/constants'
|
||||
import { deleteField } from 'firebase/firestore'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -21,10 +25,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
const { contract, bets } = props
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [featured, setFeatured] = useState(
|
||||
(contract?.featuredOnHomeRank ?? 0) > 0
|
||||
)
|
||||
const isDev = useDev()
|
||||
const isAdmin = useAdmin()
|
||||
|
||||
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
|
||||
|
||||
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType } =
|
||||
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
|
||||
contract
|
||||
|
||||
const tradersCount = uniqBy(
|
||||
|
@ -105,10 +114,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<td>{formatMoney(contract.volume)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
{/* <tr>
|
||||
<td>Creator earnings</td>
|
||||
<td>{formatMoney(contract.collectedFees.creatorFee)}</td>
|
||||
</tr>
|
||||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>Traders</td>
|
||||
|
@ -121,6 +130,60 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
</td>
|
||||
<td>{contractPool(contract)}</td>
|
||||
</tr>
|
||||
|
||||
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
|
||||
{(isAdmin || isDev) && (
|
||||
<tr>
|
||||
<td>[DEV] Firestore</td>
|
||||
<td>
|
||||
<SiteLink href={firestoreConsolePath(id)}>
|
||||
Console link
|
||||
</SiteLink>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<tr>
|
||||
<td>Set featured</td>
|
||||
<td>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={featured ? 'true' : 'false'}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.value === 'true'
|
||||
if (
|
||||
newVal &&
|
||||
(contract.featuredOnHomeRank === 0 ||
|
||||
!contract?.featuredOnHomeRank)
|
||||
)
|
||||
updateContract(id, {
|
||||
featuredOnHomeRank: 1,
|
||||
})
|
||||
.then(() => {
|
||||
setFeatured(true)
|
||||
})
|
||||
.catch(console.error)
|
||||
else if (
|
||||
!newVal &&
|
||||
(contract?.featuredOnHomeRank ?? 0) > 0
|
||||
)
|
||||
updateContract(id, {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
featuredOnHomeRank: deleteField(),
|
||||
})
|
||||
.then(() => {
|
||||
setFeatured(false)
|
||||
})
|
||||
.catch(console.error)
|
||||
}}
|
||||
>
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top traders"
|
||||
title="🏅 Top bettors"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
|
|
@ -3,7 +3,6 @@ import clsx from 'clsx'
|
|||
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { ContractProbGraph } from './contract-prob-graph'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Row } from '../layout/row'
|
||||
|
@ -39,15 +38,15 @@ export const ContractOverview = (props: {
|
|||
|
||||
return (
|
||||
<Col className={clsx('mb-6', className)}>
|
||||
<Col className="gap-4 px-2">
|
||||
<Col className="gap-3 px-2 sm:gap-4">
|
||||
<Row className="justify-between gap-4">
|
||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||
<Linkify text={question} />
|
||||
</div>
|
||||
|
||||
<Row className={'hidden gap-3 xl:flex'}>
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
className="hidden items-end xl:flex"
|
||||
className="items-end"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
|
@ -56,35 +55,46 @@ export const ContractOverview = (props: {
|
|||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
className="items-end"
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
className="items-end"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
{isBinary ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
) : (
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
|
@ -107,9 +117,10 @@ export const ContractOverview = (props: {
|
|||
contract={contract}
|
||||
bets={bets}
|
||||
isCreator={isCreator}
|
||||
user={user}
|
||||
/>
|
||||
</Col>
|
||||
<Spacer h={4} />
|
||||
<div className={'my-1 md:my-2'}></div>
|
||||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
)}{' '}
|
||||
|
|
|
@ -58,7 +58,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
const { width } = useWindowSize()
|
||||
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const hoursAgo = latestTime.subtract(5, 'hours')
|
||||
const hoursAgo = latestTime.subtract(1, 'hours')
|
||||
const startDate = dayjs(times[0]).isBefore(hoursAgo)
|
||||
? times[0]
|
||||
: hoursAgo.toDate()
|
||||
|
|
|
@ -86,10 +86,12 @@ export function ContractsGrid(props: {
|
|||
/>
|
||||
))}
|
||||
</Masonry>
|
||||
{loadMore && (
|
||||
<VisibilityObserver
|
||||
onVisibilityUpdated={onVisibilityUpdated}
|
||||
className="relative -top-96 h-1"
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
40
web/components/contract/follow-market-modal.tsx
Normal file
40
web/components/contract/follow-market-modal.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Col } from 'web/components/layout/col'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { EyeIcon } from '@heroicons/react/outline'
|
||||
import React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export const FollowMarketModal = (props: {
|
||||
open: boolean
|
||||
setOpen: (b: boolean) => void
|
||||
title?: string
|
||||
}) => {
|
||||
const { open, setOpen, title } = props
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<EyeIcon className={clsx('h-20 w-20')} aria-hidden="true" />
|
||||
<span className="text-xl">{title ? title : 'Watching questions'}</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What is watching?</span>
|
||||
<span className={'ml-2'}>
|
||||
You can receive notifications on questions you're interested in by
|
||||
clicking the
|
||||
<EyeIcon
|
||||
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
️ button on a question.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• What types of notifications will I receive?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
You'll receive in-app notifications for new comments, answers, and
|
||||
updates to the question.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -14,9 +14,10 @@ import { Button } from '../button'
|
|||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { REFERRAL_AMOUNT, User } from 'common/user'
|
||||
import { User } from 'common/user'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
|
||||
export function ShareModal(props: {
|
||||
contract: Contract
|
||||
|
|
|
@ -10,6 +10,7 @@ import { User } from 'common/user'
|
|||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { ShareModal } from './share-modal'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||
|
||||
export function ShareRow(props: {
|
||||
contract: Contract
|
||||
|
@ -25,7 +26,7 @@ export function ShareRow(props: {
|
|||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Row className="mt-2">
|
||||
<Row className="mt-0.5 sm:mt-2">
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
|
@ -62,6 +63,7 @@ export function ShareRow(props: {
|
|||
/>
|
||||
</Button>
|
||||
)}
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Contract } from 'common/contract'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export function DuplicateContractButton(props: {
|
||||
|
@ -33,22 +31,29 @@ export function DuplicateContractButton(props: {
|
|||
|
||||
// Pass along the Uri to create a new contract
|
||||
function duplicateContractHref(contract: Contract) {
|
||||
const descriptionString = JSON.stringify(contract.description)
|
||||
// Don't set a closeTime that's in the past
|
||||
const closeTime =
|
||||
(contract?.closeTime ?? 0) <= Date.now() ? 0 : contract.closeTime
|
||||
const params = {
|
||||
q: contract.question,
|
||||
closeTime: contract.closeTime || 0,
|
||||
description:
|
||||
(contract.description ? `${contract.description}\n\n` : '') +
|
||||
`(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`,
|
||||
closeTime,
|
||||
description: descriptionString,
|
||||
outcomeType: contract.outcomeType,
|
||||
} as Record<string, any>
|
||||
|
||||
if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
||||
params.min = contract.min
|
||||
params.max = contract.max
|
||||
params.isLogScale = contract.isLogScale
|
||||
if (contract.isLogScale) {
|
||||
// Conditional, because `?isLogScale=false` evaluates to `true`
|
||||
params.isLogScale = true
|
||||
}
|
||||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||
}
|
||||
|
||||
// TODO: Support multiple choice markets?
|
||||
|
||||
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
||||
params.groupId = contract.groupLinks[0].groupId
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
JSONContent,
|
||||
Content,
|
||||
Editor,
|
||||
mergeAttributes,
|
||||
} from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
|
@ -38,7 +39,16 @@ const DisplayImage = Image.configure({
|
|||
},
|
||||
})
|
||||
|
||||
const DisplayLink = Link.configure({
|
||||
const DisplayLink = Link.extend({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
delete HTMLAttributes.class // only use our classes (don't duplicate on paste)
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0,
|
||||
]
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||
},
|
||||
|
|
77
web/components/follow-market-button.tsx
Normal file
77
web/components/follow-market-button.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { Button } from 'web/components/button'
|
||||
import {
|
||||
Contract,
|
||||
followContract,
|
||||
unFollowContract,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckIcon, EyeIcon, EyeOffIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { useContractFollows } from 'web/hooks/use-follows'
|
||||
import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
||||
import { useState } from 'react'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
||||
export const FollowMarketButton = (props: {
|
||||
contract: Contract
|
||||
user: User | undefined | null
|
||||
}) => {
|
||||
const { contract, user } = props
|
||||
const followers = useContractFollows(contract.id)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'lg'}
|
||||
color={'gray-white'}
|
||||
onClick={async () => {
|
||||
if (!user) return firebaseLogin()
|
||||
if (followers?.includes(user.id)) {
|
||||
await unFollowContract(contract.id, user.id)
|
||||
toast("You'll no longer receive notifications from this market", {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
track('Unwatch Market', {
|
||||
slug: contract.slug,
|
||||
})
|
||||
} else {
|
||||
await followContract(contract.id, user.id)
|
||||
toast("You'll now receive notifications from this market!", {
|
||||
icon: <CheckIcon className={'text-primary h-5 w-5'} />,
|
||||
})
|
||||
track('Watch Market', {
|
||||
slug: contract.slug,
|
||||
})
|
||||
}
|
||||
if (!user.hasSeenContractFollowModal) {
|
||||
await updateUser(user.id, {
|
||||
hasSeenContractFollowModal: true,
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{followers?.includes(user?.id ?? 'nope') ? (
|
||||
<Row className={'gap-2'}>
|
||||
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
||||
Unwatch
|
||||
</Row>
|
||||
) : (
|
||||
<Row className={'gap-2'}>
|
||||
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
||||
Watch
|
||||
</Row>
|
||||
)}
|
||||
<FollowMarketModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={`You ${
|
||||
followers?.includes(user?.id ?? 'nope') ? 'watched' : 'unwatched'
|
||||
} a question!`}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -163,13 +163,15 @@ export function OrderBookButton(props: {
|
|||
const { limitBets, contract, className } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const yesBets = sortBy(
|
||||
limitBets.filter((bet) => bet.outcome === 'YES'),
|
||||
const sortedBets = sortBy(
|
||||
limitBets,
|
||||
(bet) => -1 * bet.limitProb,
|
||||
(bet) => bet.createdTime
|
||||
)
|
||||
|
||||
const yesBets = sortedBets.filter((bet) => bet.outcome === 'YES')
|
||||
const noBets = sortBy(
|
||||
limitBets.filter((bet) => bet.outcome === 'NO'),
|
||||
sortedBets.filter((bet) => bet.outcome === 'NO'),
|
||||
(bet) => bet.limitProb,
|
||||
(bet) => bet.createdTime
|
||||
)
|
||||
|
@ -202,7 +204,7 @@ export function OrderBookButton(props: {
|
|||
</Row>
|
||||
<Col className="md:hidden">
|
||||
<LimitOrderTable
|
||||
limitBets={limitBets}
|
||||
limitBets={sortedBets}
|
||||
contract={contract}
|
||||
isYou={false}
|
||||
/>
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { Transition, Dialog } from '@headlessui/react'
|
||||
import { useState, Fragment } from 'react'
|
||||
import Sidebar, { Item } from './sidebar'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Avatar } from '../avatar'
|
||||
import clsx from 'clsx'
|
||||
|
@ -17,8 +17,6 @@ import { useRouter } from 'next/router'
|
|||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { PrivateUser } from 'common/user'
|
||||
|
||||
function getNavigation() {
|
||||
return [
|
||||
|
@ -44,7 +42,6 @@ export function BottomNavBar() {
|
|||
const currentPage = router.pathname
|
||||
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
|
||||
const isIframe = useIsIframe()
|
||||
if (isIframe) {
|
||||
|
@ -85,11 +82,7 @@ export function BottomNavBar() {
|
|||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<MenuAlt3Icon className=" my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||
{privateUser ? (
|
||||
<MoreMenuWithGroupNotifications privateUser={privateUser} />
|
||||
) : (
|
||||
'More'
|
||||
)}
|
||||
More
|
||||
</div>
|
||||
|
||||
<MobileSidebar
|
||||
|
@ -100,22 +93,6 @@ export function BottomNavBar() {
|
|||
)
|
||||
}
|
||||
|
||||
function MoreMenuWithGroupNotifications(props: { privateUser: PrivateUser }) {
|
||||
const { privateUser } = props
|
||||
const preferredNotifications = useUnseenPreferredNotifications(privateUser, {
|
||||
customHref: '/group/',
|
||||
})
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
preferredNotifications.length > 0 ? 'font-bold' : 'font-normal'
|
||||
}
|
||||
>
|
||||
More
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||
const { item, currentPage } = props
|
||||
const track = trackCallback(`navbar: ${item.trackingEventName ?? item.name}`)
|
||||
|
|
|
@ -12,22 +12,20 @@ import {
|
|||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import Router, { useRouter } from 'next/router'
|
||||
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogout, User } from 'web/lib/firebase/users'
|
||||
import { ManifoldLogo } from './manifold-logo'
|
||||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||
import { Group } from 'common/group'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { buildArray } from 'common/util/array'
|
||||
|
@ -58,7 +56,14 @@ function getNavigation() {
|
|||
|
||||
function getMoreNavigation(user?: User | null) {
|
||||
if (IS_PRIVATE_MANIFOLD) {
|
||||
return [{ name: 'Leaderboards', href: '/leaderboards' }]
|
||||
return [
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: logout,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
@ -88,7 +93,7 @@ function getMoreNavigation(user?: User | null) {
|
|||
href: 'https://salemcenter.manifold.markets/',
|
||||
},
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
|
@ -102,16 +107,16 @@ const signedOutNavigation = [
|
|||
{ name: 'Home', href: '/', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/home', icon: SearchIcon },
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const signedOutMobileNavigation = [
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
|
@ -125,8 +130,8 @@ const signedInMobileNavigation = [
|
|||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
name: 'Help & About',
|
||||
href: 'https://help.manifold.markets/',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
]
|
||||
|
@ -221,8 +226,6 @@ export default function Sidebar(props: { className?: string }) {
|
|||
const currentPage = router.pathname
|
||||
|
||||
const user = useUser()
|
||||
const privateUser = usePrivateUser()
|
||||
// usePing(user?.id)
|
||||
|
||||
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||
const mobileNavigationOptions = !user
|
||||
|
@ -230,11 +233,9 @@ export default function Sidebar(props: { className?: string }) {
|
|||
: signedInMobileNavigation
|
||||
|
||||
const memberItems = (
|
||||
useMemberGroups(
|
||||
user?.id,
|
||||
{ withChatEnabled: true },
|
||||
{ by: 'mostRecentChatActivityTime' }
|
||||
) ?? []
|
||||
useMemberGroups(user?.id, undefined, {
|
||||
by: 'mostRecentContractAddedTime',
|
||||
}) ?? []
|
||||
).map((group: Group) => ({
|
||||
name: group.name,
|
||||
href: `${groupPath(group.slug)}`,
|
||||
|
@ -268,13 +269,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
{memberItems.length > 0 && (
|
||||
<hr className="!my-4 mr-2 border-gray-300" />
|
||||
)}
|
||||
{privateUser && (
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
privateUser={privateUser}
|
||||
/>
|
||||
)}
|
||||
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
|
@ -289,46 +284,36 @@ export default function Sidebar(props: { className?: string }) {
|
|||
|
||||
{/* Spacer if there are any groups */}
|
||||
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
|
||||
{privateUser && (
|
||||
<GroupsList
|
||||
currentPage={router.asPath}
|
||||
memberItems={memberItems}
|
||||
privateUser={privateUser}
|
||||
/>
|
||||
)}
|
||||
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsList(props: {
|
||||
currentPage: string
|
||||
memberItems: Item[]
|
||||
privateUser: PrivateUser
|
||||
}) {
|
||||
const { currentPage, memberItems, privateUser } = props
|
||||
const preferredNotifications = useUnseenPreferredNotifications(
|
||||
privateUser,
|
||||
{
|
||||
customHref: '/group/',
|
||||
},
|
||||
memberItems.length > 0 ? memberItems.length : undefined
|
||||
)
|
||||
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||
const { currentPage, memberItems } = props
|
||||
|
||||
const { height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
||||
|
||||
const notifIsForThisItem = useMemo(
|
||||
() => (itemHref: string) =>
|
||||
preferredNotifications.some(
|
||||
(n) =>
|
||||
!n.isSeen &&
|
||||
(n.isSeenOnHref === itemHref ||
|
||||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||
),
|
||||
[preferredNotifications]
|
||||
)
|
||||
// const preferredNotifications = useUnseenPreferredNotifications(
|
||||
// privateUser,
|
||||
// {
|
||||
// customHref: '/group/',
|
||||
// },
|
||||
// memberItems.length > 0 ? memberItems.length : undefined
|
||||
// )
|
||||
// const notifIsForThisItem = useMemo(
|
||||
// () => (itemHref: string) =>
|
||||
// preferredNotifications.some(
|
||||
// (n) =>
|
||||
// !n.isSeen &&
|
||||
// (n.isSeenOnHref === itemHref ||
|
||||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||
// ),
|
||||
// [preferredNotifications]
|
||||
// )
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -344,16 +329,12 @@ function GroupsList(props: {
|
|||
>
|
||||
{memberItems.map((item) => (
|
||||
<a
|
||||
href={
|
||||
item.href +
|
||||
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
|
||||
}
|
||||
href={item.href}
|
||||
key={item.name}
|
||||
onClick={trackCallback('sidebar: ' + item.name)}
|
||||
onClick={trackCallback('click sidebar group', { name: item.name })}
|
||||
className={clsx(
|
||||
'cursor-pointer truncate',
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||
notifIsForThisItem(item.href) && 'font-bold'
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
|
|
|
@ -14,7 +14,7 @@ export const PortfolioValueSection = memo(
|
|||
}) {
|
||||
const { disableSelector, userId } = props
|
||||
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('allTime')
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
||||
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
||||
PortfolioMetrics[]
|
||||
>([])
|
||||
|
@ -53,13 +53,15 @@ export const PortfolioValueSection = memo(
|
|||
{!disableSelector && (
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
value={portfolioPeriod}
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">{allTimeLabel}</option>
|
||||
<option value="weekly">7 days</option>
|
||||
<option value="daily">24 hours</option>
|
||||
<option value="weekly">Last 7d</option>
|
||||
{/* Note: 'daily' seems to be broken? */}
|
||||
{/* <option value="daily">Last 24h</option> */}
|
||||
</select>
|
||||
)}
|
||||
</Row>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Col } from 'web/components/layout/col'
|
|||
import {
|
||||
BETTING_STREAK_BONUS_AMOUNT,
|
||||
BETTING_STREAK_BONUS_MAX,
|
||||
} from 'common/numeric-constants'
|
||||
} from 'common/economy'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export function BettingStreakModal(props: {
|
||||
|
@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
|
|||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🔥</span>
|
||||
<span>Daily betting streaks</span>
|
||||
<span className="text-xl">Daily betting streaks</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are they?</span>
|
||||
<span className={'ml-2'}>
|
||||
|
|
48
web/components/profile/loans-modal.tsx
Normal file
48
web/components/profile/loans-modal.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
||||
export function LoansModal(props: {
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { isOpen, setOpen } = props
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<span className={'text-8xl'}>🏦</span>
|
||||
<span className="text-xl">Daily loans on your bets</span>
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
Every day at midnight PT, get 1% of your total bet amount back as a
|
||||
loan.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• Do I have to pay back a loan?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
Yes, don't worry! You will automatically pay back loans when the
|
||||
market resolves or you sell your bet.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• What is the purpose of loans?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
Loans make it worthwhile to bet on markets that won't resolve for
|
||||
months or years, because your investment won't be locked up as long.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>• What is an example?</span>
|
||||
<span className={'ml-2'}>
|
||||
For example, if you bet M$1000 on "Will I become a millionare?" on
|
||||
Monday, you will get M$10 back on Tuesday.
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
Previous loans count against your total bet amount. So on Wednesday,
|
||||
you would get back 1% of M$990 = M$9.9.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -8,10 +8,8 @@ import { Spacer } from './layout/spacer'
|
|||
import { ResolveConfirmationButton } from './confirmation-button'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||
import { ProbabilitySelector } from './probability-selector'
|
||||
import { DPM_CREATOR_FEE } from 'common/fees'
|
||||
import { getProbability } from 'common/calculate'
|
||||
import { BinaryContract, resolution } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
||||
export function ResolutionPanel(props: {
|
||||
creator: User
|
||||
|
@ -20,10 +18,10 @@ export function ResolutionPanel(props: {
|
|||
}) {
|
||||
const { contract, className } = props
|
||||
|
||||
const earnedFees =
|
||||
contract.mechanism === 'dpm-2'
|
||||
? `${DPM_CREATOR_FEE * 100}% of trader profits`
|
||||
: `${formatMoney(contract.collectedFees.creatorFee)} in fees`
|
||||
// const earnedFees =
|
||||
// contract.mechanism === 'dpm-2'
|
||||
// ? `${DPM_CREATOR_FEE * 100}% of trader profits`
|
||||
// : `${formatMoney(contract.collectedFees.creatorFee)} in fees`
|
||||
|
||||
const [outcome, setOutcome] = useState<resolution | undefined>()
|
||||
|
||||
|
@ -86,16 +84,16 @@ export function ResolutionPanel(props: {
|
|||
{outcome === 'YES' ? (
|
||||
<>
|
||||
Winnings will be paid out to YES bettors.
|
||||
{/* <br />
|
||||
<br />
|
||||
<br />
|
||||
You will earn {earnedFees}.
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'NO' ? (
|
||||
<>
|
||||
Winnings will be paid out to NO bettors.
|
||||
{/* <br />
|
||||
<br />
|
||||
<br />
|
||||
You will earn {earnedFees}.
|
||||
You will earn {earnedFees}. */}
|
||||
</>
|
||||
) : outcome === 'CANCEL' ? (
|
||||
<>All trades will be returned with no fees.</>
|
||||
|
@ -106,7 +104,7 @@ export function ResolutionPanel(props: {
|
|||
probabilityInt={Math.round(prob)}
|
||||
setProbabilityInt={setProb}
|
||||
/>
|
||||
You will earn {earnedFees}.
|
||||
{/* You will earn {earnedFees}. */}
|
||||
</Col>
|
||||
) : (
|
||||
<>Resolving this market will immediately pay out traders.</>
|
||||
|
|
|
@ -29,6 +29,8 @@ import { formatMoney } from 'common/util/format'
|
|||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
import { LoansModal } from './profile/loans-modal'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -67,6 +69,7 @@ export function UserPage(props: { user: User }) {
|
|||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
|
@ -74,6 +77,9 @@ export function UserPage(props: { user: User }) {
|
|||
setShowBettingStreakModal(showBettingStreak)
|
||||
setShowConfetti(claimedMana || showBettingStreak)
|
||||
|
||||
const showLoansModel = router.query['show'] === 'loans'
|
||||
setShowLoansModal(showLoansModel)
|
||||
|
||||
const query = { ...router.query }
|
||||
if (query.claimedMana || query.show) {
|
||||
delete query['claimed-mana']
|
||||
|
@ -106,6 +112,9 @@ export function UserPage(props: { user: User }) {
|
|||
isOpen={showBettingStreakModal}
|
||||
setOpen={setShowBettingStreakModal}
|
||||
/>
|
||||
{showLoansModal && (
|
||||
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||
)}
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
|
@ -127,7 +136,7 @@ export function UserPage(props: { user: User }) {
|
|||
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||
{isCurrentUser && (
|
||||
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
||||
<SiteLink className="btn-sm btn" href="/profile">
|
||||
<PencilIcon className="h-5 w-5" />{' '}
|
||||
<div className="ml-2">Edit</div>
|
||||
</SiteLink>
|
||||
|
@ -137,9 +146,14 @@ export function UserPage(props: { user: User }) {
|
|||
|
||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||
<Col className="mx-4 -mt-6">
|
||||
<Row className={'justify-between'}>
|
||||
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||
<Col>
|
||||
<span className="text-2xl font-bold">{user.name}</span>
|
||||
<span
|
||||
className="text-2xl font-bold"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
</Col>
|
||||
<Col className={'justify-center'}>
|
||||
|
@ -162,6 +176,17 @@ export function UserPage(props: { user: User }) {
|
|||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||
<span>streak</span>
|
||||
</Col>
|
||||
<Col
|
||||
className={
|
||||
'flex-shrink-0 cursor-pointer items-center text-gray-500'
|
||||
}
|
||||
onClick={() => setShowLoansModal(true)}
|
||||
>
|
||||
<span className="text-green-600">
|
||||
🏦 {formatMoney(user.nextLoanCached ?? 0)}
|
||||
</span>
|
||||
<span>next loan</span>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
@ -226,7 +251,7 @@ export function UserPage(props: { user: User }) {
|
|||
)}
|
||||
</Row>
|
||||
<Spacer h={5} />
|
||||
{currentUser?.id === user.id && (
|
||||
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
|
||||
<Row
|
||||
className={
|
||||
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
||||
|
@ -234,7 +259,7 @@ export function UserPage(props: { user: User }) {
|
|||
>
|
||||
<span>
|
||||
<SiteLink href="/referrals">
|
||||
Earn {formatMoney(500)} when you refer a friend!
|
||||
Earn {formatMoney(REFERRAL_AMOUNT)} when you refer a friend!
|
||||
</SiteLink>{' '}
|
||||
You have <ReferralsButton user={user} currentUser={currentUser} />
|
||||
</span>
|
||||
|
@ -278,10 +303,7 @@ export function UserPage(props: { user: User }) {
|
|||
>
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
{currentUser &&
|
||||
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
||||
currentUser.username
|
||||
) && <ReferralsButton user={user} />}
|
||||
<ReferralsButton user={user} />
|
||||
<GroupsButton user={user} />
|
||||
</Row>
|
||||
),
|
||||
|
|
|
@ -5,3 +5,7 @@ export const useAdmin = () => {
|
|||
const privateUser = usePrivateUser()
|
||||
return isAdmin(privateUser?.email || '')
|
||||
}
|
||||
|
||||
export const useDev = () => {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
||||
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useFollows = (userId: string | null | undefined) => {
|
||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||
|
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
|
|||
|
||||
return followerIds
|
||||
}
|
||||
|
||||
export const useContractFollows = (contractId: string) => {
|
||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
return listenForContractFollows(contractId, setFollowIds)
|
||||
}, [contractId])
|
||||
|
||||
return followIds
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
getNotificationsQuery,
|
||||
listenForNotifications,
|
||||
} from 'web/lib/firebase/notifications'
|
||||
import { groupBy, map } from 'lodash'
|
||||
import { groupBy, map, partition } from 'lodash'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
|
||||
|
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
||||
new Date(notification.createdTime).toDateString()
|
||||
)
|
||||
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
|
||||
|
||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
||||
const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
|
||||
notificationsGroupedByDay,
|
||||
(notification) =>
|
||||
notification.sourceType === 'bonus' ||
|
||||
notification.sourceType === 'tip' ||
|
||||
notification.sourceType === 'betting_streak_bonus'
|
||||
)
|
||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
||||
(notification) =>
|
||||
notification.sourceType !== 'bonus' &&
|
||||
notification.sourceType !== 'tip' &&
|
||||
notification.sourceType !== 'betting_streak_bonus'
|
||||
incomeSourceTypes.includes(notification.sourceType ?? '')
|
||||
)
|
||||
if (incomeNotifications.length > 0) {
|
||||
notificationGroups = notificationGroups.concat({
|
||||
|
@ -152,6 +147,7 @@ export function useUnseenPreferredNotifications(
|
|||
const lessPriorityReasons = [
|
||||
'on_contract_with_users_comment',
|
||||
'on_contract_with_users_answer',
|
||||
// Notifications not currently generated for users who've sold their shares
|
||||
'on_contract_with_users_shares_out',
|
||||
// Not sure if users will want to see these w/ less:
|
||||
// 'on_contract_with_users_shares_in',
|
||||
|
|
|
@ -125,9 +125,10 @@ export async function listTaggedContractsCaseInsensitive(
|
|||
|
||||
export async function listAllContracts(
|
||||
n: number,
|
||||
before?: string
|
||||
before?: string,
|
||||
sortDescBy = 'createdTime'
|
||||
): Promise<Contract[]> {
|
||||
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
|
||||
let q = query(contracts, orderBy(sortDescBy, 'desc'), limit(n))
|
||||
if (before != null) {
|
||||
const snap = await getDoc(doc(contracts, before))
|
||||
q = query(q, startAfter(snap))
|
||||
|
@ -211,6 +212,29 @@ export function listenForContract(
|
|||
return listenForValue<Contract>(contractRef, setContract)
|
||||
}
|
||||
|
||||
export function listenForContractFollows(
|
||||
contractId: string,
|
||||
setFollowIds: (followIds: string[]) => void
|
||||
) {
|
||||
const follows = collection(contracts, contractId, 'follows')
|
||||
return listenForValues<{ id: string }>(follows, (docs) =>
|
||||
setFollowIds(docs.map(({ id }) => id))
|
||||
)
|
||||
}
|
||||
|
||||
export async function followContract(contractId: string, userId: string) {
|
||||
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
|
||||
return await setDoc(followDoc, {
|
||||
id: userId,
|
||||
createdTime: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function unFollowContract(contractId: string, userId: string) {
|
||||
const followDoc = doc(collection(contracts, contractId, 'follows'), userId)
|
||||
await deleteDoc(followDoc)
|
||||
}
|
||||
|
||||
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||
|
@ -271,6 +295,26 @@ export async function getClosingSoonContracts() {
|
|||
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
||||
}
|
||||
|
||||
export const getRandTopCreatorContracts = async (
|
||||
creatorId: string,
|
||||
count: number,
|
||||
excluding: string[] = []
|
||||
) => {
|
||||
const creatorContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
where('creatorId', '==', creatorId),
|
||||
orderBy('popularityScore', 'desc'),
|
||||
limit(Math.max(count * 2, 15))
|
||||
)
|
||||
const data = await getValues<Contract>(creatorContractsQuery)
|
||||
const open = data
|
||||
.filter((c) => c.closeTime && c.closeTime > Date.now())
|
||||
.filter((c) => !excluding.includes(c.id))
|
||||
|
||||
return chooseRandomSubset(open, count)
|
||||
}
|
||||
|
||||
export async function getRecentBetsAndComments(contract: Contract) {
|
||||
const contractDoc = doc(contracts, contract.id)
|
||||
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
/** @type {import('next-sitemap').IConfig} */
|
||||
|
||||
module.exports = {
|
||||
siteUrl: process.env.SITE_URL || 'https://manifold.markets',
|
||||
changefreq: 'hourly',
|
||||
priority: 0.7, // Set high priority by default
|
||||
exclude: ['/admin', '/server-sitemap.xml'],
|
||||
generateRobotsTxt: true,
|
||||
robotsTxtOptions: {
|
||||
additionalSitemaps: [
|
||||
'https://manifold.markets/server-sitemap.xml', // <==== Add here
|
||||
],
|
||||
},
|
||||
// Other options: https://github.com/iamvishnusankar/next-sitemap#configuration-options
|
||||
}
|
|
@ -15,7 +15,6 @@
|
|||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "npx prettier --write .",
|
||||
"postbuild": "next-sitemap",
|
||||
"verify": "(cd .. && yarn verify)",
|
||||
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
||||
},
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
import { groupBy, keyBy, mapValues, sortBy, sumBy } from 'lodash'
|
||||
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { ContractOverview } from 'web/components/contract/contract-overview'
|
||||
import { BetPanel } from 'web/components/bet-panel'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ResolutionPanel } from 'web/components/resolution-panel'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import {
|
||||
Contract,
|
||||
getContractFromSlug,
|
||||
getRandTopCreatorContracts,
|
||||
tradingAllowed,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
|
@ -21,9 +21,6 @@ import { listAllComments } from 'web/lib/firebase/comments'
|
|||
import Custom404 from '../404'
|
||||
import { AnswersPanel } from 'web/components/answers/answers-panel'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { Leaderboard } from 'web/components/leaderboard'
|
||||
import { resolvedPayout } from 'common/calculate'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { ContractTabs } from 'web/components/contract/contract-tabs'
|
||||
import { FullscreenConfetti } from 'web/components/fullscreen-confetti'
|
||||
import { NumericBetPanel } from 'web/components/numeric-bet-panel'
|
||||
|
@ -34,15 +31,17 @@ import { useBets } from 'web/hooks/use-bets'
|
|||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { AlertBox } from 'web/components/alert-box'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { User } from 'common/user'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { listUsers } from 'web/lib/firebase/users'
|
||||
import { FeedComment } from 'web/components/feed/feed-comments'
|
||||
import { Title } from 'web/components/title'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { getOpenGraphProps } from 'common/contract-details'
|
||||
import {
|
||||
ContractLeaderboard,
|
||||
ContractTopTrades,
|
||||
} from 'web/components/contract/contract-leaderboard'
|
||||
import { Subtitle } from 'web/components/subtitle'
|
||||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -52,9 +51,12 @@ export async function getStaticPropz(props: {
|
|||
const contract = (await getContractFromSlug(contractSlug)) || null
|
||||
const contractId = contract?.id
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
const [bets, comments, recommendedContracts] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
contract
|
||||
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
|
||||
: [],
|
||||
])
|
||||
|
||||
return {
|
||||
|
@ -65,6 +67,7 @@ export async function getStaticPropz(props: {
|
|||
// Limit the data sent to the client. Client will still load all bets and comments directly.
|
||||
bets: bets.slice(0, 5000),
|
||||
comments: comments.slice(0, 1000),
|
||||
recommendedContracts,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
|
@ -81,6 +84,7 @@ export default function ContractPage(props: {
|
|||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
slug: string
|
||||
recommendedContracts: Contract[]
|
||||
backToHome?: () => void
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
|
@ -88,6 +92,7 @@ export default function ContractPage(props: {
|
|||
username: '',
|
||||
comments: [],
|
||||
bets: [],
|
||||
recommendedContracts: [],
|
||||
slug: '',
|
||||
}
|
||||
|
||||
|
@ -149,7 +154,7 @@ export function ContractPageContent(
|
|||
user?: User | null
|
||||
}
|
||||
) {
|
||||
const { backToHome, comments, user } = props
|
||||
const { backToHome, comments, user, recommendedContracts } = props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
|
@ -263,128 +268,13 @@ export function ContractPageContent(
|
|||
comments={comments}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{recommendedContracts.length > 0 && (
|
||||
<Col className="gap-2 px-2 sm:px-0">
|
||||
<Subtitle text="Recommended" />
|
||||
<ContractsGrid contracts={recommendedContracts} />
|
||||
</Col>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
function ContractLeaderboard(props: { contract: Contract; bets: Bet[] }) {
|
||||
const { contract, bets } = props
|
||||
const [users, setUsers] = useState<User[]>()
|
||||
|
||||
const { userProfits, top5Ids } = useMemo(() => {
|
||||
// Create a map of userIds to total profits (including sales)
|
||||
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
const betsByUser = groupBy(openBets, 'userId')
|
||||
|
||||
const userProfits = mapValues(betsByUser, (bets) =>
|
||||
sumBy(bets, (bet) => resolvedPayout(contract, bet) - bet.amount)
|
||||
)
|
||||
// Find the 5 users with the most profits
|
||||
const top5Ids = Object.entries(userProfits)
|
||||
.sort(([_i1, p1], [_i2, p2]) => p2 - p1)
|
||||
.filter(([, p]) => p > 0)
|
||||
.slice(0, 5)
|
||||
.map(([id]) => id)
|
||||
return { userProfits, top5Ids }
|
||||
}, [contract, bets])
|
||||
|
||||
useEffect(() => {
|
||||
if (top5Ids.length > 0) {
|
||||
listUsers(top5Ids).then((users) => {
|
||||
const sortedUsers = sortBy(users, (user) => -userProfits[user.id])
|
||||
setUsers(sortedUsers)
|
||||
})
|
||||
}
|
||||
}, [userProfits, top5Ids])
|
||||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
header: 'Total profit',
|
||||
renderCell: (user) => formatMoney(userProfits[user.id] || 0),
|
||||
},
|
||||
]}
|
||||
className="mt-12 max-w-sm"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
function ContractTopTrades(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, bets, comments, tips } = props
|
||||
const commentsById = keyBy(comments, 'id')
|
||||
const betsById = keyBy(bets, 'id')
|
||||
|
||||
// If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit
|
||||
// Otherwise, we record the profit at resolution time
|
||||
const profitById: Record<string, number> = {}
|
||||
for (const bet of bets) {
|
||||
if (bet.sale) {
|
||||
const originalBet = betsById[bet.sale.betId]
|
||||
const profit = bet.sale.amount - originalBet.amount
|
||||
profitById[bet.id] = profit
|
||||
profitById[originalBet.id] = profit
|
||||
} else {
|
||||
profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the betId with the highest profit
|
||||
const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id
|
||||
const topBettor = useUserById(betsById[topBetId]?.userId)
|
||||
|
||||
// And also the commentId of the comment with the highest profit
|
||||
const topCommentId = sortBy(
|
||||
comments,
|
||||
(c) => c.betId && -profitById[c.betId]
|
||||
)[0]?.id
|
||||
|
||||
return (
|
||||
<div className="mt-12 max-w-sm">
|
||||
{topCommentId && profitById[topCommentId] > 0 && (
|
||||
<>
|
||||
<Title text="💬 Proven correct" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedComment
|
||||
contract={contract}
|
||||
comment={commentsById[topCommentId]}
|
||||
tips={tips[topCommentId]}
|
||||
betsBySameUser={[betsById[topCommentId]]}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{commentsById[topCommentId].userName} made{' '}
|
||||
{formatMoney(profitById[topCommentId] || 0)}!
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Smartest money" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedBet
|
||||
contract={contract}
|
||||
bet={betsById[topBetId]}
|
||||
hideOutcome={false}
|
||||
smallAvatar={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { mapKeys } from 'lodash'
|
|||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { firestoreConsolePath } from 'common/envs/constants'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||
|
||||
|
@ -198,7 +199,7 @@ function ContractsTable() {
|
|||
html(`<a
|
||||
class="hover:underline hover:decoration-indigo-400 hover:decoration-2"
|
||||
target="_blank"
|
||||
href="https://console.firebase.google.com/project/mantic-markets/firestore/data/~2Fcontracts~2F${cell}">${cell}</a>`),
|
||||
href="${firestoreConsolePath(cell as string)}">${cell}</a>`),
|
||||
},
|
||||
]}
|
||||
search={true}
|
||||
|
|
|
@ -22,8 +22,6 @@ export type LiteMarket = {
|
|||
// Market attributes. All times are in milliseconds since epoch
|
||||
closeTime?: number
|
||||
question: string
|
||||
description: string | JSONContent
|
||||
textDescription: string // string version of description
|
||||
tags: string[]
|
||||
url: string
|
||||
outcomeType: string
|
||||
|
@ -54,6 +52,8 @@ export type FullMarket = LiteMarket & {
|
|||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers?: ApiAnswer[]
|
||||
description: string | JSONContent
|
||||
textDescription: string // string version of description
|
||||
}
|
||||
|
||||
export type ApiError = {
|
||||
|
@ -81,7 +81,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
creatorAvatarUrl,
|
||||
closeTime,
|
||||
question,
|
||||
description,
|
||||
tags,
|
||||
slug,
|
||||
pool,
|
||||
|
@ -118,11 +117,6 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
? Math.min(resolutionTime, closeTime)
|
||||
: closeTime,
|
||||
question,
|
||||
description,
|
||||
textDescription:
|
||||
typeof description === 'string'
|
||||
? description
|
||||
: richTextToString(description),
|
||||
tags,
|
||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||
pool,
|
||||
|
@ -158,11 +152,18 @@ export function toFullMarket(
|
|||
)
|
||||
: undefined
|
||||
|
||||
const { description } = contract
|
||||
|
||||
return {
|
||||
...liteMarket,
|
||||
answers,
|
||||
comments,
|
||||
bets,
|
||||
description,
|
||||
textDescription:
|
||||
typeof description === 'string'
|
||||
? description
|
||||
: richTextToString(description),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ const queryParams = z
|
|||
.object({
|
||||
limit: z
|
||||
.number()
|
||||
.default(1000)
|
||||
.default(500)
|
||||
.or(z.string().regex(/\d+/).transform(Number))
|
||||
.refine((n) => n >= 0 && n <= 1000, 'Limit must be between 0 and 1000'),
|
||||
before: z.string().optional(),
|
||||
|
|
|
@ -39,8 +39,8 @@ export async function getStaticProps() {
|
|||
])
|
||||
const matches = quadraticMatches(txns, totalRaised)
|
||||
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
|
||||
const mostRecentDonor = await getUser(txns[0].fromId)
|
||||
const mostRecentCharity = txns[0].toId
|
||||
const mostRecentDonor = txns[0] ? await getUser(txns[0].fromId) : null
|
||||
const mostRecentCharity = txns[0]?.toId ?? ''
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
@ -94,8 +94,8 @@ export default function Charity(props: {
|
|||
matches: { [charityId: string]: number }
|
||||
txns: Txn[]
|
||||
numDonors: number
|
||||
mostRecentDonor: User
|
||||
mostRecentCharity: string
|
||||
mostRecentDonor?: User | null
|
||||
mostRecentCharity?: string
|
||||
}) {
|
||||
const {
|
||||
totalRaised,
|
||||
|
@ -159,8 +159,8 @@ export default function Charity(props: {
|
|||
},
|
||||
{
|
||||
name: 'Most recent donor',
|
||||
stat: mostRecentDonor.name ?? 'Nobody',
|
||||
url: `/${mostRecentDonor.username}`,
|
||||
stat: mostRecentDonor?.name ?? 'Nobody',
|
||||
url: `/${mostRecentDonor?.username}`,
|
||||
},
|
||||
{
|
||||
name: 'Most recent donation',
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer'
|
|||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
import { Contract, contractPath } from 'web/lib/firebase/contracts'
|
||||
import { createMarket } from 'web/lib/firebase/api'
|
||||
import { FIXED_ANTE } from 'common/antes'
|
||||
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from 'common/economy'
|
||||
import { InfoTooltip } from 'web/components/info-tooltip'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
@ -158,6 +158,8 @@ export function NewContract(props: {
|
|||
: undefined
|
||||
|
||||
const balance = creator.balance || 0
|
||||
const deservesFreeMarket =
|
||||
(creator.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX
|
||||
|
||||
const min = minString ? parseFloat(minString) : undefined
|
||||
const max = maxString ? parseFloat(maxString) : undefined
|
||||
|
@ -177,7 +179,7 @@ export function NewContract(props: {
|
|||
question.length > 0 &&
|
||||
ante !== undefined &&
|
||||
ante !== null &&
|
||||
ante <= balance &&
|
||||
(ante <= balance || deservesFreeMarket) &&
|
||||
// closeTime must be in the future
|
||||
closeTime &&
|
||||
closeTime > Date.now() &&
|
||||
|
@ -207,6 +209,7 @@ export function NewContract(props: {
|
|||
max: MAX_DESCRIPTION_LENGTH,
|
||||
placeholder: descriptionPlaceholder,
|
||||
disabled: isSubmitting,
|
||||
defaultValue: JSON.parse(params?.description ?? '{}'),
|
||||
})
|
||||
|
||||
const isEditorFilled = editor != null && !editor.isEmpty
|
||||
|
@ -460,12 +463,25 @@ export function NewContract(props: {
|
|||
text={`Cost to create your question. This amount is used to subsidize betting.`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!deservesFreeMarket ? (
|
||||
<div className="label-text text-neutral pl-1">
|
||||
{formatMoney(ante)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="label-text text-primary pl-1">
|
||||
FREE{' '}
|
||||
<span className="label-text pl-1 text-gray-500">
|
||||
(You have{' '}
|
||||
{FREE_MARKETS_PER_USER_MAX -
|
||||
(creator?.freeMarketsCreated ?? 0)}{' '}
|
||||
free markets left)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ante > balance && (
|
||||
{ante > balance && !deservesFreeMarket && (
|
||||
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
|
||||
<span className="mr-2 text-red-500">Insufficient balance</span>
|
||||
<button
|
||||
|
|
|
@ -109,6 +109,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
|
|||
contract={contract}
|
||||
bets={bets}
|
||||
isCreator={false}
|
||||
user={null}
|
||||
disabled
|
||||
/>
|
||||
|
||||
|
|
118
web/pages/experimental/home.tsx
Normal file
118
web/pages/experimental/home.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import React from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PlusSmIcon } 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 { User } from 'common/user'
|
||||
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { Sort } from 'web/hooks/use-sort-and-query-params'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { Group } from 'common/group'
|
||||
import { Title } from 'web/components/title'
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const creds = await authenticateOnServer(ctx)
|
||||
const auth = creds ? await getUserAndPrivateUser(creds.user.uid) : null
|
||||
return { props: { auth } }
|
||||
}
|
||||
|
||||
const Home = (props: { auth: { user: User } | null }) => {
|
||||
const user = props.auth ? props.auth.user : null
|
||||
|
||||
const router = useRouter()
|
||||
useTracking('view home')
|
||||
|
||||
useSaveReferral()
|
||||
|
||||
const memberGroups = (useMemberGroups(user?.id) ?? []).filter(
|
||||
(group) => group.contractIds.length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="mx-auto mb-8 w-full">
|
||||
<SearchSection label="Trending" sort="score" user={user} />
|
||||
<SearchSection label="Newest" sort="newest" user={user} />
|
||||
<SearchSection label="Closing soon" sort="close-date" user={user} />
|
||||
{memberGroups.map((group) => (
|
||||
<GroupSection key={group.id} group={group} user={user} />
|
||||
))}
|
||||
</Col>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed bottom-[70px] right-3 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
|
||||
sort: Sort
|
||||
}) {
|
||||
const { label, user, sort } = props
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Title className="mx-2 !text-gray-800 sm:mx-0" text={label} />
|
||||
<Spacer h={2} />
|
||||
<ContractSearch user={user} defaultSort={sort} maxItems={4} noControls />
|
||||
<Button
|
||||
className="self-end"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/home?s=${sort}`)}
|
||||
>
|
||||
See more
|
||||
</Button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSection(props: { group: Group; user: User | null }) {
|
||||
const { group, user } = props
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Col className="">
|
||||
<Title className="mx-2 !text-gray-800 sm:mx-0" text={group.name} />
|
||||
<Spacer h={2} />
|
||||
<ContractSearch
|
||||
user={user}
|
||||
defaultSort={'score'}
|
||||
additionalFilter={{ groupSlug: group.slug }}
|
||||
maxItems={4}
|
||||
noControls
|
||||
/>
|
||||
<Button
|
||||
className="mr-2 self-end"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/group/${group.slug}`)}
|
||||
>
|
||||
See more
|
||||
</Button>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
|
@ -33,12 +33,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator'
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useCommentsOnGroup } from 'web/hooks/use-comments'
|
||||
import { REFERRAL_AMOUNT } from 'common/user'
|
||||
import { ContractSearch } from 'web/components/contract-search'
|
||||
import { FollowList } from 'web/components/follow-list'
|
||||
import { SearchIcon } from '@heroicons/react/outline'
|
||||
import { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||
|
@ -47,7 +44,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
|
|||
import { Button } from 'web/components/button'
|
||||
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||
import { GroupComment } from 'common/comment'
|
||||
import { GroupChat } from 'web/components/groups/group-chat'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
|
@ -149,9 +146,6 @@ export default function GroupPage(props: {
|
|||
const page = slugs?.[1] as typeof groupSubpages[number]
|
||||
|
||||
const group = useGroup(props.group?.id) ?? props.group
|
||||
const tips = useTipTxns({ groupId: group?.id })
|
||||
|
||||
const messages = useCommentsOnGroup(group?.id) ?? props.messages
|
||||
|
||||
const user = useUser()
|
||||
|
||||
|
@ -201,21 +195,12 @@ export default function GroupPage(props: {
|
|||
/>
|
||||
)
|
||||
|
||||
const chatTab = (
|
||||
<GroupChat messages={messages} group={group} user={user} tips={tips} />
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: 'Markets',
|
||||
content: questionsTab,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
},
|
||||
{
|
||||
title: 'Chat',
|
||||
content: chatTab,
|
||||
href: groupPath(group.slug, 'chat'),
|
||||
},
|
||||
{
|
||||
title: 'Leaderboards',
|
||||
content: leaderboard,
|
||||
|
|
|
@ -71,6 +71,7 @@ const Home = (props: { auth: { user: User } | null }) => {
|
|||
backToHome={() => {
|
||||
history.back()
|
||||
}}
|
||||
recommendedContracts={[]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -25,8 +25,8 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
|||
import { ManalinkCardFromView } from 'web/components/manalink-card'
|
||||
import { Pagination } from 'web/components/pagination'
|
||||
import { Manalink } from 'common/manalink'
|
||||
import { REFERRAL_AMOUNT } from 'common/user'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
|
||||
const LINKS_PER_PAGE = 24
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ import { groupPath } from 'web/lib/firebase/groups'
|
|||
import {
|
||||
BETTING_STREAK_BONUS_AMOUNT,
|
||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||
} from 'common/numeric-constants'
|
||||
} from 'common/economy'
|
||||
import { groupBy, sum, uniq } from 'lodash'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { Pagination } from 'web/components/pagination'
|
||||
|
@ -44,6 +44,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { NotificationSettings } from 'web/components/NotificationSettings'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
||||
export const NOTIFICATIONS_PER_PAGE = 30
|
||||
const MULTIPLE_USERS_KEY = 'multipleUsers'
|
||||
|
@ -165,7 +166,7 @@ function NotificationsList(props: {
|
|||
if (!paginatedGroupedNotifications || !allGroupedNotifications) return <div />
|
||||
|
||||
return (
|
||||
<div className={'min-h-[100vh]'}>
|
||||
<div className={'min-h-[100vh] text-sm'}>
|
||||
{paginatedGroupedNotifications.length === 0 && (
|
||||
<div className={'mt-2'}>
|
||||
You don't have any notifications. Try changing your settings to see
|
||||
|
@ -271,9 +272,17 @@ function IncomeNotificationGroupItem(props: {
|
|||
}
|
||||
return newNotifications
|
||||
}
|
||||
|
||||
const combinedNotifs =
|
||||
combineNotificationsByAddingNumericSourceTexts(notifications)
|
||||
const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(
|
||||
notifications.filter((n) => n.sourceType !== 'betting_streak_bonus')
|
||||
)
|
||||
// Because the server's reset time will never align with the client's, we may
|
||||
// erroneously sum 2 betting streak bonuses, therefore just show the most recent
|
||||
const mostRecentBettingStreakBonus = notifications
|
||||
.filter((n) => n.sourceType === 'betting_streak_bonus')
|
||||
.sort((a, b) => a.createdTime - b.createdTime)
|
||||
.pop()
|
||||
if (mostRecentBettingStreakBonus)
|
||||
combinedNotifs.unshift(mostRecentBettingStreakBonus)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -370,6 +379,8 @@ function IncomeNotificationItem(props: {
|
|||
const [highlighted] = useState(!notification.isSeen)
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width && width < 768) || false
|
||||
const user = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsAsSeen([notification])
|
||||
}, [notification])
|
||||
|
@ -388,20 +399,30 @@ function IncomeNotificationItem(props: {
|
|||
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||
} else if (sourceType === 'betting_streak_bonus') {
|
||||
reasonText = 'for your'
|
||||
} else if (sourceType === 'loan' && sourceText) {
|
||||
reasonText = `of your invested bets returned as a`
|
||||
}
|
||||
|
||||
const streakInDays =
|
||||
Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
|
||||
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
|
||||
: user?.currentBettingStreak ?? 0
|
||||
const bettingStreakText =
|
||||
sourceType === 'betting_streak_bonus' &&
|
||||
(sourceText
|
||||
? `🔥 ${
|
||||
parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT
|
||||
} day Betting Streak`
|
||||
: 'Betting Streak')
|
||||
(sourceText ? `🔥 ${streakInDays} day Betting Streak` : 'Betting Streak')
|
||||
|
||||
return (
|
||||
<>
|
||||
{reasonText}
|
||||
{sourceType === 'betting_streak_bonus' ? (
|
||||
{sourceType === 'loan' ? (
|
||||
simple ? (
|
||||
<span className={'ml-1 font-bold'}>🏦 Loan</span>
|
||||
) : (
|
||||
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
|
||||
🏦 Loan
|
||||
</SiteLink>
|
||||
)
|
||||
) : sourceType === 'betting_streak_bonus' ? (
|
||||
simple ? (
|
||||
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||
) : (
|
||||
|
@ -445,6 +466,7 @@ function IncomeNotificationItem(props: {
|
|||
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||
if (sourceType === 'betting_streak_bonus')
|
||||
return `/${sourceUserUsername}/?show=betting-streak`
|
||||
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
|
||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||
sourceId ?? '',
|
||||
|
|
|
@ -5,11 +5,11 @@ import { useUser } from 'web/hooks/use-user'
|
|||
import { Page } from 'web/components/page'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { REFERRAL_AMOUNT } from 'common/user'
|
||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { InfoBox } from 'web/components/info-box'
|
||||
import { QRCode } from 'web/components/qr-code'
|
||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
import { sortBy } from 'lodash'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { getServerSideSitemap, ISitemapField } from 'next-sitemap'
|
||||
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { LiteMarket } from './api/v0/_types'
|
||||
import { listAllContracts } from 'web/lib/firebase/contracts'
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
// Fetching data from https://manifold.markets/api
|
||||
const response = await fetch(`https://${DOMAIN}/api/v0/markets`)
|
||||
const contracts = await listAllContracts(1000, undefined, 'popularityScore')
|
||||
|
||||
const liteMarkets = (await response.json()) as LiteMarket[]
|
||||
const sortedMarkets = sortBy(liteMarkets, (m) => -m.volume24Hours)
|
||||
const score = (popularity: number) => Math.tanh(Math.log10(popularity + 1))
|
||||
|
||||
const fields = sortedMarkets.map((market) => ({
|
||||
// See https://www.sitemaps.org/protocol.html
|
||||
loc: market.url,
|
||||
const fields = contracts
|
||||
.sort((x) => x.popularityScore ?? 0)
|
||||
.map((market) => ({
|
||||
loc: `https://manifold.markets/${market.creatorUsername}/${market.slug}`,
|
||||
changefreq: market.volume24Hours > 10 ? 'hourly' : 'daily',
|
||||
priority: market.volume24Hours + market.volume7Days > 100 ? 0.7 : 0.1,
|
||||
// TODO: Add `lastmod` aka last modified time
|
||||
priority: score(market.popularityScore ?? 0),
|
||||
lastmod: market.lastUpdatedTime,
|
||||
})) as ISitemapField[]
|
||||
|
||||
return await getServerSideSitemap(ctx, fields)
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||
</urlset>
|
|
@ -1,4 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>https://manifold.markets/sitemap-0.xml</loc></sitemap>
|
||||
</sitemapindex>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://manifold.markets</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://manifold.markets/home</loc><changefreq>hourly</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://manifold.markets/leaderboards</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://manifold.markets/add-funds</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://manifold.markets/challenges</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||
<url><loc>https://manifold.markets/charity</loc><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://manifold.markets/groups</loc><changefreq>daily</changefreq><priority>0.2</priority></url>
|
||||
</urlset>
|
||||
|
|
Loading…
Reference in New Issue
Block a user