Merge branch 'main' into limit-orders
This commit is contained in:
commit
53e2ff7327
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -52,4 +52,4 @@ jobs:
|
|||
- name: Run Typescript checker on cloud functions
|
||||
if: ${{ success() || failure() }}
|
||||
working-directory: functions
|
||||
run: tsc --pretty --project tsconfig.json --noEmit
|
||||
run: tsc -b -v --pretty
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -3,3 +3,5 @@
|
|||
.vercel
|
||||
node_modules
|
||||
yarn-error.log
|
||||
|
||||
firebase-debug.log
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -6,7 +6,8 @@
|
|||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"toba.vsfire"
|
||||
"toba.vsfire",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": []
|
||||
|
|
5
common/.gitignore
vendored
5
common/.gitignore
vendored
|
@ -1,6 +1,5 @@
|
|||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
@ -10,4 +9,4 @@ node_modules/
|
|||
|
||||
package-lock.json
|
||||
ui-debug.log
|
||||
firebase-debug.log
|
||||
firebase-debug.log
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Answer } from './answer'
|
||||
import { Fees } from './fees'
|
||||
import { GroupDetails } from 'common/group'
|
||||
|
||||
export type AnyMechanism = DPM | CPMM
|
||||
export type AnyOutcomeType = Binary | FreeResponse | Numeric
|
||||
|
@ -25,8 +24,6 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
lowercaseTags: string[]
|
||||
visibility: 'public' | 'unlisted'
|
||||
|
||||
groupDetails?: GroupDetails[] // Starting with one group per contract
|
||||
|
||||
createdTime: number // Milliseconds since epoch
|
||||
lastUpdatedTime?: number // Updated on new bet or comment
|
||||
lastBetTime?: number
|
||||
|
@ -36,6 +33,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
|||
isResolved: boolean
|
||||
resolutionTime?: number // When the contract creator resolved the market
|
||||
resolution?: string
|
||||
resolutionProbability?: number,
|
||||
|
||||
closeEmailsSent?: number
|
||||
|
||||
|
|
|
@ -12,12 +12,7 @@ export const DEV_CONFIG: EnvConfig = {
|
|||
appId: '1:134303100058:web:27f9ea8b83347251f80323',
|
||||
measurementId: 'G-YJC9E37P37',
|
||||
},
|
||||
functionEndpoints: {
|
||||
placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app',
|
||||
sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app',
|
||||
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
|
||||
creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app',
|
||||
},
|
||||
cloudRunId: 'w3txbmd3ba',
|
||||
cloudRunRegion: 'uc',
|
||||
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
|
||||
}
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
export type V2CloudFunction =
|
||||
| 'placebet'
|
||||
| 'sellbet'
|
||||
| 'sellshares'
|
||||
| 'createmarket'
|
||||
| 'creategroup'
|
||||
|
||||
export type EnvConfig = {
|
||||
domain: string
|
||||
firebaseConfig: FirebaseConfig
|
||||
functionEndpoints: Record<V2CloudFunction, string>
|
||||
amplitudeApiKey?: string
|
||||
|
||||
// IDs for v2 cloud functions -- find these by deploying a cloud function and
|
||||
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
|
||||
cloudRunId: string
|
||||
cloudRunRegion: string
|
||||
|
||||
// Access controls
|
||||
adminEmails: string[]
|
||||
whitelistEmail?: string // e.g. '@theoremone.co'. If not provided, all emails are whitelisted
|
||||
|
@ -48,13 +45,8 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
|
||||
measurementId: 'G-SSFK1Q138D',
|
||||
},
|
||||
functionEndpoints: {
|
||||
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
|
||||
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
|
||||
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
|
||||
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
|
||||
},
|
||||
cloudRunId: 'nggbo3neva',
|
||||
cloudRunRegion: 'uc',
|
||||
adminEmails: [
|
||||
'akrolsmir@gmail.com', // Austin
|
||||
'jahooma@gmail.com', // James
|
||||
|
|
|
@ -12,14 +12,8 @@ export const THEOREMONE_CONFIG: EnvConfig = {
|
|||
appId: '1:698012149198:web:b342af75662831aa84b79f',
|
||||
measurementId: 'G-Y3EZ1WNT6E',
|
||||
},
|
||||
// TODO: fill in real endpoints for T1
|
||||
functionEndpoints: {
|
||||
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
|
||||
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
|
||||
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
|
||||
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
|
||||
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
|
||||
},
|
||||
cloudRunId: 'nggbo3neva', // TODO: fill in real ID for T1
|
||||
cloudRunRegion: 'uc',
|
||||
adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'],
|
||||
whitelistEmail: '@theoremone.co',
|
||||
moneyMoniker: 'T$',
|
||||
|
|
|
@ -13,9 +13,3 @@ export type Group = {
|
|||
export const MAX_GROUP_NAME_LENGTH = 75
|
||||
export const MAX_ABOUT_LENGTH = 140
|
||||
export const MAX_ID_LENGTH = 60
|
||||
|
||||
export type GroupDetails = {
|
||||
groupId: string
|
||||
groupSlug: string
|
||||
groupName: string
|
||||
}
|
||||
|
|
35
common/manalink.ts
Normal file
35
common/manalink.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
export type Manalink = {
|
||||
// The link to send: https://manifold.markets/send/{slug}
|
||||
// Also functions as the unique id for the link.
|
||||
slug: string
|
||||
|
||||
// Note: we assume both fromId and toId are of SourceType 'USER'
|
||||
fromId: string
|
||||
|
||||
// Displayed to people claiming the link
|
||||
message: string
|
||||
|
||||
// How much to send with the link
|
||||
amount: number
|
||||
token: 'M$' // TODO: could send eg YES shares too??
|
||||
|
||||
createdTime: number
|
||||
// If null, the link is valid forever
|
||||
expiresTime: number | null
|
||||
// If null, the link can be used infinitely
|
||||
maxUses: number | null
|
||||
|
||||
// Used for simpler caching
|
||||
claimedUserIds: string[]
|
||||
// Successful redemptions of the link
|
||||
claims: Claim[]
|
||||
}
|
||||
|
||||
export type Claim = {
|
||||
toId: string
|
||||
|
||||
// The ID of the successful txn that tracks the money moved
|
||||
txnId: string
|
||||
|
||||
claimedTime: number
|
||||
}
|
|
@ -11,7 +11,6 @@ import {
|
|||
import { User } from './user'
|
||||
import { parseTags } from './util/parse'
|
||||
import { removeUndefinedProps } from './util/object'
|
||||
import { GroupDetails } from 'common/group'
|
||||
|
||||
export function getNewContract(
|
||||
id: string,
|
||||
|
@ -28,8 +27,7 @@ export function getNewContract(
|
|||
// used for numeric markets
|
||||
bucketCount: number,
|
||||
min: number,
|
||||
max: number,
|
||||
groupDetails?: GroupDetails
|
||||
max: number
|
||||
) {
|
||||
const tags = parseTags(
|
||||
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
|
||||
|
@ -71,7 +69,6 @@ export function getNewContract(
|
|||
liquidityFee: 0,
|
||||
platformFee: 0,
|
||||
},
|
||||
groupDetails: groupDetails ? [groupDetails] : undefined,
|
||||
})
|
||||
|
||||
return contract as Contract
|
||||
|
|
23
common/stats.ts
Normal file
23
common/stats.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
export type Stats = {
|
||||
startDate: number
|
||||
dailyActiveUsers: number[]
|
||||
weeklyActiveUsers: number[]
|
||||
monthlyActiveUsers: number[]
|
||||
dailyBetCounts: number[]
|
||||
dailyContractCounts: number[]
|
||||
dailyCommentCounts: number[]
|
||||
dailySignups: number[]
|
||||
weekOnWeekRetention: number[]
|
||||
monthlyRetention: number[]
|
||||
weeklyActivationRate: number[]
|
||||
topTenthActions: {
|
||||
daily: number[]
|
||||
weekly: number[]
|
||||
monthly: number[]
|
||||
}
|
||||
manaBet: {
|
||||
daily: number[]
|
||||
weekly: number[]
|
||||
monthly: number[]
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||
type AnyTxnType = Donation | Tip
|
||||
type AnyTxnType = Donation | Tip | Manalink
|
||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||
|
||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||
|
@ -16,6 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
|
||||
// Any extra data
|
||||
data?: { [key: string]: any }
|
||||
|
||||
|
@ -39,5 +40,12 @@ type Tip = {
|
|||
}
|
||||
}
|
||||
|
||||
type Manalink = {
|
||||
fromType: 'USER'
|
||||
toType: 'USER'
|
||||
category: 'MANALINK'
|
||||
}
|
||||
|
||||
export type DonationTxn = Txn & Donation
|
||||
export type TipTxn = Txn & Tip
|
||||
export type ManalinkTxn = Txn & Manalink
|
||||
|
|
402
docs/docs/api.md
402
docs/docs/api.md
|
@ -106,7 +106,7 @@ Requires no authorization.
|
|||
// A list of tags on each market. Any user can add tags to any market.
|
||||
// This list also includes the predefined categories shown as filters on the home page.
|
||||
tags: string[]
|
||||
|
||||
|
||||
// Note: This url always points to https://manifold.markets, regardless of what instance the api is running on.
|
||||
// This url includes the creator's username, but this doesn't need to be correct when constructing valid URLs.
|
||||
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
|
||||
|
@ -115,10 +115,10 @@ Requires no authorization.
|
|||
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
|
||||
mechanism: string // dpm-2 or cpmm-1
|
||||
|
||||
pool: number // sum of YES and NO shares in liquidity pool for CPMM, null for DPM
|
||||
probability: number
|
||||
p?: number // probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity?: number
|
||||
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
|
||||
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
|
||||
|
||||
volume: number
|
||||
volume7Days: number
|
||||
|
@ -127,6 +127,7 @@ Requires no authorization.
|
|||
isResolved: boolean
|
||||
resolutionTime?: number
|
||||
resolution?: string
|
||||
resolutionProbability?: number // Used for BINARY markets resolved to MKT
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -146,202 +147,204 @@ Requires no authorization.
|
|||
|
||||
```json
|
||||
{
|
||||
"id":"lEoqtnDgJzft6apSKzYK",
|
||||
"creatorUsername":"Angela",
|
||||
"creatorName":"Angela",
|
||||
"createdTime":1655258914863,
|
||||
"creatorAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"closeTime":1655265001448,
|
||||
"question":"What is good?",
|
||||
"description":"Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
|
||||
"tags":[],
|
||||
"url":"https://manifold.markets/Angela/what-is-good",
|
||||
"pool":null,
|
||||
"outcomeType":"FREE_RESPONSE",
|
||||
"mechanism":"dpm-2",
|
||||
"volume":112,
|
||||
"volume7Days":212,
|
||||
"volume24Hours":0,
|
||||
"isResolved":true,
|
||||
"resolution":"MKT",
|
||||
"resolutionTime":1655265001448,
|
||||
"answers":[
|
||||
"id": "lEoqtnDgJzft6apSKzYK",
|
||||
"creatorUsername": "Angela",
|
||||
"creatorName": "Angela",
|
||||
"createdTime": 1655258914863,
|
||||
"creatorAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"closeTime": 1655265001448,
|
||||
"question": "What is good?",
|
||||
"description": "Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
|
||||
"tags": [],
|
||||
"url": "https://manifold.markets/Angela/what-is-good",
|
||||
"pool": null,
|
||||
"outcomeType": "FREE_RESPONSE",
|
||||
"mechanism": "dpm-2",
|
||||
"volume": 112,
|
||||
"volume7Days": 212,
|
||||
"volume24Hours": 0,
|
||||
"isResolved": true,
|
||||
"resolution": "MKT",
|
||||
"resolutionTime": 1655265001448,
|
||||
"answers": [
|
||||
{
|
||||
"createdTime":1655258941573,
|
||||
"avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"id":"1",
|
||||
"username":"Angela",
|
||||
"number":1,
|
||||
"name":"Angela",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"text":"ANTE",
|
||||
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"probability":0.66749733001068
|
||||
"createdTime": 1655258941573,
|
||||
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"id": "1",
|
||||
"username": "Angela",
|
||||
"number": 1,
|
||||
"name": "Angela",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"text": "ANTE",
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"probability": 0.66749733001068
|
||||
},
|
||||
{
|
||||
"name":"Isaac King",
|
||||
"username":"IsaacKing",
|
||||
"text":"This answer",
|
||||
"userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2",
|
||||
"id":"2",
|
||||
"number":2,
|
||||
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"createdTime":1655261198074,
|
||||
"probability":0.008922214311142757
|
||||
"name": "Isaac King",
|
||||
"username": "IsaacKing",
|
||||
"text": "This answer",
|
||||
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
|
||||
"id": "2",
|
||||
"number": 2,
|
||||
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"createdTime": 1655261198074,
|
||||
"probability": 0.008922214311142757
|
||||
},
|
||||
{
|
||||
"createdTime":1655263226587,
|
||||
"userId":"jbgplxty4kUKIa1MmgZk22byJq03",
|
||||
"id":"3",
|
||||
"avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
|
||||
"text":"Toyota Camry",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"name":"Undox",
|
||||
"username":"Undox",
|
||||
"number":3,
|
||||
"probability":0.008966714133143469
|
||||
"createdTime": 1655263226587,
|
||||
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
|
||||
"id": "3",
|
||||
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
|
||||
"text": "Toyota Camry",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"name": "Undox",
|
||||
"username": "Undox",
|
||||
"number": 3,
|
||||
"probability": 0.008966714133143469
|
||||
},
|
||||
{
|
||||
"number":4,
|
||||
"name":"James Grugett",
|
||||
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"text":"Utility (Defined by your personal utility function.)",
|
||||
"createdTime":1655264793224,
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"username":"JamesGrugett",
|
||||
"id":"4",
|
||||
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
|
||||
"probability":0.09211463154147384
|
||||
"number": 4,
|
||||
"name": "James Grugett",
|
||||
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"text": "Utility (Defined by your personal utility function.)",
|
||||
"createdTime": 1655264793224,
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"username": "JamesGrugett",
|
||||
"id": "4",
|
||||
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
|
||||
"probability": 0.09211463154147384
|
||||
}
|
||||
],
|
||||
"comments":[
|
||||
"comments": [
|
||||
{
|
||||
"id":"ZdHIyfQazHyl8nI0ENS7",
|
||||
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"createdTime":1655265807433,
|
||||
"text":"ok what\ni did not resolve this intentionally",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"userName":"Angela",
|
||||
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"userUsername":"Angela"
|
||||
"id": "ZdHIyfQazHyl8nI0ENS7",
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"createdTime": 1655265807433,
|
||||
"text": "ok what\ni did not resolve this intentionally",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"userName": "Angela",
|
||||
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"userUsername": "Angela"
|
||||
},
|
||||
{
|
||||
"userName":"James Grugett",
|
||||
"userUsername":"JamesGrugett",
|
||||
"id":"F7fvHGhTiFal8uTsUc9P",
|
||||
"userAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c","replyToCommentId":"ZdHIyfQazHyl8nI0ENS7",
|
||||
"text":"@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
|
||||
"createdTime":1655266286514,
|
||||
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK"
|
||||
"userName": "James Grugett",
|
||||
"userUsername": "JamesGrugett",
|
||||
"id": "F7fvHGhTiFal8uTsUc9P",
|
||||
"userAvatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
|
||||
"replyToCommentId": "ZdHIyfQazHyl8nI0ENS7",
|
||||
"text": "@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
|
||||
"createdTime": 1655266286514,
|
||||
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK"
|
||||
},
|
||||
{
|
||||
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"id":"PIHhXy5hLHSgW8uoUD0Q",
|
||||
"userName":"Angela",
|
||||
"text":"lmk if anyone lost manna from this situation and i'll try to fix it",
|
||||
"userUsername":"Angela",
|
||||
"createdTime":1655277581308,
|
||||
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
|
||||
},{
|
||||
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"userName":"Angela",
|
||||
"text":"from my end it looks like no one did",
|
||||
"replyToCommentId":"PIHhXy5hLHSgW8uoUD0Q",
|
||||
"createdTime":1655287149528,
|
||||
"userUsername":"Angela",
|
||||
"id":"5slnWEQWwm6dHjDi6oiH",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"id": "PIHhXy5hLHSgW8uoUD0Q",
|
||||
"userName": "Angela",
|
||||
"text": "lmk if anyone lost manna from this situation and i'll try to fix it",
|
||||
"userUsername": "Angela",
|
||||
"createdTime": 1655277581308,
|
||||
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
|
||||
},
|
||||
{
|
||||
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
|
||||
"userName": "Angela",
|
||||
"text": "from my end it looks like no one did",
|
||||
"replyToCommentId": "PIHhXy5hLHSgW8uoUD0Q",
|
||||
"createdTime": 1655287149528,
|
||||
"userUsername": "Angela",
|
||||
"id": "5slnWEQWwm6dHjDi6oiH",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
|
||||
}
|
||||
],
|
||||
"bets":[
|
||||
"bets": [
|
||||
{
|
||||
"outcome":"0",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"fees":{
|
||||
"liquidityFee":0,
|
||||
"creatorFee":0,
|
||||
"platformFee":0
|
||||
"outcome": "0",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"fees": {
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0,
|
||||
"platformFee": 0
|
||||
},
|
||||
"isAnte":true,
|
||||
"shares":100,
|
||||
"probAfter":1,
|
||||
"amount":100,
|
||||
"userId":"IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"createdTime":1655258914863,
|
||||
"probBefore":0,
|
||||
"id":"2jNZqnwoEQL7WDTTAWDP"
|
||||
"isAnte": true,
|
||||
"shares": 100,
|
||||
"probAfter": 1,
|
||||
"amount": 100,
|
||||
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||
"createdTime": 1655258914863,
|
||||
"probBefore": 0,
|
||||
"id": "2jNZqnwoEQL7WDTTAWDP"
|
||||
},
|
||||
{
|
||||
"shares":173.20508075688772,
|
||||
"fees":{
|
||||
"platformFee":0,
|
||||
"liquidityFee":0,
|
||||
"creatorFee":0
|
||||
"shares": 173.20508075688772,
|
||||
"fees": {
|
||||
"platformFee": 0,
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0
|
||||
},
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"probBefore":0,
|
||||
"createdTime":1655258941573,
|
||||
"loanAmount":0,
|
||||
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"amount":100,
|
||||
"outcome":"1",
|
||||
"probAfter":0.75,
|
||||
"id":"xuc3JoiNkE8lXPh15mUb"
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"probBefore": 0,
|
||||
"createdTime": 1655258941573,
|
||||
"loanAmount": 0,
|
||||
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
|
||||
"amount": 100,
|
||||
"outcome": "1",
|
||||
"probAfter": 0.75,
|
||||
"id": "xuc3JoiNkE8lXPh15mUb"
|
||||
},
|
||||
{
|
||||
"userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"loanAmount":0,
|
||||
"probAfter":0.009925496893641248,
|
||||
"id":"8TBlzPtOdO0q5BgSyRbi",
|
||||
"createdTime":1655261198074,
|
||||
"shares":20.024984394500787,
|
||||
"amount":1,
|
||||
"outcome":"2",
|
||||
"probBefore":0,
|
||||
"fees":{
|
||||
"liquidityFee":0,
|
||||
"creatorFee":0,
|
||||
"platformFee":0
|
||||
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"loanAmount": 0,
|
||||
"probAfter": 0.009925496893641248,
|
||||
"id": "8TBlzPtOdO0q5BgSyRbi",
|
||||
"createdTime": 1655261198074,
|
||||
"shares": 20.024984394500787,
|
||||
"amount": 1,
|
||||
"outcome": "2",
|
||||
"probBefore": 0,
|
||||
"fees": {
|
||||
"liquidityFee": 0,
|
||||
"creatorFee": 0,
|
||||
"platformFee": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"probAfter":0.00987648269777473,
|
||||
"outcome":"3",
|
||||
"id":"9vdwes6s9QxbYZUBhHs4",
|
||||
"createdTime":1655263226587,
|
||||
"shares":20.074859899884732,
|
||||
"amount":1,
|
||||
"loanAmount":0,
|
||||
"fees":{
|
||||
"liquidityFee":0,
|
||||
"platformFee":0,
|
||||
"creatorFee":0
|
||||
"probAfter": 0.00987648269777473,
|
||||
"outcome": "3",
|
||||
"id": "9vdwes6s9QxbYZUBhHs4",
|
||||
"createdTime": 1655263226587,
|
||||
"shares": 20.074859899884732,
|
||||
"amount": 1,
|
||||
"loanAmount": 0,
|
||||
"fees": {
|
||||
"liquidityFee": 0,
|
||||
"platformFee": 0,
|
||||
"creatorFee": 0
|
||||
},
|
||||
"userId":"jbgplxty4kUKIa1MmgZk22byJq03",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"probBefore":0
|
||||
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"probBefore": 0
|
||||
},
|
||||
{
|
||||
"createdTime":1655264793224,
|
||||
"fees":{
|
||||
"creatorFee":0,
|
||||
"liquidityFee":0,
|
||||
"platformFee":0
|
||||
"createdTime": 1655264793224,
|
||||
"fees": {
|
||||
"creatorFee": 0,
|
||||
"liquidityFee": 0,
|
||||
"platformFee": 0
|
||||
},
|
||||
"probAfter":0.09211463154147384,
|
||||
"amount":10,
|
||||
"id":"BehiSGgk1wAkIWz1a8L4",
|
||||
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"contractId":"lEoqtnDgJzft6apSKzYK",
|
||||
"loanAmount":0,
|
||||
"probBefore":0,
|
||||
"outcome":"4",
|
||||
"shares":64.34283176858165
|
||||
"probAfter": 0.09211463154147384,
|
||||
"amount": 10,
|
||||
"id": "BehiSGgk1wAkIWz1a8L4",
|
||||
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
|
||||
"contractId": "lEoqtnDgJzft6apSKzYK",
|
||||
"loanAmount": 0,
|
||||
"probBefore": 0,
|
||||
"outcome": "4",
|
||||
"shares": 64.34283176858165
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -395,6 +398,65 @@ Requires no authorization.
|
|||
```
|
||||
- Response type: A `FullMarket` ; same as above.
|
||||
|
||||
### `GET /v0/users`
|
||||
|
||||
Lists all users.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
- Example request
|
||||
```
|
||||
https://manifold.markets/api/v0/users
|
||||
```
|
||||
- Example response
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id":"igi2zGXsfxYPgB0DJTXVJVmwCOr2",
|
||||
"createdTime":1639011767273,
|
||||
"name":"Austin",
|
||||
"username":"Austin",
|
||||
"url":"https://manifold.markets/Austin",
|
||||
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
|
||||
"bio":"I build Manifold! Always happy to chat; reach out on Discord or find a time on https://calendly.com/austinchen/manifold!",
|
||||
"bannerUrl":"https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1531&q=80",
|
||||
"website":"https://blog.austn.io",
|
||||
"twitterHandle":"akrolsmir",
|
||||
"discordHandle":"akrolsmir#4125",
|
||||
"balance":9122.607163564959,
|
||||
"totalDeposits":10339.004780544328,
|
||||
"totalPnLCached":9376.601262721899,
|
||||
"creatorVolumeCached":76078.46984199001
|
||||
}
|
||||
```
|
||||
- Response type: Array of `LiteUser`
|
||||
|
||||
```tsx
|
||||
// Basic information about a user
|
||||
type LiteUser = {
|
||||
id: string // user's unique id
|
||||
createdTime: number
|
||||
|
||||
name: string // display name, may contain spaces
|
||||
username: string // username, used in urls
|
||||
url: string // link to user's profile
|
||||
avatarUrl?: string
|
||||
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
||||
// Note: the following are here for convenience only and may be removed in the future.
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
totalPnLCached: number
|
||||
creatorVolumeCached: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### `POST /v0/bet`
|
||||
|
||||
Places a new bet on behalf of the authorized user.
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"functions": {
|
||||
"predeploy": "npm --prefix \"$RESOURCE_DIR\" run build",
|
||||
"runtime": "nodejs12",
|
||||
"source": "functions"
|
||||
"predeploy": "cd functions && yarn build",
|
||||
"runtime": "nodejs16",
|
||||
"source": "functions/dist"
|
||||
},
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
|
|
|
@ -1,5 +1,69 @@
|
|||
{
|
||||
"indexes": [
|
||||
{
|
||||
"collectionGroup": "bets",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isAnte",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRedemption",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "bets",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "comments",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "creatorId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -14,6 +78,20 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "autoResolutionTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -104,6 +182,24 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -118,6 +214,84 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "closeTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "ASCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "isResolved",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "volume7Days",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "lowercaseTags",
|
||||
"arrayConfig": "CONTAINS"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "contracts",
|
||||
"queryScope": "COLLECTION",
|
||||
|
@ -131,6 +305,38 @@
|
|||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "txns",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "toId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toType",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "manalinks",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "fromId",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdTime",
|
||||
"order": "DESCENDING"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": [
|
||||
|
@ -295,6 +501,50 @@
|
|||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "follows",
|
||||
"fieldPath": "userId",
|
||||
"indexes": [
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "DESCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"arrayConfig": "CONTAINS",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "portfolioHistory",
|
||||
"fieldPath": "timestamp",
|
||||
"indexes": [
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "DESCENDING",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"arrayConfig": "CONTAINS",
|
||||
"queryScope": "COLLECTION"
|
||||
},
|
||||
{
|
||||
"order": "ASCENDING",
|
||||
"queryScope": "COLLECTION_GROUP"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -12,13 +12,17 @@ service cloud.firestore {
|
|||
|| request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold
|
||||
}
|
||||
|
||||
match /stats/stats {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /users/{userId} {
|
||||
allow read;
|
||||
allow update: if resource.data.id == request.auth.uid
|
||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
|
||||
}
|
||||
|
||||
|
||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||
allow read;
|
||||
}
|
||||
|
@ -104,6 +108,16 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
// Note: `resource` = existing doc, `request.resource` = incoming doc
|
||||
match /manalinks/{slug} {
|
||||
// Anyone can view any manalink
|
||||
allow get;
|
||||
// Only you can create a manalink with your fromId
|
||||
allow create: if request.auth.uid == request.resource.data.fromId;
|
||||
// Only you can list and change your own manalinks
|
||||
allow list, update: if request.auth.uid == resource.data.fromId;
|
||||
}
|
||||
|
||||
match /users/{userId}/notifications/{notificationId} {
|
||||
allow read;
|
||||
allow update: if resource.data.userId == request.auth.uid
|
||||
|
|
6
functions/.gitignore
vendored
6
functions/.gitignore
vendored
|
@ -2,9 +2,11 @@
|
|||
.env*
|
||||
.runtimeconfig.json
|
||||
|
||||
# GCP deployment artifact
|
||||
dist/
|
||||
|
||||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
|
|
@ -52,7 +52,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
|
|||
## Deploying
|
||||
|
||||
0. `$ firebase use prod` to switch to prod
|
||||
1. `$ yarn deploy` to push your changes live!
|
||||
1. `$ firebase deploy --only functions` to push your changes live!
|
||||
(Future TODO: auto-deploy functions on Git push)
|
||||
|
||||
## Secrets management
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
"firestore": "dev-mantic-markets.appspot.com"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist",
|
||||
"compile": "tsc -b",
|
||||
"watch": "tsc -w",
|
||||
"shell": "yarn build && firebase functions:shell",
|
||||
"start": "yarn shell",
|
||||
|
@ -18,7 +19,7 @@
|
|||
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
||||
"verify": "(cd .. && yarn verify)"
|
||||
},
|
||||
"main": "lib/functions/src/index.js",
|
||||
"main": "functions/src/index.js",
|
||||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"fetch": "1.1.0",
|
||||
|
|
|
@ -110,6 +110,9 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||
|
||||
const DEFAULT_OPTS: HttpsOptions = {
|
||||
minInstances: 1,
|
||||
concurrency: 100,
|
||||
memory: '2GiB',
|
||||
cpu: 1,
|
||||
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||
}
|
||||
|
||||
|
|
102
functions/src/claim-manalink.ts
Normal file
102
functions/src/claim-manalink.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { User } from 'common/user'
|
||||
import { Manalink } from 'common/manalink'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
|
||||
export const claimManalink = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(async (slug: string, context) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
// Look up the manalink
|
||||
const manalinkDoc = firestore.doc(`manalinks/${slug}`)
|
||||
const manalinkSnap = await transaction.get(manalinkDoc)
|
||||
if (!manalinkSnap.exists) {
|
||||
return { status: 'error', message: 'Manalink not found' }
|
||||
}
|
||||
const manalink = manalinkSnap.data() as Manalink
|
||||
|
||||
const { amount, fromId, claimedUserIds } = manalink
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||
const fromSnap = await transaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: `User ${fromId} not found` }
|
||||
}
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
// Only permit one redemption per user per link
|
||||
if (claimedUserIds.includes(userId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${fromUser.name} already redeemed manalink ${slug}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Disallow expired or maxed out links
|
||||
if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Manalink ${slug} expired on ${new Date(
|
||||
manalink.expiresTime
|
||||
).toLocaleString()}`,
|
||||
}
|
||||
}
|
||||
if (
|
||||
manalink.maxUses != null &&
|
||||
manalink.maxUses <= manalink.claims.length
|
||||
) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (fromUser.balance < amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
// Actually execute the txn
|
||||
const data: TxnData = {
|
||||
fromId,
|
||||
fromType: 'USER',
|
||||
toId: userId,
|
||||
toType: 'USER',
|
||||
amount,
|
||||
token: 'M$',
|
||||
category: 'MANALINK',
|
||||
description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`,
|
||||
}
|
||||
const result = await runTxn(transaction, data)
|
||||
const txnId = result.txn?.id
|
||||
if (!txnId) {
|
||||
return { status: 'error', message: result.message }
|
||||
}
|
||||
|
||||
// Update the manalink object with this info
|
||||
const claim = {
|
||||
toId: userId,
|
||||
txnId,
|
||||
claimedTime: Date.now(),
|
||||
}
|
||||
transaction.update(manalinkDoc, {
|
||||
claimedUserIds: [...claimedUserIds, userId],
|
||||
claims: [...manalink.claims, claim],
|
||||
})
|
||||
|
||||
return { status: 'success', message: 'Manalink claimed' }
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -22,7 +22,6 @@ import {
|
|||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getNumericAnte,
|
||||
HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||
} from '../../common/antes'
|
||||
import { getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
|
@ -64,46 +63,42 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
|||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
}
|
||||
|
||||
// Uses utc time on server:
|
||||
const today = new Date()
|
||||
let freeMarketResetTime = new Date().setUTCHours(16, 0, 0, 0)
|
||||
if (today.getTime() < freeMarketResetTime) {
|
||||
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||
if (!userDoc.exists) {
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
}
|
||||
const user = userDoc.data() as User
|
||||
|
||||
const ante = FIXED_ANTE
|
||||
|
||||
// TODO: this is broken because it's not in a transaction
|
||||
if (ante > user.balance)
|
||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||
|
||||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
|
||||
let group = null
|
||||
if (groupId) {
|
||||
const groupDoc = await firestore.collection('groups').doc(groupId).get()
|
||||
const groupDocRef = await firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
}
|
||||
|
||||
group = groupDoc.data() as Group
|
||||
if (!group.memberIds.includes(user.id)) {
|
||||
throw new APIError(400, 'User is not a member of the group.')
|
||||
throw new APIError(
|
||||
400,
|
||||
'User must be a member of the group to add markets to it.'
|
||||
)
|
||||
}
|
||||
if (!group.contractIds.includes(contractRef.id))
|
||||
await groupDocRef.update({
|
||||
contractIds: [...group.contractIds, contractRef.id],
|
||||
})
|
||||
}
|
||||
|
||||
const userContractsCreatedTodaySnapshot = await firestore
|
||||
.collection(`contracts`)
|
||||
.where('creatorId', '==', auth.uid)
|
||||
.where('createdTime', '>=', freeMarketResetTime)
|
||||
.get()
|
||||
console.log('free market reset time: ', freeMarketResetTime)
|
||||
const isFree = userContractsCreatedTodaySnapshot.size === 0
|
||||
|
||||
const ante = FIXED_ANTE
|
||||
|
||||
// TODO: this is broken because it's not in a transaction
|
||||
if (ante > user.balance && !isFree)
|
||||
throw new APIError(400, `Balance must be at least ${ante}.`)
|
||||
|
||||
console.log(
|
||||
'creating contract for',
|
||||
user.username,
|
||||
|
@ -113,8 +108,6 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
|||
ante || 0
|
||||
)
|
||||
|
||||
const slug = await getSlug(question)
|
||||
const contractRef = firestore.collection('contracts').doc()
|
||||
const contract = getNewContract(
|
||||
contractRef.id,
|
||||
slug,
|
||||
|
@ -128,21 +121,14 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
|||
tags ?? [],
|
||||
NUMERIC_BUCKET_COUNT,
|
||||
min ?? 0,
|
||||
max ?? 0,
|
||||
group
|
||||
? {
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
groupSlug: group.slug,
|
||||
}
|
||||
: undefined
|
||||
max ?? 0
|
||||
)
|
||||
|
||||
if (!isFree && ante) await chargeUser(user.id, ante, true)
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
||||
await contractRef.create(contract)
|
||||
|
||||
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id
|
||||
const providerId = user.id
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
const liquidityDoc = firestore
|
||||
|
|
|
@ -4,6 +4,7 @@ admin.initializeApp()
|
|||
|
||||
// v1
|
||||
// export * from './keep-awake'
|
||||
export * from './claim-manalink'
|
||||
export * from './transact'
|
||||
export * from './resolve-market'
|
||||
export * from './stripe'
|
||||
|
@ -14,6 +15,7 @@ export * from './on-create-comment'
|
|||
export * from './on-view'
|
||||
export * from './unsubscribe'
|
||||
export * from './update-metrics'
|
||||
export * from './update-stats'
|
||||
export * from './backup-db'
|
||||
export * from './change-user-info'
|
||||
export * from './market-close-notifications'
|
||||
|
|
15
functions/src/scripts/update-stats.ts
Normal file
15
functions/src/scripts/update-stats.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { initAdmin } from './script-init'
|
||||
initAdmin()
|
||||
|
||||
import { log, logMemory } from '../utils'
|
||||
import { updateStatsCore } from '../update-stats'
|
||||
|
||||
async function updateStats() {
|
||||
logMemory()
|
||||
log('Updating stats...')
|
||||
await updateStatsCore()
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
updateStats().then(() => process.exit())
|
||||
}
|
|
@ -5,23 +5,15 @@ import { User } from '../../common/user'
|
|||
import { Txn } from '../../common/txn'
|
||||
import { removeUndefinedProps } from '../../common/util/object'
|
||||
|
||||
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
|
||||
|
||||
export const transact = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => {
|
||||
.https.onCall(async (data: TxnData, context) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const {
|
||||
amount,
|
||||
fromType,
|
||||
fromId,
|
||||
toId,
|
||||
toType,
|
||||
category,
|
||||
token,
|
||||
data: innerData,
|
||||
description,
|
||||
} = data
|
||||
const { amount, fromType, fromId } = data
|
||||
|
||||
if (fromType !== 'USER')
|
||||
return {
|
||||
|
@ -40,69 +32,53 @@ export const transact = functions
|
|||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const fromDoc = firestore.doc(`users/${userId}`)
|
||||
const fromSnap = await transaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
if (amount > 0 && fromUser.balance < amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === 'USER') {
|
||||
const toDoc = firestore.doc(`users/${toId}`)
|
||||
const toSnap = await transaction.get(toDoc)
|
||||
if (!toSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const toUser = toSnap.data() as User
|
||||
if (amount < 0 && toUser.balance < -amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${
|
||||
toUser.username
|
||||
} needed ${-amount} but only had ${toUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
transaction.update(toDoc, {
|
||||
balance: toUser.balance + amount,
|
||||
totalDeposits: toUser.totalDeposits + amount,
|
||||
})
|
||||
}
|
||||
|
||||
const newTxnDoc = firestore.collection(`txns/`).doc()
|
||||
|
||||
const txn = removeUndefinedProps({
|
||||
id: newTxnDoc.id,
|
||||
createdTime: Date.now(),
|
||||
|
||||
fromId,
|
||||
fromType,
|
||||
toId,
|
||||
toType,
|
||||
|
||||
amount,
|
||||
category,
|
||||
data: innerData,
|
||||
token,
|
||||
|
||||
description,
|
||||
})
|
||||
|
||||
transaction.create(newTxnDoc, txn)
|
||||
transaction.update(fromDoc, {
|
||||
balance: fromUser.balance - amount,
|
||||
totalDeposits: fromUser.totalDeposits - amount,
|
||||
})
|
||||
|
||||
return { status: 'success', txn }
|
||||
await runTxn(transaction, data)
|
||||
})
|
||||
})
|
||||
|
||||
export async function runTxn(
|
||||
fbTransaction: admin.firestore.Transaction,
|
||||
data: TxnData
|
||||
) {
|
||||
const { amount, fromId, toId, toType } = data
|
||||
|
||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||
const fromSnap = await fbTransaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
if (fromUser.balance < amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Track payments received by charities, bank, contracts too.
|
||||
if (toType === 'USER') {
|
||||
const toDoc = firestore.doc(`users/${toId}`)
|
||||
const toSnap = await fbTransaction.get(toDoc)
|
||||
if (!toSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const toUser = toSnap.data() as User
|
||||
fbTransaction.update(toDoc, {
|
||||
balance: toUser.balance + amount,
|
||||
totalDeposits: toUser.totalDeposits + amount,
|
||||
})
|
||||
}
|
||||
|
||||
const newTxnDoc = firestore.collection(`txns/`).doc()
|
||||
const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
|
||||
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
|
||||
fbTransaction.update(fromDoc, {
|
||||
balance: fromUser.balance - amount,
|
||||
totalDeposits: fromUser.totalDeposits - amount,
|
||||
})
|
||||
|
||||
return { status: 'success', txn }
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
316
functions/src/update-stats.ts
Normal file
316
functions/src/update-stats.ts
Normal file
|
@ -0,0 +1,316 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash'
|
||||
import { getValues, log, logMemory } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { User } from '../../common/user'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { average } from '../../common/util/math'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const numberOfDays = 90
|
||||
|
||||
const getBetsQuery = (startTime: number, endTime: number) =>
|
||||
firestore
|
||||
.collectionGroup('bets')
|
||||
.where('createdTime', '>=', startTime)
|
||||
.where('createdTime', '<', endTime)
|
||||
.orderBy('createdTime', 'asc')
|
||||
|
||||
export async function getDailyBets(startTime: number, numberOfDays: number) {
|
||||
const query = getBetsQuery(startTime, startTime + DAY_MS * numberOfDays)
|
||||
const bets = await getValues<Bet>(query)
|
||||
|
||||
const betsByDay = range(0, numberOfDays).map(() => [] as Bet[])
|
||||
for (const bet of bets) {
|
||||
const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_MS)
|
||||
betsByDay[dayIndex].push(bet)
|
||||
}
|
||||
|
||||
return betsByDay
|
||||
}
|
||||
|
||||
const getCommentsQuery = (startTime: number, endTime: number) =>
|
||||
firestore
|
||||
.collectionGroup('comments')
|
||||
.where('createdTime', '>=', startTime)
|
||||
.where('createdTime', '<', endTime)
|
||||
.orderBy('createdTime', 'asc')
|
||||
|
||||
export async function getDailyComments(
|
||||
startTime: number,
|
||||
numberOfDays: number
|
||||
) {
|
||||
const query = getCommentsQuery(startTime, startTime + DAY_MS * numberOfDays)
|
||||
const comments = await getValues<Comment>(query)
|
||||
|
||||
const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[])
|
||||
for (const comment of comments) {
|
||||
const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_MS)
|
||||
commentsByDay[dayIndex].push(comment)
|
||||
}
|
||||
|
||||
return commentsByDay
|
||||
}
|
||||
|
||||
const getContractsQuery = (startTime: number, endTime: number) =>
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.where('createdTime', '>=', startTime)
|
||||
.where('createdTime', '<', endTime)
|
||||
.orderBy('createdTime', 'asc')
|
||||
|
||||
export async function getDailyContracts(
|
||||
startTime: number,
|
||||
numberOfDays: number
|
||||
) {
|
||||
const query = getContractsQuery(startTime, startTime + DAY_MS * numberOfDays)
|
||||
const contracts = await getValues<Contract>(query)
|
||||
|
||||
const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[])
|
||||
for (const contract of contracts) {
|
||||
const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_MS)
|
||||
contractsByDay[dayIndex].push(contract)
|
||||
}
|
||||
|
||||
return contractsByDay
|
||||
}
|
||||
|
||||
const getUsersQuery = (startTime: number, endTime: number) =>
|
||||
firestore
|
||||
.collection('users')
|
||||
.where('createdTime', '>=', startTime)
|
||||
.where('createdTime', '<', endTime)
|
||||
.orderBy('createdTime', 'asc')
|
||||
|
||||
export async function getDailyNewUsers(
|
||||
startTime: number,
|
||||
numberOfDays: number
|
||||
) {
|
||||
const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays)
|
||||
const users = await getValues<User>(query)
|
||||
|
||||
const usersByDay = range(0, numberOfDays).map(() => [] as User[])
|
||||
for (const user of users) {
|
||||
const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS)
|
||||
usersByDay[dayIndex].push(user)
|
||||
}
|
||||
|
||||
return usersByDay
|
||||
}
|
||||
|
||||
export const updateStatsCore = async () => {
|
||||
const today = Date.now()
|
||||
const startDate = today - numberOfDays * DAY_MS
|
||||
|
||||
log('Fetching data for stats update...')
|
||||
const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] =
|
||||
await Promise.all([
|
||||
getDailyBets(startDate.valueOf(), numberOfDays),
|
||||
getDailyContracts(startDate.valueOf(), numberOfDays),
|
||||
getDailyComments(startDate.valueOf(), numberOfDays),
|
||||
getDailyNewUsers(startDate.valueOf(), numberOfDays),
|
||||
])
|
||||
logMemory()
|
||||
|
||||
const dailyBetCounts = dailyBets.map((bets) => bets.length)
|
||||
const dailyContractCounts = dailyContracts.map(
|
||||
(contracts) => contracts.length
|
||||
)
|
||||
const dailyCommentCounts = dailyComments.map((comments) => comments.length)
|
||||
|
||||
const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map(
|
||||
([contracts, bets, comments]) => {
|
||||
const creatorIds = (contracts ?? []).map((c) => c.creatorId)
|
||||
const betUserIds = (bets ?? []).map((bet) => bet.userId)
|
||||
const commentUserIds = (comments ?? []).map((comment) => comment.userId)
|
||||
return uniq([...creatorIds, ...betUserIds, ...commentUserIds])
|
||||
}
|
||||
)
|
||||
log(
|
||||
`Fetched ${sum(dailyBetCounts)} bets, ${sum(
|
||||
dailyContractCounts
|
||||
)} contracts, ${sum(dailyComments)} comments, from ${sum(
|
||||
dailyNewUsers
|
||||
)} unique users.`
|
||||
)
|
||||
|
||||
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
|
||||
|
||||
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
||||
return uniques.size
|
||||
})
|
||||
|
||||
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
||||
return uniques.size
|
||||
})
|
||||
|
||||
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
|
||||
const twoWeeksAgo = {
|
||||
start: Math.max(0, i - 13),
|
||||
end: Math.max(0, i - 7),
|
||||
}
|
||||
const lastWeek = {
|
||||
start: Math.max(0, i - 6),
|
||||
end: i,
|
||||
}
|
||||
|
||||
const activeTwoWeeksAgo = new Set<string>()
|
||||
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
|
||||
}
|
||||
const activeLastWeek = new Set<string>()
|
||||
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
|
||||
}
|
||||
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
|
||||
activeLastWeek.has(userId) ? 1 : 0
|
||||
)
|
||||
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
|
||||
return Math.round(retainedFrac * 100 * 100) / 100
|
||||
})
|
||||
|
||||
const monthlyRetention = dailyUserIds.map((_userId, i) => {
|
||||
const twoMonthsAgo = {
|
||||
start: Math.max(0, i - 60),
|
||||
end: Math.max(0, i - 30),
|
||||
}
|
||||
const lastMonth = {
|
||||
start: Math.max(0, i - 30),
|
||||
end: i,
|
||||
}
|
||||
|
||||
const activeTwoMonthsAgo = new Set<string>()
|
||||
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
|
||||
}
|
||||
const activeLastMonth = new Set<string>()
|
||||
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
|
||||
}
|
||||
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
|
||||
activeLastMonth.has(userId) ? 1 : 0
|
||||
)
|
||||
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
|
||||
return Math.round(retainedFrac * 100 * 100) / 100
|
||||
})
|
||||
|
||||
const firstBetDict: { [userId: string]: number } = {}
|
||||
for (let i = 0; i < dailyBets.length; i++) {
|
||||
const bets = dailyBets[i]
|
||||
for (const bet of bets) {
|
||||
if (bet.userId in firstBetDict) continue
|
||||
firstBetDict[bet.userId] = i
|
||||
}
|
||||
}
|
||||
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
let activatedCount = 0
|
||||
let newUsers = 0
|
||||
for (let j = start; j <= end; j++) {
|
||||
const userIds = dailyNewUsers[j].map((user) => user.id)
|
||||
newUsers += userIds.length
|
||||
for (const userId of userIds) {
|
||||
const dayIndex = firstBetDict[userId]
|
||||
if (dayIndex !== undefined && dayIndex <= end) {
|
||||
activatedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
const frac = activatedCount / (newUsers || 1)
|
||||
return Math.round(frac * 100 * 100) / 100
|
||||
})
|
||||
const dailySignups = dailyNewUsers.map((users) => users.length)
|
||||
|
||||
const dailyTopTenthActions = zip(
|
||||
dailyContracts,
|
||||
dailyBets,
|
||||
dailyComments
|
||||
).map(([contracts, bets, comments]) => {
|
||||
const userIds = concat(
|
||||
contracts?.map((c) => c.creatorId) ?? [],
|
||||
bets?.map((b) => b.userId) ?? [],
|
||||
comments?.map((c) => c.userId) ?? []
|
||||
)
|
||||
const counts = Object.values(countBy(userIds))
|
||||
const sortedCounts = sortBy(counts, (count) => count).reverse()
|
||||
if (sortedCounts.length === 0) return 0
|
||||
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
|
||||
return tenthPercentile
|
||||
})
|
||||
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
|
||||
// Total mana divided by 100.
|
||||
const dailyManaBet = dailyBets.map((bets) => {
|
||||
return Math.round(sumBy(bets, (bet) => bet.amount) / 100)
|
||||
})
|
||||
const weeklyManaBet = dailyManaBet.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
if (end - start < 7) return (total * 7) / (end - start)
|
||||
return total
|
||||
})
|
||||
const monthlyManaBet = dailyManaBet.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
const range = end - start + 1
|
||||
if (range < 30) return (total * 30) / range
|
||||
return total
|
||||
})
|
||||
|
||||
const statsData = {
|
||||
startDate: startDate.valueOf(),
|
||||
dailyActiveUsers,
|
||||
weeklyActiveUsers,
|
||||
monthlyActiveUsers,
|
||||
dailyBetCounts,
|
||||
dailyContractCounts,
|
||||
dailyCommentCounts,
|
||||
dailySignups,
|
||||
weekOnWeekRetention,
|
||||
weeklyActivationRate,
|
||||
monthlyRetention,
|
||||
topTenthActions: {
|
||||
daily: dailyTopTenthActions,
|
||||
weekly: weeklyTopTenthActions,
|
||||
monthly: monthlyTopTenthActions,
|
||||
},
|
||||
manaBet: {
|
||||
daily: dailyManaBet,
|
||||
weekly: weeklyManaBet,
|
||||
monthly: monthlyManaBet,
|
||||
},
|
||||
}
|
||||
log('Computed stats: ', statsData)
|
||||
await firestore.doc('stats/stats').set(statsData)
|
||||
}
|
||||
|
||||
export const updateStats = functions
|
||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 60 minutes')
|
||||
.onRun(updateStatsCore)
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "../",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
|
@ -8,6 +9,11 @@
|
|||
"strict": true,
|
||||
"target": "es2017"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
"compileOnSave": true,
|
||||
"include": ["src", "../common/**/*.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { findBestMatch } from 'string-similarity'
|
||||
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { BuyAmountInput } from '../amount-input'
|
||||
|
@ -23,6 +24,7 @@ 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'
|
||||
import { lowerCase } from 'lodash'
|
||||
|
||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||
const { contract } = props
|
||||
|
@ -30,9 +32,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
const [text, setText] = useState('')
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
||||
const [amountError, setAmountError] = useState<string | undefined>()
|
||||
const [answerError, setAnswerError] = useState<string | undefined>()
|
||||
const [possibleDuplicateAnswer, setPossibleDuplicateAnswer] = useState<
|
||||
string | undefined
|
||||
>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { answers } = contract
|
||||
|
||||
const canSubmit = text && betAmount && !amountError && !isSubmitting
|
||||
const canSubmit =
|
||||
text && betAmount && !amountError && !isSubmitting && !answerError
|
||||
|
||||
const submitAnswer = async () => {
|
||||
if (canSubmit) {
|
||||
|
@ -54,6 +62,36 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
}
|
||||
}
|
||||
|
||||
const changeAnswer = (text: string) => {
|
||||
setText(text)
|
||||
const existingAnswer = answers.find(
|
||||
(a) => lowerCase(a.text) === lowerCase(text)
|
||||
)
|
||||
|
||||
if (existingAnswer) {
|
||||
setAnswerError(
|
||||
existingAnswer
|
||||
? `"${existingAnswer.text}" already exists as an answer`
|
||||
: ''
|
||||
)
|
||||
return
|
||||
} else {
|
||||
setAnswerError('')
|
||||
}
|
||||
|
||||
if (answers.length && text) {
|
||||
const matches = findBestMatch(
|
||||
lowerCase(text),
|
||||
answers.map((a) => lowerCase(a.text))
|
||||
)
|
||||
setPossibleDuplicateAnswer(
|
||||
matches.bestMatch.rating > 0.8
|
||||
? answers[matches.bestMatchIndex].text
|
||||
: ''
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resultProb = getDpmOutcomeProbabilityAfterBet(
|
||||
contract.totalShares,
|
||||
'new',
|
||||
|
@ -79,12 +117,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onChange={(e) => changeAnswer(e.target.value)}
|
||||
className="textarea textarea-bordered w-full resize-none"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
/>
|
||||
{answerError ? (
|
||||
<AnswerError key={1} level="error" text={answerError} />
|
||||
) : possibleDuplicateAnswer ? (
|
||||
<AnswerError
|
||||
key={2}
|
||||
level="warning"
|
||||
text={`Did you mean to bet on "${possibleDuplicateAnswer}"?`}
|
||||
/>
|
||||
) : undefined}
|
||||
<div />
|
||||
<Col
|
||||
className={clsx(
|
||||
|
@ -163,3 +210,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
type answerErrorLevel = 'warning' | 'error'
|
||||
|
||||
const AnswerError = (props: { text: string; level: answerErrorLevel }) => {
|
||||
const { text, level } = props
|
||||
const colorClass =
|
||||
{
|
||||
error: 'text-red-500',
|
||||
warning: 'text-orange-500',
|
||||
}[level] ?? ''
|
||||
return (
|
||||
<div
|
||||
className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Router from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { MouseEvent } from 'react'
|
||||
import { UserCircleIcon } from '@heroicons/react/solid'
|
||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
|
||||
export function Avatar(props: {
|
||||
username?: string
|
||||
|
@ -45,3 +45,17 @@ export function Avatar(props: {
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
|
||||
const { size = 8, multi } = props
|
||||
const insize = size - 3
|
||||
const Icon = multi ? UsersIcon : UserIcon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`}
|
||||
>
|
||||
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ import { ContractsGrid } from './contract/contracts-list'
|
|||
import { Row } from './layout/row'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { ENV } from 'common/envs/constants'
|
||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { EditCategoriesButton } from './feed/category-selector'
|
||||
|
@ -28,6 +28,7 @@ import { Tabs } from './layout/tabs'
|
|||
import { EditFollowingButton } from './following-button'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
'GJQPAYENIF',
|
||||
|
@ -115,12 +116,21 @@ export function ContractSearch(props: {
|
|||
showCategorySelector,
|
||||
mode,
|
||||
Object.values(additionalFilter ?? {}).join(','),
|
||||
followedCategories?.join(','),
|
||||
follows?.join(','),
|
||||
(followedCategories ?? []).join(','),
|
||||
(follows ?? []).join(','),
|
||||
])
|
||||
|
||||
const indexName = `${indexPrefix}contracts-${sort}`
|
||||
|
||||
if (IS_PRIVATE_MANIFOLD) {
|
||||
return (
|
||||
<ContractSearchFirestore
|
||||
querySortOptions={querySortOptions}
|
||||
additionalFilter={additionalFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
||||
<Row className="gap-1 sm:gap-2">
|
||||
|
@ -233,12 +243,18 @@ export function ContractSearchInner(props: {
|
|||
|
||||
if (isInitialLoad && contracts.length === 0) return <></>
|
||||
|
||||
const showTime = index.endsWith('close-date')
|
||||
? 'close-date'
|
||||
: index.endsWith('resolve-date')
|
||||
? 'resolve-date'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<ContractsGrid
|
||||
contracts={contracts}
|
||||
loadMore={showMore}
|
||||
hasMore={!isLastPage}
|
||||
showCloseTime={index.endsWith('close-date')}
|
||||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
FreeResponseOutcomeLabel,
|
||||
} from '../outcome-label'
|
||||
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
|
||||
import { AvatarDetails, MiscDetails } from './contract-details'
|
||||
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
|
@ -28,13 +28,12 @@ import { trackCallback } from 'web/lib/service/analytics'
|
|||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showCloseTime?: boolean
|
||||
showTime?: ShowTime
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
hideQuickBet?: boolean
|
||||
}) {
|
||||
const { showHotVolume, showCloseTime, className, onClick, hideQuickBet } =
|
||||
props
|
||||
const { showHotVolume, showTime, className, onClick, hideQuickBet } = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const { question, outcomeType } = contract
|
||||
const { resolution } = contract
|
||||
|
@ -118,7 +117,7 @@ export function ContractCard(props: {
|
|||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
showTime={showTime}
|
||||
/>
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
|
|
|
@ -28,15 +28,25 @@ import { UserFollowButton } from '../follow-button'
|
|||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
export function MiscDetails(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showCloseTime?: boolean
|
||||
showTime?: ShowTime
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime } = props
|
||||
const { volume, volume24Hours, closeTime, tags, isResolved, createdTime } =
|
||||
contract
|
||||
const { contract, showHotVolume, showTime } = props
|
||||
const {
|
||||
volume,
|
||||
volume24Hours,
|
||||
closeTime,
|
||||
tags,
|
||||
isResolved,
|
||||
createdTime,
|
||||
resolutionTime,
|
||||
} = contract
|
||||
// Show at most one category that this contract is tagged by
|
||||
const categories = CATEGORY_LIST.filter((category) =>
|
||||
tags.map((t) => t.toLowerCase()).includes(category)
|
||||
|
@ -49,12 +59,18 @@ export function MiscDetails(props: {
|
|||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showCloseTime ? (
|
||||
) : showTime === 'close-date' ? (
|
||||
<Row className="gap-0.5">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
{fromNow(closeTime || 0)}
|
||||
</Row>
|
||||
) : showTime === 'resolve-date' && resolutionTime !== undefined ? (
|
||||
<Row className="gap-0.5">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{'Resolved '}
|
||||
{fromNow(resolutionTime || 0)}
|
||||
</Row>
|
||||
) : volume > 0 || !isNew ? (
|
||||
<Row>{contractPool(contract)} pool</Row>
|
||||
) : (
|
||||
|
@ -87,9 +103,9 @@ export function AvatarDetails(props: { contract: Contract }) {
|
|||
export function AbbrContractDetails(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showCloseTime?: boolean
|
||||
showTime?: ShowTime
|
||||
}) {
|
||||
const { contract, showHotVolume, showCloseTime } = props
|
||||
const { contract, showHotVolume, showTime } = props
|
||||
return (
|
||||
<Row className="items-center justify-between">
|
||||
<AvatarDetails contract={contract} />
|
||||
|
@ -97,7 +113,7 @@ export function AbbrContractDetails(props: {
|
|||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showCloseTime={showCloseTime}
|
||||
showTime={showTime}
|
||||
/>
|
||||
</Row>
|
||||
)
|
||||
|
@ -110,10 +126,10 @@ export function ContractDetails(props: {
|
|||
disabled?: boolean
|
||||
}) {
|
||||
const { contract, bets, isCreator, disabled } = props
|
||||
const { closeTime, creatorName, creatorUsername, creatorId, groupDetails } =
|
||||
contract
|
||||
const { closeTime, creatorName, creatorUsername, creatorId } = contract
|
||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||
|
||||
// Find a group that this contract id is in
|
||||
const groups = useGroupsWithContract(contract.id)
|
||||
return (
|
||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||
<Row className="items-center gap-2">
|
||||
|
@ -134,11 +150,12 @@ export function ContractDetails(props: {
|
|||
)}
|
||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||
</Row>
|
||||
{groupDetails && (
|
||||
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/}
|
||||
{groups && groups.length > 0 && (
|
||||
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
|
||||
<SiteLink href={`${groupPath(groupDetails[0].groupSlug)}`}>
|
||||
<SiteLink href={`${groupPath(groups[0].slug)}`}>
|
||||
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
|
||||
<span>{groupDetails[0].groupName}</span>
|
||||
<span>{groups[0].name}</span>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
)}
|
||||
|
|
|
@ -56,7 +56,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
|
||||
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
|
||||
const points: { x: Date; y: number }[] = []
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
for (let i = 0; i < times.length - 1; i++) {
|
||||
points[points.length] = { x: times[i], y: probs[i] * 100 }
|
||||
const numPoints: number = Math.floor(
|
||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
||||
|
|
|
@ -8,15 +8,17 @@ import { Spacer } from '../layout/spacer'
|
|||
import { Tabs } from '../layout/tabs'
|
||||
import { Col } from '../layout/col'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
user: User | null | undefined
|
||||
bets: Bet[]
|
||||
liquidityProvisions: LiquidityProvision[]
|
||||
comments: Comment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, bets, comments, tips } = props
|
||||
const { contract, user, bets, comments, tips, liquidityProvisions } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||
|
@ -25,6 +27,7 @@ export function ContractTabs(props: {
|
|||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
@ -38,6 +41,7 @@ export function ContractTabs(props: {
|
|||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
@ -55,6 +59,7 @@ export function ContractTabs(props: {
|
|||
<ContractActivity
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
comments={comments}
|
||||
tips={tips}
|
||||
user={user}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { User } from '../../lib/firebase/users'
|
|||
import { Col } from '../layout/col'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { ContractCard } from './contract-card'
|
||||
import { ShowTime } from './contract-details'
|
||||
import { ContractSearch } from '../contract-search'
|
||||
import { useIsVisible } from 'web/hooks/use-is-visible'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
@ -12,14 +13,14 @@ export function ContractsGrid(props: {
|
|||
contracts: Contract[]
|
||||
loadMore: () => void
|
||||
hasMore: boolean
|
||||
showCloseTime?: boolean
|
||||
showTime?: ShowTime
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
}) {
|
||||
const {
|
||||
contracts,
|
||||
showCloseTime,
|
||||
showTime,
|
||||
hasMore,
|
||||
loadMore,
|
||||
onContractClick,
|
||||
|
@ -60,7 +61,7 @@ export function ContractsGrid(props: {
|
|||
<ContractCard
|
||||
contract={contract}
|
||||
key={contract.id}
|
||||
showCloseTime={showCloseTime}
|
||||
showTime={showTime}
|
||||
onClick={
|
||||
onContractClick ? () => onContractClick(contract) : undefined
|
||||
}
|
||||
|
|
|
@ -325,14 +325,6 @@ export function getColor(contract: Contract) {
|
|||
)
|
||||
}
|
||||
|
||||
if (contract.outcomeType === 'NUMERIC') {
|
||||
return 'blue-400'
|
||||
}
|
||||
|
||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
||||
return 'blue-400'
|
||||
}
|
||||
|
||||
if ((contract.closeTime ?? Infinity) < Date.now()) {
|
||||
return 'gray-400'
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Comment } from 'common/comment'
|
|||
import { Contract, FreeResponseContract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export type ActivityItem =
|
||||
| DescriptionItem
|
||||
|
@ -17,6 +18,7 @@ export type ActivityItem =
|
|||
| ResolveItem
|
||||
| CommentInputItem
|
||||
| CommentThreadItem
|
||||
| LiquidityItem
|
||||
|
||||
type BaseActivityItem = {
|
||||
id: string
|
||||
|
@ -72,6 +74,14 @@ export type ResolveItem = BaseActivityItem & {
|
|||
type: 'resolve'
|
||||
}
|
||||
|
||||
export type LiquidityItem = BaseActivityItem & {
|
||||
type: 'liquidity'
|
||||
liquidity: LiquidityProvision
|
||||
hideOutcome: boolean
|
||||
smallAvatar: boolean
|
||||
hideComment?: boolean
|
||||
}
|
||||
|
||||
function getAnswerAndCommentInputGroups(
|
||||
contract: FreeResponseContract,
|
||||
bets: Bet[],
|
||||
|
@ -139,6 +149,7 @@ export function getSpecificContractActivityItems(
|
|||
contract: Contract,
|
||||
bets: Bet[],
|
||||
comments: Comment[],
|
||||
liquidityProvisions: LiquidityProvision[],
|
||||
tips: CommentTipMap,
|
||||
user: User | null | undefined,
|
||||
options: {
|
||||
|
@ -146,7 +157,7 @@ export function getSpecificContractActivityItems(
|
|||
}
|
||||
) {
|
||||
const { mode } = options
|
||||
const items = [] as ActivityItem[]
|
||||
let items = [] as ActivityItem[]
|
||||
|
||||
switch (mode) {
|
||||
case 'bets':
|
||||
|
@ -163,6 +174,23 @@ export function getSpecificContractActivityItems(
|
|||
hideComment: true,
|
||||
}))
|
||||
)
|
||||
items.push(
|
||||
...liquidityProvisions.map((liquidity) => ({
|
||||
type: 'liquidity' as const,
|
||||
id: liquidity.id,
|
||||
contract,
|
||||
liquidity,
|
||||
hideOutcome: false,
|
||||
smallAvatar: false,
|
||||
}))
|
||||
)
|
||||
items = sortBy(items, (item) =>
|
||||
item.type === 'bet'
|
||||
? item.bet.createdTime
|
||||
: item.type === 'liquidity'
|
||||
? item.liquidity.createdTime
|
||||
: undefined
|
||||
)
|
||||
break
|
||||
|
||||
case 'comments': {
|
||||
|
|
|
@ -8,11 +8,13 @@ import { FeedItems } from './feed-items'
|
|||
import { User } from 'common/user'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function ContractActivity(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
liquidityProvisions: LiquidityProvision[]
|
||||
tips: CommentTipMap
|
||||
user: User | null | undefined
|
||||
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
||||
|
@ -20,7 +22,8 @@ export function ContractActivity(props: {
|
|||
className?: string
|
||||
betRowClassName?: string
|
||||
}) {
|
||||
const { user, mode, tips, className, betRowClassName } = props
|
||||
const { user, mode, tips, className, betRowClassName, liquidityProvisions } =
|
||||
props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
|
@ -33,6 +36,7 @@ export function ContractActivity(props: {
|
|||
contract,
|
||||
bets,
|
||||
comments,
|
||||
liquidityProvisions,
|
||||
tips,
|
||||
user,
|
||||
{ mode }
|
||||
|
|
|
@ -88,26 +88,11 @@ export function FeedAnswerCommentGroup(props: {
|
|||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
mostRecentCommentableBet &&
|
||||
usersMostRecentBetTimeAtLoad !== undefined &&
|
||||
mostRecentCommentableBet.createdTime > usersMostRecentBetTimeAtLoad &&
|
||||
!showReply
|
||||
)
|
||||
scrollAndOpenReplyInput(undefined, answer)
|
||||
}, [
|
||||
answer,
|
||||
usersMostRecentBetTimeAtLoad,
|
||||
mostRecentCommentableBet,
|
||||
scrollAndOpenReplyInput,
|
||||
showReply,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
// Only show one comment input for a bet at a time
|
||||
if (
|
||||
betsByCurrentUser.length > 1 &&
|
||||
inputRef?.textContent?.length === 0 &&
|
||||
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
|
||||
?.outcome !== answer.number.toString()
|
||||
)
|
||||
|
|
|
@ -4,9 +4,9 @@ import { Bet } from 'common/bet'
|
|||
import { User } from 'common/user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import clsx from 'clsx'
|
||||
import { UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
import { UsersIcon } from '@heroicons/react/solid'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
|
@ -50,9 +50,7 @@ export function FeedBet(props: {
|
|||
/>
|
||||
) : (
|
||||
<div className="relative px-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
||||
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
</div>
|
||||
<EmptyAvatar />
|
||||
</div>
|
||||
)}
|
||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
||||
|
|
|
@ -530,8 +530,7 @@ export function CommentInputTextArea(props: {
|
|||
{user && !isSubmitting && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize',
|
||||
isReply ? ' bottom-4' : ' bottom-2',
|
||||
'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize',
|
||||
!commentText && 'pointer-events-none text-gray-500'
|
||||
)}
|
||||
onClick={() => {
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from 'web/components/feed/feed-comments'
|
||||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { CPMMBinaryContract, NumericContract } from 'common/contract'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
|
||||
export function FeedItems(props: {
|
||||
contract: Contract
|
||||
|
@ -86,6 +87,8 @@ export function FeedItem(props: { item: ActivityItem }) {
|
|||
return <FeedDescription {...item} />
|
||||
case 'bet':
|
||||
return <FeedBet {...item} />
|
||||
case 'liquidity':
|
||||
return <FeedLiquidity {...item} />
|
||||
case 'answergroup':
|
||||
return <FeedAnswerCommentGroup {...item} />
|
||||
case 'close':
|
||||
|
|
85
web/components/feed/feed-liquidity.tsx
Normal file
85
web/components/feed/feed-liquidity.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { User } from 'common/user'
|
||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import clsx from 'clsx'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import React from 'react'
|
||||
import { UserLink } from '../user-page'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function FeedLiquidity(props: {
|
||||
liquidity: LiquidityProvision
|
||||
smallAvatar: boolean
|
||||
}) {
|
||||
const { liquidity, smallAvatar } = props
|
||||
const { userId, createdTime } = liquidity
|
||||
|
||||
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const bettor = isBeforeJune2022 ? undefined : useUserById(userId)
|
||||
|
||||
const user = useUser()
|
||||
const isSelf = user?.id === userId
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className={'flex w-full gap-2 pt-3'}>
|
||||
{isSelf ? (
|
||||
<Avatar
|
||||
className={clsx(smallAvatar && 'ml-1')}
|
||||
size={smallAvatar ? 'sm' : undefined}
|
||||
avatarUrl={user.avatarUrl}
|
||||
username={user.username}
|
||||
/>
|
||||
) : bettor ? (
|
||||
<Avatar
|
||||
className={clsx(smallAvatar && 'ml-1')}
|
||||
size={smallAvatar ? 'sm' : undefined}
|
||||
avatarUrl={bettor.avatarUrl}
|
||||
username={bettor.username}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative px-1">
|
||||
<EmptyAvatar />
|
||||
</div>
|
||||
)}
|
||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
||||
<LiquidityStatusText
|
||||
liquidity={liquidity}
|
||||
isSelf={isSelf}
|
||||
bettor={bettor}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LiquidityStatusText(props: {
|
||||
liquidity: LiquidityProvision
|
||||
isSelf: boolean
|
||||
bettor?: User
|
||||
}) {
|
||||
const { liquidity, bettor, isSelf } = props
|
||||
const { amount, createdTime } = liquidity
|
||||
|
||||
// TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal.
|
||||
const bought = amount >= 0 ? 'added' : 'withdrew'
|
||||
const money = formatMoney(Math.abs(amount))
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-500">
|
||||
{bettor ? (
|
||||
<UserLink name={bettor.name} username={bettor.username} />
|
||||
) : (
|
||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
||||
)}{' '}
|
||||
{bought} {money}
|
||||
{' of liquidity'}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
import { UserIcon } from '@heroicons/react/outline'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { User } from 'common/user'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
export function FilterSelectUsers(props: {
|
||||
setSelectedUsers: (users: User[]) => void
|
||||
|
@ -16,18 +15,20 @@ export function FilterSelectUsers(props: {
|
|||
const { ignoreUserIds, selectedUsers, setSelectedUsers } = props
|
||||
const users = useUsers()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const filteredUsers =
|
||||
query === ''
|
||||
? users
|
||||
: users.filter((user: User) => {
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
|
||||
const beginQuerying = query.length > 2
|
||||
useMemo(() => {
|
||||
if (beginQuerying)
|
||||
setFilteredUsers(
|
||||
users.filter((user: User) => {
|
||||
return (
|
||||
!selectedUsers.map((user) => user.name).includes(user.name) &&
|
||||
!ignoreUserIds.includes(user.id) &&
|
||||
user.name.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
})
|
||||
const debouncedQuery = debounce(setQuery, 50)
|
||||
)
|
||||
}, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -40,7 +41,7 @@ export function FilterSelectUsers(props: {
|
|||
name="user name"
|
||||
id="user name"
|
||||
value={query}
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
|
||||
placeholder="Austin Chen"
|
||||
/>
|
||||
|
@ -48,13 +49,13 @@ export function FilterSelectUsers(props: {
|
|||
<Menu
|
||||
as="div"
|
||||
className={clsx(
|
||||
'relative inline-block w-full text-right',
|
||||
query !== '' && 'h-36'
|
||||
'relative inline-block w-full overflow-y-scroll text-right',
|
||||
beginQuerying && 'h-36'
|
||||
)}
|
||||
>
|
||||
{({}) => (
|
||||
<Transition
|
||||
show={query !== ''}
|
||||
show={beginQuerying}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { User } from 'common/user'
|
||||
import React, { useEffect, memo, useState } from 'react'
|
||||
import React, { useEffect, memo, useState, useMemo } from 'react'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Group } from 'common/group'
|
||||
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
|
||||
|
@ -19,7 +19,7 @@ import { UserLink } from 'web/components/user-page'
|
|||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
|
||||
export function Discussion(props: {
|
||||
export function GroupChat(props: {
|
||||
messages: Comment[]
|
||||
user: User | null | undefined
|
||||
group: Group
|
||||
|
@ -33,15 +33,39 @@ export function Discussion(props: {
|
|||
const [scrollToMessageRef, setScrollToMessageRef] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
const [replyToUsername, setReplyToUsername] = useState('')
|
||||
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
|
||||
const [groupedMessages, setGroupedMessages] = useState<Comment[]>([])
|
||||
const router = useRouter()
|
||||
|
||||
useMemo(() => {
|
||||
// Group messages with createdTime within 2 minutes of each other.
|
||||
const tempMessages = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (i === 0) tempMessages.push({ ...message })
|
||||
else {
|
||||
const prevMessage = messages[i - 1]
|
||||
const diff = message.createdTime - prevMessage.createdTime
|
||||
const creatorsMatch = message.userId === prevMessage.userId
|
||||
if (diff < 2 * 60 * 1000 && creatorsMatch) {
|
||||
tempMessages[tempMessages.length - 1].text += `\n${message.text}`
|
||||
} else {
|
||||
tempMessages.push({ ...message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setGroupedMessages(tempMessages)
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToMessageRef?.scrollIntoView()
|
||||
}, [scrollToMessageRef])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottomRef?.scrollIntoView()
|
||||
}, [isSubmitting, scrollToBottomRef])
|
||||
if (!isSubmitting)
|
||||
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 })
|
||||
}, [scrollToBottomRef, isSubmitting])
|
||||
|
||||
useEffect(() => {
|
||||
const elementInUrl = router.asPath.split('#')[1]
|
||||
|
@ -65,6 +89,7 @@ export function Discussion(props: {
|
|||
setMessageText('')
|
||||
setIsSubmitting(false)
|
||||
setReplyToUsername('')
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -73,8 +98,9 @@ export function Discussion(props: {
|
|||
className={
|
||||
'max-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll'
|
||||
}
|
||||
ref={setScrollToBottomRef}
|
||||
>
|
||||
{messages.map((message, i) => (
|
||||
{groupedMessages.map((message) => (
|
||||
<GroupMessage
|
||||
user={user}
|
||||
key={message.id}
|
||||
|
@ -85,8 +111,6 @@ export function Discussion(props: {
|
|||
setRef={
|
||||
scrollToMessageId === message.id
|
||||
? setScrollToMessageRef
|
||||
: i === messages.length - 1
|
||||
? setScrollToBottomRef
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
@ -116,6 +140,7 @@ export function Discussion(props: {
|
|||
submitComment={submitMessage}
|
||||
isSubmitting={isSubmitting}
|
||||
enterToSubmit={true}
|
||||
setRef={setInputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -128,58 +153,62 @@ const GroupMessage = memo(function GroupMessage_(props: {
|
|||
user: User | null | undefined
|
||||
comment: Comment
|
||||
group: Group
|
||||
truncate?: boolean
|
||||
smallAvatar?: boolean
|
||||
onReplyClick?: (comment: Comment) => void
|
||||
setRef?: (ref: HTMLDivElement) => void
|
||||
highlight?: boolean
|
||||
}) {
|
||||
const { comment, truncate, onReplyClick, group, setRef, highlight, user } =
|
||||
props
|
||||
const { comment, onReplyClick, group, setRef, highlight, user } = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
const isCreatorsComment = user && comment.userId === user.id
|
||||
return (
|
||||
<Row
|
||||
<Col
|
||||
ref={setRef}
|
||||
className={clsx(
|
||||
comment.userId === user?.id ? 'mr-2 self-end' : ' ml-2',
|
||||
'w-fit space-x-1.5 rounded-md bg-white p-2 px-4 transition-all duration-1000 sm:space-x-3',
|
||||
isCreatorsComment ? 'mr-2 self-end' : '',
|
||||
'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]',
|
||||
highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : ''
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
className={'ml-1'}
|
||||
size={'sm'}
|
||||
username={userUsername}
|
||||
avatarUrl={userAvatarUrl}
|
||||
/>
|
||||
<div className="w-full">
|
||||
<div className="mt-0.5 pl-0.5 text-sm text-gray-500">
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
<CopyLinkDateTimeComponent
|
||||
prefix={'group'}
|
||||
slug={group.slug}
|
||||
createdTime={createdTime}
|
||||
elementId={comment.id}
|
||||
/>
|
||||
</div>
|
||||
<Row className={'items-center'}>
|
||||
{!isCreatorsComment && (
|
||||
<Col>
|
||||
<Avatar
|
||||
className={'mx-2 ml-2.5'}
|
||||
size={'xs'}
|
||||
username={userUsername}
|
||||
avatarUrl={userAvatarUrl}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
{!isCreatorsComment ? (
|
||||
<UserLink username={userUsername} name={userName} />
|
||||
) : (
|
||||
<span className={'ml-2.5'}>{'You'}</span>
|
||||
)}
|
||||
<CopyLinkDateTimeComponent
|
||||
prefix={'group'}
|
||||
slug={group.slug}
|
||||
createdTime={createdTime}
|
||||
elementId={comment.id}
|
||||
/>
|
||||
</Row>
|
||||
<Row className={'text-black'}>
|
||||
<TruncatedComment
|
||||
comment={text}
|
||||
moreHref={groupPath(group.slug)}
|
||||
shouldTruncate={truncate}
|
||||
shouldTruncate={false}
|
||||
/>
|
||||
{onReplyClick && (
|
||||
<button
|
||||
className={'text-xs font-bold text-gray-500 hover:underline'}
|
||||
onClick={() => onReplyClick(comment)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</Row>
|
||||
{!isCreatorsComment && onReplyClick && (
|
||||
<button
|
||||
className={
|
||||
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
|
||||
}
|
||||
onClick={() => onReplyClick(comment)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
})
|
|
@ -28,6 +28,7 @@ export function Tabs(props: {
|
|||
{tabs.map((tab, i) => (
|
||||
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
|
||||
<a
|
||||
id={`tab-${i}`}
|
||||
key={tab.title}
|
||||
onClick={(e) => {
|
||||
if (!tab.href) {
|
||||
|
|
69
web/components/manalink-card.tsx
Normal file
69
web/components/manalink-card.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import clsx from 'clsx'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
||||
export type ManalinkInfo = {
|
||||
expiresTime: number | null
|
||||
maxUses: number | null
|
||||
uses: number
|
||||
amount: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export function ManalinkCard(props: {
|
||||
className?: string
|
||||
info: ManalinkInfo
|
||||
defaultMessage: string
|
||||
isClaiming: boolean
|
||||
onClaim?: () => void
|
||||
}) {
|
||||
const { className, defaultMessage, isClaiming, info, onClaim } = props
|
||||
const { expiresTime, maxUses, uses, amount, message } = info
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
|
||||
)}
|
||||
>
|
||||
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
|
||||
<div>
|
||||
{maxUses != null
|
||||
? `${maxUses - uses}/${maxUses} uses left`
|
||||
: `Unlimited use`}
|
||||
</div>
|
||||
<div>
|
||||
{expiresTime != null
|
||||
? `Expires ${fromNow(expiresTime)}`
|
||||
: 'Never expires'}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<img
|
||||
className="mb-6 block self-center transition-all group-hover:rotate-12"
|
||||
src="/logo-white.svg"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
<Row className="justify-end rounded-b-xl bg-white p-4">
|
||||
<Col>
|
||||
<div className="mb-1 text-xl text-indigo-500">
|
||||
{formatMoney(amount)}
|
||||
</div>
|
||||
<div>{message || defaultMessage}</div>
|
||||
</Col>
|
||||
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
className={clsx('btn', isClaiming ? 'loading disabled' : '')}
|
||||
onClick={onClaim}
|
||||
>
|
||||
{isClaiming ? '' : 'Claim'}
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -6,9 +6,6 @@ import {
|
|||
CashIcon,
|
||||
HeartIcon,
|
||||
PresentationChartLineIcon,
|
||||
PresentationChartBarIcon,
|
||||
SparklesIcon,
|
||||
NewspaperIcon,
|
||||
UserGroupIcon,
|
||||
ChevronDownIcon,
|
||||
TrendingUpIcon,
|
||||
|
@ -21,13 +18,8 @@ import { firebaseLogout, User } from 'web/lib/firebase/users'
|
|||
import { ManifoldLogo } from './manifold-logo'
|
||||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import {
|
||||
getUtcFreeMarketResetTime,
|
||||
useHasCreatedContractToday,
|
||||
} from 'web/hooks/use-has-created-contract-today'
|
||||
import { Row } from '../layout/row'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
|
@ -35,13 +27,6 @@ import { groupPath } from 'web/lib/firebase/groups'
|
|||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group } from 'common/group'
|
||||
|
||||
// Create an icon from the url of an image
|
||||
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
|
||||
return function Icon(props) {
|
||||
return <img src={url} className={clsx(props.className, 'h-6 w-6')} />
|
||||
}
|
||||
}
|
||||
|
||||
function getNavigation(username: string) {
|
||||
return [
|
||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||
|
@ -56,7 +41,9 @@ function getNavigation(username: string) {
|
|||
icon: NotificationsIcon,
|
||||
},
|
||||
|
||||
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -69,6 +56,7 @@ function getMoreNavigation(user?: User | null) {
|
|||
return [
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
]
|
||||
|
@ -82,7 +70,11 @@ function getMoreNavigation(user?: User | null) {
|
|||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
{ name: 'Statistics', href: '/stats' },
|
||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||
{ name: 'Sign out', href: '#', onClick: () => firebaseLogout() },
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -100,22 +92,6 @@ const signedOutNavigation = [
|
|||
const signedOutMobileNavigation = [
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets', icon: NewspaperIcon },
|
||||
{
|
||||
name: 'Discord',
|
||||
href: 'https://discord.gg/eHQBNBqXuh',
|
||||
icon: IconFromUrl('/discord-logo.svg'),
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
href: 'https://twitter.com/ManifoldMarkets',
|
||||
icon: IconFromUrl('/twitter-logo.svg'),
|
||||
},
|
||||
{
|
||||
name: 'Statistics',
|
||||
href: '/stats',
|
||||
icon: PresentationChartBarIcon,
|
||||
},
|
||||
{
|
||||
name: 'About',
|
||||
href: 'https://docs.manifold.markets/$how-to',
|
||||
|
@ -124,7 +100,9 @@ const signedOutMobileNavigation = [
|
|||
]
|
||||
|
||||
const signedInMobileNavigation = [
|
||||
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
...signedOutMobileNavigation,
|
||||
]
|
||||
|
||||
|
@ -197,28 +175,8 @@ export default function Sidebar(props: { className?: string }) {
|
|||
const { className } = props
|
||||
const router = useRouter()
|
||||
const currentPage = router.pathname
|
||||
const [countdown, setCountdown] = useState('...')
|
||||
useEffect(() => {
|
||||
const nextUtcResetTime = getUtcFreeMarketResetTime({ previousTime: false })
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date().getTime()
|
||||
const timeUntil = nextUtcResetTime - now
|
||||
const hoursUntil = timeUntil / 1000 / 60 / 60
|
||||
const minutesUntil = (hoursUntil * 60) % 60
|
||||
const secondsUntil = Math.round((hoursUntil * 60 * 60) % 60)
|
||||
const timeString =
|
||||
hoursUntil < 1 && minutesUntil < 1
|
||||
? `${secondsUntil}s`
|
||||
: hoursUntil < 1
|
||||
? `${Math.round(minutesUntil)}m`
|
||||
: `${Math.floor(hoursUntil)}h`
|
||||
setCountdown(timeString)
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const user = useUser()
|
||||
const mustWaitForFreeMarketStatus = useHasCreatedContractToday(user)
|
||||
const navigationOptions = !user
|
||||
? signedOutNavigation
|
||||
: getNavigation(user?.username || 'error')
|
||||
|
@ -239,6 +197,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<div className="space-y-1 lg:hidden">
|
||||
{user && (
|
||||
<MenuButton
|
||||
|
@ -261,6 +220,22 @@ export default function Sidebar(props: { className?: string }) {
|
|||
{user && (
|
||||
<MenuButton
|
||||
menuItems={[
|
||||
{
|
||||
name: 'Blog',
|
||||
href: 'https://news.manifold.markets',
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
href: 'https://discord.gg/eHQBNBqXuh',
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
href: 'https://twitter.com/ManifoldMarkets',
|
||||
},
|
||||
{
|
||||
name: 'Statistics',
|
||||
href: '/stats',
|
||||
},
|
||||
{
|
||||
name: 'Sign out',
|
||||
href: '#',
|
||||
|
@ -272,6 +247,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
<div className="hidden space-y-1 lg:block">
|
||||
{navigationOptions.map((item) =>
|
||||
item.name === 'Notifications' ? (
|
||||
|
@ -304,27 +280,6 @@ export default function Sidebar(props: { className?: string }) {
|
|||
/>
|
||||
</div>
|
||||
<CreateQuestionButton user={user} />
|
||||
|
||||
{user &&
|
||||
mustWaitForFreeMarketStatus != 'loading' &&
|
||||
mustWaitForFreeMarketStatus ? (
|
||||
<Row className="mt-2 justify-center">
|
||||
<Row className="gap-1 text-sm text-gray-400">
|
||||
Next free question in {countdown}
|
||||
</Row>
|
||||
</Row>
|
||||
) : (
|
||||
user &&
|
||||
mustWaitForFreeMarketStatus != 'loading' &&
|
||||
!mustWaitForFreeMarketStatus && (
|
||||
<Row className="mt-2 justify-center">
|
||||
<Row className="gap-1 text-sm text-indigo-400">
|
||||
Daily free question
|
||||
<SparklesIcon className="mt-0.5 h-4 w-4" aria-hidden="true" />
|
||||
</Row>
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
83
web/components/portfolio/portfolio-value-graph.tsx
Normal file
83
web/components/portfolio/portfolio-value-graph.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { last } from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { formatTime } from 'web/lib/util/time'
|
||||
|
||||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
height?: number
|
||||
period?: string
|
||||
}) {
|
||||
const { portfolioHistory, height, period } = props
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const portfolioHistoryFiltered = portfolioHistory.filter((p) => {
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
return p.timestamp > Date.now() - 1 * DAY_MS
|
||||
case 'weekly':
|
||||
return p.timestamp > Date.now() - 7 * DAY_MS
|
||||
case 'monthly':
|
||||
return p.timestamp > Date.now() - 30 * DAY_MS
|
||||
case 'allTime':
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const points = portfolioHistoryFiltered.map((p) => {
|
||||
return {
|
||||
x: new Date(p.timestamp),
|
||||
y: p.balance + p.investmentValue,
|
||||
}
|
||||
})
|
||||
const data = [{ id: 'Value', data: points, color: '#11b981' }]
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const numYTickValues = 4
|
||||
const endDate = last(points)?.x
|
||||
const includeTime = period === 'daily'
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
|
||||
>
|
||||
<ResponsiveLine
|
||||
data={data}
|
||||
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: points[0].x,
|
||||
max: endDate,
|
||||
}}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
stacked: false,
|
||||
min: Math.min(...points.map((p) => p.y)),
|
||||
}}
|
||||
gridYValues={numYTickValues}
|
||||
curve="monotoneX"
|
||||
colors={{ datum: 'color' }}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, includeTime),
|
||||
}}
|
||||
pointBorderColor="#fff"
|
||||
pointSize={points.length > 100 ? 0 : 6}
|
||||
axisLeft={{
|
||||
tickValues: numYTickValues,
|
||||
format: (value) => formatMoney(value),
|
||||
}}
|
||||
enableGridX={!!width && width >= 800}
|
||||
enableGridY={true}
|
||||
enableSlices="x"
|
||||
animate={false}
|
||||
></ResponsiveLine>
|
||||
</div>
|
||||
)
|
||||
})
|
47
web/components/portfolio/portfolio-value-section.tsx
Normal file
47
web/components/portfolio/portfolio-value-section.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { PortfolioMetrics } from 'common/user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { last } from 'lodash'
|
||||
import { memo, useState } from 'react'
|
||||
import { Period } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
function PortfolioValueSection(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
}) {
|
||||
const { portfolioHistory } = props
|
||||
const lastPortfolioMetrics = last(portfolioHistory)
|
||||
const [portfolioPeriod] = useState<Period>('allTime')
|
||||
|
||||
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
||||
return <div> No portfolio history data yet </div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row className="gap-8">
|
||||
<div className="mb-4 w-full">
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(
|
||||
lastPortfolioMetrics.balance +
|
||||
lastPortfolioMetrics.investmentValue
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{
|
||||
//TODO: enable day/week/monthly as data becomes available
|
||||
}
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={portfolioHistory}
|
||||
period={portfolioPeriod}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
|
@ -7,8 +7,8 @@ import clsx from 'clsx'
|
|||
import { Comment } from 'common/comment'
|
||||
import { User } from 'common/user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { debounce, sumBy } from 'lodash'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { debounce, sum } from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CommentTips } from 'web/hooks/use-tip-txns'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { transact } from 'web/lib/firebase/fn-call'
|
||||
|
@ -16,33 +16,24 @@ import { track } from 'web/lib/service/analytics'
|
|||
import { Row } from './layout/row'
|
||||
import { Tooltip } from './tooltip'
|
||||
|
||||
// xth triangle number * 5 = 5 + 10 + 15 + ... + (x * 5)
|
||||
const quad = (x: number) => (5 / 2) * x * (x + 1)
|
||||
|
||||
// inverse (see https://math.stackexchange.com/questions/2041988/how-to-get-inverse-of-formula-for-sum-of-integers-from-1-to-nsee )
|
||||
const invQuad = (y: number) => Math.sqrt((2 / 5) * y + 1 / 4) - 1 / 2
|
||||
|
||||
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||
const { comment, tips } = prop
|
||||
|
||||
const me = useUser()
|
||||
const myId = me?.id ?? ''
|
||||
const savedTip = tips[myId] as number | undefined
|
||||
const savedTip = tips[myId] ?? 0
|
||||
|
||||
// optimistically increase the tip count, but debounce the update
|
||||
const [localTip, setLocalTip] = useState(savedTip ?? 0)
|
||||
const [localTip, setLocalTip] = useState(savedTip)
|
||||
// listen for user being set
|
||||
const initialized = useRef(false)
|
||||
useEffect(() => {
|
||||
if (savedTip && !initialized.current) {
|
||||
setLocalTip(savedTip)
|
||||
if (tips[myId] && !initialized.current) {
|
||||
setLocalTip(tips[myId])
|
||||
initialized.current = true
|
||||
}
|
||||
}, [savedTip])
|
||||
}, [tips, myId])
|
||||
|
||||
const score = useMemo(() => {
|
||||
const tipVals = Object.values({ ...tips, [myId]: localTip })
|
||||
return sumBy(tipVals, invQuad)
|
||||
}, [localTip, tips, myId])
|
||||
const total = sum(Object.values(tips)) - savedTip + localTip
|
||||
|
||||
// declare debounced function only on first render
|
||||
const [saveTip] = useState(() =>
|
||||
|
@ -80,7 +71,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
|
||||
const changeTip = (tip: number) => {
|
||||
setLocalTip(tip)
|
||||
me && saveTip(me, tip - (savedTip ?? 0))
|
||||
me && saveTip(me, tip - savedTip)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -88,13 +79,13 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
|||
<DownTip
|
||||
value={localTip}
|
||||
onChange={changeTip}
|
||||
disabled={!me || localTip <= 0}
|
||||
disabled={!me || localTip <= savedTip}
|
||||
/>
|
||||
<span className="font-bold">{Math.floor(score)} </span>
|
||||
<span className="font-bold">{Math.floor(total)}</span>
|
||||
<UpTip
|
||||
value={localTip}
|
||||
onChange={changeTip}
|
||||
disabled={!me || me.id === comment.userId}
|
||||
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
|
||||
/>
|
||||
{localTip === 0 ? (
|
||||
''
|
||||
|
@ -118,16 +109,15 @@ function DownTip(prop: {
|
|||
disabled?: boolean
|
||||
}) {
|
||||
const { onChange, value, disabled } = prop
|
||||
const marginal = 5 * invQuad(value)
|
||||
return (
|
||||
<Tooltip
|
||||
className="tooltip-bottom"
|
||||
text={!disabled && `Refund ${formatMoney(marginal)}`}
|
||||
text={!disabled && `-${formatMoney(5)}`}
|
||||
>
|
||||
<button
|
||||
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(value - marginal)}
|
||||
onClick={() => onChange(value - 5)}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
@ -141,19 +131,18 @@ function UpTip(prop: {
|
|||
disabled?: boolean
|
||||
}) {
|
||||
const { onChange, value, disabled } = prop
|
||||
const marginal = 5 * invQuad(value) + 5
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
className="tooltip-bottom"
|
||||
text={!disabled && `Tip ${formatMoney(marginal)}`}
|
||||
text={!disabled && `Tip ${formatMoney(5)}`}
|
||||
>
|
||||
<button
|
||||
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(value + marginal)}
|
||||
onClick={() => onChange(value + 5)}
|
||||
>
|
||||
{value >= quad(2) ? (
|
||||
{value >= 10 ? (
|
||||
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
||||
) : value > 0 ? (
|
||||
<ChevronRightIcon className="text-primary h-6 w-6" />
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
import clsx from 'clsx'
|
||||
import { uniq } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { LinkIcon } from '@heroicons/react/solid'
|
||||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
import Confetti from 'react-confetti'
|
||||
|
||||
import { follow, unfollow, User } from 'web/lib/firebase/users'
|
||||
import {
|
||||
follow,
|
||||
unfollow,
|
||||
User,
|
||||
getPortfolioHistory,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { CreatorContractsList } from './contract/contracts-list'
|
||||
import { SEO } from './SEO'
|
||||
import { Page } from './page'
|
||||
|
@ -16,7 +24,7 @@ import { Row } from './layout/row'
|
|||
import { genHash } from 'common/util/random'
|
||||
import { Tabs } from './layout/tabs'
|
||||
import { UserCommentsList } from './comments-list'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Comment, getUsersComments } from 'web/lib/firebase/comments'
|
||||
import { Contract } from 'common/contract'
|
||||
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
|
||||
|
@ -27,7 +35,7 @@ import { getUserBets } from 'web/lib/firebase/bets'
|
|||
import { FollowersButton, FollowingButton } from './following-button'
|
||||
import { useFollows } from 'web/hooks/use-follows'
|
||||
import { FollowButton } from './follow-button'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -57,6 +65,7 @@ export function UserPage(props: {
|
|||
defaultTabTitle?: string | undefined
|
||||
}) {
|
||||
const { user, currentUser, defaultTabTitle } = props
|
||||
const router = useRouter()
|
||||
const isCurrentUser = user.id === currentUser?.id
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
const [usersComments, setUsersComments] = useState<Comment[]>([] as Comment[])
|
||||
|
@ -64,16 +73,24 @@ export function UserPage(props: {
|
|||
'loading'
|
||||
)
|
||||
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
|
||||
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
|
||||
const [commentsByContract, setCommentsByContract] = useState<
|
||||
Map<Contract, Comment[]> | 'loading'
|
||||
>('loading')
|
||||
const router = useRouter()
|
||||
const [showConfetti, setShowConfetti] = useState(false)
|
||||
const { width, height } = useWindowSize()
|
||||
|
||||
useEffect(() => {
|
||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||
setShowConfetti(claimedMana)
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
getUsersComments(user.id).then(setUsersComments)
|
||||
listContracts(user.id).then(setUsersContracts)
|
||||
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
|
||||
getPortfolioHistory(user.id).then(setUsersPortfolioHistory)
|
||||
}, [user])
|
||||
|
||||
// TODO: display comments on groups
|
||||
|
@ -117,7 +134,14 @@ export function UserPage(props: {
|
|||
description={user.bio ?? ''}
|
||||
url={`/${user.username}`}
|
||||
/>
|
||||
|
||||
{showConfetti && (
|
||||
<Confetti
|
||||
width={width ? width : 500}
|
||||
height={height ? height : 500}
|
||||
recycle={false}
|
||||
numberOfPieces={300}
|
||||
/>
|
||||
)}
|
||||
{/* Banner image up top, with an circle avatar overlaid */}
|
||||
<div
|
||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||
|
@ -227,6 +251,7 @@ export function UserPage(props: {
|
|||
</Col>
|
||||
|
||||
<Spacer h={10} />
|
||||
|
||||
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
|
||||
<Tabs
|
||||
className={'pb-2 pt-1 '}
|
||||
|
@ -268,6 +293,9 @@ export function UserPage(props: {
|
|||
title: 'Bets',
|
||||
content: (
|
||||
<div>
|
||||
{
|
||||
// TODO: add portfolio-value-section here
|
||||
}
|
||||
<BetsList
|
||||
user={user}
|
||||
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}
|
||||
|
|
38
web/components/widgets/short-toggle.tsx
Normal file
38
web/components/widgets/short-toggle.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Switch } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function ShortToggle(props: {
|
||||
enabled: boolean
|
||||
setEnabled: (enabled: boolean) => void
|
||||
}) {
|
||||
const { enabled, setEnabled } = props
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-full w-full rounded-md bg-white"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
enabled ? 'bg-indigo-600' : 'bg-gray-200',
|
||||
'pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
)
|
||||
}
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import {
|
||||
Comment,
|
||||
listenForCommentsOnContract,
|
||||
listenForCommentsOnGroup,
|
||||
listenForRecentComments,
|
||||
} from 'web/lib/firebase/comments'
|
||||
|
||||
|
@ -14,6 +15,15 @@ export const useComments = (contractId: string) => {
|
|||
|
||||
return comments
|
||||
}
|
||||
export const useCommentsOnGroup = (groupId: string | undefined) => {
|
||||
const [comments, setComments] = useState<Comment[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) return listenForCommentsOnGroup(groupId, setComments)
|
||||
}, [groupId])
|
||||
|
||||
return comments
|
||||
}
|
||||
|
||||
export const useRecentComments = () => {
|
||||
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { Group } from 'common/group'
|
||||
import { User } from 'common/user'
|
||||
import {
|
||||
getGroupsWithContractId,
|
||||
listenForGroup,
|
||||
listenForGroups,
|
||||
listenForMemberGroups,
|
||||
|
@ -64,15 +65,24 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
|||
export function useMembers(group: Group) {
|
||||
const [members, setMembers] = useState<User[]>([])
|
||||
useEffect(() => {
|
||||
const { memberIds, creatorId } = group
|
||||
if (memberIds.length > 1)
|
||||
// get users via their user ids:
|
||||
Promise.all(
|
||||
memberIds.filter((mId) => mId !== creatorId).map(getUser)
|
||||
).then((users) => {
|
||||
const members = users.filter((user) => user)
|
||||
setMembers(members)
|
||||
})
|
||||
const { memberIds } = group
|
||||
if (memberIds.length > 0) {
|
||||
listMembers(group).then((members) => setMembers(members))
|
||||
}
|
||||
}, [group])
|
||||
return members
|
||||
}
|
||||
|
||||
export async function listMembers(group: Group) {
|
||||
return await Promise.all(group.memberIds.map(getUser))
|
||||
}
|
||||
|
||||
export const useGroupsWithContract = (contractId: string | undefined) => {
|
||||
const [groups, setGroups] = useState<Group[] | null | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (contractId) getGroupsWithContractId(contractId, setGroups)
|
||||
}, [contractId])
|
||||
|
||||
return groups
|
||||
}
|
||||
|
|
|
@ -20,6 +20,16 @@ export function checkAgainstQuery(query: string, corpus: string) {
|
|||
return queryWords.every((word) => corpus.toLowerCase().includes(word))
|
||||
}
|
||||
|
||||
export function getSavedSort() {
|
||||
// TODO: this obviously doesn't work with SSR, common sense would suggest
|
||||
// that we should save things like this in cookies so the server has them
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem(MARKETS_SORT) as Sort | null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useInitialQueryAndSort(options?: {
|
||||
defaultSort: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
|
@ -45,7 +55,7 @@ export function useInitialQueryAndSort(options?: {
|
|||
|
||||
if (!sort && shouldLoadFromStorage) {
|
||||
console.log('ready loading from storage ', sort ?? defaultSort)
|
||||
const localSort = localStorage.getItem(MARKETS_SORT) as Sort
|
||||
const localSort = getSavedSort()
|
||||
if (localSort) {
|
||||
router.query.s = localSort
|
||||
// Use replace to not break navigating back.
|
||||
|
|
|
@ -2,7 +2,6 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
|||
import { promisify } from 'util'
|
||||
import { pipeline } from 'stream'
|
||||
import { getFunctionUrl } from 'web/lib/firebase/api-call'
|
||||
import { V2CloudFunction } from 'common/envs/prod'
|
||||
import fetch, { Headers, Response } from 'node-fetch'
|
||||
|
||||
function getProxiedRequestHeaders(req: NextApiRequest, whitelist: string[]) {
|
||||
|
@ -33,7 +32,7 @@ function getProxiedResponseHeaders(res: Response, whitelist: string[]) {
|
|||
return result
|
||||
}
|
||||
|
||||
export const fetchBackend = (req: NextApiRequest, name: V2CloudFunction) => {
|
||||
export const fetchBackend = (req: NextApiRequest, name: string) => {
|
||||
const url = getFunctionUrl(name)
|
||||
const headers = getProxiedRequestHeaders(req, [
|
||||
'Authorization',
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { auth } from './users'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { V2CloudFunction } from 'common/envs/prod'
|
||||
|
||||
export class APIError extends Error {
|
||||
code: number
|
||||
|
@ -41,8 +40,9 @@ export async function call(url: string, method: string, params: any) {
|
|||
// app just hit the cloud functions directly -- there's no difference and it's
|
||||
// one less hop
|
||||
|
||||
export function getFunctionUrl(name: V2CloudFunction) {
|
||||
return ENV_CONFIG.functionEndpoints[name]
|
||||
export function getFunctionUrl(name: string) {
|
||||
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
|
||||
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
|
||||
}
|
||||
|
||||
export function createMarket(params: any) {
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
where,
|
||||
orderBy,
|
||||
} from 'firebase/firestore'
|
||||
import { range, uniq } from 'lodash'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import { db } from './init'
|
||||
import { Bet } from 'common/bet'
|
||||
|
@ -136,24 +136,3 @@ export function withoutAnteBets(contract: Contract, bets?: Bet[]) {
|
|||
|
||||
return bets?.filter((bet) => !bet.isAnte) ?? []
|
||||
}
|
||||
|
||||
const getBetsQuery = (startTime: number, endTime: number) =>
|
||||
query(
|
||||
collectionGroup(db, 'bets'),
|
||||
where('createdTime', '>=', startTime),
|
||||
where('createdTime', '<', endTime),
|
||||
orderBy('createdTime', 'asc')
|
||||
)
|
||||
|
||||
export async function getDailyBets(startTime: number, numberOfDays: number) {
|
||||
const query = getBetsQuery(startTime, startTime + DAY_IN_MS * numberOfDays)
|
||||
const bets = await getValues<Bet>(query)
|
||||
|
||||
const betsByDay = range(0, numberOfDays).map(() => [] as Bet[])
|
||||
for (const bet of bets) {
|
||||
const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_IN_MS)
|
||||
betsByDay[dayIndex].push(bet)
|
||||
}
|
||||
|
||||
return betsByDay
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
setDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { range } from 'lodash'
|
||||
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { db } from './init'
|
||||
|
@ -136,33 +135,6 @@ export function listenForRecentComments(
|
|||
return listenForValues<Comment>(recentCommentsQuery, setComments)
|
||||
}
|
||||
|
||||
const getCommentsQuery = (startTime: number, endTime: number) =>
|
||||
query(
|
||||
collectionGroup(db, 'comments'),
|
||||
where('createdTime', '>=', startTime),
|
||||
where('createdTime', '<', endTime),
|
||||
orderBy('createdTime', 'asc')
|
||||
)
|
||||
|
||||
export async function getDailyComments(
|
||||
startTime: number,
|
||||
numberOfDays: number
|
||||
) {
|
||||
const query = getCommentsQuery(
|
||||
startTime,
|
||||
startTime + DAY_IN_MS * numberOfDays
|
||||
)
|
||||
const comments = await getValues<Comment>(query)
|
||||
|
||||
const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[])
|
||||
for (const comment of comments) {
|
||||
const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_IN_MS)
|
||||
commentsByDay[dayIndex].push(comment)
|
||||
}
|
||||
|
||||
return commentsByDay
|
||||
}
|
||||
|
||||
const getUsersCommentsQuery = (userId: string) =>
|
||||
query(
|
||||
collectionGroup(db, 'comments'),
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
limit,
|
||||
startAfter,
|
||||
} from 'firebase/firestore'
|
||||
import { range, sortBy, sum } from 'lodash'
|
||||
import { sortBy, sum } from 'lodash'
|
||||
|
||||
import { app } from './init'
|
||||
import { getValues, listenForValue, listenForValues } from './utils'
|
||||
|
@ -303,35 +303,6 @@ export async function getClosingSoonContracts() {
|
|||
)
|
||||
}
|
||||
|
||||
const getContractsQuery = (startTime: number, endTime: number) =>
|
||||
query(
|
||||
collection(db, 'contracts'),
|
||||
where('createdTime', '>=', startTime),
|
||||
where('createdTime', '<', endTime),
|
||||
orderBy('createdTime', 'asc')
|
||||
)
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export async function getDailyContracts(
|
||||
startTime: number,
|
||||
numberOfDays: number
|
||||
) {
|
||||
const query = getContractsQuery(
|
||||
startTime,
|
||||
startTime + DAY_IN_MS * numberOfDays
|
||||
)
|
||||
const contracts = await getValues<Contract>(query)
|
||||
|
||||
const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[])
|
||||
for (const contract of contracts) {
|
||||
const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_IN_MS)
|
||||
contractsByDay[dayIndex].push(contract)
|
||||
}
|
||||
|
||||
return contractsByDay
|
||||
}
|
||||
|
||||
export async function getRecentBetsAndComments(contract: Contract) {
|
||||
const contractDoc = doc(db, 'contracts', contract.id)
|
||||
|
||||
|
|
|
@ -68,3 +68,8 @@ export const addLiquidity = (data: { amount: number; contractId: string }) => {
|
|||
.then((r) => r.data as { status: string })
|
||||
.catch((e) => ({ status: 'error', message: e.message }))
|
||||
}
|
||||
|
||||
export const claimManalink = cloudFunction<
|
||||
string,
|
||||
{ status: 'error' | 'success'; message?: string }
|
||||
>('claimManalink')
|
||||
|
|
|
@ -17,7 +17,7 @@ const groupCollection = collection(db, 'groups')
|
|||
|
||||
export function groupPath(
|
||||
groupSlug: string,
|
||||
subpath?: 'edit' | 'questions' | 'details' | 'discussion'
|
||||
subpath?: 'edit' | 'questions' | 'about' | 'chat'
|
||||
) {
|
||||
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
||||
}
|
||||
|
@ -82,3 +82,15 @@ export function listenForMemberGroups(
|
|||
setGroups(sorted)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGroupsWithContractId(
|
||||
contractId: string,
|
||||
setGroups: (groups: Group[]) => void
|
||||
) {
|
||||
const q = query(
|
||||
groupCollection,
|
||||
where('contractIds', 'array-contains', contractId)
|
||||
)
|
||||
const groups = await getValues<Group>(q)
|
||||
setGroups(groups)
|
||||
}
|
||||
|
|
94
web/lib/firebase/manalinks.ts
Normal file
94
web/lib/firebase/manalinks.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
collection,
|
||||
getDoc,
|
||||
orderBy,
|
||||
query,
|
||||
setDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { doc } from 'firebase/firestore'
|
||||
import { Manalink } from '../../../common/manalink'
|
||||
import { db } from './init'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
import { listenForValues } from './utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export async function createManalink(data: {
|
||||
fromId: string
|
||||
amount: number
|
||||
expiresTime: number | null
|
||||
maxUses: number | null
|
||||
message: string
|
||||
}) {
|
||||
const { fromId, amount, expiresTime, maxUses, message } = data
|
||||
|
||||
// At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years
|
||||
// See https://zelark.github.io/nano-id-cc/
|
||||
const nanoid = customAlphabet(
|
||||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
8
|
||||
)
|
||||
const slug = nanoid()
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return null
|
||||
|
||||
const manalink: Manalink = {
|
||||
slug,
|
||||
fromId,
|
||||
amount,
|
||||
token: 'M$',
|
||||
createdTime: Date.now(),
|
||||
expiresTime,
|
||||
maxUses,
|
||||
claimedUserIds: [],
|
||||
claims: [],
|
||||
message,
|
||||
}
|
||||
|
||||
const ref = doc(db, 'manalinks', slug)
|
||||
await setDoc(ref, manalink)
|
||||
return slug
|
||||
}
|
||||
|
||||
const manalinkCol = collection(db, 'manalinks')
|
||||
|
||||
// TODO: This required an index, make sure to also set up in prod
|
||||
function listUserManalinks(fromId?: string) {
|
||||
return query(
|
||||
manalinkCol,
|
||||
where('fromId', '==', fromId),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
}
|
||||
|
||||
export async function getManalink(slug: string) {
|
||||
const docSnap = await getDoc(doc(db, 'manalinks', slug))
|
||||
return docSnap.data() as Manalink
|
||||
}
|
||||
|
||||
export function useManalink(slug: string) {
|
||||
const [manalink, setManalink] = useState<Manalink | null>(null)
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
getManalink(slug).then(setManalink)
|
||||
}
|
||||
}, [slug])
|
||||
return manalink
|
||||
}
|
||||
|
||||
export function listenForUserManalinks(
|
||||
fromId: string | undefined,
|
||||
setLinks: (links: Manalink[]) => void
|
||||
) {
|
||||
return listenForValues<Manalink>(listUserManalinks(fromId), setLinks)
|
||||
}
|
||||
|
||||
export const useUserManalinks = (fromId: string) => {
|
||||
const [links, setLinks] = useState<Manalink[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return listenForUserManalinks(fromId, setLinks)
|
||||
}, [fromId])
|
||||
|
||||
return links
|
||||
}
|
15
web/lib/firebase/stats.ts
Normal file
15
web/lib/firebase/stats.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {
|
||||
CollectionReference,
|
||||
doc,
|
||||
collection,
|
||||
getDoc,
|
||||
} from 'firebase/firestore'
|
||||
import { db } from 'web/lib/firebase/init'
|
||||
import { Stats } from 'common/stats'
|
||||
|
||||
const statsCollection = collection(db, 'stats') as CollectionReference<Stats>
|
||||
const statsDoc = doc(statsCollection, 'stats')
|
||||
|
||||
export const getStats = async () => {
|
||||
return (await getDoc(statsDoc)).data()
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import { DonationTxn, TipTxn } from 'common/txn'
|
||||
import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn'
|
||||
import { collection, orderBy, query, where } from 'firebase/firestore'
|
||||
import { db } from './init'
|
||||
import { getValues, listenForValues } from './utils'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { orderBy as _orderBy } from 'lodash'
|
||||
|
||||
const txnCollection = collection(db, 'txns')
|
||||
|
||||
|
@ -39,3 +41,29 @@ export function listenForTipTxns(
|
|||
) {
|
||||
return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns)
|
||||
}
|
||||
|
||||
// Find all manalink Txns that are from or to this user
|
||||
export function useManalinkTxns(userId: string) {
|
||||
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
|
||||
const [toTxns, setToTxns] = useState<ManalinkTxn[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Need to instantiate these indexes too
|
||||
const fromQuery = query(
|
||||
txnCollection,
|
||||
where('fromId', '==', userId),
|
||||
where('category', '==', 'MANALINK'),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
const toQuery = query(
|
||||
txnCollection,
|
||||
where('toId', '==', userId),
|
||||
where('category', '==', 'MANALINK'),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
listenForValues(fromQuery, setFromTxns)
|
||||
listenForValues(toQuery, setToTxns)
|
||||
}, [userId])
|
||||
|
||||
return _orderBy([...fromTxns, ...toTxns], ['createdTime'], ['desc'])
|
||||
}
|
||||
|
|
|
@ -21,13 +21,12 @@ import {
|
|||
GoogleAuthProvider,
|
||||
signInWithPopup,
|
||||
} from 'firebase/auth'
|
||||
import { range, throttle, zip } from 'lodash'
|
||||
import { throttle, zip } from 'lodash'
|
||||
|
||||
import { app } from './init'
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||
import { createUser } from './fn-call'
|
||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { feed } from 'common/feed'
|
||||
import { CATEGORY_LIST } from 'common/categories'
|
||||
import { safeLocalStorage } from '../util/local'
|
||||
|
@ -35,7 +34,7 @@ import { filterDefined } from 'common/util/array'
|
|||
|
||||
export type { User }
|
||||
|
||||
export type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
||||
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
||||
|
||||
const db = getFirestore(app)
|
||||
export const auth = getAuth(app)
|
||||
|
@ -128,7 +127,7 @@ export function listenForLogin(onUser: (user: User | null) => void) {
|
|||
|
||||
export async function firebaseLogin() {
|
||||
const provider = new GoogleAuthProvider()
|
||||
signInWithPopup(auth, provider)
|
||||
return signInWithPopup(auth, provider)
|
||||
}
|
||||
|
||||
export async function firebaseLogout() {
|
||||
|
@ -180,7 +179,7 @@ export function listenForPrivateUsers(
|
|||
listenForValues(q, setUsers)
|
||||
}
|
||||
|
||||
export function getTopTraders(period: LeaderboardPeriod) {
|
||||
export function getTopTraders(period: Period) {
|
||||
const topTraders = query(
|
||||
collection(db, 'users'),
|
||||
orderBy('profitCached.' + period, 'desc'),
|
||||
|
@ -190,7 +189,7 @@ export function getTopTraders(period: LeaderboardPeriod) {
|
|||
return getValues(topTraders)
|
||||
}
|
||||
|
||||
export function getTopCreators(period: LeaderboardPeriod) {
|
||||
export function getTopCreators(period: Period) {
|
||||
const topCreators = query(
|
||||
collection(db, 'users'),
|
||||
orderBy('creatorVolumeCached.' + period, 'desc'),
|
||||
|
@ -214,30 +213,6 @@ export function getUsers() {
|
|||
return getValues<User>(collection(db, 'users'))
|
||||
}
|
||||
|
||||
const getUsersQuery = (startTime: number, endTime: number) =>
|
||||
query(
|
||||
collection(db, 'users'),
|
||||
where('createdTime', '>=', startTime),
|
||||
where('createdTime', '<', endTime),
|
||||
orderBy('createdTime', 'asc')
|
||||
)
|
||||
|
||||
export async function getDailyNewUsers(
|
||||
startTime: number,
|
||||
numberOfDays: number
|
||||
) {
|
||||
const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays)
|
||||
const users = await getValues<User>(query)
|
||||
|
||||
const usersByDay = range(0, numberOfDays).map(() => [] as User[])
|
||||
for (const user of users) {
|
||||
const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS)
|
||||
usersByDay[dayIndex].push(user)
|
||||
}
|
||||
|
||||
return usersByDay
|
||||
}
|
||||
|
||||
export async function getUserFeed(userId: string) {
|
||||
const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed')
|
||||
const userFeed = await getValue<{
|
||||
|
@ -270,6 +245,16 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
|
|||
await deleteDoc(followDoc)
|
||||
}
|
||||
|
||||
export async function getPortfolioHistory(userId: string) {
|
||||
return getValues<PortfolioMetrics>(
|
||||
query(
|
||||
collectionGroup(db, 'portfolioHistory'),
|
||||
where('userId', '==', userId),
|
||||
orderBy('timestamp', 'asc')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function listenForFollows(
|
||||
userId: string,
|
||||
setFollowIds: (followIds: string[]) => void
|
||||
|
|
|
@ -5,3 +5,13 @@ dayjs.extend(relativeTime)
|
|||
export function fromNow(time: number) {
|
||||
return dayjs(time).fromNow()
|
||||
}
|
||||
|
||||
export function formatTime(time: number, includeTime: boolean) {
|
||||
const d = dayjs(time)
|
||||
|
||||
if (d.isSame(Date.now(), 'day')) return d.format('ha')
|
||||
|
||||
if (includeTime) return dayjs(time).format('MMM D, ha')
|
||||
|
||||
return dayjs(time).format('MMM D')
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
|
|||
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
staticPageGenerationTimeout: 600, // e.g. stats page
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
externalDir: true,
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
"gridjs": "5.0.2",
|
||||
"gridjs-react": "5.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"nanoid": "^3.3.4",
|
||||
"next": "12.1.2",
|
||||
"node-fetch": "3.2.4",
|
||||
"react": "17.0.2",
|
||||
|
@ -40,7 +41,8 @@
|
|||
"react-expanding-textarea": "2.3.5",
|
||||
"react-hot-toast": "2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-query": "3.39.0"
|
||||
"react-query": "3.39.0",
|
||||
"string-similarity": "^4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "0.4.0",
|
||||
|
@ -49,6 +51,7 @@
|
|||
"@types/lodash": "4.14.178",
|
||||
"@types/node": "16.11.11",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/string-similarity": "^4.0.0",
|
||||
"autoprefixer": "10.2.6",
|
||||
"concurrently": "6.5.1",
|
||||
"critters": "0.0.16",
|
||||
|
|
|
@ -43,6 +43,7 @@ import { CPMMBinaryContract } from 'common/contract'
|
|||
import { AlertBox } from 'web/components/alert-box'
|
||||
import { useTracking } from 'web/hooks/use-tracking'
|
||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -118,6 +119,8 @@ export function ContractPageContent(
|
|||
})
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
const liquidityProvisions =
|
||||
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
|
||||
// Sort for now to see if bug is fixed.
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
||||
|
@ -237,6 +240,7 @@ export function ContractPageContent(
|
|||
<ContractTabs
|
||||
contract={contract}
|
||||
user={user}
|
||||
liquidityProvisions={liquidityProvisions}
|
||||
bets={bets}
|
||||
tips={tips}
|
||||
comments={comments}
|
||||
|
|
|
@ -3,7 +3,9 @@ import { Answer } from 'common/answer'
|
|||
import { getOutcomeProbability, getProbability } from 'common/calculate'
|
||||
import { Comment } from 'common/comment'
|
||||
import { Contract } from 'common/contract'
|
||||
import { User } from 'common/user'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export type LiteMarket = {
|
||||
// Unique identifer for this market
|
||||
|
@ -24,7 +26,7 @@ export type LiteMarket = {
|
|||
outcomeType: string
|
||||
mechanism: string
|
||||
|
||||
pool: number
|
||||
pool: { [outcome: string]: number }
|
||||
probability?: number
|
||||
p?: number
|
||||
totalLiquidity?: number
|
||||
|
@ -36,6 +38,7 @@ export type LiteMarket = {
|
|||
isResolved: boolean
|
||||
resolution?: string
|
||||
resolutionTime?: number
|
||||
resolutionProbability?: number
|
||||
}
|
||||
|
||||
export type ApiAnswer = Answer & {
|
||||
|
@ -73,6 +76,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
isResolved,
|
||||
resolution,
|
||||
resolutionTime,
|
||||
resolutionProbability,
|
||||
} = contract
|
||||
|
||||
const { p, totalLiquidity } = contract as any
|
||||
|
@ -94,7 +98,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
description,
|
||||
tags,
|
||||
url: `https://manifold.markets/${creatorUsername}/${slug}`,
|
||||
pool: pool.YES + pool.NO,
|
||||
pool,
|
||||
probability,
|
||||
p,
|
||||
totalLiquidity,
|
||||
|
@ -106,6 +110,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
|
|||
isResolved,
|
||||
resolution,
|
||||
resolutionTime,
|
||||
resolutionProbability,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -140,3 +145,73 @@ function augmentAnswerWithProbability(
|
|||
probability,
|
||||
}
|
||||
}
|
||||
|
||||
export type LiteUser = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
||||
name: string
|
||||
username: string
|
||||
url: string
|
||||
avatarUrl?: string
|
||||
|
||||
bio?: string
|
||||
bannerUrl?: string
|
||||
website?: string
|
||||
twitterHandle?: string
|
||||
discordHandle?: string
|
||||
|
||||
balance: number
|
||||
totalDeposits: number
|
||||
|
||||
profitCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
|
||||
creatorVolumeCached: {
|
||||
daily: number
|
||||
weekly: number
|
||||
monthly: number
|
||||
allTime: number
|
||||
}
|
||||
}
|
||||
|
||||
export function toLiteUser(user: User): LiteUser {
|
||||
const {
|
||||
id,
|
||||
createdTime,
|
||||
name,
|
||||
username,
|
||||
avatarUrl,
|
||||
bio,
|
||||
bannerUrl,
|
||||
website,
|
||||
twitterHandle,
|
||||
discordHandle,
|
||||
balance,
|
||||
totalDeposits,
|
||||
profitCached,
|
||||
creatorVolumeCached,
|
||||
} = user
|
||||
|
||||
return removeUndefinedProps({
|
||||
id,
|
||||
createdTime,
|
||||
name,
|
||||
username,
|
||||
url: `https://${ENV_CONFIG.domain}/${username}`,
|
||||
avatarUrl,
|
||||
bio,
|
||||
bannerUrl,
|
||||
website,
|
||||
twitterHandle,
|
||||
discordHandle,
|
||||
balance,
|
||||
totalDeposits,
|
||||
profitCached,
|
||||
creatorVolumeCached,
|
||||
})
|
||||
}
|
||||
|
|
17
web/pages/api/v0/users.ts
Normal file
17
web/pages/api/v0/users.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
// Next.js API route support: https://vercel.com/docs/concepts/functions/serverless-functions
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { listAllUsers } from 'web/lib/firebase/users'
|
||||
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
||||
import { toLiteUser } from './_types'
|
||||
|
||||
type Data = any[]
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
|
||||
const users = await listAllUsers()
|
||||
res.setHeader('Cache-Control', 'max-age=0')
|
||||
res.status(200).json(users.map(toLiteUser))
|
||||
}
|
124
web/pages/contract-search-firestore.tsx
Normal file
124
web/pages/contract-search-firestore.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { Answer } from 'common/answer'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { ContractsGrid } from 'web/components/contract/contracts-list'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { useContracts } from 'web/hooks/use-contracts'
|
||||
import {
|
||||
Sort,
|
||||
useInitialQueryAndSort,
|
||||
} from 'web/hooks/use-sort-and-query-params'
|
||||
|
||||
export default function ContractSearchFirestore(props: {
|
||||
querySortOptions?: {
|
||||
defaultSort: Sort
|
||||
shouldLoadFromStorage?: boolean
|
||||
}
|
||||
additionalFilter?: {
|
||||
creatorId?: string
|
||||
tag?: string
|
||||
}
|
||||
}) {
|
||||
const contracts = useContracts()
|
||||
const { querySortOptions, additionalFilter } = props
|
||||
|
||||
const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions)
|
||||
const [sort, setSort] = useState(initialSort || 'newest')
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
|
||||
const queryWords = query.toLowerCase().split(' ')
|
||||
function check(corpus: string) {
|
||||
return queryWords.every((word) => corpus.toLowerCase().includes(word))
|
||||
}
|
||||
|
||||
let matches = (contracts ?? []).filter(
|
||||
(c) =>
|
||||
check(c.question) ||
|
||||
check(c.description) ||
|
||||
check(c.creatorName) ||
|
||||
check(c.creatorUsername) ||
|
||||
check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) ||
|
||||
check(
|
||||
((c as any).answers ?? [])
|
||||
.map((answer: Answer) => answer.text)
|
||||
.join(' ')
|
||||
)
|
||||
)
|
||||
|
||||
if (sort === 'newest') {
|
||||
matches.sort((a, b) => b.createdTime - a.createdTime)
|
||||
} else if (sort === 'resolve-date') {
|
||||
matches = sortBy(matches, (contract) => -1 * (contract.resolutionTime ?? 0))
|
||||
} else if (sort === 'oldest') {
|
||||
matches.sort((a, b) => a.createdTime - b.createdTime)
|
||||
} else if (sort === 'close-date') {
|
||||
matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours)
|
||||
matches = sortBy(
|
||||
matches,
|
||||
(contract) =>
|
||||
(sort === 'close-date' ? -1 : 1) * (contract.closeTime ?? Infinity)
|
||||
)
|
||||
} else if (sort === 'most-traded') {
|
||||
matches.sort((a, b) => b.volume - a.volume)
|
||||
} else if (sort === '24-hour-vol') {
|
||||
// Use lodash for stable sort, so previous sort breaks all ties.
|
||||
matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days)
|
||||
matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours)
|
||||
}
|
||||
|
||||
if (additionalFilter) {
|
||||
const { creatorId, tag } = additionalFilter
|
||||
|
||||
if (creatorId) {
|
||||
matches = matches.filter((c) => c.creatorId === creatorId)
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
matches = matches.filter((c) =>
|
||||
c.lowercaseTags.includes(tag.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const showTime = ['close-date', 'closed'].includes(sort)
|
||||
? 'close-date'
|
||||
: sort === 'resolve-date'
|
||||
? 'resolve-date'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Show a search input next to a sort dropdown */}
|
||||
<div className="mt-2 mb-8 flex justify-between gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search markets"
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as Sort)}
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="most-traded">Most traded</option>
|
||||
<option value="24-hour-vol">24h volume</option>
|
||||
<option value="close-date">Closing soon</option>
|
||||
</select>
|
||||
</div>
|
||||
{contracts === undefined ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<ContractsGrid
|
||||
contracts={matches}
|
||||
loadMore={() => {}}
|
||||
hasMore={false}
|
||||
showTime={showTime}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -17,7 +17,6 @@ import {
|
|||
outcomeType,
|
||||
} from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { getGroup, updateGroup } from 'web/lib/firebase/groups'
|
||||
|
@ -27,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
|||
import { track } from 'web/lib/service/analytics'
|
||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||
import { CATEGORIES } from 'common/categories'
|
||||
import { User } from 'common/user'
|
||||
|
||||
export default function Create() {
|
||||
const [question, setQuestion] = useState('')
|
||||
|
@ -34,7 +34,13 @@ export default function Create() {
|
|||
const router = useRouter()
|
||||
const { groupId } = router.query as { groupId: string }
|
||||
useTracking('view create page')
|
||||
if (!router.isReady) return <div />
|
||||
const creator = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (creator === null) router.push('/')
|
||||
}, [creator, router])
|
||||
|
||||
if (!router.isReady || !creator) return <div />
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
@ -59,7 +65,11 @@ export default function Create() {
|
|||
</div>
|
||||
</form>
|
||||
<Spacer h={6} />
|
||||
<NewContract question={question} groupId={groupId} />
|
||||
<NewContract
|
||||
question={question}
|
||||
groupId={groupId}
|
||||
creator={creator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
@ -67,14 +77,12 @@ export default function Create() {
|
|||
}
|
||||
|
||||
// Allow user to create a new contract
|
||||
export function NewContract(props: { question: string; groupId?: string }) {
|
||||
const { question, groupId } = props
|
||||
const creator = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (creator === null) router.push('/')
|
||||
}, [creator])
|
||||
|
||||
export function NewContract(props: {
|
||||
creator: User
|
||||
question: string
|
||||
groupId?: string
|
||||
}) {
|
||||
const { creator, question, groupId } = props
|
||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||
const [initialProb] = useState(50)
|
||||
const [minString, setMinString] = useState('')
|
||||
|
@ -93,11 +101,6 @@ export function NewContract(props: { question: string; groupId?: string }) {
|
|||
}, [creator, groupId])
|
||||
const [ante, _setAnte] = useState(FIXED_ANTE)
|
||||
|
||||
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
|
||||
const isFree =
|
||||
mustWaitForDailyFreeMarketStatus != 'loading' &&
|
||||
!mustWaitForDailyFreeMarketStatus
|
||||
|
||||
// useEffect(() => {
|
||||
// if (ante === null && creator) {
|
||||
// const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100
|
||||
|
@ -138,9 +141,7 @@ export function NewContract(props: { question: string; groupId?: string }) {
|
|||
ante !== undefined &&
|
||||
ante !== null &&
|
||||
ante >= MINIMUM_ANTE &&
|
||||
(ante <= balance ||
|
||||
(mustWaitForDailyFreeMarketStatus != 'loading' &&
|
||||
!mustWaitForDailyFreeMarketStatus)) &&
|
||||
ante <= balance &&
|
||||
// closeTime must be in the future
|
||||
closeTime &&
|
||||
closeTime > Date.now() &&
|
||||
|
@ -175,13 +176,14 @@ export function NewContract(props: { question: string; groupId?: string }) {
|
|||
min,
|
||||
max,
|
||||
groupId: selectedGroup?.id,
|
||||
tags: category ? [category] : undefined,
|
||||
})
|
||||
)
|
||||
track('create market', {
|
||||
slug: result.slug,
|
||||
initialProb,
|
||||
selectedGroup: selectedGroup?.id,
|
||||
isFree,
|
||||
isFree: false,
|
||||
})
|
||||
if (result && selectedGroup) {
|
||||
await updateGroup(selectedGroup, {
|
||||
|
@ -369,41 +371,26 @@ export function NewContract(props: { question: string; groupId?: string }) {
|
|||
<div className="form-control mb-1 items-start">
|
||||
<label className="label mb-1 gap-2">
|
||||
<span>Cost</span>
|
||||
{mustWaitForDailyFreeMarketStatus != 'loading' &&
|
||||
mustWaitForDailyFreeMarketStatus && (
|
||||
<InfoTooltip
|
||||
text={`Cost to create your question. This amount is used to subsidize betting.`}
|
||||
/>
|
||||
)}
|
||||
<InfoTooltip
|
||||
text={`Cost to create your question. This amount is used to subsidize betting.`}
|
||||
/>
|
||||
</label>
|
||||
{mustWaitForDailyFreeMarketStatus != 'loading' &&
|
||||
!mustWaitForDailyFreeMarketStatus ? (
|
||||
<div className="label-text text-primary pl-1">
|
||||
<span className={'label-text text-neutral line-through '}>
|
||||
{formatMoney(ante)}
|
||||
</span>{' '}
|
||||
FREE
|
||||
|
||||
<div className="label-text text-neutral pl-1">
|
||||
{formatMoney(ante)}
|
||||
</div>
|
||||
|
||||
{ante > balance && (
|
||||
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
|
||||
<span className="mr-2 text-red-500">Insufficient balance</span>
|
||||
<button
|
||||
className="btn btn-xs btn-primary"
|
||||
onClick={() => (window.location.href = '/add-funds')}
|
||||
>
|
||||
Get M$
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
mustWaitForDailyFreeMarketStatus != 'loading' && (
|
||||
<div className="label-text text-neutral pl-1">
|
||||
{formatMoney(ante)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{mustWaitForDailyFreeMarketStatus != 'loading' &&
|
||||
mustWaitForDailyFreeMarketStatus &&
|
||||
ante > balance && (
|
||||
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
|
||||
<span className="mr-2 text-red-500">Insufficient balance</span>
|
||||
<button
|
||||
className="btn btn-xs btn-primary"
|
||||
onClick={() => (window.location.href = '/add-funds')}
|
||||
>
|
||||
Get M$
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { fromPropz } from 'web/hooks/use-propz'
|
||||
import Analytics, {
|
||||
CustomAnalytics,
|
||||
FirebaseAnalytics,
|
||||
getStaticPropz,
|
||||
} from '../stats'
|
||||
import { CustomAnalytics, FirebaseAnalytics } from '../stats'
|
||||
import { getStats } from 'web/lib/firebase/stats'
|
||||
import { Stats } from 'common/stats'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
|
||||
export default function AnalyticsEmbed(props: Parameters<typeof Analytics>[0]) {
|
||||
export default function AnalyticsEmbed() {
|
||||
const [stats, setStats] = useState<Stats | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
getStats().then(setStats)
|
||||
}, [])
|
||||
if (stats == null) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<Col className="w-full bg-white px-2">
|
||||
<CustomAnalytics {...props} />
|
||||
<CustomAnalytics {...stats} />
|
||||
<Spacer h={8} />
|
||||
<FirebaseAnalytics />
|
||||
</Col>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { take, sortBy, debounce } from 'lodash'
|
||||
|
||||
import { Group } from 'common/group'
|
||||
import { Comment } from 'common/comment'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
import { listAllBets } from 'web/lib/firebase/bets'
|
||||
|
@ -14,11 +13,11 @@ import {
|
|||
} from 'web/lib/firebase/groups'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
import { getUser, User } from 'web/lib/firebase/users'
|
||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useGroup, useMembers } from 'web/hooks/use-group'
|
||||
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
|
||||
import { useRouter } from 'next/router'
|
||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
||||
import { Leaderboard } from 'web/components/leaderboard'
|
||||
|
@ -32,20 +31,22 @@ import { Tabs } from 'web/components/layout/tabs'
|
|||
import { ContractsGrid } from 'web/components/contract/contracts-list'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Discussion } from 'web/components/groups/discussion'
|
||||
import { listenForCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||
import { GroupChat } from 'web/components/groups/group-chat'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { PlusIcon } from '@heroicons/react/outline'
|
||||
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useCommentsOnGroup } from 'web/hooks/use-comments'
|
||||
import ShortToggle from 'web/components/widgets/short-toggle'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
const { slugs } = props.params
|
||||
|
||||
const group = await getGroupBySlug(slugs[0])
|
||||
const members = group ? await listMembers(group) : []
|
||||
const creatorPromise = group ? getUser(group.creatorId) : null
|
||||
|
||||
const contracts = group ? await getGroupContracts(group).catch((_) => []) : []
|
||||
|
@ -66,6 +67,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
|||
return {
|
||||
props: {
|
||||
group,
|
||||
members,
|
||||
creator,
|
||||
traderScores,
|
||||
topTraders,
|
||||
|
@ -92,10 +94,11 @@ async function toTopUsers(userScores: { [userId: string]: number }) {
|
|||
export async function getStaticPaths() {
|
||||
return { paths: [], fallback: 'blocking' }
|
||||
}
|
||||
const groupSubpages = [undefined, 'discussion', 'questions', 'details'] as const
|
||||
const groupSubpages = [undefined, 'chat', 'questions', 'about'] as const
|
||||
|
||||
export default function GroupPage(props: {
|
||||
group: Group | null
|
||||
members: User[]
|
||||
creator: User
|
||||
traderScores: { [userId: string]: number }
|
||||
topTraders: User[]
|
||||
|
@ -104,25 +107,42 @@ export default function GroupPage(props: {
|
|||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
group: null,
|
||||
members: [],
|
||||
creator: null,
|
||||
traderScores: {},
|
||||
topTraders: [],
|
||||
creatorScores: {},
|
||||
topCreators: [],
|
||||
}
|
||||
const { creator, traderScores, topTraders, creatorScores, topCreators } =
|
||||
props
|
||||
const {
|
||||
creator,
|
||||
members,
|
||||
traderScores,
|
||||
topTraders,
|
||||
creatorScores,
|
||||
topCreators,
|
||||
} = props
|
||||
|
||||
const router = useRouter()
|
||||
const { slugs } = router.query as { slugs: string[] }
|
||||
const page = (slugs?.[1] ?? 'discussion') as typeof groupSubpages[number]
|
||||
const page = (slugs?.[1] ?? 'chat') as typeof groupSubpages[number]
|
||||
|
||||
const group = useGroup(props.group?.id) ?? props.group
|
||||
const [messages, setMessages] = useState<Comment[] | undefined>(undefined)
|
||||
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (group) listenForCommentsOnGroup(group.id, setMessages)
|
||||
}, [group])
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const messages = useCommentsOnGroup(group?.id)
|
||||
const debouncedQuery = debounce(setQuery, 50)
|
||||
const filteredContracts =
|
||||
query != '' && contracts
|
||||
? contracts.filter(
|
||||
(c) =>
|
||||
checkAgainstQuery(query, c.question) ||
|
||||
checkAgainstQuery(query, c.description || '') ||
|
||||
checkAgainstQuery(query, c.creatorName) ||
|
||||
checkAgainstQuery(query, c.creatorUsername)
|
||||
)
|
||||
: []
|
||||
|
||||
useEffect(() => {
|
||||
if (group)
|
||||
|
@ -139,20 +159,11 @@ export default function GroupPage(props: {
|
|||
|
||||
const rightSidebar = (
|
||||
<Col className="mt-6 hidden xl:block">
|
||||
<GroupOverview
|
||||
group={group}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
/>
|
||||
<YourPerformance
|
||||
traderScores={traderScores}
|
||||
creatorScores={creatorScores}
|
||||
user={user}
|
||||
/>
|
||||
<JoinOrCreateButton group={group} user={user} isMember={!!isMember} />
|
||||
<Spacer h={6} />
|
||||
{contracts && (
|
||||
<div className={'mt-2'}>
|
||||
<div className={'my-2 text-lg text-indigo-700'}>Recent Questions</div>
|
||||
<div className={'my-2 text-gray-500'}>Recent Questions</div>
|
||||
<ContractsGrid
|
||||
contracts={contracts
|
||||
.sort((a, b) => b.createdTime - a.createdTime)
|
||||
|
@ -166,13 +177,22 @@ export default function GroupPage(props: {
|
|||
</Col>
|
||||
)
|
||||
|
||||
const leaderboardsTab = (
|
||||
<Col className="mt-4 gap-8 px-4 md:flex-row">
|
||||
const aboutTab = (
|
||||
<Col>
|
||||
<GroupOverview
|
||||
group={group}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
|
||||
<GroupLeaderboards
|
||||
traderScores={traderScores}
|
||||
creatorScores={creatorScores}
|
||||
topTraders={topTraders}
|
||||
topCreators={topCreators}
|
||||
members={members}
|
||||
user={user}
|
||||
/>
|
||||
</Col>
|
||||
|
@ -185,49 +205,56 @@ export default function GroupPage(props: {
|
|||
url={groupPath(group.slug)}
|
||||
/>
|
||||
|
||||
<div className="px-3 lg:px-1">
|
||||
<Row className={' items-center justify-between gap-4 '}>
|
||||
<Col className="px-3 lg:px-1">
|
||||
<Row className={'items-center justify-between gap-4'}>
|
||||
<div className={'mb-1'}>
|
||||
<Title className={'line-clamp-2'} text={group.name} />
|
||||
<span className={'text-gray-700'}>{group.about}</span>
|
||||
<Linkify text={group.about} />
|
||||
</div>
|
||||
{isMember && (
|
||||
<CreateQuestionButton
|
||||
<div className="hidden sm:block xl:hidden">
|
||||
<JoinOrCreateButton
|
||||
group={group}
|
||||
user={user}
|
||||
overrideText={'Add a new question'}
|
||||
className={'w-48 flex-shrink-0'}
|
||||
query={`?groupId=${group.id}`}
|
||||
isMember={!!isMember}
|
||||
/>
|
||||
)}
|
||||
{!isMember && group.anyoneCanJoin && (
|
||||
<JoinGroupButton group={group} user={user} />
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
<div className="block sm:hidden">
|
||||
<JoinOrCreateButton group={group} user={user} isMember={!!isMember} />
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Tabs
|
||||
defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0}
|
||||
defaultIndex={page === 'about' ? 2 : page === 'questions' ? 1 : 0}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Discussion',
|
||||
title: 'Chat',
|
||||
content: messages ? (
|
||||
<Discussion messages={messages} user={user} group={group} />
|
||||
<GroupChat messages={messages} user={user} group={group} />
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
),
|
||||
href: groupPath(group.slug, 'discussion'),
|
||||
href: groupPath(group.slug, 'chat'),
|
||||
},
|
||||
{
|
||||
title: 'Questions',
|
||||
content: (
|
||||
<div className={'mt-2'}>
|
||||
<div className={'mt-2 px-1'}>
|
||||
{contracts ? (
|
||||
contracts.length > 0 ? (
|
||||
<ContractsGrid
|
||||
contracts={contracts}
|
||||
hasMore={false}
|
||||
loadMore={() => {}}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
onChange={(e) => debouncedQuery(e.target.value)}
|
||||
placeholder="Search the group's questions"
|
||||
className="input input-bordered mb-4 w-full"
|
||||
/>
|
||||
<ContractsGrid
|
||||
contracts={query != '' ? filteredContracts : contracts}
|
||||
hasMore={false}
|
||||
loadMore={() => {}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-2 text-gray-500">
|
||||
No questions yet. 🦗... Why not add one?
|
||||
|
@ -242,26 +269,9 @@ export default function GroupPage(props: {
|
|||
href: groupPath(group.slug, 'questions'),
|
||||
},
|
||||
{
|
||||
title: 'Details',
|
||||
content: (
|
||||
<>
|
||||
<div className={'xl:hidden'}>
|
||||
<GroupOverview
|
||||
group={group}
|
||||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
/>
|
||||
<YourPerformance
|
||||
traderScores={traderScores}
|
||||
creatorScores={creatorScores}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
{leaderboardsTab}
|
||||
</>
|
||||
),
|
||||
href: groupPath(group.slug, 'details'),
|
||||
title: 'About',
|
||||
content: aboutTab,
|
||||
href: groupPath(group.slug, 'about'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
@ -269,6 +279,24 @@ export default function GroupPage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function JoinOrCreateButton(props: {
|
||||
group: Group
|
||||
user: User | null | undefined
|
||||
isMember: boolean
|
||||
}) {
|
||||
const { group, user, isMember } = props
|
||||
return isMember ? (
|
||||
<CreateQuestionButton
|
||||
user={user}
|
||||
overrideText={'Add a new question'}
|
||||
className={'w-48 flex-shrink-0'}
|
||||
query={`?groupId=${group.id}`}
|
||||
/>
|
||||
) : group.anyoneCanJoin ? (
|
||||
<JoinGroupButton group={group} user={user} />
|
||||
) : null
|
||||
}
|
||||
|
||||
function GroupOverview(props: {
|
||||
group: Group
|
||||
creator: User
|
||||
|
@ -276,7 +304,6 @@ function GroupOverview(props: {
|
|||
isCreator: boolean
|
||||
}) {
|
||||
const { group, creator, isCreator, user } = props
|
||||
const { about } = group
|
||||
const anyoneCanJoinChoices: { [key: string]: string } = {
|
||||
Closed: 'false',
|
||||
Open: 'true',
|
||||
|
@ -295,7 +322,7 @@ function GroupOverview(props: {
|
|||
return (
|
||||
<Col>
|
||||
<Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white">
|
||||
<Row className="flex-1 justify-start">About group</Row>
|
||||
<Row className="flex-1 justify-start">About {group.name}</Row>
|
||||
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
|
||||
</Row>
|
||||
<Col className="gap-2 rounded-b bg-white p-4">
|
||||
|
@ -307,7 +334,6 @@ function GroupOverview(props: {
|
|||
username={creator.username}
|
||||
/>
|
||||
</Row>
|
||||
<GroupMembersList group={group} />
|
||||
<Row className={'items-center gap-1'}>
|
||||
<span className={'text-gray-500'}>Membership</span>
|
||||
{user && user.id === creator.id ? (
|
||||
|
@ -326,14 +352,6 @@ function GroupOverview(props: {
|
|||
</span>
|
||||
)}
|
||||
</Row>
|
||||
{about && (
|
||||
<>
|
||||
<Spacer h={2} />
|
||||
<div className="text-gray-500">
|
||||
<Linkify text={about} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
|
@ -364,40 +382,24 @@ export function GroupMembersList(props: { group: Group }) {
|
|||
)
|
||||
}
|
||||
|
||||
function YourPerformance(props: {
|
||||
traderScores: { [userId: string]: number }
|
||||
creatorScores: { [userId: string]: number }
|
||||
|
||||
user: User | null | undefined
|
||||
function SortedLeaderboard(props: {
|
||||
users: User[]
|
||||
scoreFunction: (user: User) => number
|
||||
title: string
|
||||
header: string
|
||||
}) {
|
||||
const { traderScores, creatorScores, user } = props
|
||||
|
||||
const yourTraderScore = user ? traderScores[user.id] : undefined
|
||||
const yourCreatorScore = user ? creatorScores[user.id] : undefined
|
||||
|
||||
return user ? (
|
||||
<Col>
|
||||
<div className="rounded bg-indigo-500 px-4 py-3 text-sm text-white">
|
||||
Your performance
|
||||
</div>
|
||||
<div className="bg-white p-2">
|
||||
<table className="table-compact table w-full text-gray-500">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Total profit</td>
|
||||
<td>{formatMoney(yourTraderScore ?? 0)}</td>
|
||||
</tr>
|
||||
{yourCreatorScore && (
|
||||
<tr>
|
||||
<td>Total created pool</td>
|
||||
<td>{formatMoney(yourCreatorScore)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Col>
|
||||
) : null
|
||||
const { users, scoreFunction, title, header } = props
|
||||
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
|
||||
return (
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
users={sortedUsers}
|
||||
title={title}
|
||||
columns={[
|
||||
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupLeaderboards(props: {
|
||||
|
@ -405,41 +407,69 @@ function GroupLeaderboards(props: {
|
|||
creatorScores: { [userId: string]: number }
|
||||
topTraders: User[]
|
||||
topCreators: User[]
|
||||
members: User[]
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { traderScores, creatorScores, topTraders, topCreators } = props
|
||||
|
||||
const topTraderScores = topTraders.map((user) => traderScores[user.id])
|
||||
const topCreatorScores = topCreators.map((user) => creatorScores[user.id])
|
||||
const { traderScores, creatorScores, members, topTraders, topCreators } =
|
||||
props
|
||||
const [includeOutsiders, setIncludeOutsiders] = useState(false)
|
||||
|
||||
// Consider hiding M$0
|
||||
return (
|
||||
<>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
header: 'Profit',
|
||||
renderCell: (user) =>
|
||||
formatMoney(topTraderScores[topTraders.indexOf(user)]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Col>
|
||||
<Row className="items-center justify-end gap-4 text-gray-500">
|
||||
Include all users
|
||||
<ShortToggle
|
||||
enabled={includeOutsiders}
|
||||
setEnabled={setIncludeOutsiders}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top creators"
|
||||
users={topCreators}
|
||||
columns={[
|
||||
{
|
||||
header: 'Market volume',
|
||||
renderCell: (user) =>
|
||||
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
|
||||
{!includeOutsiders ? (
|
||||
<>
|
||||
<SortedLeaderboard
|
||||
users={members}
|
||||
scoreFunction={(user) => traderScores[user.id] ?? 0}
|
||||
title="🏅 Top bettors"
|
||||
header="Profit"
|
||||
/>
|
||||
<SortedLeaderboard
|
||||
users={members}
|
||||
scoreFunction={(user) => creatorScores[user.id] ?? 0}
|
||||
title="🏅 Top creators"
|
||||
header="Market volume"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top bettors"
|
||||
users={topTraders}
|
||||
columns={[
|
||||
{
|
||||
header: 'Profit',
|
||||
renderCell: (user) => formatMoney(traderScores[user.id] ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Leaderboard
|
||||
className="max-w-xl"
|
||||
title="🏅 Top creators"
|
||||
users={topCreators}
|
||||
columns={[
|
||||
{
|
||||
header: 'Market volume',
|
||||
renderCell: (user) =>
|
||||
formatMoney(creatorScores[user.id] ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -451,15 +481,7 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
|
||||
useEffect(() => {
|
||||
return listenForUserContracts(user.id, (contracts) => {
|
||||
setContracts(
|
||||
contracts.filter(
|
||||
(c) =>
|
||||
!group.contractIds.includes(c.id) &&
|
||||
// TODO: It'll be easy to allow questions to be in multiple groups as long as we
|
||||
// have the on-update-group function update the newly added contract's groupDetails (via contractIds)
|
||||
!c.groupDetails
|
||||
)
|
||||
)
|
||||
setContracts(contracts.filter((c) => !group.contractIds.includes(c.id)))
|
||||
})
|
||||
}, [group.contractIds, user.id])
|
||||
|
||||
|
@ -549,8 +571,11 @@ function JoinGroupButton(props: {
|
|||
}
|
||||
return (
|
||||
<div>
|
||||
<button onClick={joinGroup} className={'btn-md btn-outline btn '}>
|
||||
Join Group
|
||||
<button
|
||||
onClick={user ? joinGroup : firebaseLogin}
|
||||
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
|
||||
>
|
||||
{user ? 'Join group' : 'Login to join group'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ import { PlusSmIcon } from '@heroicons/react/solid'
|
|||
import { Page } from 'web/components/page'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
|
||||
import { ContractSearch } from 'web/components/contract-search'
|
||||
import { Contract } from 'common/contract'
|
||||
import { ContractPageContent } from './[username]/[contractSlug]'
|
||||
|
@ -17,7 +18,6 @@ const Home = () => {
|
|||
const [contract, setContract] = useContractPage()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
useTracking('view home')
|
||||
|
||||
if (user === null) {
|
||||
|
@ -32,7 +32,7 @@ const Home = () => {
|
|||
<ContractSearch
|
||||
querySortOptions={{
|
||||
shouldLoadFromStorage: true,
|
||||
defaultSort: '24-hour-vol',
|
||||
defaultSort: getSavedSort() ?? '24-hour-vol',
|
||||
}}
|
||||
showCategorySelector
|
||||
onContractClick={(c) => {
|
||||
|
|
|
@ -4,8 +4,8 @@ import { Page } from 'web/components/page'
|
|||
import {
|
||||
getTopCreators,
|
||||
getTopTraders,
|
||||
LeaderboardPeriod,
|
||||
getTopFollowed,
|
||||
Period,
|
||||
User,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
@ -20,7 +20,7 @@ export const getStaticProps = fromPropz(getStaticPropz)
|
|||
export async function getStaticPropz() {
|
||||
return queryLeaderboardUsers('allTime')
|
||||
}
|
||||
const queryLeaderboardUsers = async (period: LeaderboardPeriod) => {
|
||||
const queryLeaderboardUsers = async (period: Period) => {
|
||||
const [topTraders, topCreators, topFollowed] = await Promise.all([
|
||||
getTopTraders(period).catch(() => {}),
|
||||
getTopCreators(period).catch(() => {}),
|
||||
|
@ -50,7 +50,7 @@ export default function Leaderboards(props: {
|
|||
const [topTradersState, setTopTraders] = useState(props.topTraders)
|
||||
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [period, setPeriod] = useState<LeaderboardPeriod>('allTime')
|
||||
const [period, setPeriod] = useState<Period>('allTime')
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
|
@ -61,13 +61,13 @@ export default function Leaderboards(props: {
|
|||
})
|
||||
}, [period])
|
||||
|
||||
const LeaderboardWithPeriod = (period: LeaderboardPeriod) => {
|
||||
const LeaderboardWithPeriod = (period: Period) => {
|
||||
return (
|
||||
<>
|
||||
<Col className="mx-4 items-center gap-10 lg:flex-row">
|
||||
{!isLoading ? (
|
||||
<>
|
||||
{period === 'allTime' ? ( //TODO: show other periods once they're available
|
||||
{period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
users={topTradersState}
|
||||
|
@ -127,7 +127,7 @@ export default function Leaderboards(props: {
|
|||
defaultIndex={0}
|
||||
onClick={(title, index) => {
|
||||
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
|
||||
setPeriod(period as LeaderboardPeriod)
|
||||
setPeriod(period as Period)
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
|
|
68
web/pages/link/[slug].tsx
Normal file
68
web/pages/link/[slug].tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Title } from 'web/components/title'
|
||||
import { claimManalink } from 'web/lib/firebase/fn-call'
|
||||
import { useManalink } from 'web/lib/firebase/manalinks'
|
||||
import { ManalinkCard } from 'web/components/manalink-card'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { useUserById } from 'web/hooks/use-users'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
|
||||
export default function ClaimPage() {
|
||||
const user = useUser()
|
||||
const router = useRouter()
|
||||
const { slug } = router.query as { slug: string }
|
||||
const manalink = useManalink(slug)
|
||||
const [claiming, setClaiming] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
const fromUser = useUserById(manalink?.fromId)
|
||||
if (!manalink) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const info = { ...manalink, uses: manalink.claims.length }
|
||||
return (
|
||||
<>
|
||||
<SEO
|
||||
title="Send Mana"
|
||||
description="Send mana to anyone via link!"
|
||||
url="/send"
|
||||
/>
|
||||
<div className="mx-auto max-w-xl">
|
||||
<Title text={`Claim ${manalink.amount} mana`} />
|
||||
<ManalinkCard
|
||||
defaultMessage={fromUser?.name || 'Enjoy this mana!'}
|
||||
info={info}
|
||||
isClaiming={claiming}
|
||||
onClaim={async () => {
|
||||
setClaiming(true)
|
||||
try {
|
||||
if (user == null) {
|
||||
await firebaseLogin()
|
||||
}
|
||||
const result = await claimManalink(manalink.slug)
|
||||
if (result.data.status == 'error') {
|
||||
throw new Error(result.data.message)
|
||||
}
|
||||
router.push('/account?claimed-mana=yes')
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
const message =
|
||||
e && e instanceof Object ? e.toString() : 'An error occurred.'
|
||||
setError(message)
|
||||
}
|
||||
setClaiming(false)
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<section className="my-5 text-red-500">
|
||||
<p>Failed to claim manalink.</p>
|
||||
<p>{error}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
386
web/pages/links.tsx
Normal file
386
web/pages/links.tsx
Normal file
|
@ -0,0 +1,386 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||
import { Claim, Manalink } from 'common/manalink'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { Page } from 'web/components/page'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Title } from 'web/components/title'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { createManalink, useUserManalinks } from 'web/lib/firebase/manalinks'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { useUserById } from 'web/hooks/use-users'
|
||||
import { ManalinkTxn } from 'common/txn'
|
||||
import { User } from 'common/user'
|
||||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
|
||||
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
function getLinkUrl(slug: string) {
|
||||
return `${location.protocol}//${location.host}/link/${slug}`
|
||||
}
|
||||
|
||||
// TODO: incredibly gross, but the tab component is wrongly designed and
|
||||
// keeps the tab state inside of itself, so this seems like the only
|
||||
// way we can tell it to switch tabs from outside after initial render.
|
||||
function setTabIndex(tabIndex: number) {
|
||||
const tabHref = document.getElementById(`tab-${tabIndex}`)
|
||||
if (tabHref) {
|
||||
tabHref.click()
|
||||
}
|
||||
}
|
||||
|
||||
export default function LinkPage() {
|
||||
const user = useUser()
|
||||
const links = useUserManalinks(user?.id ?? '')
|
||||
// const manalinkTxns = useManalinkTxns(user?.id ?? '')
|
||||
const [highlightedSlug, setHighlightedSlug] = useState('')
|
||||
const unclaimedLinks = links.filter(
|
||||
(l) =>
|
||||
(l.maxUses == null || l.claimedUserIds.length < l.maxUses) &&
|
||||
(l.expiresTime == null || l.expiresTime > Date.now())
|
||||
)
|
||||
|
||||
if (user == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
title="Manalinks"
|
||||
description="Send mana to anyone via link!"
|
||||
url="/send"
|
||||
/>
|
||||
<Col className="w-full px-8">
|
||||
<Title text="Manalinks" />
|
||||
<Tabs
|
||||
className={'pb-2 pt-1 '}
|
||||
defaultIndex={0}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Create a link',
|
||||
content: (
|
||||
<CreateManalinkForm
|
||||
user={user}
|
||||
onCreate={async (newManalink) => {
|
||||
const slug = await createManalink({
|
||||
fromId: user.id,
|
||||
amount: newManalink.amount,
|
||||
expiresTime: newManalink.expiresTime,
|
||||
maxUses: newManalink.maxUses,
|
||||
message: newManalink.message,
|
||||
})
|
||||
setTabIndex(1)
|
||||
setHighlightedSlug(slug || '')
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Unclaimed links',
|
||||
content: (
|
||||
<LinksTable
|
||||
links={unclaimedLinks}
|
||||
highlightedSlug={highlightedSlug}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: we have no use case for this atm and it's also really inefficient
|
||||
// {
|
||||
// title: 'Claimed',
|
||||
// content: <ClaimsList txns={manalinkTxns} />,
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateManalinkForm(props: {
|
||||
user: User
|
||||
onCreate: (m: ManalinkInfo) => Promise<void>
|
||||
}) {
|
||||
const { user, onCreate } = props
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newManalink, setNewManalink] = useState<ManalinkInfo>({
|
||||
expiresTime: null,
|
||||
amount: 100,
|
||||
maxUses: 1,
|
||||
uses: 0,
|
||||
message: '',
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
You can use manalinks to send mana to other people, even if they
|
||||
don't yet have a Manifold account.
|
||||
</p>
|
||||
<form
|
||||
className="my-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setIsCreating(true)
|
||||
onCreate(newManalink).finally(() => setIsCreating(false))
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row flex-wrap gap-x-5 gap-y-2">
|
||||
<div className="form-control flex-auto">
|
||||
<label className="label">Amount</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={newManalink.amount}
|
||||
onChange={(e) =>
|
||||
setNewManalink((m) => {
|
||||
return { ...m, amount: parseInt(e.target.value) }
|
||||
})
|
||||
}
|
||||
></input>
|
||||
</div>
|
||||
<div className="form-control flex-auto">
|
||||
<label className="label">Uses</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={newManalink.maxUses ?? ''}
|
||||
onChange={(e) =>
|
||||
setNewManalink((m) => {
|
||||
return { ...m, maxUses: parseInt(e.target.value) }
|
||||
})
|
||||
}
|
||||
></input>
|
||||
</div>
|
||||
<div className="form-control flex-auto">
|
||||
<label className="label">Expires at</label>
|
||||
<input
|
||||
value={
|
||||
newManalink.expiresTime != null
|
||||
? dayjs(newManalink.expiresTime).format('YYYY-MM-DDTHH:mm')
|
||||
: ''
|
||||
}
|
||||
className="input"
|
||||
type="datetime-local"
|
||||
onChange={(e) => {
|
||||
setNewManalink((m) => {
|
||||
return {
|
||||
...m,
|
||||
expiresTime: e.target.value
|
||||
? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf()
|
||||
: null,
|
||||
}
|
||||
})
|
||||
}}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-control w-full">
|
||||
<label className="label">Message</label>
|
||||
<Textarea
|
||||
placeholder={`From ${user.name}`}
|
||||
className="input input-bordered resize-none"
|
||||
autoFocus
|
||||
value={newManalink.message}
|
||||
onChange={(e) =>
|
||||
setNewManalink((m) => {
|
||||
return { ...m, message: e.target.value }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx('btn mt-5', isCreating ? 'loading disabled' : '')}
|
||||
>
|
||||
{isCreating ? '' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Title text="Preview" />
|
||||
<p>This is what the person you send the link to will see:</p>
|
||||
<ManalinkCard
|
||||
className="my-5"
|
||||
defaultMessage={`From ${user.name}`}
|
||||
info={newManalink}
|
||||
isClaiming={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClaimsList(props: { txns: ManalinkTxn[] }) {
|
||||
const { txns } = props
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-4 text-xl font-semibold text-gray-900">
|
||||
Claimed links
|
||||
</h1>
|
||||
{txns.map((txn) => (
|
||||
<ClaimDescription txn={txn} key={txn.id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClaimDescription(props: { txn: ManalinkTxn }) {
|
||||
const { txn } = props
|
||||
const from = useUserById(txn.fromId)
|
||||
const to = useUserById(txn.toId)
|
||||
|
||||
if (!from || !to) {
|
||||
return <>Loading...</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flow-root pr-2 md:pr-0">
|
||||
<div className="relative flex items-center space-x-3">
|
||||
<Avatar username={to.name} avatarUrl={to.avatarUrl} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
username={to.username}
|
||||
name={to.name}
|
||||
/>{' '}
|
||||
claimed {formatMoney(txn.amount)} from{' '}
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
username={from.username}
|
||||
name={from.name}
|
||||
/>
|
||||
<RelativeTimestamp time={txn.createdTime} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClaimTableRow(props: { claim: Claim }) {
|
||||
const { claim } = props
|
||||
const who = useUserById(claim.toId)
|
||||
return (
|
||||
<tr>
|
||||
<td className="px-5 py-2">{who?.name || 'Loading...'}</td>
|
||||
<td className="px-5 py-2">{`${new Date(
|
||||
claim.claimedTime
|
||||
).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkDetailsTable(props: { link: Manalink }) {
|
||||
const { link } = props
|
||||
return (
|
||||
<table className="w-full divide-y divide-gray-300 border border-gray-400">
|
||||
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
|
||||
<tr>
|
||||
<th className="px-5 py-2">Claimed by</th>
|
||||
<th className="px-5 py-2">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500">
|
||||
{link.claims.length ? (
|
||||
link.claims.map((claim) => <ClaimTableRow claim={claim} />)
|
||||
) : (
|
||||
<tr>
|
||||
<td className="px-5 py-2" colSpan={2}>
|
||||
No claims yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkTableRow(props: { link: Manalink; highlight: boolean }) {
|
||||
const { link, highlight } = props
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<LinkSummaryRow
|
||||
link={link}
|
||||
highlight={highlight}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((exp) => !exp)}
|
||||
/>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<td className="bg-gray-100 p-3" colSpan={5}>
|
||||
<LinkDetailsTable link={link} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkSummaryRow(props: {
|
||||
link: Manalink
|
||||
highlight: boolean
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { link, highlight, expanded, onToggle } = props
|
||||
const className = clsx(
|
||||
'whitespace-nowrap text-sm hover:cursor-pointer',
|
||||
highlight ? 'bg-primary' : 'text-gray-500 hover:bg-sky-50 bg-white'
|
||||
)
|
||||
return (
|
||||
<tr id={link.slug} key={link.slug} className={className}>
|
||||
<td className="py-4 pl-5" onClick={onToggle}>
|
||||
{expanded ? (
|
||||
<ChevronUpIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-5 w-5" />
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 font-medium text-gray-900">
|
||||
{formatMoney(link.amount)}
|
||||
</td>
|
||||
<td className="px-5 py-4">{getLinkUrl(link.slug)}</td>
|
||||
<td className="px-5 py-4">{link.claimedUserIds.length}</td>
|
||||
<td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td>
|
||||
<td className="px-5 py-4">
|
||||
{link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) {
|
||||
const { links, highlightedSlug } = props
|
||||
return links.length == 0 ? (
|
||||
<p>You don't currently have any outstanding manalinks.</p>
|
||||
) : (
|
||||
<table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200">
|
||||
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th className="px-5 py-3.5">Amount</th>
|
||||
<th className="px-5 py-3.5">Link</th>
|
||||
<th className="px-5 py-3.5">Uses</th>
|
||||
<th className="px-5 py-3.5">Max Uses</th>
|
||||
<th className="px-5 py-3.5">Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{links.map((link) => (
|
||||
<LinkTableRow link={link} highlight={link.slug === highlightedSlug} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
|
@ -7,7 +7,7 @@ import {
|
|||
notification_source_types,
|
||||
notification_source_update_types,
|
||||
} from 'common/notification'
|
||||
import { Avatar } from 'web/components/avatar'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
|
@ -25,7 +25,6 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
|||
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import clsx from 'clsx'
|
||||
import { UsersIcon } from '@heroicons/react/solid'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
import { Linkify } from 'web/components/linkify'
|
||||
import {
|
||||
|
@ -235,9 +234,7 @@ function NotificationGroupItem(props: {
|
|||
/>
|
||||
)}
|
||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
||||
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
</div>
|
||||
<EmptyAvatar multi />
|
||||
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { zip, uniq, sumBy, concat, countBy, sortBy, sum } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
DailyCountChart,
|
||||
DailyPercentChart,
|
||||
|
@ -9,265 +9,18 @@ import { Spacer } from 'web/components/layout/spacer'
|
|||
import { Tabs } from 'web/components/layout/tabs'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
import { fromPropz, usePropz } from 'web/hooks/use-propz'
|
||||
import { getDailyBets } from 'web/lib/firebase/bets'
|
||||
import { getDailyComments } from 'web/lib/firebase/comments'
|
||||
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'
|
||||
import { getStats } from 'web/lib/firebase/stats'
|
||||
import { Stats } from 'common/stats'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz() {
|
||||
const numberOfDays = 90
|
||||
const today = dayjs(dayjs().format('YYYY-MM-DD'))
|
||||
// Convert from UTC midnight to PT midnight.
|
||||
.add(7, 'hours')
|
||||
|
||||
const startDate = today.subtract(numberOfDays, 'day')
|
||||
|
||||
const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] =
|
||||
await Promise.all([
|
||||
getDailyBets(startDate.valueOf(), numberOfDays),
|
||||
getDailyContracts(startDate.valueOf(), numberOfDays),
|
||||
getDailyComments(startDate.valueOf(), numberOfDays),
|
||||
getDailyNewUsers(startDate.valueOf(), numberOfDays),
|
||||
])
|
||||
|
||||
const dailyBetCounts = dailyBets.map((bets) => bets.length)
|
||||
const dailyContractCounts = dailyContracts.map(
|
||||
(contracts) => contracts.length
|
||||
)
|
||||
const dailyCommentCounts = dailyComments.map((comments) => comments.length)
|
||||
|
||||
const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map(
|
||||
([contracts, bets, comments]) => {
|
||||
const creatorIds = (contracts ?? []).map((c) => c.creatorId)
|
||||
const betUserIds = (bets ?? []).map((bet) => bet.userId)
|
||||
const commentUserIds = (comments ?? []).map((comment) => comment.userId)
|
||||
return uniq([...creatorIds, ...betUserIds, ...commentUserIds])
|
||||
}
|
||||
)
|
||||
|
||||
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
|
||||
|
||||
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
||||
return uniques.size
|
||||
})
|
||||
|
||||
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const uniques = new Set<string>()
|
||||
for (let j = start; j <= end; j++)
|
||||
dailyUserIds[j].forEach((userId) => uniques.add(userId))
|
||||
return uniques.size
|
||||
})
|
||||
|
||||
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
|
||||
const twoWeeksAgo = {
|
||||
start: Math.max(0, i - 13),
|
||||
end: Math.max(0, i - 7),
|
||||
}
|
||||
const lastWeek = {
|
||||
start: Math.max(0, i - 6),
|
||||
end: i,
|
||||
}
|
||||
|
||||
const activeTwoWeeksAgo = new Set<string>()
|
||||
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
|
||||
}
|
||||
const activeLastWeek = new Set<string>()
|
||||
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
|
||||
}
|
||||
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
|
||||
activeLastWeek.has(userId) ? 1 : 0
|
||||
)
|
||||
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
|
||||
return Math.round(retainedFrac * 100 * 100) / 100
|
||||
})
|
||||
|
||||
const monthlyRetention = dailyUserIds.map((_userId, i) => {
|
||||
const twoMonthsAgo = {
|
||||
start: Math.max(0, i - 60),
|
||||
end: Math.max(0, i - 30),
|
||||
}
|
||||
const lastMonth = {
|
||||
start: Math.max(0, i - 30),
|
||||
end: i,
|
||||
}
|
||||
|
||||
const activeTwoMonthsAgo = new Set<string>()
|
||||
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
|
||||
}
|
||||
const activeLastMonth = new Set<string>()
|
||||
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
|
||||
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
|
||||
}
|
||||
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
|
||||
activeLastMonth.has(userId) ? 1 : 0
|
||||
)
|
||||
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
|
||||
return Math.round(retainedFrac * 100 * 100) / 100
|
||||
})
|
||||
|
||||
const firstBetDict: { [userId: string]: number } = {}
|
||||
for (let i = 0; i < dailyBets.length; i++) {
|
||||
const bets = dailyBets[i]
|
||||
for (const bet of bets) {
|
||||
if (bet.userId in firstBetDict) continue
|
||||
firstBetDict[bet.userId] = i
|
||||
}
|
||||
}
|
||||
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
let activatedCount = 0
|
||||
let newUsers = 0
|
||||
for (let j = start; j <= end; j++) {
|
||||
const userIds = dailyNewUsers[j].map((user) => user.id)
|
||||
newUsers += userIds.length
|
||||
for (const userId of userIds) {
|
||||
const dayIndex = firstBetDict[userId]
|
||||
if (dayIndex !== undefined && dayIndex <= end) {
|
||||
activatedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
const frac = activatedCount / (newUsers || 1)
|
||||
return Math.round(frac * 100 * 100) / 100
|
||||
})
|
||||
const dailySignups = dailyNewUsers.map((users) => users.length)
|
||||
|
||||
const dailyTopTenthActions = zip(
|
||||
dailyContracts,
|
||||
dailyBets,
|
||||
dailyComments
|
||||
).map(([contracts, bets, comments]) => {
|
||||
const userIds = concat(
|
||||
contracts?.map((c) => c.creatorId) ?? [],
|
||||
bets?.map((b) => b.userId) ?? [],
|
||||
comments?.map((c) => c.userId) ?? []
|
||||
)
|
||||
const counts = Object.values(countBy(userIds))
|
||||
const sortedCounts = sortBy(counts, (count) => count).reverse()
|
||||
if (sortedCounts.length === 0) return 0
|
||||
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
|
||||
return tenthPercentile
|
||||
})
|
||||
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
return average(dailyTopTenthActions.slice(start, end))
|
||||
})
|
||||
|
||||
// Total mana divided by 100.
|
||||
const dailyManaBet = dailyBets.map((bets) => {
|
||||
return Math.round(sumBy(bets, (bet) => bet.amount) / 100)
|
||||
})
|
||||
const weeklyManaBet = dailyManaBet.map((_, i) => {
|
||||
const start = Math.max(0, i - 6)
|
||||
const end = i
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
if (end - start < 7) return (total * 7) / (end - start)
|
||||
return total
|
||||
})
|
||||
const monthlyManaBet = dailyManaBet.map((_, i) => {
|
||||
const start = Math.max(0, i - 29)
|
||||
const end = i
|
||||
const total = sum(dailyManaBet.slice(start, end))
|
||||
const range = end - start + 1
|
||||
if (range < 30) return (total * 30) / range
|
||||
return total
|
||||
})
|
||||
|
||||
return {
|
||||
props: {
|
||||
startDate: startDate.valueOf(),
|
||||
dailyActiveUsers,
|
||||
weeklyActiveUsers,
|
||||
monthlyActiveUsers,
|
||||
dailyBetCounts,
|
||||
dailyContractCounts,
|
||||
dailyCommentCounts,
|
||||
dailySignups,
|
||||
weekOnWeekRetention,
|
||||
weeklyActivationRate,
|
||||
monthlyRetention,
|
||||
topTenthActions: {
|
||||
daily: dailyTopTenthActions,
|
||||
weekly: weeklyTopTenthActions,
|
||||
monthly: monthlyTopTenthActions,
|
||||
},
|
||||
manaBet: {
|
||||
daily: dailyManaBet,
|
||||
weekly: weeklyManaBet,
|
||||
monthly: monthlyManaBet,
|
||||
},
|
||||
},
|
||||
revalidate: 60 * 60, // Regenerate after an hour
|
||||
}
|
||||
}
|
||||
|
||||
export default function Analytics(props: {
|
||||
startDate: number
|
||||
dailyActiveUsers: number[]
|
||||
weeklyActiveUsers: number[]
|
||||
monthlyActiveUsers: number[]
|
||||
dailyBetCounts: number[]
|
||||
dailyContractCounts: number[]
|
||||
dailyCommentCounts: number[]
|
||||
dailySignups: number[]
|
||||
weekOnWeekRetention: number[]
|
||||
monthlyRetention: number[]
|
||||
weeklyActivationRate: number[]
|
||||
topTenthActions: {
|
||||
daily: number[]
|
||||
weekly: number[]
|
||||
monthly: number[]
|
||||
}
|
||||
manaBet: {
|
||||
daily: number[]
|
||||
weekly: number[]
|
||||
monthly: number[]
|
||||
}
|
||||
}) {
|
||||
props = usePropz(props, getStaticPropz) ?? {
|
||||
startDate: 0,
|
||||
dailyActiveUsers: [],
|
||||
weeklyActiveUsers: [],
|
||||
monthlyActiveUsers: [],
|
||||
dailyBetCounts: [],
|
||||
dailyContractCounts: [],
|
||||
dailyCommentCounts: [],
|
||||
dailySignups: [],
|
||||
weekOnWeekRetention: [],
|
||||
monthlyRetention: [],
|
||||
weeklyActivationRate: [],
|
||||
topTenthActions: {
|
||||
daily: [],
|
||||
weekly: [],
|
||||
monthly: [],
|
||||
},
|
||||
manaBet: {
|
||||
daily: [],
|
||||
weekly: [],
|
||||
monthly: [],
|
||||
},
|
||||
export default function Analytics() {
|
||||
const [stats, setStats] = useState<Stats | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
getStats().then(setStats)
|
||||
}, [])
|
||||
if (stats == null) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<Page>
|
||||
|
@ -275,7 +28,7 @@ export default function Analytics(props: {
|
|||
tabs={[
|
||||
{
|
||||
title: 'Activity',
|
||||
content: <CustomAnalytics {...props} />,
|
||||
content: <CustomAnalytics {...stats} />,
|
||||
},
|
||||
{
|
||||
title: 'Market Stats',
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
"watchOptions": {
|
||||
"excludeDirectories": [".next"]
|
||||
},
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -3148,6 +3148,11 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/string-similarity@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/string-similarity/-/string-similarity-4.0.0.tgz#8cc03d5d1baad2b74530fe6c7d849d5768d391ad"
|
||||
integrity sha512-dMS4S07fbtY1AILG/RhuwmptmzK1Ql8scmAebOTJ/8iBtK/KI17NwGwKzu1uipjj8Kk+3mfPxum56kKZE93mzQ==
|
||||
|
||||
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
|
||||
|
@ -10315,6 +10320,11 @@ streamsearch@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
string-similarity@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
|
||||
integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
|
||||
|
||||
string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
|
|
Loading…
Reference in New Issue
Block a user