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: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@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', sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
createmarket: 'https://createmarket-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 domain: string
firebaseConfig: FirebaseConfig firebaseConfig: FirebaseConfig
functionEndpoints: Record<V2CloudFunction, string> functionEndpoints: Record<V2CloudFunction, string>
amplitudeApiKey?: string
// Access controls // Access controls
adminEmails: string[] adminEmails: string[]
@ -34,6 +35,8 @@ type FirebaseConfig = {
export const PROD_CONFIG: EnvConfig = { export const PROD_CONFIG: EnvConfig = {
domain: 'manifold.markets', domain: 'manifold.markets',
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
firebaseConfig: { firebaseConfig: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com', 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) => { export const logInterpolation = (min: number, max: number, value: number) => {
if (value <= min) return 0 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 const TAU = Math.PI * 2
export function median(values: number[]) { export function median(xs: number[]) {
if (values.length === 0) return NaN 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) const mid = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 0) { if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2 return (sorted[mid - 1] + sorted[mid]) / 2
} }
return sorted[mid] 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 # 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?** ### **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! 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?** ### **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)**. 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?", \ "question":"Is there life on Mars?", \
"description":"I'm not going to type some long ass example description.", \ "description":"I'm not going to type some long ass example description.", \
"closeTime":1700000000000, \ "closeTime":1700000000000, \
initialProb:25}' "initialProb":25}'
``` ```
## Changelog ## Changelog

View File

@ -15,35 +15,97 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties ## 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. - 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. - 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. - 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_ ⛑️ _Awarded 2022-01-09_

View File

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

View File

@ -20,6 +20,7 @@
}, },
"main": "lib/functions/src/index.js", "main": "lib/functions/src/index.js",
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0",
"cors": "2.8.5", "cors": "2.8.5",
"fetch": "1.1.0", "fetch": "1.1.0",
"firebase-admin": "10.0.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-fold-delete'
export * from './on-view' export * from './on-view'
export * from './unsubscribe' export * from './unsubscribe'
export * from './update-contract-metrics' export * from './update-metrics'
export * from './update-user-metrics'
export * from './update-recommendations' export * from './update-recommendations'
export * from './backup-db' export * from './backup-db'
export * from './change-user-info' export * from './change-user-info'

View File

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

View File

@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { APIError, newEndpoint, validate } from './api' 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 { User } from '../../common/user'
import { getCpmmSellBetInfo } from '../../common/sell-bet' import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
@ -57,8 +57,12 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
prevLoanAmount prevLoanAmount
) )
if (!isFinite(newP)) { if (
throw new APIError(500, 'Trade rejected due to overflow error.') !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() 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 { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails' import { sendThankYouEmail } from './emails'
import { track } from './analytics'
export type StripeSession = Stripe.Event.Data.Object & { export type StripeSession = Stripe.Event.Data.Object & {
id: string id: string
@ -152,6 +153,13 @@ const issueMoneys = async (session: StripeSession) => {
if (!privateUser) return if (!privateUser) return
await sendThankYouEmail(user, privateUser) await sendThankYouEmail(user, privateUser)
await track(
userId,
'M$ purchase',
{ amount: payout, sessionId },
{ revenue: payout / 100 }
)
} }
const firestore = admin.firestore() 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>( type UpdateSpec = {
xs: T[], doc: admin.firestore.DocumentReference
fn: (x: T) => Promise<U>, fields: { [k: string]: unknown }
concurrency = 100 }
export const writeUpdatesAsync = async (
db: admin.firestore.Firestore,
updates: UpdateSpec[],
batchSize = 500 // 500 = Firestore batch limit
) => { ) => {
const results = [] const chunks = chunk(updates, batchSize)
const chunks = chunk(xs, concurrency)
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
log(`${i * concurrency}/${xs.length} processed...`) log(`${i * batchSize}/${updates.length} updates written...`)
results.push(...(await Promise.all(chunks[i].map(fn)))) const batch = db.batch()
for (const { doc, fields } of chunks[i]) {
batch.update(doc, fields)
}
await batch.commit()
} }
return results
} }
export const isProd = export const isProd =

View File

@ -78,7 +78,13 @@ export const withdrawLiquidity = functions
} as Partial<User>) } as Partial<User>)
const newPool = subtractObjects(contract.pool, userShares) 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, { trans.update(contractDoc, {
pool: newPool, pool: newPool,
totalLiquidity: newTotalLiquidity, totalLiquidity: newTotalLiquidity,

View File

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

View File

@ -61,7 +61,7 @@ export function AddFundsButton(props: { className?: string }) {
> >
<button <button
type="submit" 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 Checkout
</button> </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, calculateDpmPayoutAfterCorrectBet,
getDpmOutcomeProbabilityAfterBet, getDpmOutcomeProbabilityAfterBet,
} from 'common/calculate-dpm' } from 'common/calculate-dpm'
import { firebaseLogin } from 'web/lib/firebase/users'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics'
import { SignUpPrompt } from '../sign-up-prompt'
export function AnswerBetPanel(props: { export function AnswerBetPanel(props: {
answer: Answer answer: Answer
@ -72,6 +73,15 @@ export function AnswerBetPanel(props: {
} }
setIsSubmitting(false) 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 const betDisabled = isSubmitting || !betAmount || error
@ -173,12 +183,7 @@ export function AnswerBetPanel(props: {
{isSubmitting ? 'Submitting...' : 'Submit trade'} {isSubmitting ? 'Submitting...' : 'Submit trade'}
</button> </button>
) : ( ) : (
<button <SignUpPrompt />
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>
)} )}
</Col> </Col>
) )

View File

@ -22,6 +22,7 @@ import {
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { MAX_ANSWER_LENGTH } from 'common/answer' import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props const { contract } = props
@ -143,7 +144,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
isSubmitting && 'loading' isSubmitting && 'loading'
)} )}
disabled={!canSubmit} disabled={!canSubmit}
onClick={submitAnswer} onClick={withTracking(submitAnswer, 'submit answer')}
> >
Submit answer & buy Submit answer & buy
</button> </button>
@ -151,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
text && ( text && (
<button <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" 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 Sign in
</button> </button>

View File

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

View File

@ -482,7 +482,10 @@ export function ContractBetsTable(props: {
}) { }) {
const { contract, className, isYourBets } = 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) 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 { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Col } from '../layout/col'
export function CharityCard(props: { charity: Charity }) { export function CharityCard(props: { charity: Charity; match?: number }) {
const { slug, photo, preview, id, tags } = props.charity const { charity, match } = props
const { slug, photo, preview, id, tags } = charity
const txns = useCharityTxns(id) const txns = useCharityTxns(id)
const raised = sumBy(txns, (txn) => txn.amount) 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> */} {/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div> <div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && ( {raised > 0 && (
<Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> <>
<span className="text-3xl"> <Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
{raised < 100 <Col>
? manaToUSD(raised) <span className="text-3xl font-semibold">
: '$' + Math.floor(raised / 100)} {formatUsd(raised)}
</span> </span>
<span>raised</span> <span>raised</span>
</Row> </Col>
{match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)}
</Row>
</>
)} )}
</div> </div>
</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() { function FeaturedBadge() {
return ( return (
<span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800"> <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 { CATEGORIES } from 'common/categories'
import { Tabs } from './layout/tabs' import { Tabs } from './layout/tabs'
import { EditFollowingButton } from './following-button' import { EditFollowingButton } from './following-button'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
const searchClient = algoliasearch( const searchClient = algoliasearch(
'GJQPAYENIF', 'GJQPAYENIF',
@ -134,6 +136,7 @@ export function ContractSearch(props: {
className="!select !select-bordered" className="!select !select-bordered"
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value as filter)} onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter')}
> >
<option value="open">Open</option> <option value="open">Open</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
@ -145,6 +148,7 @@ export function ContractSearch(props: {
classNames={{ classNames={{
select: '!select !select-bordered', select: '!select !select-bordered',
}} }}
onBlur={trackCallback('select search sort')}
/> />
<Configure <Configure
facetFilters={filters} 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 { QuickBet, ProbBar, getColor } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -71,12 +73,22 @@ export function ContractCard(props: {
if (e.ctrlKey || e.metaKey) return if (e.ctrlKey || e.metaKey) return
e.preventDefault() e.preventDefault()
track('click market card', {
slug: contract.slug,
contractId: contract.id,
})
onClick() onClick()
}} }}
/> />
) : ( ) : (
<Link href={contractPath(contract)}> <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> </Link>
)} )}
</div> </div>

View File

@ -24,6 +24,7 @@ import { OUTCOME_TO_COLOR } from '../outcome-label'
import { useSaveShares } from '../use-save-shares' import { useSaveShares } from '../use-save-shares'
import { sellShares } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics'
const BET_SIZE = 10 const BET_SIZE = 10
@ -121,6 +122,12 @@ export function QuickBet(props: { contract: Contract; user: User }) {
success: message, success: message,
error: (err) => `${err.message}`, error: (err) => `${err.message}`,
}) })
track('quick bet', {
slug: contract.slug,
direction,
contractId: contract.id,
})
} }
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { 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 { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { copyToClipboard } from 'web/lib/util/copy' import { copyToClipboard } from 'web/lib/util/copy'
import { contractPath } from 'web/lib/firebase/contracts' import { contractPath } from 'web/lib/firebase/contracts'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { track } from 'web/lib/service/analytics'
function copyContractUrl(contract: Contract) { function copyContractUrl(contract: Contract) {
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`) copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
@ -23,7 +25,10 @@ export function CopyLinkButton(props: {
<Menu <Menu
as="div" as="div"
className="relative z-10 flex-shrink-0" className="relative z-10 flex-shrink-0"
onMouseUp={() => copyContractUrl(contract)} onMouseUp={() => {
copyContractUrl(contract)
track('copy share link')
}}
> >
<Menu.Button <Menu.Button
className={clsx( 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 { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
@ -6,14 +6,11 @@ import { getOutcomeProbability } from 'common/calculate'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { Contract, FreeResponseContract } from 'common/contract' import { Contract, FreeResponseContract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { mapCommentsByBetId } from 'web/lib/firebase/comments'
export type ActivityItem = export type ActivityItem =
| DescriptionItem | DescriptionItem
| QuestionItem | QuestionItem
| BetItem | BetItem
| CommentItem
| BetGroupItem
| AnswerGroupItem | AnswerGroupItem
| CloseItem | CloseItem
| ResolveItem | ResolveItem
@ -49,15 +46,6 @@ export type BetItem = BaseActivityItem & {
hideComment?: boolean hideComment?: boolean
} }
export type CommentItem = BaseActivityItem & {
type: 'comment'
comment: Comment
betsBySameUser: Bet[]
probAtCreatedTime?: number
truncate?: boolean
smallAvatar?: boolean
}
export type CommentThreadItem = BaseActivityItem & { export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread' type: 'commentThread'
parentComment: Comment parentComment: Comment
@ -65,12 +53,6 @@ export type CommentThreadItem = BaseActivityItem & {
bets: Bet[] bets: Bet[]
} }
export type BetGroupItem = BaseActivityItem & {
type: 'betgroup'
bets: Bet[]
hideOutcome: boolean
}
export type AnswerGroupItem = BaseActivityItem & { export type AnswerGroupItem = BaseActivityItem & {
type: 'answergroup' type: 'answergroup'
user: User | undefined | null user: User | undefined | null
@ -87,172 +69,6 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve' 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( function getAnswerAndCommentInputGroups(
contract: FreeResponseContract, contract: FreeResponseContract,
bets: Bet[], bets: Bet[],
@ -284,54 +100,6 @@ function getAnswerAndCommentInputGroups(
return answerGroups 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( function getCommentThreads(
bets: Bet[], bets: Bet[],
comments: Comment[], comments: Comment[],
@ -351,122 +119,6 @@ function getCommentThreads(
return items 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) { function commentIsGeneralComment(comment: Comment, contract: Contract) {
return ( return (
comment.answerOutcome === undefined && comment.answerOutcome === undefined &&

View File

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

View File

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

View File

@ -24,6 +24,7 @@ import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline' import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
contract: Contract contract: Contract
@ -354,6 +355,7 @@ export function CommentInput(props: {
async function submitComment(betId: string | undefined) { async function submitComment(betId: string | undefined) {
if (!user) { if (!user) {
track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!comment || isSubmitting) return 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 { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group'
import { import {
FeedCommentThread, FeedCommentThread,
FeedComment,
CommentInput, CommentInput,
TruncatedComment, TruncatedComment,
} from 'web/components/feed/feed-comments' } 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' import { CPMMBinaryContract, NumericContract } from 'common/contract'
export function FeedItems(props: { export function FeedItems(props: {
@ -85,12 +84,8 @@ export function FeedItem(props: { item: ActivityItem }) {
return <FeedQuestion {...item} /> return <FeedQuestion {...item} />
case 'description': case 'description':
return <FeedDescription {...item} /> return <FeedDescription {...item} />
case 'comment':
return <FeedComment {...item} />
case 'bet': case 'bet':
return <FeedBet {...item} /> return <FeedBet {...item} />
case 'betgroup':
return <FeedBetGroup {...item} />
case 'answergroup': case 'answergroup':
return <FeedAnswerCommentGroup {...item} /> return <FeedAnswerCommentGroup {...item} />
case 'close': case 'close':

View File

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

View File

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

View File

@ -7,10 +7,14 @@ import { firebaseLogin } from 'web/lib/firebase/users'
import { ContractsGrid } from './contract/contracts-list' import { ContractsGrid } from './contract/contracts-list'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Row } from './layout/row' 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[] }) { export function LandingPagePanel(props: { hotContracts: Contract[] }) {
const { hotContracts } = props const { hotContracts } = props
useTracking('view landing page')
return ( return (
<> <>
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> <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} /> <Spacer h={6} />
<button <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" 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 Get started
</button>{' '} </button>{' '}

View File

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

View File

@ -17,6 +17,7 @@ import clsx from 'clsx'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon' import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
import { trackCallback } from 'web/lib/service/analytics'
function getNavigation(username: string) { function getNavigation(username: string) {
return [ 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', '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' 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.icon className="my-1 mx-auto h-6 w-6" />
{item.name} {item.name}

View File

@ -1,13 +1,18 @@
import Link from 'next/link' import Link from 'next/link'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}`}> <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 /> <Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
<div className="truncate"> <div className="truncate">

View File

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

View File

@ -19,6 +19,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { SignUpPrompt } from './sign-up-prompt' import { SignUpPrompt } from './sign-up-prompt'
import { track } from 'web/lib/service/analytics'
export function NumericBetPanel(props: { export function NumericBetPanel(props: {
contract: NumericContract contract: NumericContract
@ -96,6 +97,15 @@ function NumericBuyPanel(props: {
} }
setIsSubmitting(false) 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 const betDisabled = isSubmitting || !betAmount || !bucketChoice || error

View File

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

View File

@ -1,14 +1,15 @@
import React from 'react' import React from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
export function SignUpPrompt() { export function SignUpPrompt() {
const user = useUser() const user = useUser()
return user === null ? ( return user === null ? (
<button <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" 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={firebaseLogin} onClick={withTracking(firebaseLogin, 'sign up to bet')}
> >
Sign up to bet! Sign up to bet!
</button> </button>

View File

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

View File

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

View File

@ -1,4 +1,8 @@
import clsx from 'clsx' 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 { follow, unfollow, User } from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-list' import { CreatorContractsList } from './contract/contracts-list'
import { SEO } from './SEO' import { SEO } from './SEO'
@ -9,9 +13,7 @@ import { Col } from './layout/col'
import { Linkify } from './linkify' import { Linkify } from './linkify'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { Row } from './layout/row' import { Row } from './layout/row'
import { LinkIcon } from '@heroicons/react/solid'
import { genHash } from 'common/util/random' import { genHash } from 'common/util/random'
import { PencilIcon } from '@heroicons/react/outline'
import { Tabs } from './layout/tabs' import { Tabs } from './layout/tabs'
import { UserCommentsList } from './comments-list' import { UserCommentsList } from './comments-list'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -22,8 +24,10 @@ import { LoadingIndicator } from './loading-indicator'
import { BetsList } from './bets-list' import { BetsList } from './bets-list'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { getUserBets } from 'web/lib/firebase/bets' import { getUserBets } from 'web/lib/firebase/bets'
import { uniq } from 'lodash'
import { FollowersButton, FollowingButton } from './following-button' 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: { export function UserLink(props: {
name: string name: string
@ -305,29 +309,3 @@ export function defaultBannerUrl(userId: string) {
] ]
return defaultBanner[genHash(userId)() % defaultBanner.length] 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, userDocRef,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { useStateCheckEquality } from './use-state-check-equality' import { useStateCheckEquality } from './use-state-check-equality'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
export const useUser = () => { export const useUser = () => {
const [user, setUser] = useStateCheckEquality<User | null | undefined>( const [user, setUser] = useStateCheckEquality<User | null | undefined>(
@ -21,11 +22,14 @@ export const useUser = () => {
useEffect(() => listenForLogin(setUser), [setUser]) useEffect(() => listenForLogin(setUser), [setUser])
const userId = user?.id
useEffect(() => { useEffect(() => {
if (userId) return listenForUser(userId, setUser) if (user) {
}, [userId, setUser]) identifyUser(user.id)
setUserProperty('username', user.username)
return listenForUser(user.id, setUser)
}
}, [user, setUser])
return user 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 { User } from 'common/user'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { track } from '@amplitude/analytics-browser'
export type { Comment } export type { Comment }
@ -43,6 +44,12 @@ export async function createComment(
answerOutcome: answerOutcome, answerOutcome: answerOutcome,
replyToCommentId: replyToCommentId, replyToCommentId: replyToCommentId,
}) })
track('comment', {
contractId,
commentId: ref.id,
betId: betId,
replyToCommentId: replyToCommentId,
})
return await setDoc(ref, comment) 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)" "verify": "(cd .. && yarn verify)"
}, },
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "0.4.1",
"@headlessui/react": "1.6.1", "@headlessui/react": "1.6.1",
"@heroicons/react": "1.0.5", "@heroicons/react": "1.0.5",
"@nivo/core": "0.74.0", "@nivo/core": "0.74.0",
@ -37,7 +38,7 @@
"react-confetti": "6.0.1", "react-confetti": "6.0.1",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-expanding-textarea": "2.3.5", "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-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0" "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 ContractEmbedPage from '../embed/[username]/[contractSlug]'
import { useBets } from 'web/hooks/use-bets' import { useBets } from 'web/hooks/use-bets'
import { CPMMBinaryContract } from 'common/contract' import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -108,6 +110,12 @@ export function ContractPageContent(
const contract = useContractWithPreload(props.contract) ?? props.contract 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 const bets = useBets(contract.id) ?? props.bets
// Sort for now to see if bug is fixed. // Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -193,6 +201,12 @@ export function ContractPageContent(
bets={bets} bets={bets}
comments={comments ?? []} comments={comments ?? []}
/> />
{isNumeric && (
<AlertBox
title="Warning"
text="Numeric markets were introduced as an experimental feature and are now deprecated."
/>
)}
{outcomeType === 'FREE_RESPONSE' && ( {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 { UserPage } from 'web/components/user-page'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import Custom404 from '../404' import Custom404 from '../404'
import { useTracking } from 'web/hooks/use-tracking'
export default function UserProfile(props: { export default function UserProfile(props: {
tab?: 'markets' | 'comments' | 'bets' tab?: 'markets' | 'comments' | 'bets'
@ -12,6 +13,7 @@ export default function UserProfile(props: {
const router = useRouter() const router = useRouter()
const [user, setUser] = useState<User | null | 'loading'>('loading') const [user, setUser] = useState<User | null | 'loading'>('loading')
const { username } = router.query as { username: string } const { username } = router.query as { username: string }
useEffect(() => { useEffect(() => {
if (username) { if (username) {
getUserByUsername(username).then(setUser) getUserByUsername(username).then(setUser)
@ -20,6 +22,8 @@ export default function UserProfile(props: {
const currentUser = useUser() const currentUser = useUser()
useTracking('view user profile', { username })
if (user === 'loading') return <></> if (user === 'loading') return <></>
return user ? ( return user ? (

View File

@ -39,19 +39,6 @@ function MyApp({ Component, pageProps }: AppProps) {
gtag('config', 'G-SSFK1Q138D'); gtag('config', 'G-SSFK1Q138D');
`} `}
</Script> </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> <Head>
<title>Manifold Markets A market for every question</title> <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 { useUser } from 'web/hooks/use-user'
import { checkoutURL } from 'web/lib/service/stripe' import { checkoutURL } from 'web/lib/service/stripe'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { useTracking } from 'web/hooks/use-tracking'
import { trackCallback } from 'web/lib/service/analytics'
export default function AddFundsPage() { export default function AddFundsPage() {
const user = useUser() const user = useUser()
@ -14,6 +16,8 @@ export default function AddFundsPage() {
2500 2500
) )
useTracking('view add funds')
return ( return (
<Page> <Page>
<SEO <SEO
@ -59,6 +63,7 @@ export default function AddFundsPage() {
<button <button
type="submit" 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" 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 Checkout
</button> </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 { useState, useMemo } from 'react'
import { charities, Charity as CharityType } from 'common/charity' import { charities, Charity as CharityType } from 'common/charity'
import { CharityCard } from 'web/components/charity/charity-card' 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 { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { getAllCharityTxns } from 'web/lib/firebase/txns' 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() { export async function getStaticProps() {
const txns = await getAllCharityTxns() const txns = await getAllCharityTxns()
@ -20,21 +31,55 @@ export async function getStaticProps() {
(charity) => (charity.tags?.includes('Featured') ? 0 : 1), (charity) => (charity.tags?.includes('Featured') ? 0 : 1),
(charity) => -totals[charity.id], (charity) => -totals[charity.id],
]) ])
const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
return { return {
props: { props: {
totalRaised, totalRaised,
charities: sortedCharities, charities: sortedCharities,
matches,
txns,
numDonors,
}, },
revalidate: 60, 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: { export default function Charity(props: {
totalRaised: number totalRaised: number
charities: CharityType[] 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 [query, setQuery] = useState('')
const debouncedQuery = debounce(setQuery, 50) const debouncedQuery = debounce(setQuery, 50)
@ -51,29 +96,56 @@ export default function Charity(props: {
[charities, query] [charities, query]
) )
useTracking('view charity')
return ( return (
<Page> <Page>
<Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <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" /> <Title className="!mt-0" text="Manifold for Charity" />
<div className="mb-6 text-gray-500"> <span className="text-gray-600">
Donate your winnings to charity! Every {formatMoney(100)} you give Through July 15, up to $25k of donations will be matched via{' '}
turns into $1 USD we send to your chosen charity. <SiteLink href="https://wtfisqf.com/" className="font-bold">
<Spacer h={5} /> quadratic funding
Together we've donated over ${Math.floor(totalRaised / 100)} USD so </SiteLink>
far! , courtesy of{' '}
</div> <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 <input
type="text" type="text"
onChange={(e) => debouncedQuery(e.target.value)} onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search charities" placeholder="Find a charity"
className="input input-bordered mb-6 w-full" className="input input-bordered mb-6 w-full"
/> />
</Col> </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"> <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) => ( {filterCharities.map((charity) => (
<CharityCard charity={charity} key={charity.name} /> <CharityCard
charity={charity}
key={charity.name}
match={matches[charity.id]}
/>
))} ))}
</div> </div>
{filterCharities.length === 0 && ( {filterCharities.length === 0 && (
@ -82,32 +154,28 @@ export default function Charity(props: {
</div> </div>
)} )}
<iframe <div className="mt-10 w-full rounded-xl bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-5">
height="405" <iframe
src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go" height="405"
title="Total donations for Manifold for Charity this May (in USD)" src="https://manifold.markets/ManifoldMarkets/how-much-will-be-donated-through-ma"
frameBorder="0" title="Total donations for Manifold for Charity this May (in USD)"
className="m-10 w-full rounded-xl bg-white p-10" frameBorder="0"
></iframe> className="w-full rounded-xl bg-white p-10"
></iframe>
</div>
<div className="mt-10 text-gray-500"> <div className="mt-10 text-gray-500">
Don't see your favorite charity? Recommend it{' '} <span className="font-semibold">Notes</span>
<SiteLink
href="https://manifold.markets/Sinclair/which-charities-should-manifold-add"
className="text-indigo-700"
>
here
</SiteLink>
!
<br /> <br />
- Don't see your favorite charity? Recommend it by emailing
charity@manifold.markets!
<br /> <br />
<span className="italic"> - Manifold is not affiliated with non-Featured charities; we're just
Note: Manifold is not affiliated with non-Featured charities; we're fans of their work.
just fans of their work! <br />
<br /> - As Manifold itself is a for-profit entity, your contributions will
As Manifold is a for-profit entity, your contributions will not be not be tax deductible.
tax deductible. <br />- Donations + matches are wired once each quarter.
</span>
</div> </div>
</Col> </Col>
</Page> </Page>

View File

@ -21,10 +21,15 @@ import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-t
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
import { CATEGORIES } from 'common/categories' import { CATEGORIES } from 'common/categories'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' 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() { export default function Create() {
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
useTracking('view create page')
return ( return (
<Page> <Page>
<div className="mx-auto w-full max-w-2xl"> <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 [ante, _setAnte] = useState(FIXED_ANTE)
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator) const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
const isFree =
mustWaitForDailyFreeMarketStatus != 'loading' &&
!mustWaitForDailyFreeMarketStatus
// useEffect(() => { // useEffect(() => {
// if (ante === null && creator) { // if (ante === null && creator) {
@ -104,6 +112,9 @@ export function NewContract(props: { question: string }) {
// get days from today until the end of this year: // get days from today until the end of this year:
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
const hasUnsavedChanges = !isSubmitting && Boolean(question || description)
useWarnUnsavedChanges(hasUnsavedChanges)
const isValid = const isValid =
(outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) && (outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) &&
question.length > 0 && question.length > 0 &&
@ -149,6 +160,14 @@ export function NewContract(props: { question: string }) {
max, max,
}) })
) )
track('create market', {
slug: result.slug,
initialProb,
category,
isFree,
})
await router.push(contractPath(result as Contract)) await router.push(contractPath(result as Contract))
} catch (e) { } catch (e) {
console.log('error creating contract', 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 { Contract } from 'common/contract'
import { ContractPageContent } from './[username]/[contractSlug]' import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics'
const Home = () => { const Home = () => {
const user = useUser() const user = useUser()
@ -16,6 +18,8 @@ const Home = () => {
const router = useRouter() const router = useRouter()
useTracking('view home')
if (user === null) { if (user === null) {
Router.replace('/') Router.replace('/')
return <></> return <></>
@ -42,7 +46,10 @@ const Home = () => {
<button <button
type="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" 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" /> <PlusSmIcon className="h-8 w-8" aria-hidden="true" />
</button> </button>
@ -68,7 +75,7 @@ const useContractPage = () => {
const [contract, setContract] = useState<Contract | undefined>() const [contract, setContract] = useState<Contract | undefined>()
useEffect(() => { useEffect(() => {
const onBack = () => { const updateContract = () => {
const path = location.pathname.split('/').slice(1) const path = location.pathname.split('/').slice(1)
if (path[0] === 'home') setContract(undefined) if (path[0] === 'home') setContract(undefined)
else { else {
@ -80,23 +87,24 @@ const useContractPage = () => {
} }
} }
} }
window.addEventListener('popstate', onBack)
// Hack. Listen to changes in href to clear contract on navigate home. const { pushState, replaceState } = window.history
let href = document.location.href
const observer = new MutationObserver(function (_mutations) {
if (href != document.location.href) {
href = document.location.href
const path = location.pathname.split('/').slice(1) window.history.pushState = function () {
if (path[0] === 'home') setContract(undefined) // eslint-disable-next-line prefer-rest-params
} pushState.apply(history, arguments as any)
}) updateContract()
observer.observe(document, { subtree: true, childList: true }) }
window.history.replaceState = function () {
// eslint-disable-next-line prefer-rest-params
replaceState.apply(history, arguments as any)
updateContract()
}
return () => { return () => {
window.removeEventListener('popstate', onBack) window.history.pushState = pushState
observer.disconnect() window.history.replaceState = replaceState
} }
}, []) }, [])

View File

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

View File

@ -4,6 +4,7 @@ import { Page } from 'web/components/page'
import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users' import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { fromPropz, usePropz } from 'web/hooks/use-propz' import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() { export async function getStaticPropz() {
@ -32,6 +33,8 @@ export default function Leaderboards(props: {
} }
const { topTraders, topCreators } = props const { topTraders, topCreators } = props
useTracking('view leaderboards')
return ( return (
<Page margin> <Page margin>
<Col className="items-center gap-10 lg:flex-row"> <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 { getDailyNewUsers } from 'web/lib/firebase/users'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { average } from 'common/util/math'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() { export async function getStaticPropz() {
@ -61,7 +62,7 @@ export async function getStaticPropz() {
}) })
const monthlyActiveUsers = dailyUserIds.map((_, i) => { const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 30) const start = Math.max(0, i - 29)
const end = i const end = i
const uniques = new Set<string>() const uniques = new Set<string>()
for (let j = start; j <= end; j++) for (let j = start; j <= end; j++)
@ -166,16 +167,12 @@ export async function getStaticPropz() {
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 6) const start = Math.max(0, i - 6)
const end = i const end = i
const total = sum(dailyTopTenthActions.slice(start, end)) return average(dailyTopTenthActions.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start)
return total
}) })
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 29) const start = Math.max(0, i - 29)
const end = i const end = i
const total = sum(dailyTopTenthActions.slice(start, end)) return average(dailyTopTenthActions.slice(start, end))
if (end - start < 30) return (total * 30) / (end - start)
return total
}) })
// Total mana divided by 100. // Total mana divided by 100.
@ -193,7 +190,8 @@ export async function getStaticPropz() {
const start = Math.max(0, i - 29) const start = Math.max(0, i - 29)
const end = i const end = i
const total = sum(dailyManaBet.slice(start, end)) 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 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/logger-common" "4.13.1"
"@algolia/requester-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": "@ampproject/remapping@^2.1.0":
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" 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" react-fast-compare "^3.2.0"
shallowequal "^1.1.0" shallowequal "^1.1.0"
react-hot-toast@^2.2.0: react-hot-toast@2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.2.0.tgz#ab6f4caed4214b9534f94bb8cfaaf21b051e62b9" resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.2.0.tgz#ab6f4caed4214b9534f94bb8cfaaf21b051e62b9"
integrity sha512-248rXw13uhf/6TNDVzagX+y7R8J183rp7MwUMNkcrBRyHj/jWOggfXTGlM8zAOuh701WyVW+eUaWG2LeSufX9g== integrity sha512-248rXw13uhf/6TNDVzagX+y7R8J183rp7MwUMNkcrBRyHj/jWOggfXTGlM8zAOuh701WyVW+eUaWG2LeSufX9g==
@ -10617,7 +10677,7 @@ tsconfig-paths@^3.14.1:
minimist "^1.2.6" minimist "^1.2.6"
strip-bom "^3.0.0" 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" version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==