Merge branch 'main' into limit-orders
This commit is contained in:
commit
47abe94639
|
@ -17,6 +17,14 @@ module.exports = {
|
|||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
|
|||
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
|
||||
},
|
||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export type EnvConfig = {
|
|||
domain: string
|
||||
firebaseConfig: FirebaseConfig
|
||||
functionEndpoints: Record<V2CloudFunction, string>
|
||||
amplitudeApiKey?: string
|
||||
|
||||
// Access controls
|
||||
adminEmails: string[]
|
||||
|
@ -34,6 +35,8 @@ type FirebaseConfig = {
|
|||
|
||||
export const PROD_CONFIG: EnvConfig = {
|
||||
domain: 'manifold.markets',
|
||||
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
|
||||
|
||||
firebaseConfig: {
|
||||
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
|
||||
authDomain: 'mantic-markets.firebaseapp.com',
|
||||
|
|
27
common/quadratic-funding.ts
Normal file
27
common/quadratic-funding.ts
Normal 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))
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { sortBy } from 'lodash'
|
||||
import { sortBy, sum } from 'lodash'
|
||||
|
||||
export const logInterpolation = (min: number, max: number, value: number) => {
|
||||
if (value <= min) return 0
|
||||
|
@ -20,13 +20,17 @@ export function normpdf(x: number, mean = 0, variance = 1) {
|
|||
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
export function median(values: number[]) {
|
||||
if (values.length === 0) return NaN
|
||||
export function median(xs: number[]) {
|
||||
if (xs.length === 0) return NaN
|
||||
|
||||
const sorted = sortBy(values, (x) => x)
|
||||
const sorted = sortBy(xs, (x) => x)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
return sorted[mid]
|
||||
}
|
||||
|
||||
export function average(xs: number[]) {
|
||||
return sum(xs) / xs.length
|
||||
}
|
||||
|
|
58
docs/docs/$how-to.md
Normal file
58
docs/docs/$how-to.md
Normal 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 don’t 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. Don’t 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/)**!
|
|
@ -5,7 +5,7 @@ slug: /
|
|||
|
||||
# About Manifold Markets
|
||||
|
||||
Manifold Markets lets anyone create a prediction market on any topic. Win virtual money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market!
|
||||
Manifold Markets lets anyone create a prediction market on any topic. Win virtual play money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market!
|
||||
|
||||
### **What are prediction markets?**
|
||||
|
||||
|
@ -17,20 +17,6 @@ If I think the Democrats are very likely to win, and you disagree, I might offer
|
|||
|
||||
Now, you or I could be mistaken and overshooting the true probability one way or another. If so, there's an incentive for someone else to bet and correct it! Over time, the implied probability will converge to the **[market's best estimate](https://en.wikipedia.org/wiki/Efficient-market_hypothesis)**. Since these probabilities are public, anyone can use them to make better decisions!
|
||||
|
||||
### **How does Manifold Markets work?**
|
||||
|
||||
1. **Anyone can create a market for any yes-or-no question.**
|
||||
|
||||
You can ask questions about the future like "Will Taiwan remove its 14-day COVID quarantine by Jun 01, 2022?" If the market thinks this is very likely, you can plan more activities for your trip.
|
||||
|
||||
You can also ask subjective, personal questions like "Will I enjoy my 2022 Taiwan trip?". Then share the market with your family and friends and get their takes!
|
||||
|
||||
2. **Anyone can bet on a market using Manifold Dollars (M$), our platform currency.**
|
||||
|
||||
You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, you'll win Manifold Dollars from people who bet against you.
|
||||
|
||||
More questions? Check out **[this community-driven FAQ](https://outsidetheasylum.blog/manifold-markets-faq/)**!
|
||||
|
||||
### **Can prediction markets work without real money?**
|
||||
|
||||
Yes! There is substantial evidence that play-money prediction markets provide real predictive power. Examples include **[sports betting](http://www.electronicmarkets.org/fileadmin/user_upload/doc/Issues/Volume_16/Issue_01/V16I1_Statistical_Tests_of_Real-Money_versus_Play-Money_Prediction_Markets.pdf)** and internal prediction markets at firms like **[Google](https://www.networkworld.com/article/2284098/google-bets-on-value-of-prediction-markets.html)**.
|
||||
|
|
|
@ -449,7 +449,7 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
|||
"question":"Is there life on Mars?", \
|
||||
"description":"I'm not going to type some long ass example description.", \
|
||||
"closeTime":1700000000000, \
|
||||
initialProb:25}'
|
||||
"initialProb":25}'
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
|
|
@ -15,35 +15,97 @@ Our community is the beating heart of Manifold; your individual contributions ar
|
|||
|
||||
## Awarded bounties
|
||||
|
||||
🥧 *Awarded 2022-03-14*
|
||||
🎈 *Awarded on 2022-06-14*
|
||||
|
||||
**[Kevin Zielnicki](https://manifold.markets/kjz): M$ 10,000**
|
||||
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**
|
||||
|
||||
- For creating an awesome stats page which features and analyses various data sets! This can be found on the second tab of our [analytics page](https://manifold.markets/stats).
|
||||
|
||||
**[Jack](https://manifold.markets/jack): M$10,000**
|
||||
|
||||
- For adding a bunch of charities to [Manifold for Good](https://manifold.markets/charity), working out market math with Austin, and excellent comment activity.
|
||||
|
||||
**[Forrest](https://manifold.markets/Forrest): M$10,000**
|
||||
|
||||
- For a variety of [open source code contributions](https://github.com/manifoldmarkets/manifold/commits?author=ForrestWeiswolf), making our code base easier to use and maintain.
|
||||
|
||||
**[IsaacKing](https://manifold.markets/IsaacKing): M$10,000**
|
||||
|
||||
- For responsible disclosure of an exploit involving liquidity withdrawal, which has [now been fixed](https://github.com/manifoldmarkets/manifold/pull/472)! Removing one infinite money glitch at a time.
|
||||
|
||||
**[Sjlver](https://manifold.markets/Sjlver): M$5,000**
|
||||
|
||||
- For responsible disclosure of a potential exploit. We would say what it is, but it isn’t 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 he’s 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 we’re doing okay!
|
||||
|
||||
**[Akhil Wable](https://manifold.markets/AkhilWable): M$10,000**
|
||||
|
||||
- For writing up [Akhil’s Product Suggestions](https://www.notion.so/Akhil-s-Product-Suggestions-672e1cba393d4242852ff95ae79528df), an extensive, thoughtful list of improvements we could make to our platform.
|
||||
|
||||
**[Alex K. Chen](https://manifold.markets/AlexKChen): M$6,000**
|
||||
|
||||
- For the creation of a metric ton of innovative, long term questions. At the time of award, Alex was singlehandedly responsible for 20% of all markets posted in April.
|
||||
|
||||
**[ZorbaTHut](https://manifold.markets/ZorbaTHut): M$5,000**
|
||||
|
||||
- For [testing out futarchy](https://manifold.markets/tag/themotte_leaving) on an important problem for the community of The Motte.
|
||||
|
||||
**[Tetraspace](https://manifold.markets/Tetraspace): M$3,500**
|
||||
|
||||
- For the creation of [a focused set of questions on UK politics](https://twitter.com/TetraspaceWest/status/1516824123149848579), with relevant real-world predictions.
|
||||
- For the idea and execution of using FR bounded buckets for mapping out a scalar range ([example market](https://manifold.markets/Tetraspace/if-ron-desantis-is-elected-presiden), [discussion here](https://manifold.markets/StephenMalina/how-many-daily-active-users-will-ma)).
|
||||
|
||||
**[tcheasdfjkl](https://manifold.markets/tcheasdfjkl): M$2,500**
|
||||
|
||||
- For calling out numerous areas of improvement, e.g. around our profit numbers being wonky, and problems with the DPM ⇒ CFMM market conversions.
|
||||
|
||||
**[Jack](https://manifold.markets/JackC): M$500**
|
||||
|
||||
- For recommending we list the Long-Term Future Fund as a supported charity.
|
||||
|
||||
**[N.C. Young](https://manifold.markets/NcyRocks): M$500**
|
||||
|
||||
- For recommending we list the Givewell Maximum Impact Fund as a supported charity.
|
||||
|
||||
\**🥧 *Awarded 2022-03-14\*
|
||||
|
||||
**[Kevin Zielnicki](https://manifold.markets/kjz): M$10,000**
|
||||
|
||||
- For identifying issues with our Dynamic Parimutuel Market Maker in an [excellent blog post](https://kevin.zielnicki.com/2022/02/17/manifold/) (and [associated market](https://manifold.markets/kjz/will-manifolds-developers-agree-wit)), leading us to change to a different mechanism.
|
||||
|
||||
**[Pepe](https://manifold.markets/Pepe): M$ 10,000**
|
||||
**[Pepe](https://manifold.markets/Pepe): M$10,000**
|
||||
|
||||
- For developing the function used in our Constant Function Market Maker and working with us to polish it on Discord, making it easier for us to provision liquidity compared to a CPMM.
|
||||
- For developing the function used in our Constant Function Market Maker, making it easier for us to provision liquidity compared to a CPMM.
|
||||
|
||||
**[Gurkenglas](https://manifold.markets/Gurkenglas): M$ 5,000**
|
||||
**[Gurkenglas](https://manifold.markets/Gurkenglas): M$5,000**
|
||||
|
||||
- For concrete suggestions on Discord around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible.
|
||||
- For concrete suggestions around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible.
|
||||
|
||||
**[Scott Alexander](https://manifold.markets/ScottAlexander): M$ 5,000**
|
||||
**[Scott Alexander](https://manifold.markets/ScottAlexander): M$5,000**
|
||||
|
||||
- For [developing and publicizing the idea of providing interest-free loans on each market](https://astralcodexten.substack.com/p/play-money-and-reputation-systems), helping make long-term markets more accurate.
|
||||
|
||||
**[David Glidden](https://manifold.markets/dglid): M$ 5,000**
|
||||
**[David Glidden](https://manifold.markets/dglid): M$5,000**
|
||||
|
||||
- For taking on the mantle of [@MetaculusBot](https://manifold.markets/MetaculusBot), which allows traders access to a wider spread of topics, and permits head-to-head comparisons between our prediction markets and other forecasting platforms.
|
||||
|
||||
**[Isaac King](https://manifold.markets/IsaacKing): M$ 5,000**
|
||||
**[Isaac King](https://manifold.markets/IsaacKing): M$5,000**
|
||||
|
||||
- For [compiling a comprehensive FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/).
|
||||
- For [compiling an FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/).
|
||||
|
||||
**[Blazer](https://manifold.markets/BlazingDarkness): M$ 2,500**
|
||||
**[Blazer](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when): M$2,500**
|
||||
|
||||
- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing all market creator’s 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 creator’s trades, leading us to revert this feature entirely.
|
||||
|
||||
⛑️ _Awarded 2022-01-09_
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ module.exports = {
|
|||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-extra-semi': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
},
|
||||
"main": "lib/functions/src/index.js",
|
||||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"cors": "2.8.5",
|
||||
"fetch": "1.1.0",
|
||||
"firebase-admin": "10.0.0",
|
||||
|
|
24
functions/src/analytics.ts
Normal file
24
functions/src/analytics.ts
Normal 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,
|
||||
})
|
||||
}
|
|
@ -16,8 +16,7 @@ export * from './on-fold-follow'
|
|||
export * from './on-fold-delete'
|
||||
export * from './on-view'
|
||||
export * from './unsubscribe'
|
||||
export * from './update-contract-metrics'
|
||||
export * from './update-user-metrics'
|
||||
export * from './update-metrics'
|
||||
export * from './update-recommendations'
|
||||
export * from './backup-db'
|
||||
export * from './change-user-info'
|
||||
|
|
|
@ -2,15 +2,12 @@ import { initAdmin } from './script-init'
|
|||
initAdmin()
|
||||
|
||||
import { log, logMemory } from '../utils'
|
||||
import { updateContractMetricsCore } from '../update-contract-metrics'
|
||||
import { updateUserMetricsCore } from '../update-user-metrics'
|
||||
import { updateMetricsCore } from '../update-metrics'
|
||||
|
||||
async function updateMetrics() {
|
||||
logMemory()
|
||||
log('Updating contract metrics...')
|
||||
await updateContractMetricsCore()
|
||||
log('Updating user metrics...')
|
||||
await updateUserMetricsCore()
|
||||
log('Updating metrics...')
|
||||
await updateMetricsCore()
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as admin from 'firebase-admin'
|
|||
import { z } from 'zod'
|
||||
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||
|
@ -57,8 +57,12 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
|||
prevLoanAmount
|
||||
)
|
||||
|
||||
if (!isFinite(newP)) {
|
||||
throw new APIError(500, 'Trade rejected due to overflow error.')
|
||||
if (
|
||||
!newP ||
|
||||
!isFinite(newP) ||
|
||||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY
|
||||
) {
|
||||
throw new APIError(400, 'Sale too large for current liquidity pool.')
|
||||
}
|
||||
|
||||
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||
|
|
|
@ -4,6 +4,7 @@ import Stripe from 'stripe'
|
|||
|
||||
import { getPrivateUser, getUser, isProd, payUser } from './utils'
|
||||
import { sendThankYouEmail } from './emails'
|
||||
import { track } from './analytics'
|
||||
|
||||
export type StripeSession = Stripe.Event.Data.Object & {
|
||||
id: string
|
||||
|
@ -152,6 +153,13 @@ const issueMoneys = async (session: StripeSession) => {
|
|||
if (!privateUser) return
|
||||
|
||||
await sendThankYouEmail(user, privateUser)
|
||||
|
||||
await track(
|
||||
userId,
|
||||
'M$ purchase',
|
||||
{ amount: payout, sessionId },
|
||||
{ revenue: payout / 100 }
|
||||
)
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -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)
|
94
functions/src/update-metrics.ts
Normal file
94
functions/src/update-metrics.ts
Normal 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)
|
|
@ -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)
|
|
@ -15,18 +15,25 @@ export const logMemory = () => {
|
|||
}
|
||||
}
|
||||
|
||||
export const mapAsync = async <T, U>(
|
||||
xs: T[],
|
||||
fn: (x: T) => Promise<U>,
|
||||
concurrency = 100
|
||||
type UpdateSpec = {
|
||||
doc: admin.firestore.DocumentReference
|
||||
fields: { [k: string]: unknown }
|
||||
}
|
||||
|
||||
export const writeUpdatesAsync = async (
|
||||
db: admin.firestore.Firestore,
|
||||
updates: UpdateSpec[],
|
||||
batchSize = 500 // 500 = Firestore batch limit
|
||||
) => {
|
||||
const results = []
|
||||
const chunks = chunk(xs, concurrency)
|
||||
const chunks = chunk(updates, batchSize)
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
log(`${i * concurrency}/${xs.length} processed...`)
|
||||
results.push(...(await Promise.all(chunks[i].map(fn))))
|
||||
log(`${i * batchSize}/${updates.length} updates written...`)
|
||||
const batch = db.batch()
|
||||
for (const { doc, fields } of chunks[i]) {
|
||||
batch.update(doc, fields)
|
||||
}
|
||||
await batch.commit()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export const isProd =
|
||||
|
|
|
@ -78,7 +78,13 @@ export const withdrawLiquidity = functions
|
|||
} as Partial<User>)
|
||||
|
||||
const newPool = subtractObjects(contract.pool, userShares)
|
||||
const newTotalLiquidity = contract.totalLiquidity - payout
|
||||
|
||||
const minPoolShares = Math.min(...Object.values(newPool))
|
||||
const adjustedTotal = contract.totalLiquidity - payout
|
||||
|
||||
// total liquidity is a bogus number; use minPoolShares to prevent from going negative
|
||||
const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares)
|
||||
|
||||
trans.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
|
|
|
@ -10,7 +10,7 @@ module.exports = {
|
|||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
|
|
|
@ -61,7 +61,7 @@ export function AddFundsButton(props: { className?: string }) {
|
|||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary bg-gradient-to-r from-teal-500 to-green-500 px-10 font-medium hover:from-teal-600 hover:to-green-600"
|
||||
className="btn btn-primary bg-gradient-to-r from-indigo-500 to-blue-500 px-10 font-medium hover:from-indigo-600 hover:to-blue-600"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
|
|
24
web/components/alert-box.tsx
Normal file
24
web/components/alert-box.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -22,8 +22,9 @@ import {
|
|||
calculateDpmPayoutAfterCorrectBet,
|
||||
getDpmOutcomeProbabilityAfterBet,
|
||||
} from 'common/calculate-dpm'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { Bet } from 'common/bet'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { SignUpPrompt } from '../sign-up-prompt'
|
||||
|
||||
export function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
|
@ -72,6 +73,15 @@ export function AnswerBetPanel(props: {
|
|||
}
|
||||
setIsSubmitting(false)
|
||||
})
|
||||
|
||||
track('bet', {
|
||||
location: 'answer panel',
|
||||
outcomeType: contract.outcomeType,
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
amount: betAmount,
|
||||
outcome: answerId,
|
||||
})
|
||||
}
|
||||
|
||||
const betDisabled = isSubmitting || !betAmount || error
|
||||
|
@ -173,12 +183,7 @@ export function AnswerBetPanel(props: {
|
|||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn self-stretch whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
|
||||
onClick={firebaseLogin}
|
||||
>
|
||||
Sign up to bet!
|
||||
</button>
|
||||
<SignUpPrompt />
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { Bet } from 'common/bet'
|
||||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
|
||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||
const { contract } = props
|
||||
|
@ -143,7 +144,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
isSubmitting && 'loading'
|
||||
)}
|
||||
disabled={!canSubmit}
|
||||
onClick={submitAnswer}
|
||||
onClick={withTracking(submitAnswer, 'submit answer')}
|
||||
>
|
||||
Submit answer & buy
|
||||
</button>
|
||||
|
@ -151,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
text && (
|
||||
<button
|
||||
className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
|
||||
onClick={firebaseLogin}
|
||||
onClick={withTracking(firebaseLogin, 'answer panel sign in')}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
|
|
|
@ -40,6 +40,7 @@ import { useSaveShares } from './use-save-shares'
|
|||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { isIOS } from 'web/lib/util/device'
|
||||
import { ProbabilityInput } from './probability-input'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: CPMMBinaryContract
|
||||
|
@ -266,6 +267,15 @@ function BuyPanel(props: {
|
|||
}
|
||||
setIsSubmitting(false)
|
||||
})
|
||||
|
||||
track('bet', {
|
||||
location: 'bet panel',
|
||||
outcomeType: contract.outcomeType,
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
amount: betAmount,
|
||||
outcome: betChoice,
|
||||
})
|
||||
}
|
||||
|
||||
const betDisabled = isSubmitting || !betAmount || error
|
||||
|
@ -429,6 +439,14 @@ export function SellPanel(props: {
|
|||
}
|
||||
setIsSubmitting(false)
|
||||
})
|
||||
|
||||
track('sell shares', {
|
||||
outcomeType: contract.outcomeType,
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
shares: sellAmount,
|
||||
outcome: sharesOutcome,
|
||||
})
|
||||
}
|
||||
|
||||
const initialProb = getProbability(contract)
|
||||
|
|
|
@ -482,7 +482,10 @@ export function ContractBetsTable(props: {
|
|||
}) {
|
||||
const { contract, className, isYourBets } = props
|
||||
|
||||
const bets = props.bets.filter((b) => !b.isAnte)
|
||||
const bets = sortBy(
|
||||
props.bets.filter((b) => !b.isAnte),
|
||||
(bet) => bet.createdTime
|
||||
).reverse()
|
||||
|
||||
const [sales, buys] = partition(bets, (bet) => bet.sale)
|
||||
|
||||
|
|
|
@ -6,9 +6,11 @@ import { Charity } from 'common/charity'
|
|||
import { useCharityTxns } from 'web/hooks/use-charity-txns'
|
||||
import { manaToUSD } from '../../../common/util/format'
|
||||
import { Row } from '../layout/row'
|
||||
import { Col } from '../layout/col'
|
||||
|
||||
export function CharityCard(props: { charity: Charity }) {
|
||||
const { slug, photo, preview, id, tags } = props.charity
|
||||
export function CharityCard(props: { charity: Charity; match?: number }) {
|
||||
const { charity, match } = props
|
||||
const { slug, photo, preview, id, tags } = charity
|
||||
|
||||
const txns = useCharityTxns(id)
|
||||
const raised = sumBy(txns, (txn) => txn.amount)
|
||||
|
@ -32,14 +34,22 @@ export function CharityCard(props: { charity: Charity }) {
|
|||
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
|
||||
<div className="line-clamp-4 text-sm">{preview}</div>
|
||||
{raised > 0 && (
|
||||
<Row className="text-primary mt-4 flex-1 items-end justify-center gap-2">
|
||||
<span className="text-3xl">
|
||||
{raised < 100
|
||||
? manaToUSD(raised)
|
||||
: '$' + Math.floor(raised / 100)}
|
||||
</span>
|
||||
<span>raised</span>
|
||||
</Row>
|
||||
<>
|
||||
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
|
||||
<Col>
|
||||
<span className="text-3xl font-semibold">
|
||||
{formatUsd(raised)}
|
||||
</span>
|
||||
<span>raised</span>
|
||||
</Col>
|
||||
{match && (
|
||||
<Col className="text-gray-500">
|
||||
<span className="text-xl">+{formatUsd(match)}</span>
|
||||
<span className="">match</span>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -47,6 +57,10 @@ export function CharityCard(props: { charity: Charity }) {
|
|||
)
|
||||
}
|
||||
|
||||
function formatUsd(mana: number) {
|
||||
return mana < 100 ? manaToUSD(mana) : '$' + Math.floor(mana / 100)
|
||||
}
|
||||
|
||||
function FeaturedBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800">
|
||||
|
|
|
@ -26,6 +26,8 @@ import { EditCategoriesButton } from './feed/category-selector'
|
|||
import { CATEGORIES } from 'common/categories'
|
||||
import { Tabs } from './layout/tabs'
|
||||
import { EditFollowingButton } from './following-button'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -134,6 +136,7 @@ export function ContractSearch(props: {
|
|||
className="!select !select-bordered"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as filter)}
|
||||
onBlur={trackCallback('select search filter')}
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
|
@ -145,6 +148,7 @@ export function ContractSearch(props: {
|
|||
classNames={{
|
||||
select: '!select !select-bordered',
|
||||
}}
|
||||
onBlur={trackCallback('select search sort')}
|
||||
/>
|
||||
<Configure
|
||||
facetFilters={filters}
|
||||
|
@ -296,7 +300,11 @@ function CategoryFollowSelector(props: {
|
|||
]
|
||||
: []),
|
||||
]}
|
||||
onClick={(_, index) => setMode(index === 0 ? 'categories' : 'following')}
|
||||
onClick={(_, index) => {
|
||||
const mode = index === 0 ? 'categories' : 'following'
|
||||
setMode(mode)
|
||||
track(`click ${mode} tab`)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
|||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
|
@ -71,12 +73,22 @@ export function ContractCard(props: {
|
|||
if (e.ctrlKey || e.metaKey) return
|
||||
|
||||
e.preventDefault()
|
||||
track('click market card', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
})
|
||||
onClick()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Link href={contractPath(contract)}>
|
||||
<a className="absolute top-0 left-0 right-0 bottom-0" />
|
||||
<a
|
||||
onClick={trackCallback('click market card', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
})}
|
||||
className="absolute top-0 left-0 right-0 bottom-0"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -24,6 +24,7 @@ import { OUTCOME_TO_COLOR } from '../outcome-label'
|
|||
import { useSaveShares } from '../use-save-shares'
|
||||
import { sellShares } from 'web/lib/firebase/api-call'
|
||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
const BET_SIZE = 10
|
||||
|
||||
|
@ -121,6 +122,12 @@ export function QuickBet(props: { contract: Contract; user: User }) {
|
|||
success: message,
|
||||
error: (err) => `${err.message}`,
|
||||
})
|
||||
|
||||
track('quick bet', {
|
||||
slug: contract.slug,
|
||||
direction,
|
||||
contractId: contract.id,
|
||||
})
|
||||
}
|
||||
|
||||
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
|
||||
|
|
|
@ -2,11 +2,13 @@ import React, { Fragment } from 'react'
|
|||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
function copyContractUrl(contract: Contract) {
|
||||
copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
|
||||
|
@ -23,7 +25,10 @@ export function CopyLinkButton(props: {
|
|||
<Menu
|
||||
as="div"
|
||||
className="relative z-10 flex-shrink-0"
|
||||
onMouseUp={() => copyContractUrl(contract)}
|
||||
onMouseUp={() => {
|
||||
copyContractUrl(contract)
|
||||
track('copy share link')
|
||||
}}
|
||||
>
|
||||
<Menu.Button
|
||||
className={clsx(
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { last, findLastIndex, uniq, sortBy } from 'lodash'
|
||||
import { uniq, sortBy } from 'lodash'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
import { Bet } from 'common/bet'
|
||||
|
@ -6,14 +6,11 @@ import { getOutcomeProbability } from 'common/calculate'
|
|||
import { Comment } from 'common/comment'
|
||||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { mapCommentsByBetId } from 'web/lib/firebase/comments'
|
||||
|
||||
export type ActivityItem =
|
||||
| DescriptionItem
|
||||
| QuestionItem
|
||||
| BetItem
|
||||
| CommentItem
|
||||
| BetGroupItem
|
||||
| AnswerGroupItem
|
||||
| CloseItem
|
||||
| ResolveItem
|
||||
|
@ -49,15 +46,6 @@ export type BetItem = BaseActivityItem & {
|
|||
hideComment?: boolean
|
||||
}
|
||||
|
||||
export type CommentItem = BaseActivityItem & {
|
||||
type: 'comment'
|
||||
comment: Comment
|
||||
betsBySameUser: Bet[]
|
||||
probAtCreatedTime?: number
|
||||
truncate?: boolean
|
||||
smallAvatar?: boolean
|
||||
}
|
||||
|
||||
export type CommentThreadItem = BaseActivityItem & {
|
||||
type: 'commentThread'
|
||||
parentComment: Comment
|
||||
|
@ -65,12 +53,6 @@ export type CommentThreadItem = BaseActivityItem & {
|
|||
bets: Bet[]
|
||||
}
|
||||
|
||||
export type BetGroupItem = BaseActivityItem & {
|
||||
type: 'betgroup'
|
||||
bets: Bet[]
|
||||
hideOutcome: boolean
|
||||
}
|
||||
|
||||
export type AnswerGroupItem = BaseActivityItem & {
|
||||
type: 'answergroup'
|
||||
user: User | undefined | null
|
||||
|
@ -87,172 +69,6 @@ export type ResolveItem = BaseActivityItem & {
|
|||
type: 'resolve'
|
||||
}
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
|
||||
|
||||
// Group together bets that are:
|
||||
// - Within a day of the first in the group
|
||||
// (Unless the bets are older: then are grouped by 7-days.)
|
||||
// - Do not have a comment
|
||||
// - Were not created by this user
|
||||
// Return a list of ActivityItems
|
||||
function groupBets(
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
contract: Contract,
|
||||
userId: string | undefined,
|
||||
options: {
|
||||
hideOutcome: boolean
|
||||
abbreviated: boolean
|
||||
smallAvatar: boolean
|
||||
reversed: boolean
|
||||
}
|
||||
) {
|
||||
const { hideOutcome, abbreviated, smallAvatar, reversed } = options
|
||||
|
||||
const commentsMap = mapCommentsByBetId(comments)
|
||||
const items: ActivityItem[] = []
|
||||
let group: Bet[] = []
|
||||
|
||||
// Turn the current group into an ActivityItem
|
||||
function pushGroup() {
|
||||
if (group.length == 1) {
|
||||
items.push(toActivityItem(group[0]))
|
||||
} else if (group.length > 1) {
|
||||
items.push({
|
||||
type: 'betgroup',
|
||||
bets: [...group],
|
||||
id: group[0].id,
|
||||
contract,
|
||||
hideOutcome,
|
||||
})
|
||||
}
|
||||
group = []
|
||||
}
|
||||
|
||||
function toActivityItem(bet: Bet): ActivityItem {
|
||||
const comment = commentsMap[bet.id]
|
||||
return comment
|
||||
? {
|
||||
type: 'comment' as const,
|
||||
id: bet.id,
|
||||
comment,
|
||||
betsBySameUser: [bet],
|
||||
contract,
|
||||
truncate: abbreviated,
|
||||
smallAvatar,
|
||||
}
|
||||
: {
|
||||
type: 'bet' as const,
|
||||
id: bet.id,
|
||||
bet,
|
||||
contract,
|
||||
hideOutcome,
|
||||
smallAvatar,
|
||||
}
|
||||
}
|
||||
|
||||
for (const bet of bets) {
|
||||
const isCreator = userId === bet.userId
|
||||
|
||||
// If first bet in group is older than 3 days, group by 7 days. Otherwise, group by 1 day.
|
||||
const windowMs =
|
||||
Date.now() - (group[0]?.createdTime ?? bet.createdTime) > DAY_IN_MS * 3
|
||||
? DAY_IN_MS * 7
|
||||
: DAY_IN_MS
|
||||
|
||||
if (commentsMap[bet.id] || isCreator) {
|
||||
pushGroup()
|
||||
// Create a single item for this
|
||||
items.push(toActivityItem(bet))
|
||||
} else {
|
||||
if (
|
||||
group.length > 0 &&
|
||||
bet.createdTime - group[0].createdTime > windowMs
|
||||
) {
|
||||
// More than `windowMs` has passed; start a new group
|
||||
pushGroup()
|
||||
}
|
||||
group.push(bet)
|
||||
}
|
||||
}
|
||||
if (group.length > 0) {
|
||||
pushGroup()
|
||||
}
|
||||
const abbrItems = abbreviated
|
||||
? items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
|
||||
: items
|
||||
if (reversed) abbrItems.reverse()
|
||||
return abbrItems
|
||||
}
|
||||
|
||||
function getAnswerGroups(
|
||||
contract: FreeResponseContract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
user: User | undefined | null,
|
||||
options: {
|
||||
sortByProb: boolean
|
||||
abbreviated: boolean
|
||||
reversed: boolean
|
||||
}
|
||||
) {
|
||||
const { sortByProb, abbreviated, reversed } = options
|
||||
|
||||
let outcomes = uniq(bets.map((bet) => bet.outcome))
|
||||
if (abbreviated) {
|
||||
const lastComment = last(comments)
|
||||
const lastCommentOutcome = bets.find(
|
||||
(bet) => bet.id === lastComment?.betId
|
||||
)?.outcome
|
||||
const lastBetOutcome = last(bets)?.outcome
|
||||
if (lastCommentOutcome && lastBetOutcome) {
|
||||
outcomes = uniq([
|
||||
...outcomes.filter(
|
||||
(outcome) =>
|
||||
outcome !== lastCommentOutcome && outcome !== lastBetOutcome
|
||||
),
|
||||
lastCommentOutcome,
|
||||
lastBetOutcome,
|
||||
])
|
||||
}
|
||||
outcomes = outcomes.slice(-2)
|
||||
}
|
||||
if (sortByProb) {
|
||||
outcomes = sortBy(outcomes, (outcome) =>
|
||||
getOutcomeProbability(contract, outcome)
|
||||
)
|
||||
} else {
|
||||
// Sort by recent bet.
|
||||
outcomes = sortBy(outcomes, (outcome) =>
|
||||
findLastIndex(bets, (bet) => bet.outcome === outcome)
|
||||
)
|
||||
}
|
||||
|
||||
const answerGroups = outcomes
|
||||
.map((outcome) => {
|
||||
const answer = contract.answers?.find(
|
||||
(answer) => answer.id === outcome
|
||||
) as Answer
|
||||
|
||||
// TODO: this doesn't abbreviate these groups for activity feed anymore
|
||||
return {
|
||||
id: outcome,
|
||||
type: 'answergroup' as const,
|
||||
contract,
|
||||
user,
|
||||
answer,
|
||||
comments,
|
||||
bets,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.answer)
|
||||
|
||||
if (reversed) answerGroups.reverse()
|
||||
|
||||
return answerGroups
|
||||
}
|
||||
|
||||
function getAnswerAndCommentInputGroups(
|
||||
contract: FreeResponseContract,
|
||||
bets: Bet[],
|
||||
|
@ -284,54 +100,6 @@ function getAnswerAndCommentInputGroups(
|
|||
return answerGroups
|
||||
}
|
||||
|
||||
function groupBetsAndComments(
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
contract: Contract,
|
||||
userId: string | undefined,
|
||||
options: {
|
||||
hideOutcome: boolean
|
||||
abbreviated: boolean
|
||||
smallAvatar: boolean
|
||||
reversed: boolean
|
||||
}
|
||||
) {
|
||||
const { smallAvatar, abbreviated, reversed } = options
|
||||
// Comments in feed don't show user's position?
|
||||
const commentsWithoutBets = comments
|
||||
.filter((comment) => !comment.betId)
|
||||
.map((comment) => ({
|
||||
type: 'comment' as const,
|
||||
id: comment.id,
|
||||
contract: contract,
|
||||
comment,
|
||||
betsBySameUser: [],
|
||||
truncate: abbreviated,
|
||||
smallAvatar,
|
||||
}))
|
||||
|
||||
const groupedBets = groupBets(bets, comments, contract, userId, options)
|
||||
|
||||
// iterate through the bets and comment activity items and add them to the items in order of comment creation time:
|
||||
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
|
||||
const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => {
|
||||
if (item.type === 'comment') {
|
||||
return item.comment.createdTime
|
||||
} else if (item.type === 'bet') {
|
||||
return item.bet.createdTime
|
||||
} else if (item.type === 'betgroup') {
|
||||
return item.bets[0].createdTime
|
||||
}
|
||||
})
|
||||
|
||||
const abbrItems = abbreviated
|
||||
? sortedBetsAndComments.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW)
|
||||
: sortedBetsAndComments
|
||||
|
||||
if (reversed) abbrItems.reverse()
|
||||
return abbrItems
|
||||
}
|
||||
|
||||
function getCommentThreads(
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
|
@ -351,122 +119,6 @@ function getCommentThreads(
|
|||
return items
|
||||
}
|
||||
|
||||
export function getAllContractActivityItems(
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
user: User | null | undefined,
|
||||
options: {
|
||||
abbreviated: boolean
|
||||
}
|
||||
) {
|
||||
const { abbreviated } = options
|
||||
const { outcomeType } = contract
|
||||
const reversed = true
|
||||
|
||||
bets =
|
||||
outcomeType === 'BINARY'
|
||||
? bets.filter((bet) => !bet.isAnte && !bet.isRedemption)
|
||||
: bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0'))
|
||||
|
||||
const items: ActivityItem[] = abbreviated
|
||||
? [
|
||||
{
|
||||
type: 'question',
|
||||
id: '0',
|
||||
contract,
|
||||
showDescription: false,
|
||||
},
|
||||
]
|
||||
: [{ type: 'description', id: '0', contract }]
|
||||
|
||||
if (outcomeType === 'FREE_RESPONSE') {
|
||||
const onlyUsersBetsOrBetsWithComments = bets.filter((bet) =>
|
||||
comments.some(
|
||||
(comment) => comment.betId === bet.id || bet.userId === user?.id
|
||||
)
|
||||
)
|
||||
items.push(
|
||||
...groupBetsAndComments(
|
||||
onlyUsersBetsOrBetsWithComments,
|
||||
comments,
|
||||
contract,
|
||||
user?.id,
|
||||
{
|
||||
hideOutcome: false,
|
||||
abbreviated,
|
||||
smallAvatar: false,
|
||||
reversed,
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
...groupBetsAndComments(bets, comments, contract, user?.id, {
|
||||
hideOutcome: false,
|
||||
abbreviated,
|
||||
smallAvatar: false,
|
||||
reversed,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (contract.closeTime && contract.closeTime <= Date.now()) {
|
||||
items.push({ type: 'close', id: `${contract.closeTime}`, contract })
|
||||
}
|
||||
if (contract.resolution) {
|
||||
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
|
||||
}
|
||||
|
||||
if (reversed) items.reverse()
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function getRecentContractActivityItems(
|
||||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
user: User | null | undefined,
|
||||
options: {
|
||||
contractPath?: string
|
||||
}
|
||||
) {
|
||||
const { contractPath } = options
|
||||
bets = bets.sort((b1, b2) => b1.createdTime - b2.createdTime)
|
||||
comments = comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
||||
const questionItem: QuestionItem = {
|
||||
type: 'question',
|
||||
id: '0',
|
||||
contract,
|
||||
showDescription: false,
|
||||
contractPath,
|
||||
}
|
||||
|
||||
const items = []
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
items.push(
|
||||
...getAnswerGroups(contract, bets, comments, user, {
|
||||
sortByProb: false,
|
||||
abbreviated: true,
|
||||
reversed: true,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
...groupBetsAndComments(bets, comments, contract, user?.id, {
|
||||
hideOutcome: false,
|
||||
abbreviated: true,
|
||||
smallAvatar: false,
|
||||
reversed: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return [questionItem, ...items]
|
||||
}
|
||||
|
||||
function commentIsGeneralComment(comment: Comment, contract: Contract) {
|
||||
return (
|
||||
comment.answerOutcome === undefined &&
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Col } from '../layout/col'
|
|||
import { useState } from 'react'
|
||||
import { updateUser, User } from 'web/lib/firebase/users'
|
||||
import { Checkbox } from '../checkbox'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function CategorySelector(props: {
|
||||
category: string
|
||||
|
@ -93,7 +94,10 @@ export function EditCategoriesButton(props: {
|
|||
className,
|
||||
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
track('edit categories button')
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
Categories
|
||||
|
|
|
@ -3,11 +3,7 @@ import { Comment } from 'web/lib/firebase/comments'
|
|||
import { Bet } from 'common/bet'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import {
|
||||
getAllContractActivityItems,
|
||||
getRecentContractActivityItems,
|
||||
getSpecificContractActivityItems,
|
||||
} from './activity-items'
|
||||
import { getSpecificContractActivityItems } from './activity-items'
|
||||
import { FeedItems } from './feed-items'
|
||||
import { User } from 'common/user'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
|
@ -17,45 +13,27 @@ export function ContractActivity(props: {
|
|||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
user: User | null | undefined
|
||||
mode:
|
||||
| 'only-recent'
|
||||
| 'abbreviated'
|
||||
| 'all'
|
||||
| 'comments'
|
||||
| 'bets'
|
||||
| 'free-response-comment-answer-groups'
|
||||
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
||||
contractPath?: string
|
||||
className?: string
|
||||
betRowClassName?: string
|
||||
}) {
|
||||
const { user, mode, contractPath, className, betRowClassName } = props
|
||||
const { user, mode, className, betRowClassName } = props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
const updatedComments =
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
mode === 'only-recent' ? undefined : useComments(contract.id)
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const updatedBets =
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
mode === 'only-recent' ? undefined : useBets(contract.id)
|
||||
const updatedBets = useBets(contract.id)
|
||||
const bets = (updatedBets ?? props.bets).filter((bet) => !bet.isRedemption)
|
||||
const items =
|
||||
mode === 'only-recent'
|
||||
? getRecentContractActivityItems(contract, bets, comments, user, {
|
||||
contractPath,
|
||||
})
|
||||
: mode === 'comments' ||
|
||||
mode === 'bets' ||
|
||||
mode === 'free-response-comment-answer-groups'
|
||||
? getSpecificContractActivityItems(contract, bets, comments, user, {
|
||||
mode,
|
||||
})
|
||||
: // only used in abbreviated mode with folds/communities, all mode isn't used
|
||||
getAllContractActivityItems(contract, bets, comments, user, {
|
||||
abbreviated: mode === 'abbreviated',
|
||||
})
|
||||
const items = getSpecificContractActivityItems(
|
||||
contract,
|
||||
bets,
|
||||
comments,
|
||||
user,
|
||||
{ mode }
|
||||
)
|
||||
|
||||
return (
|
||||
<FeedItems
|
||||
|
|
|
@ -24,6 +24,7 @@ import { Col } from 'web/components/layout/col'
|
|||
import { getProbability } from 'common/calculate'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function FeedCommentThread(props: {
|
||||
contract: Contract
|
||||
|
@ -354,6 +355,7 @@ export function CommentInput(props: {
|
|||
|
||||
async function submitComment(betId: string | undefined) {
|
||||
if (!user) {
|
||||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!comment || isSubmitting) return
|
||||
|
|
|
@ -30,11 +30,10 @@ import { RelativeTimestamp } from '../relative-timestamp'
|
|||
import { FeedAnswerCommentGroup } from 'web/components/feed/feed-answer-comment-group'
|
||||
import {
|
||||
FeedCommentThread,
|
||||
FeedComment,
|
||||
CommentInput,
|
||||
TruncatedComment,
|
||||
} from 'web/components/feed/feed-comments'
|
||||
import { FeedBet, FeedBetGroup } from 'web/components/feed/feed-bets'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { CPMMBinaryContract, NumericContract } from 'common/contract'
|
||||
|
||||
export function FeedItems(props: {
|
||||
|
@ -85,12 +84,8 @@ export function FeedItem(props: { item: ActivityItem }) {
|
|||
return <FeedQuestion {...item} />
|
||||
case 'description':
|
||||
return <FeedDescription {...item} />
|
||||
case 'comment':
|
||||
return <FeedComment {...item} />
|
||||
case 'bet':
|
||||
return <FeedBet {...item} />
|
||||
case 'betgroup':
|
||||
return <FeedBetGroup {...item} />
|
||||
case 'answergroup':
|
||||
return <FeedAnswerCommentGroup {...item} />
|
||||
case 'close':
|
||||
|
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx'
|
|||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { follow, unfollow } from 'web/lib/firebase/users'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
|
||||
export function FollowButton(props: {
|
||||
isFollowing: boolean | undefined
|
||||
|
@ -34,7 +35,7 @@ export function FollowButton(props: {
|
|||
small && smallStyle,
|
||||
className
|
||||
)}
|
||||
onClick={onUnfollow}
|
||||
onClick={withTracking(onUnfollow, 'unfollow')}
|
||||
>
|
||||
Following
|
||||
</button>
|
||||
|
@ -44,7 +45,7 @@ export function FollowButton(props: {
|
|||
return (
|
||||
<button
|
||||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
onClick={onFollow}
|
||||
onClick={withTracking(onFollow, 'follow')}
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import clsx from 'clsx'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFollowers, useFollows } from 'web/hooks/use-follows'
|
||||
|
@ -10,6 +11,7 @@ import { Modal } from './layout/modal'
|
|||
import { Tabs } from './layout/tabs'
|
||||
import { useDiscoverUsers } from 'web/hooks/use-users'
|
||||
import { TextButton } from './text-button'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function FollowingButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
|
@ -48,7 +50,10 @@ export function EditFollowingButton(props: { user: User; className?: string }) {
|
|||
className,
|
||||
'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700'
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
track('edit following button')
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="inline h-4 w-4" />
|
||||
Following
|
||||
|
|
|
@ -7,10 +7,14 @@ import { firebaseLogin } from 'web/lib/firebase/users'
|
|||
import { ContractsGrid } from './contract/contracts-list'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
|
||||
export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
||||
const { hotContracts } = props
|
||||
|
||||
useTracking('view landing page')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Col className="mb-6 rounded-xl sm:m-12 sm:mt-0">
|
||||
|
@ -45,7 +49,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
|||
<Spacer h={6} />
|
||||
<button
|
||||
className="self-center rounded-md border-none bg-gradient-to-r from-indigo-500 to-blue-500 py-4 px-6 text-lg font-semibold normal-case text-white hover:from-indigo-600 hover:to-blue-600"
|
||||
onClick={firebaseLogin}
|
||||
onClick={withTracking(firebaseLogin, 'landing page button click')}
|
||||
>
|
||||
Get started
|
||||
</button>{' '}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs'
|
|||
import { NoLabel, YesLabel } from './outcome-label'
|
||||
import { Col } from './layout/col'
|
||||
import { InfoTooltip } from './info-tooltip'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
|
@ -57,7 +58,7 @@ export function LiquidityPanel(props: { contract: CPMMContract }) {
|
|||
|
||||
function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
const { id: contractId } = contract
|
||||
const { id: contractId, slug } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
|
@ -99,6 +100,8 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
|||
}
|
||||
})
|
||||
.catch((_) => setError('Server error'))
|
||||
|
||||
track('add liquidity', { amount, contractId, slug })
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -177,6 +180,8 @@ function WithdrawLiquidityPanel(props: {
|
|||
setIsLoading(false)
|
||||
})
|
||||
.catch((_) => setError('Server error'))
|
||||
|
||||
track('withdraw liquidity')
|
||||
}
|
||||
|
||||
if (isSuccess)
|
||||
|
|
|
@ -17,6 +17,7 @@ import clsx from 'clsx'
|
|||
import { useRouter } from 'next/router'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
function getNavigation(username: string) {
|
||||
return [
|
||||
|
@ -106,6 +107,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
|||
'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700',
|
||||
currentPage === item.href && 'bg-gray-200 text-indigo-700'
|
||||
)}
|
||||
onClick={trackCallback('navbar: ' + item.name)}
|
||||
>
|
||||
<item.icon className="my-1 mx-auto h-6 w-6" />
|
||||
{item.name}
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Avatar } from '../avatar'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}`}>
|
||||
<a className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700">
|
||||
<a
|
||||
onClick={trackCallback('sidebar: profile')}
|
||||
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
|
||||
|
||||
<div className="truncate">
|
||||
|
|
|
@ -26,6 +26,7 @@ import { Row } from '../layout/row'
|
|||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
|
||||
// Create an icon from the url of an image
|
||||
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
|
||||
|
@ -72,7 +73,7 @@ function getMoreNavigation(user?: User | null) {
|
|||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{ name: 'Sign out', href: '#', onClick: () => firebaseLogout() },
|
||||
]
|
||||
}
|
||||
|
@ -81,7 +82,11 @@ const signedOutNavigation = [
|
|||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
{ name: 'Explore', href: '/markets', icon: SearchIcon },
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const signedOutMobileNavigation = [
|
||||
|
@ -98,7 +103,11 @@ const signedOutMobileNavigation = [
|
|||
href: 'https://twitter.com/ManifoldMarkets',
|
||||
icon: IconFromUrl('/twitter-logo.svg'),
|
||||
},
|
||||
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const mobileNavigation = [
|
||||
|
@ -117,6 +126,7 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
|||
return (
|
||||
<Link href={item.href} key={item.name}>
|
||||
<a
|
||||
onClick={trackCallback('sidebar: ' + item.name)}
|
||||
className={clsx(
|
||||
item.href == currentPage
|
||||
? 'bg-gray-200 text-gray-900'
|
||||
|
@ -208,7 +218,11 @@ export default function Sidebar(props: { className?: string }) {
|
|||
{user && (
|
||||
<MenuButton
|
||||
menuItems={[
|
||||
{ name: 'Sign out', href: '#', onClick: () => firebaseLogout() },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
||||
},
|
||||
]}
|
||||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
|
@ -229,14 +243,17 @@ export default function Sidebar(props: { className?: string }) {
|
|||
<div className={'aligncenter flex justify-center'}>
|
||||
{user ? (
|
||||
<Link href={'/create'} passHref>
|
||||
<button className={clsx(gradient, buttonStyle)}>
|
||||
<button
|
||||
className={clsx(gradient, buttonStyle)}
|
||||
onClick={trackCallback('create question button')}
|
||||
>
|
||||
Create a question
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={firebaseLogin}
|
||||
className={clsx(gradient, buttonStyle)}
|
||||
onClick={withTracking(firebaseLogin, 'sign in')}
|
||||
className="btn btn-outline btn-sm mx-auto mt-4 -ml-1 w-full rounded-md normal-case"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
|
|
|
@ -19,6 +19,7 @@ import { Col } from './layout/col'
|
|||
import { Row } from './layout/row'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function NumericBetPanel(props: {
|
||||
contract: NumericContract
|
||||
|
@ -96,6 +97,15 @@ function NumericBuyPanel(props: {
|
|||
}
|
||||
setIsSubmitting(false)
|
||||
})
|
||||
|
||||
track('bet', {
|
||||
location: 'numeric panel',
|
||||
outcomeType: contract.outcomeType,
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
amount: betAmount,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
const betDisabled = isSubmitting || !betAmount || !bucketChoice || error
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import React, { Fragment } from 'react'
|
||||
import { CodeIcon } from '@heroicons/react/outline'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { contractPath } from 'web/lib/firebase/contracts'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
function copyEmbedCode(contract: Contract) {
|
||||
const title = contract.question
|
||||
|
@ -26,7 +28,10 @@ export function ShareEmbedButton(props: {
|
|||
<Menu
|
||||
as="div"
|
||||
className="relative z-10 flex-shrink-0"
|
||||
onMouseUp={() => copyEmbedCode(contract)}
|
||||
onMouseUp={() => {
|
||||
copyEmbedCode(contract)
|
||||
track('copy embed code')
|
||||
}}
|
||||
>
|
||||
<Menu.Button
|
||||
className="btn btn-xs normal-case"
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import React from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
|
||||
export function SignUpPrompt() {
|
||||
const user = useUser()
|
||||
|
||||
return user === null ? (
|
||||
<button
|
||||
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
|
||||
onClick={firebaseLogin}
|
||||
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-indigo-500 to-blue-500 px-10 text-lg font-medium normal-case hover:from-indigo-600 hover:to-blue-600"
|
||||
onClick={withTracking(firebaseLogin, 'sign up to bet')}
|
||||
>
|
||||
Sign up to bet!
|
||||
</button>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { Col } from './layout/col'
|
|||
import { Row } from './layout/row'
|
||||
import { TagsList } from './tags-list'
|
||||
import { MAX_TAG_LENGTH } from 'common/contract'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function TagsInput(props: { contract: Contract; className?: string }) {
|
||||
const { contract, className } = props
|
||||
|
@ -24,6 +25,7 @@ export function TagsInput(props: { contract: Contract; className?: string }) {
|
|||
})
|
||||
setIsSubmitting(false)
|
||||
setTagText('')
|
||||
track('save tags')
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export function TweetButton(props: { className?: string; tweetText: string }) {
|
||||
const { tweetText, className } = props
|
||||
|
@ -12,6 +13,7 @@ export function TweetButton(props: { className?: string; tweetText: string }) {
|
|||
color: '#1da1f2',
|
||||
}}
|
||||
href={getTweetHref(tweetText)}
|
||||
onClick={trackCallback('share tweet')}
|
||||
target="_blank"
|
||||
>
|
||||
<img className="mr-2" src={'/twitter-logo.svg'} width={15} height={15} />
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import clsx from 'clsx'
|
||||
import { uniq } from 'lodash'
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { follow, unfollow, User } from 'web/lib/firebase/users'
|
||||
import { CreatorContractsList } from './contract/contracts-list'
|
||||
import { SEO } from './SEO'
|
||||
|
@ -9,9 +13,7 @@ import { Col } from './layout/col'
|
|||
import { Linkify } from './linkify'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { Row } from './layout/row'
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { genHash } from 'common/util/random'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import { Tabs } from './layout/tabs'
|
||||
import { UserCommentsList } from './comments-list'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
@ -22,8 +24,10 @@ import { LoadingIndicator } from './loading-indicator'
|
|||
import { BetsList } from './bets-list'
|
||||
import { Bet } from 'common/bet'
|
||||
import { getUserBets } from 'web/lib/firebase/bets'
|
||||
import { uniq } from 'lodash'
|
||||
import { FollowersButton, FollowingButton } from './following-button'
|
||||
import { AlertBox } from './alert-box'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { FollowButton } from './follow-button'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -305,29 +309,3 @@ export function defaultBannerUrl(userId: string) {
|
|||
]
|
||||
return defaultBanner[genHash(userId)() % defaultBanner.length]
|
||||
}
|
||||
|
||||
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||
import { FollowButton } from './follow-button'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
|
||||
function AlertBox(props: { title: string; text: string }) {
|
||||
const { title, text } = props
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
8
web/hooks/use-tracking.ts
Normal file
8
web/hooks/use-tracking.ts
Normal 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)
|
||||
}, [])
|
||||
}
|
|
@ -13,6 +13,7 @@ import {
|
|||
userDocRef,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { useStateCheckEquality } from './use-state-check-equality'
|
||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||
|
||||
export const useUser = () => {
|
||||
const [user, setUser] = useStateCheckEquality<User | null | undefined>(
|
||||
|
@ -21,11 +22,14 @@ export const useUser = () => {
|
|||
|
||||
useEffect(() => listenForLogin(setUser), [setUser])
|
||||
|
||||
const userId = user?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) return listenForUser(userId, setUser)
|
||||
}, [userId, setUser])
|
||||
if (user) {
|
||||
identifyUser(user.id)
|
||||
setUserProperty('username', user.username)
|
||||
|
||||
return listenForUser(user.id, setUser)
|
||||
}
|
||||
}, [user, setUser])
|
||||
|
||||
return user
|
||||
}
|
||||
|
|
31
web/hooks/use-warn-unsaved-changes.ts
Normal file
31
web/hooks/use-warn-unsaved-changes.ts
Normal 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])
|
||||
}
|
|
@ -14,6 +14,7 @@ import { db } from './init'
|
|||
import { User } from 'common/user'
|
||||
import { Comment } from 'common/comment'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
|
||||
export type { Comment }
|
||||
|
||||
|
@ -43,6 +44,12 @@ export async function createComment(
|
|||
answerOutcome: answerOutcome,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
track('comment', {
|
||||
contractId,
|
||||
commentId: ref.id,
|
||||
betId: betId,
|
||||
replyToCommentId: replyToCommentId,
|
||||
})
|
||||
return await setDoc(ref, comment)
|
||||
}
|
||||
|
||||
|
|
42
web/lib/service/analytics.ts
Normal file
42
web/lib/service/analytics.ts
Normal 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)
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
"verify": "(cd .. && yarn verify)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "0.4.1",
|
||||
"@headlessui/react": "1.6.1",
|
||||
"@heroicons/react": "1.0.5",
|
||||
"@nivo/core": "0.74.0",
|
||||
|
@ -37,7 +38,7 @@
|
|||
"react-confetti": "6.0.1",
|
||||
"react-dom": "17.0.2",
|
||||
"react-expanding-textarea": "2.3.5",
|
||||
"react-hot-toast": "^2.2.0",
|
||||
"react-hot-toast": "2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-query": "3.39.0"
|
||||
},
|
||||
|
|
|
@ -40,6 +40,8 @@ import { useIsIframe } from 'web/hooks/use-is-iframe'
|
|||
import ContractEmbedPage from '../embed/[username]/[contractSlug]'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { CPMMBinaryContract } from 'common/contract'
|
||||
import { AlertBox } from 'web/components/alert-box'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -108,6 +110,12 @@ export function ContractPageContent(
|
|||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
useTracking('view market', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
creatorId: contract.creatorId,
|
||||
})
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
// Sort for now to see if bug is fixed.
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
@ -193,6 +201,12 @@ export function ContractPageContent(
|
|||
bets={bets}
|
||||
comments={comments ?? []}
|
||||
/>
|
||||
{isNumeric && (
|
||||
<AlertBox
|
||||
title="Warning"
|
||||
text="Numeric markets were introduced as an experimental feature and are now deprecated."
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
<>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { getUserByUsername, User } from 'web/lib/firebase/users'
|
|||
import { UserPage } from 'web/components/user-page'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import Custom404 from '../404'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
|
||||
export default function UserProfile(props: {
|
||||
tab?: 'markets' | 'comments' | 'bets'
|
||||
|
@ -12,6 +13,7 @@ export default function UserProfile(props: {
|
|||
const router = useRouter()
|
||||
const [user, setUser] = useState<User | null | 'loading'>('loading')
|
||||
const { username } = router.query as { username: string }
|
||||
|
||||
useEffect(() => {
|
||||
if (username) {
|
||||
getUserByUsername(username).then(setUser)
|
||||
|
@ -20,6 +22,8 @@ export default function UserProfile(props: {
|
|||
|
||||
const currentUser = useUser()
|
||||
|
||||
useTracking('view user profile', { username })
|
||||
|
||||
if (user === 'loading') return <></>
|
||||
|
||||
return user ? (
|
||||
|
|
|
@ -39,19 +39,6 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
gtag('config', 'G-SSFK1Q138D');
|
||||
`}
|
||||
</Script>
|
||||
{/* Hotjar Tracking Code for https://manifold.markets */}
|
||||
<Script id="hotjar">
|
||||
{`
|
||||
(function(h,o,t,j,a,r){
|
||||
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
|
||||
h._hjSettings={hjid:2968940,hjsv:6};
|
||||
a=o.getElementsByTagName('head')[0];
|
||||
r=o.createElement('script');r.async=1;
|
||||
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
|
||||
a.appendChild(r);
|
||||
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
|
||||
`}
|
||||
</Script>
|
||||
<Head>
|
||||
<title>Manifold Markets — A market for every question</title>
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -6,6 +6,8 @@ import { FundsSelector } from 'web/components/yes-no-selector'
|
|||
import { useUser } from 'web/hooks/use-user'
|
||||
import { checkoutURL } from 'web/lib/service/stripe'
|
||||
import { Page } from 'web/components/page'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
|
||||
export default function AddFundsPage() {
|
||||
const user = useUser()
|
||||
|
@ -14,6 +16,8 @@ export default function AddFundsPage() {
|
|||
2500
|
||||
)
|
||||
|
||||
useTracking('view add funds')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
|
@ -59,6 +63,7 @@ export default function AddFundsPage() {
|
|||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full bg-gradient-to-r from-indigo-500 to-blue-500 font-medium hover:from-indigo-600 hover:to-blue-600"
|
||||
onClick={trackCallback('checkout', { amount: amountSelected })}
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import { mapValues, groupBy, sumBy, sum, sortBy, debounce } from 'lodash'
|
||||
import {
|
||||
mapValues,
|
||||
groupBy,
|
||||
sumBy,
|
||||
sum,
|
||||
sortBy,
|
||||
debounce,
|
||||
uniqBy,
|
||||
} from 'lodash'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { charities, Charity as CharityType } from 'common/charity'
|
||||
import { CharityCard } from 'web/components/charity/charity-card'
|
||||
|
@ -8,7 +16,10 @@ import { Page } from 'web/components/page'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { Title } from 'web/components/title'
|
||||
import { getAllCharityTxns } from 'web/lib/firebase/txns'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { manaToUSD } from 'common/util/format'
|
||||
import { quadraticMatches } from 'common/quadratic-funding'
|
||||
import { Txn } from 'common/txn'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
|
||||
export async function getStaticProps() {
|
||||
const txns = await getAllCharityTxns()
|
||||
|
@ -20,21 +31,55 @@ export async function getStaticProps() {
|
|||
(charity) => (charity.tags?.includes('Featured') ? 0 : 1),
|
||||
(charity) => -totals[charity.id],
|
||||
])
|
||||
const matches = quadraticMatches(txns, totalRaised)
|
||||
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
|
||||
|
||||
return {
|
||||
props: {
|
||||
totalRaised,
|
||||
charities: sortedCharities,
|
||||
matches,
|
||||
txns,
|
||||
numDonors,
|
||||
},
|
||||
revalidate: 60,
|
||||
}
|
||||
}
|
||||
|
||||
type Stat = {
|
||||
name: string
|
||||
stat: string
|
||||
}
|
||||
|
||||
function DonatedStats(props: { stats: Stat[] }) {
|
||||
const { stats } = props
|
||||
return (
|
||||
<dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3">
|
||||
{stats.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"
|
||||
>
|
||||
<dt className="truncate text-sm font-medium text-gray-500">
|
||||
{item.name}
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{item.stat}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Charity(props: {
|
||||
totalRaised: number
|
||||
charities: CharityType[]
|
||||
matches: { [charityId: string]: number }
|
||||
txns: Txn[]
|
||||
numDonors: number
|
||||
}) {
|
||||
const { totalRaised, charities } = props
|
||||
const { totalRaised, charities, matches, numDonors } = props
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const debouncedQuery = debounce(setQuery, 50)
|
||||
|
@ -51,29 +96,56 @@ export default function Charity(props: {
|
|||
[charities, query]
|
||||
)
|
||||
|
||||
useTracking('view charity')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]">
|
||||
<Col className="max-w-xl gap-2">
|
||||
<Col className="">
|
||||
<Title className="!mt-0" text="Manifold for Charity" />
|
||||
<div className="mb-6 text-gray-500">
|
||||
Donate your winnings to charity! Every {formatMoney(100)} you give
|
||||
turns into $1 USD we send to your chosen charity.
|
||||
<Spacer h={5} />
|
||||
Together we've donated over ${Math.floor(totalRaised / 100)} USD so
|
||||
far!
|
||||
</div>
|
||||
<span className="text-gray-600">
|
||||
Through July 15, up to $25k of donations will be matched via{' '}
|
||||
<SiteLink href="https://wtfisqf.com/" className="font-bold">
|
||||
quadratic funding
|
||||
</SiteLink>
|
||||
, courtesy of{' '}
|
||||
<SiteLink href="https://ftxfuturefund.org/" className="font-bold">
|
||||
the FTX Future Fund
|
||||
</SiteLink>
|
||||
!
|
||||
</span>
|
||||
<DonatedStats
|
||||
stats={[
|
||||
{
|
||||
name: 'Raised by Manifold users',
|
||||
stat: manaToUSD(totalRaised),
|
||||
},
|
||||
{
|
||||
name: 'Number of donors',
|
||||
stat: `${numDonors}`,
|
||||
},
|
||||
{
|
||||
name: 'Matched via quadratic funding',
|
||||
stat: manaToUSD(sum(Object.values(matches))),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Spacer h={10} />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search charities"
|
||||
placeholder="Find a charity"
|
||||
className="input input-bordered mb-6 w-full"
|
||||
/>
|
||||
</Col>
|
||||
<div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3">
|
||||
{filterCharities.map((charity) => (
|
||||
<CharityCard charity={charity} key={charity.name} />
|
||||
<CharityCard
|
||||
charity={charity}
|
||||
key={charity.name}
|
||||
match={matches[charity.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filterCharities.length === 0 && (
|
||||
|
@ -82,32 +154,28 @@ export default function Charity(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
height="405"
|
||||
src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go"
|
||||
title="Total donations for Manifold for Charity this May (in USD)"
|
||||
frameBorder="0"
|
||||
className="m-10 w-full rounded-xl bg-white p-10"
|
||||
></iframe>
|
||||
<div className="mt-10 w-full rounded-xl bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-5">
|
||||
<iframe
|
||||
height="405"
|
||||
src="https://manifold.markets/ManifoldMarkets/how-much-will-be-donated-through-ma"
|
||||
title="Total donations for Manifold for Charity this May (in USD)"
|
||||
frameBorder="0"
|
||||
className="w-full rounded-xl bg-white p-10"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-gray-500">
|
||||
Don't see your favorite charity? Recommend it{' '}
|
||||
<SiteLink
|
||||
href="https://manifold.markets/Sinclair/which-charities-should-manifold-add"
|
||||
className="text-indigo-700"
|
||||
>
|
||||
here
|
||||
</SiteLink>
|
||||
!
|
||||
<span className="font-semibold">Notes</span>
|
||||
<br />
|
||||
- Don't see your favorite charity? Recommend it by emailing
|
||||
charity@manifold.markets!
|
||||
<br />
|
||||
<span className="italic">
|
||||
Note: Manifold is not affiliated with non-Featured charities; we're
|
||||
just fans of their work!
|
||||
<br />
|
||||
As Manifold is a for-profit entity, your contributions will not be
|
||||
tax deductible.
|
||||
</span>
|
||||
- Manifold is not affiliated with non-Featured charities; we're just
|
||||
fans of their work.
|
||||
<br />
|
||||
- As Manifold itself is a for-profit entity, your contributions will
|
||||
not be tax deductible.
|
||||
<br />- Donations + matches are wired once each quarter.
|
||||
</div>
|
||||
</Col>
|
||||
</Page>
|
||||
|
|
|
@ -21,10 +21,15 @@ import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-t
|
|||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { CATEGORIES } from 'common/categories'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
||||
|
||||
export default function Create() {
|
||||
const [question, setQuestion] = useState('')
|
||||
|
||||
useTracking('view create page')
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
|
@ -77,6 +82,9 @@ export function NewContract(props: { question: string }) {
|
|||
const [ante, _setAnte] = useState(FIXED_ANTE)
|
||||
|
||||
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
|
||||
const isFree =
|
||||
mustWaitForDailyFreeMarketStatus != 'loading' &&
|
||||
!mustWaitForDailyFreeMarketStatus
|
||||
|
||||
// useEffect(() => {
|
||||
// if (ante === null && creator) {
|
||||
|
@ -104,6 +112,9 @@ export function NewContract(props: { question: string }) {
|
|||
// get days from today until the end of this year:
|
||||
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
|
||||
|
||||
const hasUnsavedChanges = !isSubmitting && Boolean(question || description)
|
||||
useWarnUnsavedChanges(hasUnsavedChanges)
|
||||
|
||||
const isValid =
|
||||
(outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) &&
|
||||
question.length > 0 &&
|
||||
|
@ -149,6 +160,14 @@ export function NewContract(props: { question: string }) {
|
|||
max,
|
||||
})
|
||||
)
|
||||
|
||||
track('create market', {
|
||||
slug: result.slug,
|
||||
initialProb,
|
||||
category,
|
||||
isFree,
|
||||
})
|
||||
|
||||
await router.push(contractPath(result as Contract))
|
||||
} catch (e) {
|
||||
console.log('error creating contract', e)
|
||||
|
|
|
@ -9,6 +9,8 @@ import { ContractSearch } from 'web/components/contract-search'
|
|||
import { Contract } from 'common/contract'
|
||||
import { ContractPageContent } from './[username]/[contractSlug]'
|
||||
import { getContractFromSlug } from 'web/lib/firebase/contracts'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
const Home = () => {
|
||||
const user = useUser()
|
||||
|
@ -16,6 +18,8 @@ const Home = () => {
|
|||
|
||||
const router = useRouter()
|
||||
|
||||
useTracking('view home')
|
||||
|
||||
if (user === null) {
|
||||
Router.replace('/')
|
||||
return <></>
|
||||
|
@ -42,7 +46,10 @@ const Home = () => {
|
|||
<button
|
||||
type="button"
|
||||
className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden"
|
||||
onClick={() => router.push('/create')}
|
||||
onClick={() => {
|
||||
router.push('/create')
|
||||
track('mobile create button')
|
||||
}}
|
||||
>
|
||||
<PlusSmIcon className="h-8 w-8" aria-hidden="true" />
|
||||
</button>
|
||||
|
@ -68,7 +75,7 @@ const useContractPage = () => {
|
|||
const [contract, setContract] = useState<Contract | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
const onBack = () => {
|
||||
const updateContract = () => {
|
||||
const path = location.pathname.split('/').slice(1)
|
||||
if (path[0] === 'home') setContract(undefined)
|
||||
else {
|
||||
|
@ -80,23 +87,24 @@ const useContractPage = () => {
|
|||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('popstate', onBack)
|
||||
|
||||
// Hack. Listen to changes in href to clear contract on navigate home.
|
||||
let href = document.location.href
|
||||
const observer = new MutationObserver(function (_mutations) {
|
||||
if (href != document.location.href) {
|
||||
href = document.location.href
|
||||
const { pushState, replaceState } = window.history
|
||||
|
||||
const path = location.pathname.split('/').slice(1)
|
||||
if (path[0] === 'home') setContract(undefined)
|
||||
}
|
||||
})
|
||||
observer.observe(document, { subtree: true, childList: true })
|
||||
window.history.pushState = function () {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
pushState.apply(history, arguments as any)
|
||||
updateContract()
|
||||
}
|
||||
|
||||
window.history.replaceState = function () {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
replaceState.apply(history, arguments as any)
|
||||
updateContract()
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', onBack)
|
||||
observer.disconnect()
|
||||
window.history.pushState = pushState
|
||||
window.history.replaceState = replaceState
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
@ -11,16 +11,16 @@ import { ManifoldLogo } from 'web/components/nav/manifold-logo'
|
|||
export async function getStaticProps() {
|
||||
// These hardcoded markets will be shown in the frontpage for signed-out users:
|
||||
const hotContracts = await getContractsBySlugs([
|
||||
'if-boris-johnson-is-leader-of-the-c',
|
||||
'will-ethereum-merge-to-proofofstake',
|
||||
'will-max-go-to-prom-with-a-girl',
|
||||
'will-ethereum-switch-to-proof-of-st',
|
||||
'will-russia-control-the-majority-of',
|
||||
'will-elon-musk-buy-twitter-this-yea',
|
||||
'will-an-ai-get-gold-on-any-internat',
|
||||
'how-many-us-supreme-court-justices',
|
||||
'will-trump-be-charged-by-the-grand',
|
||||
'will-spacex-launch-a-starship-into',
|
||||
'who-will-win-the-nba-finals-champio',
|
||||
'what-database-will-manifold-be-prim',
|
||||
'will-the-supreme-court-leakers-iden',
|
||||
'will-over-25-of-participants-in-the-163d54309e43',
|
||||
'who-will-be-time-magazine-person-of',
|
||||
'will-congress-hold-any-hearings-abo-e21f987033b3',
|
||||
'will-at-least-10-world-cities-have',
|
||||
])
|
||||
|
||||
return {
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Page } from 'web/components/page'
|
|||
import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz() {
|
||||
|
@ -32,6 +33,8 @@ export default function Leaderboards(props: {
|
|||
}
|
||||
const { topTraders, topCreators } = props
|
||||
|
||||
useTracking('view leaderboards')
|
||||
|
||||
return (
|
||||
<Page margin>
|
||||
<Col className="items-center gap-10 lg:flex-row">
|
||||
|
|
|
@ -16,6 +16,7 @@ import { getDailyContracts } from 'web/lib/firebase/contracts'
|
|||
import { getDailyNewUsers } from 'web/lib/firebase/users'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { Linkify } from 'web/components/linkify'
|
||||
import { average } from 'common/util/math'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz() {
|
||||
|
@ -61,7 +62,7 @@ export async function getStaticPropz() {
|
|||
})
|
||||
|
||||
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 30)
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
|
@ -166,16 +167,12 @@ export async function getStaticPropz() {
|
|||
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const total = sum(dailyTopTenthActions.slice(start, end))
|
||||
if (end - start < 7) return (total * 7) / (end - start)
|
||||
return total
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const total = sum(dailyTopTenthActions.slice(start, end))
|
||||
if (end - start < 30) return (total * 30) / (end - start)
|
||||
return total
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
|
||||
// Total mana divided by 100.
|
||||
|
@ -193,7 +190,8 @@ export async function getStaticPropz() {
|
|||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
if (end - start < 30) return (total * 30) / (end - start)
|
||||
const range = end - start + 1
|
||||
if (range < 30) return (total * 30) / range
|
||||
return total
|
||||
})
|
||||
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 212 KiB |
64
yarn.lock
64
yarn.lock
|
@ -227,6 +227,66 @@
|
|||
"@algolia/logger-common" "4.13.1"
|
||||
"@algolia/requester-common" "4.13.1"
|
||||
|
||||
"@amplitude/analytics-browser@0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/analytics-browser/-/analytics-browser-0.4.1.tgz#d686ab89fb12cdb3ba6aaade87b8d4bb8f72e86f"
|
||||
integrity sha512-omiUvv2v+sznKjFj5s4vBoVkfqsEAFqW1FQUpZuWpaekOb4/n5zhTAzs2NQMq1hFxmIh9DxqM4wY0y347CFBHg==
|
||||
dependencies:
|
||||
"@amplitude/analytics-core" "^0.3.1"
|
||||
"@amplitude/analytics-types" "^0.2.1"
|
||||
"@amplitude/ua-parser-js" "^0.7.26"
|
||||
tslib "^2.3.1"
|
||||
|
||||
"@amplitude/analytics-core@^0.3.1":
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/analytics-core/-/analytics-core-0.3.1.tgz#e86eb8e5cbab06063f04b7cf9f41a238d9a124b8"
|
||||
integrity sha512-BgfSE49GXyYQtqL0E6xQhw9VcaYaAOgqAedyHB1VsvgVQUXEv8z1GM6GhGZVqCA5afwtq6fu80p2iqGTGuSP+g==
|
||||
dependencies:
|
||||
"@amplitude/analytics-types" "^0.2.1"
|
||||
tslib "^2.3.1"
|
||||
|
||||
"@amplitude/analytics-types@^0.2.1":
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-0.2.1.tgz#ab4f9b4bbec8afa768a27af78f75bb69dd2a3fc7"
|
||||
integrity sha512-+FfXlCjHysYWliRBjD2wQ2gZ4V6jGKskdt9j8npv9Rmzdehj04OAHudZ/UrgH5++88As9ww1wyaS7y4sm4x8vA==
|
||||
|
||||
"@amplitude/identify@^1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.10.0.tgz#d62b8b6785c29350c368810475a6fc7b04985210"
|
||||
integrity sha512-BshMDcZX9qO4mgGBR45HmiHxfcPCDY/eBOE/MTUZBW+y9+N61aKmNY3YJsAUfRPzieDiyfqs8rNm7quVkaNzJQ==
|
||||
dependencies:
|
||||
"@amplitude/types" "^1.10.0"
|
||||
"@amplitude/utils" "^1.10.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@amplitude/node@^1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/node/-/node-1.10.0.tgz#33f84ddf82b31471fce53e6fa60b688d4bc62ee4"
|
||||
integrity sha512-Jh8w1UpxhonWe0kCALVvqiBE3vo5NYmbNZbZrrI9Lfa/1HbGboZlGdg0I7/WtihbZvEjpfcfTOf8OkmtZh6vsQ==
|
||||
dependencies:
|
||||
"@amplitude/identify" "^1.10.0"
|
||||
"@amplitude/types" "^1.10.0"
|
||||
"@amplitude/utils" "^1.10.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@amplitude/types@^1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/types/-/types-1.10.0.tgz#dfaf7cc25f533a1e2b0ef0ad675371b396733c0f"
|
||||
integrity sha512-xN0gnhutztv6kqHaZ2bre18anQV5GDmMXOeipTvI670g2VjNbPfOzMwu1LN4p1NadYq+GqYI223UcZrXR+R4Pw==
|
||||
|
||||
"@amplitude/ua-parser-js@^0.7.26":
|
||||
version "0.7.31"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/ua-parser-js/-/ua-parser-js-0.7.31.tgz#749bf7cb633cfcc7ff3c10805bad7c5f6fbdbc61"
|
||||
integrity sha512-+z8UGRaj13Pt5NDzOnkTBy49HE2CX64jeL0ArB86HAtilpnfkPB7oqkigN7Lf2LxscMg4QhFD7mmCfedh3rqTg==
|
||||
|
||||
"@amplitude/utils@^1.10.0":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@amplitude/utils/-/utils-1.10.0.tgz#138b0ba4e5755540a9e4abf426b7a25d045418a9"
|
||||
integrity sha512-/R8j8IzFH0GYfA6ehQDm5IEzt71gIeMdiYYFIzZp6grERQlgJcwNJMAiza0o2JwwTDIruzqdB3c/vLVjuakp+w==
|
||||
dependencies:
|
||||
"@amplitude/types" "^1.10.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@ampproject/remapping@^2.1.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
|
||||
|
@ -9294,7 +9354,7 @@ react-helmet-async@*, react-helmet-async@^1.2.3:
|
|||
react-fast-compare "^3.2.0"
|
||||
shallowequal "^1.1.0"
|
||||
|
||||
react-hot-toast@^2.2.0:
|
||||
react-hot-toast@2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.2.0.tgz#ab6f4caed4214b9534f94bb8cfaaf21b051e62b9"
|
||||
integrity sha512-248rXw13uhf/6TNDVzagX+y7R8J183rp7MwUMNkcrBRyHj/jWOggfXTGlM8zAOuh701WyVW+eUaWG2LeSufX9g==
|
||||
|
@ -10617,7 +10677,7 @@ tsconfig-paths@^3.14.1:
|
|||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^1.8.1, tslib@^1.9.0:
|
||||
tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
|
Loading…
Reference in New Issue
Block a user