Merge branch 'main' into limit-orders

This commit is contained in:
James Grugett 2022-06-15 23:41:03 -05:00
commit 47abe94639
71 changed files with 912 additions and 833 deletions

View File

@ -17,6 +17,14 @@ module.exports = {
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
],

View File

@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
},
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
}

View File

@ -8,6 +8,7 @@ export type EnvConfig = {
domain: string
firebaseConfig: FirebaseConfig
functionEndpoints: Record<V2CloudFunction, string>
amplitudeApiKey?: string
// Access controls
adminEmails: string[]
@ -34,6 +35,8 @@ type FirebaseConfig = {
export const PROD_CONFIG: EnvConfig = {
domain: 'manifold.markets',
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
firebaseConfig: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com',

View File

@ -0,0 +1,27 @@
import { groupBy, mapValues, sum, sumBy } from 'lodash'
import { Txn } from './txn'
// Returns a map of charity ids to the amount of M$ matched
export function quadraticMatches(
allCharityTxns: Txn[],
matchingPool: number
): Record<string, number> {
// For each charity, group the donations by each individual donor
const donationsByCharity = groupBy(allCharityTxns, 'toId')
const donationsByDonors = mapValues(donationsByCharity, (txns) =>
groupBy(txns, 'fromId')
)
// Weight for each charity = [sum of sqrt(individual donor)] ^ 2
const weights = mapValues(donationsByDonors, (byDonor) => {
const sumByDonor = Object.values(byDonor).map((txns) =>
sumBy(txns, 'amount')
)
const sumOfRoots = sumBy(sumByDonor, Math.sqrt)
return sumOfRoots ** 2
})
// Then distribute the matching pool based on the individual weights
const totalWeight = sum(Object.values(weights))
return mapValues(weights, (weight) => matchingPool * (weight / totalWeight))
}

View File

@ -1,4 +1,4 @@
import { sortBy } from 'lodash'
import { sortBy, sum } from 'lodash'
export const logInterpolation = (min: number, max: number, value: number) => {
if (value <= min) return 0
@ -20,13 +20,17 @@ export function normpdf(x: number, mean = 0, variance = 1) {
export const TAU = Math.PI * 2
export function median(values: number[]) {
if (values.length === 0) return NaN
export function median(xs: number[]) {
if (xs.length === 0) return NaN
const sorted = sortBy(values, (x) => x)
const sorted = sortBy(xs, (x) => x)
const mid = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2
}
return sorted[mid]
}
export function average(xs: number[]) {
return sum(xs) / xs.length
}

58
docs/docs/$how-to.md Normal file
View File

@ -0,0 +1,58 @@
# How to Manifold
Manifold Markets is a novel site where users can bet against each other to predict the outcomes of all types of questions. Engage in intense discussion, or joke with friends, whilst putting play-money where your mouth is.
## Mana
Mana (M$) is our virtual play currency that cannot be converted to real money.
- **Its Value**
You can redeem your Mana and we will [donate to a charity](http://manifold.markets/charity) on your behalf. Redeeming and purchasing Mana occurs at a rate of M$100 to $1. You will be able to redeem it for merch and other cool items soon too!
- **It sets us apart**
Using play-money sets us apart from other similar sites as we dont want our users to solely focus on monetary gains. Instead we prioritize providing value in the form of an enjoyable experience and facilitating a more informed world through the power of prediction markets.
## How probabilities work
The probability of a market represents what the collective bets of users predict the chances of an outcome occurring is. How this is calculated depends on the type of market - see below!
## Types of markets
There are currently 3 types of markets: Yes/No (binary), Free response, and Numerical.
- **Yes/No (Binary)**
The creator asks a question where traders can bet yes or no.
Check out [Maniswap](https://www.notion.so/Maniswap-ce406e1e897d417cbd491071ea8a0c39) for more info on its automated market maker.
- **Free Response**
The creator asks an open ended question. Both the creator and users can propose answers which can be bet on. Dont be intimidated to add new answers! The payout system and initial liquidity rewards users who bet on new answers early. The algorithm used to determine the probability and payout is complicated but if you want to learn more check out [DPM](https://www.notion.so/DPM-b9b48a09ea1f45b88d991231171730c5).
- **Numerical**
Retracted whilst we make improvements. You still may see some old ones floating around though. Questions which can be answered by a number within a given range. Betting on a value will cause you to buy shares from buckets surrounding the number you choose.
## Compete and build your portfolio
Generate profits to prove your expertise and shine above your friends.
To the moon 🚀
- **Find inaccurate probabilities**
Use your superior knowledge on topics to identify markets which have inaccurate probabilities. This gives you favorable odds, so bet accordingly to shift the probability to what you think it should be.
- **React to news**
Markets are dynamic and ongoing events can drastically affect what the probability should look like. Be the keenest to react and there is a lot of Mana to be made.
- **Buy low, sell high**
Similar to a stock market, probabilities can be overvalued and undervalued. If you bet (buy shares) at one end of the spectrum and subsequently other users buy even more shares of that same type, the value of your own shares will increase. Sometimes it will be most profitable to wait for the market to resolve but often it can be wise to sell your shares and take the immediate profits. This can also be a great way to free up Mana if you are lacking funds.
- **Create innovative answers**
Certain free response markets provide room for creativity! The answers themselves can often affect the outcome based on how compelling they are.
More questions? Check out **[this community-driven FAQ](https://outsidetheasylum.blog/manifold-markets-faq/)**!

View File

@ -5,7 +5,7 @@ slug: /
# About Manifold Markets
Manifold Markets lets anyone create a prediction market on any topic. Win virtual money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market!
Manifold Markets lets anyone create a prediction market on any topic. Win virtual play money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market!
### **What are prediction markets?**
@ -17,20 +17,6 @@ If I think the Democrats are very likely to win, and you disagree, I might offer
Now, you or I could be mistaken and overshooting the true probability one way or another. If so, there's an incentive for someone else to bet and correct it! Over time, the implied probability will converge to the **[market's best estimate](https://en.wikipedia.org/wiki/Efficient-market_hypothesis)**. Since these probabilities are public, anyone can use them to make better decisions!
### **How does Manifold Markets work?**
1. **Anyone can create a market for any yes-or-no question.**
You can ask questions about the future like "Will Taiwan remove its 14-day COVID quarantine by Jun 01, 2022?" If the market thinks this is very likely, you can plan more activities for your trip.
You can also ask subjective, personal questions like "Will I enjoy my 2022 Taiwan trip?". Then share the market with your family and friends and get their takes!
2. **Anyone can bet on a market using Manifold Dollars (M$), our platform currency.**
You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, you'll win Manifold Dollars from people who bet against you.
More questions? Check out **[this community-driven FAQ](https://outsidetheasylum.blog/manifold-markets-faq/)**!
### **Can prediction markets work without real money?**
Yes! There is substantial evidence that play-money prediction markets provide real predictive power. Examples include **[sports betting](http://www.electronicmarkets.org/fileadmin/user_upload/doc/Issues/Volume_16/Issue_01/V16I1_Statistical_Tests_of_Real-Money_versus_Play-Money_Prediction_Markets.pdf)** and internal prediction markets at firms like **[Google](https://www.networkworld.com/article/2284098/google-bets-on-value-of-prediction-markets.html)**.

View File

@ -449,7 +449,7 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"question":"Is there life on Mars?", \
"description":"I'm not going to type some long ass example description.", \
"closeTime":1700000000000, \
initialProb:25}'
"initialProb":25}'
```
## Changelog

View File

@ -15,35 +15,97 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties
🥧 *Awarded 2022-03-14*
🎈 *Awarded on 2022-06-14*
**[Kevin Zielnicki](https://manifold.markets/kjz): M$ 10,000**
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
- For creating an awesome stats page which features and analyses various data sets! This can be found on the second tab of our [analytics page](https://manifold.markets/stats).
**[Jack](https://manifold.markets/jack): M$10,000**
- For adding a bunch of charities to [Manifold for Good](https://manifold.markets/charity), working out market math with Austin, and excellent comment activity.
**[Forrest](https://manifold.markets/Forrest): M$10,000**
- For a variety of [open source code contributions](https://github.com/manifoldmarkets/manifold/commits?author=ForrestWeiswolf), making our code base easier to use and maintain.
**[IsaacKing](https://manifold.markets/IsaacKing): M$10,000**
- For responsible disclosure of an exploit involving liquidity withdrawal, which has [now been fixed](https://github.com/manifoldmarkets/manifold/pull/472)! Removing one infinite money glitch at a time.
**[Sjlver](https://manifold.markets/Sjlver): M$5,000**
- For responsible disclosure of a potential exploit. We would say what it is, but it isnt quite fixed yet! 🤫
_🌿 Announced on 2022-05-02_
**[Marshall Polaris](https://manifold.markets/mqp): M$200K**
- For spearheading the effort to [open-source Manifold](https://github.com/manifoldmarkets/manifold), by documenting our processes, triaging bugs, and improving the new contributor experience.
- Marshall contributed over 2 weeks of part-time volunteer work; as such, we are awarding an amount that reflects the extraordinary amount of effort hes put in.
**[Vincent Luczkow](https://manifold.markets/VincentLuczkow): M$10,000**
- For building and releasing https://github.com/vluzko/manifold-markets-python, a super cool Python visualization of the calibration accuracy of all Manifold markets. Turns out were doing okay!
**[Akhil Wable](https://manifold.markets/AkhilWable): M$10,000**
- For writing up [Akhils Product Suggestions](https://www.notion.so/Akhil-s-Product-Suggestions-672e1cba393d4242852ff95ae79528df), an extensive, thoughtful list of improvements we could make to our platform.
**[Alex K. Chen](https://manifold.markets/AlexKChen): M$6,000**
- For the creation of a metric ton of innovative, long term questions. At the time of award, Alex was singlehandedly responsible for 20% of all markets posted in April.
**[ZorbaTHut](https://manifold.markets/ZorbaTHut): M$5,000**
- For [testing out futarchy](https://manifold.markets/tag/themotte_leaving) on an important problem for the community of The Motte.
**[Tetraspace](https://manifold.markets/Tetraspace): M$3,500**
- For the creation of [a focused set of questions on UK politics](https://twitter.com/TetraspaceWest/status/1516824123149848579), with relevant real-world predictions.
- For the idea and execution of using FR bounded buckets for mapping out a scalar range ([example market](https://manifold.markets/Tetraspace/if-ron-desantis-is-elected-presiden), [discussion here](https://manifold.markets/StephenMalina/how-many-daily-active-users-will-ma)).
**[tcheasdfjkl](https://manifold.markets/tcheasdfjkl): M$2,500**
- For calling out numerous areas of improvement, e.g. around our profit numbers being wonky, and problems with the DPM ⇒ CFMM market conversions.
**[Jack](https://manifold.markets/JackC): M$500**
- For recommending we list the Long-Term Future Fund as a supported charity.
**[N.C. Young](https://manifold.markets/NcyRocks): M$500**
- For recommending we list the Givewell Maximum Impact Fund as a supported charity.
\**🥧 *Awarded 2022-03-14\*
**[Kevin Zielnicki](https://manifold.markets/kjz): M$10,000**
- For identifying issues with our Dynamic Parimutuel Market Maker in an [excellent blog post](https://kevin.zielnicki.com/2022/02/17/manifold/) (and [associated market](https://manifold.markets/kjz/will-manifolds-developers-agree-wit)), leading us to change to a different mechanism.
**[Pepe](https://manifold.markets/Pepe): M$ 10,000**
**[Pepe](https://manifold.markets/Pepe): M$10,000**
- For developing the function used in our Constant Function Market Maker and working with us to polish it on Discord, making it easier for us to provision liquidity compared to a CPMM.
- For developing the function used in our Constant Function Market Maker, making it easier for us to provision liquidity compared to a CPMM.
**[Gurkenglas](https://manifold.markets/Gurkenglas): M$ 5,000**
**[Gurkenglas](https://manifold.markets/Gurkenglas): M$5,000**
- For concrete suggestions on Discord around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible.
- For concrete suggestions around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible.
**[Scott Alexander](https://manifold.markets/ScottAlexander): M$ 5,000**
**[Scott Alexander](https://manifold.markets/ScottAlexander): M$5,000**
- For [developing and publicizing the idea of providing interest-free loans on each market](https://astralcodexten.substack.com/p/play-money-and-reputation-systems), helping make long-term markets more accurate.
**[David Glidden](https://manifold.markets/dglid): M$ 5,000**
**[David Glidden](https://manifold.markets/dglid): M$5,000**
- For taking on the mantle of [@MetaculusBot](https://manifold.markets/MetaculusBot), which allows traders access to a wider spread of topics, and permits head-to-head comparisons between our prediction markets and other forecasting platforms.
**[Isaac King](https://manifold.markets/IsaacKing): M$ 5,000**
**[Isaac King](https://manifold.markets/IsaacKing): M$5,000**
- For [compiling a comprehensive FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/).
- For [compiling an FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/).
**[Blazer](https://manifold.markets/BlazingDarkness): M$ 2,500**
**[Blazer](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when): M$2,500**
- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing all market creators trades, leading us to revert this feature entirely.
- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing the market creators trades, leading us to revert this feature entirely.
⛑️ _Awarded 2022-01-09_

View File

@ -19,7 +19,7 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-extra-semi': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',

View File

@ -20,6 +20,7 @@
},
"main": "lib/functions/src/index.js",
"dependencies": {
"@amplitude/node": "1.10.0",
"cors": "2.8.5",
"fetch": "1.1.0",
"firebase-admin": "10.0.0",

View File

@ -0,0 +1,24 @@
import * as Amplitude from '@amplitude/node'
import { DEV_CONFIG } from '../../common/envs/dev'
import { PROD_CONFIG } from '../../common/envs/prod'
import { isProd } from './utils'
const key = isProd ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey
const amp = Amplitude.init(key ?? '')
export const track = async (
userId: string,
eventName: string,
eventProperties?: any,
amplitudeProperties?: Partial<Amplitude.Event>
) => {
await amp.logEvent({
event_type: eventName,
user_id: userId,
event_properties: eventProperties,
...amplitudeProperties,
})
}

View File

@ -16,8 +16,7 @@ export * from './on-fold-follow'
export * from './on-fold-delete'
export * from './on-view'
export * from './unsubscribe'
export * from './update-contract-metrics'
export * from './update-user-metrics'
export * from './update-metrics'
export * from './update-recommendations'
export * from './backup-db'
export * from './change-user-info'

View File

@ -2,15 +2,12 @@ import { initAdmin } from './script-init'
initAdmin()
import { log, logMemory } from '../utils'
import { updateContractMetricsCore } from '../update-contract-metrics'
import { updateUserMetricsCore } from '../update-user-metrics'
import { updateMetricsCore } from '../update-metrics'
async function updateMetrics() {
logMemory()
log('Updating contract metrics...')
await updateContractMetricsCore()
log('Updating user metrics...')
await updateUserMetricsCore()
log('Updating metrics...')
await updateMetricsCore()
}
if (require.main === module) {

View File

@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
import { z } from 'zod'
import { APIError, newEndpoint, validate } from './api'
import { Contract } from '../../common/contract'
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'
@ -57,8 +57,12 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
prevLoanAmount
)
if (!isFinite(newP)) {
throw new APIError(500, 'Trade rejected due to overflow error.')
if (
!newP ||
!isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY
) {
throw new APIError(400, 'Sale too large for current liquidity pool.')
}
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()

View File

@ -4,6 +4,7 @@ import Stripe from 'stripe'
import { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails'
import { track } from './analytics'
export type StripeSession = Stripe.Event.Data.Object & {
id: string
@ -152,6 +153,13 @@ const issueMoneys = async (session: StripeSession) => {
if (!privateUser) return
await sendThankYouEmail(user, privateUser)
await track(
userId,
'M$ purchase',
{ amount: payout, sessionId },
{ revenue: payout / 100 }
)
}
const firestore = admin.firestore()

View File

@ -1,47 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { max, sumBy } from 'lodash'
import { getValues, log, logMemory, mapAsync } from './utils'
import { Bet } from '../../common/bet'
const firestore = admin.firestore()
const oneDay = 1000 * 60 * 60 * 24
const computeVolumes = async (contractId: string, durationsMs: number[]) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const longestDurationMs = max(durationsMs)!
const allBets = await getValues<Bet>(
firestore
.collection(`contracts/${contractId}/bets`)
.where('createdTime', '>', Date.now() - longestDurationMs)
)
return durationsMs.map((duration) => {
const cutoff = Date.now() - duration
const bets = allBets.filter((b) => b.createdTime > cutoff)
return sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount)))
})
}
export const updateContractMetricsCore = async () => {
const contractDocs = await firestore.collection('contracts').listDocuments()
log(`Loaded ${contractDocs.length} contract IDs.`)
logMemory()
await mapAsync(contractDocs, async (doc) => {
const [volume24Hours, volume7Days] = await computeVolumes(doc.id, [
oneDay,
oneDay * 7,
])
return await doc.update({
volume24Hours,
volume7Days,
})
})
log(`Updated metrics for ${contractDocs.length} contracts.`)
}
export const updateContractMetrics = functions
.runWith({ memory: '1GB' })
.pubsub.schedule('every 15 minutes')
.onRun(updateContractMetricsCore)

View File

@ -0,0 +1,94 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, sum, sumBy } from 'lodash'
import { getValues, log, logMemory, writeUpdatesAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
const firestore = admin.firestore()
const oneDay = 1000 * 60 * 60 * 24
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0)
})
}
const computeTotalPool = (contracts: Contract[]) => {
return sum(contracts.map((contract) => sum(Object.values(contract.pool))))
}
export const updateMetricsCore = async () => {
const [users, contracts, bets] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
getValues<Bet>(firestore.collectionGroup('bets')),
])
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
)
logMemory()
const now = Date.now()
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contractUpdates = contracts.map((contract) => {
const contractBets = betsByContract[contract.id] ?? []
return {
doc: firestore.collection('contracts').doc(contract.id),
fields: {
volume24Hours: computeVolume(contractBets, now - oneDay),
volume7Days: computeVolume(contractBets, now - oneDay * 7),
},
}
})
await writeUpdatesAsync(firestore, contractUpdates)
log(`Updated metrics for ${contracts.length} contracts.`)
const contractsById = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId)
const userUpdates = users.map((user) => {
const investmentValue = computeInvestmentValue(
betsByUser[user.id] ?? [],
contractsById
)
const creatorContracts = contractsByUser[user.id] ?? []
const creatorVolume = computeTotalPool(creatorContracts)
const totalValue = user.balance + investmentValue
const totalPnL = totalValue - user.totalDeposits
return {
doc: firestore.collection('users').doc(user.id),
fields: {
totalPnLCached: totalPnL,
creatorVolumeCached: creatorVolume,
},
}
})
await writeUpdatesAsync(firestore, userUpdates)
log(`Updated metrics for ${users.length} users.`)
}
const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
export const updateMetrics = functions
.runWith({ memory: '1GB' })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -1,79 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, sum, sumBy } from 'lodash'
import { getValues, log, logMemory, mapAsync } from './utils'
import { Contract } from '../../common/contract'
import { Bet } from '../../common/bet'
import { User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
const firestore = admin.firestore()
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
return payout - (bet.loanAmount ?? 0)
})
}
const computeTotalPool = (
user: User,
contractsDict: { [k: string]: Contract }
) => {
const creatorContracts = Object.values(contractsDict).filter(
(contract) => contract.creatorId === user.id
)
const pools = creatorContracts.map((contract) =>
sum(Object.values(contract.pool))
)
return sum(pools)
}
export const updateUserMetricsCore = async () => {
const [users, contracts, bets] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
firestore.collectionGroup('bets').get(),
])
log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.docs.length} bets.`
)
logMemory()
const contractsDict = Object.fromEntries(
contracts.map((contract) => [contract.id, contract])
)
const betsByUser = groupBy(
bets.docs.map((doc) => doc.data() as Bet),
(bet) => bet.userId
)
await mapAsync(users, async (user) => {
const investmentValue = computeInvestmentValue(
betsByUser[user.id] ?? [],
contractsDict
)
const creatorVolume = computeTotalPool(user, contractsDict)
const totalValue = user.balance + investmentValue
const totalPnL = totalValue - user.totalDeposits
return await firestore.collection('users').doc(user.id).update({
totalPnLCached: totalPnL,
creatorVolumeCached: creatorVolume,
})
})
log(`Updated metrics for ${users.length} users.`)
}
export const updateUserMetrics = functions
.runWith({ memory: '1GB' })
.pubsub.schedule('every 15 minutes')
.onRun(updateUserMetricsCore)

View File

@ -15,18 +15,25 @@ export const logMemory = () => {
}
}
export const mapAsync = async <T, U>(
xs: T[],
fn: (x: T) => Promise<U>,
concurrency = 100
type UpdateSpec = {
doc: admin.firestore.DocumentReference
fields: { [k: string]: unknown }
}
export const writeUpdatesAsync = async (
db: admin.firestore.Firestore,
updates: UpdateSpec[],
batchSize = 500 // 500 = Firestore batch limit
) => {
const results = []
const chunks = chunk(xs, concurrency)
const chunks = chunk(updates, batchSize)
for (let i = 0; i < chunks.length; i++) {
log(`${i * concurrency}/${xs.length} processed...`)
results.push(...(await Promise.all(chunks[i].map(fn))))
log(`${i * batchSize}/${updates.length} updates written...`)
const batch = db.batch()
for (const { doc, fields } of chunks[i]) {
batch.update(doc, fields)
}
await batch.commit()
}
return results
}
export const isProd =

View File

@ -78,7 +78,13 @@ export const withdrawLiquidity = functions
} as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares)
const newTotalLiquidity = contract.totalLiquidity - payout
const minPoolShares = Math.min(...Object.values(newPool))
const adjustedTotal = contract.totalLiquidity - payout
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
trans.update(contractDoc, {
pool: newPool,
totalLiquidity: newTotalLiquidity,

View File

@ -10,7 +10,7 @@ module.exports = {
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',

View File

@ -61,7 +61,7 @@ export function AddFundsButton(props: { className?: string }) {
>
<button
type="submit"
className="btn btn-primary bg-gradient-to-r from-teal-500 to-green-500 px-10 font-medium hover:from-teal-600 hover:to-green-600"
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
>
Checkout
</button>

View File

@ -0,0 +1,24 @@
import { ExclamationIcon } from '@heroicons/react/solid'
import { Linkify } from './linkify'
export function AlertBox(props: { title: string; text: string }) {
const { title, text } = props
return (
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon
className="h-5 w-5 text-yellow-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
<div className="mt-2 text-sm text-yellow-700">
<Linkify text={text} />
</div>
</div>
</div>
</div>
)
}

View File

@ -22,8 +22,9 @@ import {
calculateDpmPayoutAfterCorrectBet,
getDpmOutcomeProbabilityAfterBet,
} from 'common/calculate-dpm'
import { firebaseLogin } from 'web/lib/firebase/users'
import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics'
import { SignUpPrompt } from '../sign-up-prompt'
export function AnswerBetPanel(props: {
answer: Answer
@ -72,6 +73,15 @@ export function AnswerBetPanel(props: {
}
setIsSubmitting(false)
})
track('bet', {
location: 'answer panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: betAmount,
outcome: answerId,
})
}
const betDisabled = isSubmitting || !betAmount || error
@ -173,12 +183,7 @@ export function AnswerBetPanel(props: {
{isSubmitting ? 'Submitting...' : 'Submit trade'}
</button>
) : (
<button
className="btn self-stretch whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up to bet!
</button>
<SignUpPrompt />
)}
</Col>
)

View File

@ -22,6 +22,7 @@ import {
import { firebaseLogin } from 'web/lib/firebase/users'
import { Bet } from 'common/bet'
import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -143,7 +144,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
isSubmitting && 'loading'
)}
disabled={!canSubmit}
onClick={submitAnswer}
onClick={withTracking(submitAnswer, 'submit answer')}
>
Submit answer & buy
</button>
@ -151,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
text && (
<button
className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
onClick={withTracking(firebaseLogin, 'answer panel sign in')}
>
Sign in
</button>

View File

@ -40,6 +40,7 @@ import { useSaveShares } from './use-save-shares'
import { SignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { ProbabilityInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
export function BetPanel(props: {
contract: CPMMBinaryContract
@ -266,6 +267,15 @@ function BuyPanel(props: {
}
setIsSubmitting(false)
})
track('bet', {
location: 'bet panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: betAmount,
outcome: betChoice,
})
}
const betDisabled = isSubmitting || !betAmount || error
@ -429,6 +439,14 @@ export function SellPanel(props: {
}
setIsSubmitting(false)
})
track('sell shares', {
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
shares: sellAmount,
outcome: sharesOutcome,
})
}
const initialProb = getProbability(contract)

View File

@ -482,7 +482,10 @@ export function ContractBetsTable(props: {
}) {
const { contract, className, isYourBets } = props
const bets = props.bets.filter((b) => !b.isAnte)
const bets = sortBy(
props.bets.filter((b) => !b.isAnte),
(bet) => bet.createdTime
).reverse()
const [sales, buys] = partition(bets, (bet) => bet.sale)

View File

@ -6,9 +6,11 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row'
import { Col } from '../layout/col'
export function CharityCard(props: { charity: Charity }) {
const { slug, photo, preview, id, tags } = props.charity
export function CharityCard(props: { charity: Charity; match?: number }) {
const { charity, match } = props
const { slug, photo, preview, id, tags } = charity
const txns = useCharityTxns(id)
const raised = sumBy(txns, (txn) => txn.amount)
@ -32,14 +34,22 @@ export function CharityCard(props: { charity: Charity }) {
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && (
<Row className="text-primary mt-4 flex-1 items-end justify-center gap-2">
<span className="text-3xl">
{raised < 100
? manaToUSD(raised)
: '$' + Math.floor(raised / 100)}
</span>
<span>raised</span>
</Row>
<>
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
<Col>
<span className="text-3xl font-semibold">
{formatUsd(raised)}
</span>
<span>raised</span>
</Col>
{match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)}
</Row>
</>
)}
</div>
</div>
@ -47,6 +57,10 @@ export function CharityCard(props: { charity: Charity }) {
)
}
function formatUsd(mana: number) {
return mana < 100 ? manaToUSD(mana) : '$' + Math.floor(mana / 100)
}
function FeaturedBadge() {
return (
<span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800">

View File

@ -26,6 +26,8 @@ import { EditCategoriesButton } from './feed/category-selector'
import { CATEGORIES } from 'common/categories'
import { Tabs } from './layout/tabs'
import { EditFollowingButton } from './following-button'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -134,6 +136,7 @@ export function ContractSearch(props: {
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter')}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
@ -145,6 +148,7 @@ export function ContractSearch(props: {
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort')}
/>
<Configure
facetFilters={filters}
@ -296,7 +300,11 @@ function CategoryFollowSelector(props: {
]
: []),
]}
onClick={(_, index) => setMode(index === 0 ? 'categories' : 'following')}
onClick={(_, index) => {
const mode = index === 0 ? 'categories' : 'following'
setMode(mode)
track(`click ${mode} tab`)
}}
/>
)
}

View File

@ -22,6 +22,8 @@ import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
export function ContractCard(props: {
contract: Contract
@ -71,12 +73,22 @@ export function ContractCard(props: {
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card', {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a className="absolute top-0 left-0 right-0 bottom-0" />
<a
onClick={trackCallback('click market card', {
slug: contract.slug,
contractId: contract.id,
})}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</div>

View File

@ -24,6 +24,7 @@ import { OUTCOME_TO_COLOR } from '../outcome-label'
import { useSaveShares } from '../use-save-shares'
import { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics'
const BET_SIZE = 10
@ -121,6 +122,12 @@ export function QuickBet(props: { contract: Contract; user: User }) {
success: message,
error: (err) => `${err.message}`,
})
track('quick bet', {
slug: contract.slug,
direction,
contractId: contract.id,
})
}
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {

View File

@ -2,11 +2,13 @@ import React, { Fragment } from 'react'
import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
@ -23,7 +25,10 @@ export function CopyLinkButton(props: {
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => copyContractUrl(contract)}
onMouseUp={() => {
copyContractUrl(contract)
track('copy share link')
}}
>
<Menu.Button
className={clsx(

View File

@ -1,38 +0,0 @@
import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments'
import { Col } from '../layout/col'
import { Bet } from 'common/bet'
import { useUser } from 'web/hooks/use-user'
import { ContractActivity } from './contract-activity'
export function ActivityFeed(props: {
feed: {
contract: Contract
recentBets: Bet[]
recentComments: Comment[]
}[]
mode: 'only-recent' | 'abbreviated' | 'all'
getContractPath?: (contract: Contract) => string
}) {
const { feed, mode, getContractPath } = props
const user = useUser()
return (
<Col className="gap-2">
{feed.map((item) => (
<ContractActivity
key={item.contract.id}
className="rounded-md bg-white py-6 px-2 sm:px-4"
user={user}
contract={item.contract}
bets={item.recentBets}
comments={item.recentComments}
mode={mode}
contractPath={
getContractPath ? getContractPath(item.contract) : undefined
}
/>
))}
</Col>
)
}

View File

@ -1,4 +1,4 @@
import { last, findLastIndex, uniq, sortBy } from 'lodash'
import { uniq, sortBy } from 'lodash'
import { Answer } from 'common/answer'
import { Bet } from 'common/bet'
@ -6,14 +6,11 @@ import { getOutcomeProbability } from 'common/calculate'
import { Comment } from 'common/comment'
import { Contract, FreeResponseContract } from 'common/contract'
import { User } from 'common/user'
import { mapCommentsByBetId } from 'web/lib/firebase/comments'
export type ActivityItem =
| DescriptionItem
| QuestionItem
| BetItem
| CommentItem
| BetGroupItem
| AnswerGroupItem
| CloseItem
| ResolveItem
@ -49,15 +46,6 @@ export type BetItem = BaseActivityItem & {
hideComment?: boolean
}
export type CommentItem = BaseActivityItem & {
type: 'comment'
comment: Comment
betsBySameUser: Bet[]
probAtCreatedTime?: number
truncate?: boolean
smallAvatar?: boolean
}
export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread'
parentComment: Comment
@ -65,12 +53,6 @@ export type CommentThreadItem = BaseActivityItem & {
bets: Bet[]
}
export type BetGroupItem = BaseActivityItem & {
type: 'betgroup'
bets: Bet[]
hideOutcome: boolean
}
export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup'
user: User | undefined | null
@ -87,172 +69,6 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve'
}
const DAY_IN_MS = 24 * 60 * 60 * 1000
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
// Group together bets that are:
// - Within a day of the first in the group
// (Unless the bets are older: then are grouped by 7-days.)
// - Do not have a comment
// - Were not created by this user
// Return a list of ActivityItems
function groupBets(
bets: Bet[],
comments: Comment[],
contract: Contract,
userId: string | undefined,
options: {
hideOutcome: boolean
abbreviated: boolean
smallAvatar: boolean
reversed: boolean
}
) {
const { hideOutcome, abbreviated, smallAvatar, reversed } = options
const commentsMap = mapCommentsByBetId(comments)
const items: ActivityItem[] = []
let group: Bet[] = []
// Turn the current group into an ActivityItem
function pushGroup() {
if (group.length == 1) {
items.push(toActivityItem(group[0]))
} else if (group.length > 1) {
items.push({
type: 'betgroup',
bets: [...group],
id: group[0].id,
contract,
hideOutcome,
})
}
group = []
}
function toActivityItem(bet: Bet): ActivityItem {
const comment = commentsMap[bet.id]
return comment
? {
type: 'comment' as const,
id: bet.id,
comment,
betsBySameUser: [bet],
contract,
truncate: abbreviated,
smallAvatar,
}
: {
type: 'bet' as const,
id: bet.id,
bet,
contract,
hideOutcome,
smallAvatar,
}
}
for (const bet of bets) {
const isCreator = userId === bet.userId
// If first bet in group is older than 3 days, group by 7 days. Otherwise, group by 1 day.
const windowMs =
Date.now() - (group[0]?.createdTime ?? bet.createdTime) > DAY_IN_MS * 3
? DAY_IN_MS * 7
: DAY_IN_MS
if (commentsMap[bet.id] || isCreator) {
pushGroup()
// Create a single item for this
items.push(toActivityItem(bet))
} else {
if (
group.length > 0 &&
bet.createdTime - group[0].createdTime > windowMs
) {
// More than `windowMs` has passed; start a new group
pushGroup()
}
group.push(bet)
}
}
if (group.length > 0) {
pushGroup()
}
const abbrItems = abbreviated
? items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
: items
if (reversed) abbrItems.reverse()
return abbrItems
}
function getAnswerGroups(
contract: FreeResponseContract,
bets: Bet[],
comments: Comment[],
user: User | undefined | null,
options: {
sortByProb: boolean
abbreviated: boolean
reversed: boolean
}
) {
const { sortByProb, abbreviated, reversed } = options
let outcomes = uniq(bets.map((bet) => bet.outcome))
if (abbreviated) {
const lastComment = last(comments)
const lastCommentOutcome = bets.find(
(bet) => bet.id === lastComment?.betId
)?.outcome
const lastBetOutcome = last(bets)?.outcome
if (lastCommentOutcome && lastBetOutcome) {
outcomes = uniq([
...outcomes.filter(
(outcome) =>
outcome !== lastCommentOutcome && outcome !== lastBetOutcome
),
lastCommentOutcome,
lastBetOutcome,
])
}
outcomes = outcomes.slice(-2)
}
if (sortByProb) {
outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome)
)
} else {
// Sort by recent bet.
outcomes = sortBy(outcomes, (outcome) =>
findLastIndex(bets, (bet) => bet.outcome === outcome)
)
}
const answerGroups = outcomes
.map((outcome) => {
const answer = contract.answers?.find(
(answer) => answer.id === outcome
) as Answer
// TODO: this doesn't abbreviate these groups for activity feed anymore
return {
id: outcome,
type: 'answergroup' as const,
contract,
user,
answer,
comments,
bets,
}
})
.filter((group) => group.answer)
if (reversed) answerGroups.reverse()
return answerGroups
}
function getAnswerAndCommentInputGroups(
contract: FreeResponseContract,
bets: Bet[],
@ -284,54 +100,6 @@ function getAnswerAndCommentInputGroups(
return answerGroups
}
function groupBetsAndComments(
bets: Bet[],
comments: Comment[],
contract: Contract,
userId: string | undefined,
options: {
hideOutcome: boolean
abbreviated: boolean
smallAvatar: boolean
reversed: boolean
}
) {
const { smallAvatar, abbreviated, reversed } = options
// Comments in feed don't show user's position?
const commentsWithoutBets = comments
.filter((comment) => !comment.betId)
.map((comment) => ({
type: 'comment' as const,
id: comment.id,
contract: contract,
comment,
betsBySameUser: [],
truncate: abbreviated,
smallAvatar,
}))
const groupedBets = groupBets(bets, comments, contract, userId, options)
// iterate through the bets and comment activity items and add them to the items in order of comment creation time:
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => {
if (item.type === 'comment') {
return item.comment.createdTime
} else if (item.type === 'bet') {
return item.bet.createdTime
} else if (item.type === 'betgroup') {
return item.bets[0].createdTime
}
})
const abbrItems = abbreviated
? sortedBetsAndComments.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
: sortedBetsAndComments
if (reversed) abbrItems.reverse()
return abbrItems
}
function getCommentThreads(
bets: Bet[],
comments: Comment[],
@ -351,122 +119,6 @@ function getCommentThreads(
return items
}
export function getAllContractActivityItems(
contract: Contract,
bets: Bet[],
comments: Comment[],
user: User | null | undefined,
options: {
abbreviated: boolean
}
) {
const { abbreviated } = options
const { outcomeType } = contract
const reversed = true
bets =
outcomeType === 'BINARY'
? bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
const items: ActivityItem[] = abbreviated
? [
{
type: 'question',
id: '0',
contract,
showDescription: false,
},
]
: [{ type: 'description', id: '0', contract }]
if (outcomeType === 'FREE_RESPONSE') {
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
comments.some(
(comment) => comment.betId === bet.id || bet.userId === user?.id
)
)
items.push(
...groupBetsAndComments(
onlyUsersBetsOrBetsWithComments,
comments,
contract,
user?.id,
{
hideOutcome: false,
abbreviated,
smallAvatar: false,
reversed,
}
)
)
} else {
items.push(
...groupBetsAndComments(bets, comments, contract, user?.id, {
hideOutcome: false,
abbreviated,
smallAvatar: false,
reversed,
})
)
}
if (contract.closeTime && contract.closeTime <= Date.now()) {
items.push({ type: 'close', id: `${contract.closeTime}`, contract })
}
if (contract.resolution) {
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
}
if (reversed) items.reverse()
return items
}
export function getRecentContractActivityItems(
contract: Contract,
bets: Bet[],
comments: Comment[],
user: User | null | undefined,
options: {
contractPath?: string
}
) {
const { contractPath } = options
bets = bets.sort((b1, b2) => b1.createdTime - b2.createdTime)
comments = comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
const questionItem: QuestionItem = {
type: 'question',
id: '0',
contract,
showDescription: false,
contractPath,
}
const items = []
if (contract.outcomeType === 'FREE_RESPONSE') {
items.push(
...getAnswerGroups(contract, bets, comments, user, {
sortByProb: false,
abbreviated: true,
reversed: true,
})
)
} else {
items.push(
...groupBetsAndComments(bets, comments, contract, user?.id, {
hideOutcome: false,
abbreviated: true,
smallAvatar: false,
reversed: true,
})
)
}
return [questionItem, ...items]
}
function commentIsGeneralComment(comment: Comment, contract: Contract) {
return (
comment.answerOutcome === undefined &&

View File

@ -9,6 +9,7 @@ import { Col } from '../layout/col'
import { useState } from 'react'
import { updateUser, User } from 'web/lib/firebase/users'
import { Checkbox } from '../checkbox'
import { track } from 'web/lib/service/analytics'
export function CategorySelector(props: {
category: string
@ -93,7 +94,10 @@ export function EditCategoriesButton(props: {
className,
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
)}
onClick={() => setIsOpen(true)}
onClick={() => {
setIsOpen(true)
track('edit categories button')
}}
>
<PencilIcon className="inline h-4 w-4" />
Categories

View File

@ -3,11 +3,7 @@ import { Comment } from 'web/lib/firebase/comments'
import { Bet } from 'common/bet'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments'
import {
getAllContractActivityItems,
getRecentContractActivityItems,
getSpecificContractActivityItems,
} from './activity-items'
import { getSpecificContractActivityItems } from './activity-items'
import { FeedItems } from './feed-items'
import { User } from 'common/user'
import { useContractWithPreload } from 'web/hooks/use-contract'
@ -17,45 +13,27 @@ export function ContractActivity(props: {
bets: Bet[]
comments: Comment[]
user: User | null | undefined
mode:
| 'only-recent'
| 'abbreviated'
| 'all'
| 'comments'
| 'bets'
| 'free-response-comment-answer-groups'
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
contractPath?: string
className?: string
betRowClassName?: string
}) {
const { user, mode, contractPath, className, betRowClassName } = props
const { user, mode, className, betRowClassName } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const updatedComments =
// eslint-disable-next-line react-hooks/rules-of-hooks
mode === 'only-recent' ? undefined : useComments(contract.id)
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const updatedBets =
// eslint-disable-next-line react-hooks/rules-of-hooks
mode === 'only-recent' ? undefined : useBets(contract.id)
const updatedBets = useBets(contract.id)
const bets = (updatedBets ?? props.bets).filter((bet) => !bet.isRedemption)
const items =
mode === 'only-recent'
? getRecentContractActivityItems(contract, bets, comments, user, {
contractPath,
})
: mode === 'comments' ||
mode === 'bets' ||
mode === 'free-response-comment-answer-groups'
? getSpecificContractActivityItems(contract, bets, comments, user, {
mode,
})
: // only used in abbreviated mode with folds/communities, all mode isn't used
getAllContractActivityItems(contract, bets, comments, user, {
abbreviated: mode === 'abbreviated',
})
const items = getSpecificContractActivityItems(
contract,
bets,
comments,
user,
{ mode }
)
return (
<FeedItems

View File

@ -24,6 +24,7 @@ import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
export function FeedCommentThread(props: {
contract: Contract
@ -354,6 +355,7 @@ export function CommentInput(props: {
async function submitComment(betId: string | undefined) {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
if (!comment || isSubmitting) return

View File

@ -30,11 +30,10 @@ import { RelativeTimestamp } from '../relative-timestamp'
import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group'
import {
FeedCommentThread,
FeedComment,
CommentInput,
TruncatedComment,
} from 'web/components/feed/feed-comments'
import { FeedBet, FeedBetGroup } from 'web/components/feed/feed-bets'
import { FeedBet } from 'web/components/feed/feed-bets'
import { CPMMBinaryContract, NumericContract } from 'common/contract'
export function FeedItems(props: {
@ -85,12 +84,8 @@ export function FeedItem(props: { item: ActivityItem }) {
return <FeedQuestion {...item} />
case 'description':
return <FeedDescription {...item} />
case 'comment':
return <FeedComment {...item} />
case 'bet':
return <FeedBet {...item} />
case 'betgroup':
return <FeedBetGroup {...item} />
case 'answergroup':
return <FeedAnswerCommentGroup {...item} />
case 'close':

View File

@ -2,6 +2,7 @@ import clsx from 'clsx'
import { useFollows } from 'web/hooks/use-follows'
import { useUser } from 'web/hooks/use-user'
import { follow, unfollow } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
export function FollowButton(props: {
isFollowing: boolean | undefined
@ -34,7 +35,7 @@ export function FollowButton(props: {
small && smallStyle,
className
)}
onClick={onUnfollow}
onClick={withTracking(onUnfollow, 'unfollow')}
>
Following
</button>
@ -44,7 +45,7 @@ export function FollowButton(props: {
return (
<button
className={clsx('btn btn-sm', small && smallStyle, className)}
onClick={onFollow}
onClick={withTracking(onFollow, 'follow')}
>
Follow
</button>

View File

@ -1,5 +1,6 @@
import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useFollowers, useFollows } from 'web/hooks/use-follows'
@ -10,6 +11,7 @@ import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs'
import { useDiscoverUsers } from 'web/hooks/use-users'
import { TextButton } from './text-button'
import { track } from 'web/lib/service/analytics'
export function FollowingButton(props: { user: User }) {
const { user } = props
@ -48,7 +50,10 @@ export function EditFollowingButton(props: { user: User; className?: string }) {
className,
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
)}
onClick={() => setIsOpen(true)}
onClick={() => {
setIsOpen(true)
track('edit following button')
}}
>
<PencilIcon className="inline h-4 w-4" />
Following

View File

@ -7,10 +7,14 @@ import { firebaseLogin } from 'web/lib/firebase/users'
import { ContractsGrid } from './contract/contracts-list'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { withTracking } from 'web/lib/service/analytics'
import { useTracking } from 'web/hooks/use-tracking'
export function LandingPagePanel(props: { hotContracts: Contract[] }) {
const { hotContracts } = props
useTracking('view landing page')
return (
<>
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
@ -45,7 +49,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<Spacer h={6} />
<button
className="self-center rounded-md border-none bg-gradient-to-r from-indigo-500 to-blue-500 py-4 px-6 text-lg font-semibold normal-case text-white hover:from-indigo-600 hover:to-blue-600"
onClick={firebaseLogin}
onClick={withTracking(firebaseLogin, 'landing page button click')}
>
Get started
</button>{' '}

View File

@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
import { NoLabel, YesLabel } from './outcome-label'
import { Col } from './layout/col'
import { InfoTooltip } from './info-tooltip'
import { track } from 'web/lib/service/analytics'
export function LiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
@ -57,7 +58,7 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
function AddLiquidityPanel(props: { contract: CPMMContract }) {
const { contract } = props
const { id: contractId } = contract
const { id: contractId, slug } = contract
const user = useUser()
@ -99,6 +100,8 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
}
})
.catch((_) => setError('Server error'))
track('add liquidity', { amount, contractId, slug })
}
return (
@ -177,6 +180,8 @@ function WithdrawLiquidityPanel(props: {
setIsLoading(false)
})
.catch((_) => setError('Server error'))
track('withdraw liquidity')
}
if (isSuccess)

View File

@ -17,6 +17,7 @@ import clsx from 'clsx'
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'
function getNavigation(username: string) {
return [
@ -106,6 +107,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
currentPage === item.href && 'bg-gray-200 text-indigo-700'
)}
onClick={trackCallback('navbar: ' + item.name)}
>
<item.icon className="my-1 mx-auto h-6 w-6" />
{item.name}

View File

@ -1,13 +1,18 @@
import Link from 'next/link'
import { User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar'
import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) {
const { user } = props
return (
<Link href={`/${user.username}`}>
<a className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700">
<a
onClick={trackCallback('sidebar: profile')}
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
<div className="truncate">

View File

@ -26,6 +26,7 @@ import { Row } from '../layout/row'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useEffect, useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
// Create an icon from the url of an image
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
@ -72,7 +73,7 @@ function getMoreNavigation(user?: User | null) {
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
{ name: 'About', href: 'https://docs.manifold.markets' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{ name: 'Sign out', href: '#', onClick: () => firebaseLogout() },
]
}
@ -81,7 +82,11 @@ const signedOutNavigation = [
{ name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Explore', href: '/markets', icon: SearchIcon },
{ name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
icon: BookOpenIcon,
},
]
const signedOutMobileNavigation = [
@ -98,7 +103,11 @@ const signedOutMobileNavigation = [
href: 'https://twitter.com/ManifoldMarkets',
icon: IconFromUrl('/twitter-logo.svg'),
},
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
icon: BookOpenIcon,
},
]
const mobileNavigation = [
@ -117,6 +126,7 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
return (
<Link href={item.href} key={item.name}>
<a
onClick={trackCallback('sidebar: ' + item.name)}
className={clsx(
item.href == currentPage
? 'bg-gray-200 text-gray-900'
@ -208,7 +218,11 @@ export default function Sidebar(props: { className?: string }) {
{user && (
<MenuButton
menuItems={[
{ name: 'Sign out', href: '#', onClick: () => firebaseLogout() },
{
name: 'Sign out',
href: '#',
onClick: withTracking(firebaseLogout, 'sign out'),
},
]}
buttonContent={<MoreButton />}
/>
@ -229,14 +243,17 @@ export default function Sidebar(props: { className?: string }) {
<div className={'aligncenter flex justify-center'}>
{user ? (
<Link href={'/create'} passHref>
<button className={clsx(gradient, buttonStyle)}>
<button
className={clsx(gradient, buttonStyle)}
onClick={trackCallback('create question button')}
>
Create a question
</button>
</Link>
) : (
<button
onClick={firebaseLogin}
className={clsx(gradient, buttonStyle)}
onClick={withTracking(firebaseLogin, 'sign in')}
className="btn btn-outline btn-sm mx-auto mt-4 -ml-1 w-full rounded-md normal-case"
>
Sign in
</button>

View File

@ -19,6 +19,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import { SignUpPrompt } from './sign-up-prompt'
import { track } from 'web/lib/service/analytics'
export function NumericBetPanel(props: {
contract: NumericContract
@ -96,6 +97,15 @@ function NumericBuyPanel(props: {
}
setIsSubmitting(false)
})
track('bet', {
location: 'numeric panel',
outcomeType: contract.outcomeType,
slug: contract.slug,
contractId: contract.id,
amount: betAmount,
value,
})
}
const betDisabled = isSubmitting || !betAmount || !bucketChoice || error

View File

@ -1,11 +1,13 @@
import React, { Fragment } from 'react'
import { CodeIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import { Contract } from 'common/contract'
import { contractPath } from 'web/lib/firebase/contracts'
import { DOMAIN } from 'common/envs/constants'
import { copyToClipboard } from 'web/lib/util/copy'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
function copyEmbedCode(contract: Contract) {
const title = contract.question
@ -26,7 +28,10 @@ export function ShareEmbedButton(props: {
<Menu
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => copyEmbedCode(contract)}
onMouseUp={() => {
copyEmbedCode(contract)
track('copy embed code')
}}
>
<Menu.Button
className="btn btn-xs normal-case"

View File

@ -1,14 +1,15 @@
import React from 'react'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
export function SignUpPrompt() {
const user = useUser()
return user === null ? (
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600"
onClick={withTracking(firebaseLogin, 'sign up to bet')}
>
Sign up to bet!
</button>

View File

@ -6,6 +6,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
import { TagsList } from './tags-list'
import { MAX_TAG_LENGTH } from 'common/contract'
import { track } from 'web/lib/service/analytics'
export function TagsInput(props: { contract: Contract; className?: string }) {
const { contract, className } = props
@ -24,6 +25,7 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
})
setIsSubmitting(false)
setTagText('')
track('save tags')
}
return (

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import { trackCallback } from 'web/lib/service/analytics'
export function TweetButton(props: { className?: string; tweetText: string }) {
const { tweetText, className } = props
@ -12,6 +13,7 @@ export function TweetButton(props: { className?: string; tweetText: string }) {
color: '#1da1f2',
}}
href={getTweetHref(tweetText)}
onClick={trackCallback('share tweet')}
target="_blank"
>
<img className="mr-2" src={'/twitter-logo.svg'} width={15} height={15} />

View File

@ -1,4 +1,8 @@
import clsx from 'clsx'
import { uniq } from 'lodash'
import { LinkIcon } from '@heroicons/react/solid'
import { PencilIcon } from '@heroicons/react/outline'
import { follow, unfollow, User } from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-list'
import { SEO } from './SEO'
@ -9,9 +13,7 @@ import { Col } from './layout/col'
import { Linkify } from './linkify'
import { Spacer } from './layout/spacer'
import { Row } from './layout/row'
import { LinkIcon } from '@heroicons/react/solid'
import { genHash } from 'common/util/random'
import { PencilIcon } from '@heroicons/react/outline'
import { Tabs } from './layout/tabs'
import { UserCommentsList } from './comments-list'
import { useEffect, useState } from 'react'
@ -22,8 +24,10 @@ import { LoadingIndicator } from './loading-indicator'
import { BetsList } from './bets-list'
import { Bet } from 'common/bet'
import { getUserBets } from 'web/lib/firebase/bets'
import { uniq } from 'lodash'
import { FollowersButton, FollowingButton } from './following-button'
import { AlertBox } from './alert-box'
import { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button'
export function UserLink(props: {
name: string
@ -305,29 +309,3 @@ export function defaultBannerUrl(userId: string) {
]
return defaultBanner[genHash(userId)() % defaultBanner.length]
}
import { ExclamationIcon } from '@heroicons/react/solid'
import { FollowButton } from './follow-button'
import { useFollows } from 'web/hooks/use-follows'
function AlertBox(props: { title: string; text: string }) {
const { title, text } = props
return (
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon
className="h-5 w-5 text-yellow-400"
aria-hidden="true"
/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
<div className="mt-2 text-sm text-yellow-700">
<Linkify text={text} />
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,8 @@
import { track } from '@amplitude/analytics-browser'
import { useEffect } from 'react'
export const useTracking = (eventName: string, eventProperties?: any) => {
useEffect(() => {
track(eventName, eventProperties)
}, [])
}

View File

@ -13,6 +13,7 @@ import {
userDocRef,
} from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
export const useUser = () => {
const [user, setUser] = useStateCheckEquality<User | null | undefined>(
@ -21,11 +22,14 @@ export const useUser = () => {
useEffect(() => listenForLogin(setUser), [setUser])
const userId = user?.id
useEffect(() => {
if (userId) return listenForUser(userId, setUser)
}, [userId, setUser])
if (user) {
identifyUser(user.id)
setUserProperty('username', user.username)
return listenForUser(user.id, setUser)
}
}, [user, setUser])
return user
}

View File

@ -0,0 +1,31 @@
import { Router } from 'next/router'
import { useEffect } from 'react'
export const useWarnUnsavedChanges = (hasUnsavedChanges: boolean) => {
useEffect(() => {
if (!hasUnsavedChanges) return
const confirmationMessage = 'Changes you made may not be saved.'
const warnUnsavedChanges = (e: BeforeUnloadEvent) => {
const event = e || window.event
event.returnValue = confirmationMessage
return confirmationMessage
}
const beforeRouteHandler = () => {
if (!confirm(confirmationMessage)) {
Router.events.emit('routeChangeError')
throw 'Abort route change. Please ignore this error.'
}
}
window.addEventListener('beforeunload', warnUnsavedChanges)
Router.events.on('routeChangeStart', beforeRouteHandler)
return () => {
window.removeEventListener('beforeunload', warnUnsavedChanges)
Router.events.off('routeChangeStart', beforeRouteHandler)
}
}, [hasUnsavedChanges])
}

View File

@ -14,6 +14,7 @@ import { db } from './init'
import { User } from 'common/user'
import { Comment } from 'common/comment'
import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser'
export type { Comment }
@ -43,6 +44,12 @@ export async function createComment(
answerOutcome: answerOutcome,
replyToCommentId: replyToCommentId,
})
track('comment', {
contractId,
commentId: ref.id,
betId: betId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment)
}

View File

@ -0,0 +1,42 @@
import {
init,
track,
identify,
setUserId,
Identify,
} from '@amplitude/analytics-browser'
import { ENV_CONFIG } from 'common/envs/constants'
init(ENV_CONFIG.amplitudeApiKey ?? '', undefined, { includeReferrer: true })
export { track }
// Convenience functions:
export const trackCallback =
(eventName: string, eventProperties?: any) => () => {
track(eventName, eventProperties)
}
export const withTracking =
(
f: (() => void) | (() => Promise<void>),
eventName: string,
eventProperties?: any
) =>
async () => {
const promise = f()
track(eventName, eventProperties)
await promise
}
export async function identifyUser(userId: string) {
setUserId(userId)
}
export async function setUserProperty(property: string, value: string) {
const identifyObj = new Identify()
identifyObj.set(property, value)
await identify(identifyObj)
}

View File

@ -17,6 +17,7 @@
"verify": "(cd .. && yarn verify)"
},
"dependencies": {
"@amplitude/analytics-browser": "0.4.1",
"@headlessui/react": "1.6.1",
"@heroicons/react": "1.0.5",
"@nivo/core": "0.74.0",
@ -37,7 +38,7 @@
"react-confetti": "6.0.1",
"react-dom": "17.0.2",
"react-expanding-textarea": "2.3.5",
"react-hot-toast": "^2.2.0",
"react-hot-toast": "2.2.0",
"react-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0"
},

View File

@ -40,6 +40,8 @@ import { useIsIframe } from 'web/hooks/use-is-iframe'
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
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'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -108,6 +110,12 @@ export function ContractPageContent(
const contract = useContractWithPreload(props.contract) ?? props.contract
useTracking('view market', {
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
})
const bets = useBets(contract.id) ?? props.bets
// Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -193,6 +201,12 @@ export function ContractPageContent(
bets={bets}
comments={comments ?? []}
/>
{isNumeric && (
<AlertBox
title="Warning"
text="Numeric markets were introduced as an experimental feature and are now deprecated."
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
<>

View File

@ -5,6 +5,7 @@ import { getUserByUsername, User } from 'web/lib/firebase/users'
import { UserPage } from 'web/components/user-page'
import { useUser } from 'web/hooks/use-user'
import Custom404 from '../404'
import { useTracking } from 'web/hooks/use-tracking'
export default function UserProfile(props: {
tab?: 'markets' | 'comments' | 'bets'
@ -12,6 +13,7 @@ export default function UserProfile(props: {
const router = useRouter()
const [user, setUser] = useState<User | null | 'loading'>('loading')
const { username } = router.query as { username: string }
useEffect(() => {
if (username) {
getUserByUsername(username).then(setUser)
@ -20,6 +22,8 @@ export default function UserProfile(props: {
const currentUser = useUser()
useTracking('view user profile', { username })
if (user === 'loading') return <></>
return user ? (

View File

@ -39,19 +39,6 @@ function MyApp({ Component, pageProps }: AppProps) {
gtag('config', 'G-SSFK1Q138D');
`}
</Script>
{/* Hotjar Tracking Code for https://manifold.markets */}
<Script id="hotjar">
{`
(function(h,o,t,j,a,r){
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
h._hjSettings={hjid:2968940,hjsv:6};
a=o.getElementsByTagName('head')[0];
r=o.createElement('script');r.async=1;
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
a.appendChild(r);
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
`}
</Script>
<Head>
<title>Manifold Markets A market for every question</title>

View File

@ -1,70 +0,0 @@
import React, { useEffect, useState } from 'react'
import Router, { useRouter } from 'next/router'
import { Page } from 'web/components/page'
import { ActivityFeed } from 'web/components/feed/activity-feed'
import { Spacer } from 'web/components/layout/spacer'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useAlgoFeed } from 'web/hooks/use-algo-feed'
import { ContractPageContent } from './[username]/[contractSlug]'
import { CategorySelector } from '../components/feed/category-selector'
export default function Activity() {
const user = useUser()
const [category, setCategory] = useState<string>('all')
const feed = useAlgoFeed(user, category)
const router = useRouter()
const { u: username, s: slug } = router.query
const contract = feed?.find(
({ contract }) => contract.slug === slug
)?.contract
useEffect(() => {
// If the page initially loads with query params, redirect to the contract page.
if (router.isReady && slug && username) {
Router.replace(`/${username}/${slug}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady])
if (user === null) {
Router.replace('/')
return <></>
}
return (
<>
<Page suspend={!!contract}>
<Col className="mx-auto w-full max-w-[700px]">
<CategorySelector category={category} setCategory={setCategory} />
<Spacer h={1} />
{feed ? (
<ActivityFeed
feed={feed}
mode="only-recent"
getContractPath={(c) =>
`activity?u=${c.creatorUsername}&s=${c.slug}`
}
/>
) : (
<LoadingIndicator className="mt-4" />
)}
</Col>
</Page>
{contract && (
<ContractPageContent
contract={contract}
username={contract.creatorUsername}
slug={contract.slug}
bets={[]}
comments={[]}
backToHome={router.back}
/>
)}
</>
)
}

View File

@ -6,6 +6,8 @@ import { FundsSelector } from 'web/components/yes-no-selector'
import { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe'
import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { trackCallback } from 'web/lib/service/analytics'
export default function AddFundsPage() {
const user = useUser()
@ -14,6 +16,8 @@ export default function AddFundsPage() {
2500
)
useTracking('view add funds')
return (
<Page>
<SEO
@ -59,6 +63,7 @@ export default function AddFundsPage() {
<button
type="submit"
className="btn btn-primary w-full bg-gradient-to-r from-indigo-500 to-blue-500 font-medium hover:from-indigo-600 hover:to-blue-600"
onClick={trackCallback('checkout', { amount: amountSelected })}
>
Checkout
</button>

View File

@ -1,4 +1,12 @@
import { mapValues, groupBy, sumBy, sum, sortBy, debounce } from 'lodash'
import {
mapValues,
groupBy,
sumBy,
sum,
sortBy,
debounce,
uniqBy,
} from 'lodash'
import { useState, useMemo } from 'react'
import { charities, Charity as CharityType } from 'common/charity'
import { CharityCard } from 'web/components/charity/charity-card'
@ -8,7 +16,10 @@ import { Page } from 'web/components/page'
import { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title'
import { getAllCharityTxns } from 'web/lib/firebase/txns'
import { formatMoney } from 'common/util/format'
import { manaToUSD } from 'common/util/format'
import { quadraticMatches } from 'common/quadratic-funding'
import { Txn } from 'common/txn'
import { useTracking } from 'web/hooks/use-tracking'
export async function getStaticProps() {
const txns = await getAllCharityTxns()
@ -20,21 +31,55 @@ export async function getStaticProps() {
(charity) => (charity.tags?.includes('Featured') ? 0 : 1),
(charity) => -totals[charity.id],
])
const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
return {
props: {
totalRaised,
charities: sortedCharities,
matches,
txns,
numDonors,
},
revalidate: 60,
}
}
type Stat = {
name: string
stat: string
}
function DonatedStats(props: { stats: Stat[] }) {
const { stats } = props
return (
<dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3">
{stats.map((item) => (
<div
key={item.name}
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"
>
<dt className="truncate text-sm font-medium text-gray-500">
{item.name}
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{item.stat}
</dd>
</div>
))}
</dl>
)
}
export default function Charity(props: {
totalRaised: number
charities: CharityType[]
matches: { [charityId: string]: number }
txns: Txn[]
numDonors: number
}) {
const { totalRaised, charities } = props
const { totalRaised, charities, matches, numDonors } = props
const [query, setQuery] = useState('')
const debouncedQuery = debounce(setQuery, 50)
@ -51,29 +96,56 @@ export default function Charity(props: {
[charities, query]
)
useTracking('view charity')
return (
<Page>
<Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]">
<Col className="max-w-xl gap-2">
<Col className="">
<Title className="!mt-0" text="Manifold for Charity" />
<div className="mb-6 text-gray-500">
Donate your winnings to charity! Every {formatMoney(100)} you give
turns into $1 USD we send to your chosen charity.
<Spacer h={5} />
Together we've donated over ${Math.floor(totalRaised / 100)} USD so
far!
</div>
<span className="text-gray-600">
Through July 15, up to $25k of donations will be matched via{' '}
<SiteLink href="https://wtfisqf.com/" className="font-bold">
quadratic funding
</SiteLink>
, courtesy of{' '}
<SiteLink href="https://ftxfuturefund.org/" className="font-bold">
the FTX Future Fund
</SiteLink>
!
</span>
<DonatedStats
stats={[
{
name: 'Raised by Manifold users',
stat: manaToUSD(totalRaised),
},
{
name: 'Number of donors',
stat: `${numDonors}`,
},
{
name: 'Matched via quadratic funding',
stat: manaToUSD(sum(Object.values(matches))),
},
]}
/>
<Spacer h={10} />
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search charities"
placeholder="Find a charity"
className="input input-bordered mb-6 w-full"
/>
</Col>
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
{filterCharities.map((charity) => (
<CharityCard charity={charity} key={charity.name} />
<CharityCard
charity={charity}
key={charity.name}
match={matches[charity.id]}
/>
))}
</div>
{filterCharities.length === 0 && (
@ -82,32 +154,28 @@ export default function Charity(props: {
</div>
)}
<iframe
height="405"
src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go"
title="Total donations for Manifold for Charity this May (in USD)"
frameBorder="0"
className="m-10 w-full rounded-xl bg-white p-10"
></iframe>
<div className="mt-10 w-full rounded-xl bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-5">
<iframe
height="405"
src="https://manifold.markets/ManifoldMarkets/how-much-will-be-donated-through-ma"
title="Total donations for Manifold for Charity this May (in USD)"
frameBorder="0"
className="w-full rounded-xl bg-white p-10"
></iframe>
</div>
<div className="mt-10 text-gray-500">
Don't see your favorite charity? Recommend it{' '}
<SiteLink
href="https://manifold.markets/Sinclair/which-charities-should-manifold-add"
className="text-indigo-700"
>
here
</SiteLink>
!
<span className="font-semibold">Notes</span>
<br />
- Don't see your favorite charity? Recommend it by emailing
charity@manifold.markets!
<br />
<span className="italic">
Note: Manifold is not affiliated with non-Featured charities; we're
just fans of their work!
<br />
As Manifold is a for-profit entity, your contributions will not be
tax deductible.
</span>
- Manifold is not affiliated with non-Featured charities; we're just
fans of their work.
<br />
- As Manifold itself is a for-profit entity, your contributions will
not be tax deductible.
<br />- Donations + matches are wired once each quarter.
</div>
</Col>
</Page>

View File

@ -21,10 +21,15 @@ import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-t
import { removeUndefinedProps } from 'common/util/object'
import { CATEGORIES } from 'common/categories'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { track } from 'web/lib/service/analytics'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
export default function Create() {
const [question, setQuestion] = useState('')
useTracking('view create page')
return (
<Page>
<div className="mx-auto w-full max-w-2xl">
@ -77,6 +82,9 @@ export function NewContract(props: { question: string }) {
const [ante, _setAnte] = useState(FIXED_ANTE)
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
const isFree =
mustWaitForDailyFreeMarketStatus != 'loading' &&
!mustWaitForDailyFreeMarketStatus
// useEffect(() => {
// if (ante === null && creator) {
@ -104,6 +112,9 @@ export function NewContract(props: { question: string }) {
// get days from today until the end of this year:
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
const hasUnsavedChanges = !isSubmitting && Boolean(question || description)
useWarnUnsavedChanges(hasUnsavedChanges)
const isValid =
(outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) &&
question.length > 0 &&
@ -149,6 +160,14 @@ export function NewContract(props: { question: string }) {
max,
})
)
track('create market', {
slug: result.slug,
initialProb,
category,
isFree,
})
await router.push(contractPath(result as Contract))
} catch (e) {
console.log('error creating contract', e)

View File

@ -9,6 +9,8 @@ import { ContractSearch } from 'web/components/contract-search'
import { Contract } from 'common/contract'
import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
const Home = () => {
const user = useUser()
@ -16,6 +18,8 @@ const Home = () => {
const router = useRouter()
useTracking('view home')
if (user === null) {
Router.replace('/')
return <></>
@ -42,7 +46,10 @@ const Home = () => {
<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')}
onClick={() => {
router.push('/create')
track('mobile create button')
}}
>
<PlusSmIcon className="h-8 w-8" aria-hidden="true" />
</button>
@ -68,7 +75,7 @@ const useContractPage = () => {
const [contract, setContract] = useState<Contract | undefined>()
useEffect(() => {
const onBack = () => {
const updateContract = () => {
const path = location.pathname.split('/').slice(1)
if (path[0] === 'home') setContract(undefined)
else {
@ -80,23 +87,24 @@ const useContractPage = () => {
}
}
}
window.addEventListener('popstate', onBack)
// Hack. Listen to changes in href to clear contract on navigate home.
let href = document.location.href
const observer = new MutationObserver(function (_mutations) {
if (href != document.location.href) {
href = document.location.href
const { pushState, replaceState } = window.history
const path = location.pathname.split('/').slice(1)
if (path[0] === 'home') setContract(undefined)
}
})
observer.observe(document, { subtree: true, childList: true })
window.history.pushState = function () {
// eslint-disable-next-line prefer-rest-params
pushState.apply(history, arguments as any)
updateContract()
}
window.history.replaceState = function () {
// eslint-disable-next-line prefer-rest-params
replaceState.apply(history, arguments as any)
updateContract()
}
return () => {
window.removeEventListener('popstate', onBack)
observer.disconnect()
window.history.pushState = pushState
window.history.replaceState = replaceState
}
}, [])

View File

@ -11,16 +11,16 @@ import { ManifoldLogo } from 'web/components/nav/manifold-logo'
export async function getStaticProps() {
// These hardcoded markets will be shown in the frontpage for signed-out users:
const hotContracts = await getContractsBySlugs([
'if-boris-johnson-is-leader-of-the-c',
'will-ethereum-merge-to-proofofstake',
'will-max-go-to-prom-with-a-girl',
'will-ethereum-switch-to-proof-of-st',
'will-russia-control-the-majority-of',
'will-elon-musk-buy-twitter-this-yea',
'will-an-ai-get-gold-on-any-internat',
'how-many-us-supreme-court-justices',
'will-trump-be-charged-by-the-grand',
'will-spacex-launch-a-starship-into',
'who-will-win-the-nba-finals-champio',
'what-database-will-manifold-be-prim',
'will-the-supreme-court-leakers-iden',
'will-over-25-of-participants-in-the-163d54309e43',
'who-will-be-time-magazine-person-of',
'will-congress-hold-any-hearings-abo-e21f987033b3',
'will-at-least-10-world-cities-have',
])
return {

View File

@ -4,6 +4,7 @@ import { Page } from 'web/components/page'
import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() {
@ -32,6 +33,8 @@ export default function Leaderboards(props: {
}
const { topTraders, topCreators } = props
useTracking('view leaderboards')
return (
<Page margin>
<Col className="items-center gap-10 lg:flex-row">

View File

@ -16,6 +16,7 @@ import { getDailyContracts } from 'web/lib/firebase/contracts'
import { getDailyNewUsers } from 'web/lib/firebase/users'
import { SiteLink } from 'web/components/site-link'
import { Linkify } from 'web/components/linkify'
import { average } from 'common/util/math'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() {
@ -61,7 +62,7 @@ export async function getStaticPropz() {
})
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 30)
const start = Math.max(0, i - 29)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
@ -166,16 +167,12 @@ export async function getStaticPropz() {
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const total = sum(dailyTopTenthActions.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start)
return total
return average(dailyTopTenthActions.slice(start, end))
})
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const total = sum(dailyTopTenthActions.slice(start, end))
if (end - start < 30) return (total * 30) / (end - start)
return total
return average(dailyTopTenthActions.slice(start, end))
})
// Total mana divided by 100.
@ -193,7 +190,8 @@ export async function getStaticPropz() {
const start = Math.max(0, i - 29)
const end = i
const total = sum(dailyManaBet.slice(start, end))
if (end - start < 30) return (total * 30) / (end - start)
const range = end - start + 1
if (range < 30) return (total * 30) / range
return total
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 212 KiB

View File

@ -227,6 +227,66 @@
"@algolia/logger-common" "4.13.1"
"@algolia/requester-common" "4.13.1"
"@amplitude/analytics-browser@0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@amplitude/analytics-browser/-/analytics-browser-0.4.1.tgz#d686ab89fb12cdb3ba6aaade87b8d4bb8f72e86f"
integrity sha512-omiUvv2v+sznKjFj5s4vBoVkfqsEAFqW1FQUpZuWpaekOb4/n5zhTAzs2NQMq1hFxmIh9DxqM4wY0y347CFBHg==
dependencies:
"@amplitude/analytics-core" "^0.3.1"
"@amplitude/analytics-types" "^0.2.1"
"@amplitude/ua-parser-js" "^0.7.26"
tslib "^2.3.1"
"@amplitude/analytics-core@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@amplitude/analytics-core/-/analytics-core-0.3.1.tgz#e86eb8e5cbab06063f04b7cf9f41a238d9a124b8"
integrity sha512-BgfSE49GXyYQtqL0E6xQhw9VcaYaAOgqAedyHB1VsvgVQUXEv8z1GM6GhGZVqCA5afwtq6fu80p2iqGTGuSP+g==
dependencies:
"@amplitude/analytics-types" "^0.2.1"
tslib "^2.3.1"
"@amplitude/analytics-types@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-0.2.1.tgz#ab4f9b4bbec8afa768a27af78f75bb69dd2a3fc7"
integrity sha512-+FfXlCjHysYWliRBjD2wQ2gZ4V6jGKskdt9j8npv9Rmzdehj04OAHudZ/UrgH5++88As9ww1wyaS7y4sm4x8vA==
"@amplitude/identify@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.10.0.tgz#d62b8b6785c29350c368810475a6fc7b04985210"
integrity sha512-BshMDcZX9qO4mgGBR45HmiHxfcPCDY/eBOE/MTUZBW+y9+N61aKmNY3YJsAUfRPzieDiyfqs8rNm7quVkaNzJQ==
dependencies:
"@amplitude/types" "^1.10.0"
"@amplitude/utils" "^1.10.0"
tslib "^1.9.3"
"@amplitude/node@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@amplitude/node/-/node-1.10.0.tgz#33f84ddf82b31471fce53e6fa60b688d4bc62ee4"
integrity sha512-Jh8w1UpxhonWe0kCALVvqiBE3vo5NYmbNZbZrrI9Lfa/1HbGboZlGdg0I7/WtihbZvEjpfcfTOf8OkmtZh6vsQ==
dependencies:
"@amplitude/identify" "^1.10.0"
"@amplitude/types" "^1.10.0"
"@amplitude/utils" "^1.10.0"
tslib "^1.9.3"
"@amplitude/types@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@amplitude/types/-/types-1.10.0.tgz#dfaf7cc25f533a1e2b0ef0ad675371b396733c0f"
integrity sha512-xN0gnhutztv6kqHaZ2bre18anQV5GDmMXOeipTvI670g2VjNbPfOzMwu1LN4p1NadYq+GqYI223UcZrXR+R4Pw==
"@amplitude/ua-parser-js@^0.7.26":
version "0.7.31"
resolved "https://registry.yarnpkg.com/@amplitude/ua-parser-js/-/ua-parser-js-0.7.31.tgz#749bf7cb633cfcc7ff3c10805bad7c5f6fbdbc61"
integrity sha512-+z8UGRaj13Pt5NDzOnkTBy49HE2CX64jeL0ArB86HAtilpnfkPB7oqkigN7Lf2LxscMg4QhFD7mmCfedh3rqTg==
"@amplitude/utils@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@amplitude/utils/-/utils-1.10.0.tgz#138b0ba4e5755540a9e4abf426b7a25d045418a9"
integrity sha512-/R8j8IzFH0GYfA6ehQDm5IEzt71gIeMdiYYFIzZp6grERQlgJcwNJMAiza0o2JwwTDIruzqdB3c/vLVjuakp+w==
dependencies:
"@amplitude/types" "^1.10.0"
tslib "^1.9.3"
"@ampproject/remapping@^2.1.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
@ -9294,7 +9354,7 @@ react-helmet-async@*, react-helmet-async@^1.2.3:
react-fast-compare "^3.2.0"
shallowequal "^1.1.0"
react-hot-toast@^2.2.0:
react-hot-toast@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.2.0.tgz#ab6f4caed4214b9534f94bb8cfaaf21b051e62b9"
integrity sha512-248rXw13uhf/6TNDVzagX+y7R8J183rp7MwUMNkcrBRyHj/jWOggfXTGlM8zAOuh701WyVW+eUaWG2LeSufX9g==
@ -10617,7 +10677,7 @@ tsconfig-paths@^3.14.1:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^1.8.1, tslib@^1.9.0:
tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==