Merge branch 'main' into pseudo-numeric
This commit is contained in:
commit
d7308a5787
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
|
@ -52,4 +52,4 @@ jobs:
|
||||||
- name: Run Typescript checker on cloud functions
|
- name: Run Typescript checker on cloud functions
|
||||||
if: ${{ success() || failure() }}
|
if: ${{ success() || failure() }}
|
||||||
working-directory: functions
|
working-directory: functions
|
||||||
run: tsc --pretty --project tsconfig.json --noEmit
|
run: tsc -b -v --pretty
|
||||||
|
|
|
||||||
5
common/.gitignore
vendored
5
common/.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
||||||
# Compiled JavaScript files
|
# Compiled JavaScript files
|
||||||
lib/**/*.js
|
lib/
|
||||||
lib/**/*.js.map
|
|
||||||
|
|
||||||
# TypeScript v1 declaration files
|
# TypeScript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
@ -10,4 +9,4 @@ node_modules/
|
||||||
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
ui-debug.log
|
ui-debug.log
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working
|
||||||
|
|
||||||
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
|
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'The Center for Election Science',
|
||||||
|
website: 'https://electionscience.org/',
|
||||||
|
photo: 'https://i.imgur.com/WvdHHZa.png',
|
||||||
|
preview:
|
||||||
|
'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.',
|
||||||
|
description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform.
|
||||||
|
|
||||||
|
Our Mission — To empower people with voting methods that strengthen democracy.
|
||||||
|
|
||||||
|
Our Vision — A world where democracies thrive because voters’ voices are heard.
|
||||||
|
|
||||||
|
With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research.
|
||||||
|
|
||||||
|
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
|
||||||
|
},
|
||||||
].map((charity) => {
|
].map((charity) => {
|
||||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export type notification_source_types =
|
||||||
| 'tip'
|
| 'tip'
|
||||||
| 'admin_message'
|
| 'admin_message'
|
||||||
| 'group'
|
| 'group'
|
||||||
|
| 'user'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
|
@ -53,3 +54,5 @@ export type notification_reason_types =
|
||||||
| 'on_new_follow'
|
| 'on_new_follow'
|
||||||
| 'you_follow_user'
|
| 'you_follow_user'
|
||||||
| 'added_you_to_group'
|
| 'added_you_to_group'
|
||||||
|
| 'you_referred_user'
|
||||||
|
| 'user_joined_to_bet_on_your_market'
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"verify": "(cd .. && yarn verify)"
|
"verify": "(cd .. && yarn verify)",
|
||||||
|
"verify:dir": "npx eslint . --max-warnings 0"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,12 @@ export type PayoutInfo = {
|
||||||
|
|
||||||
export const getPayouts = (
|
export const getPayouts = (
|
||||||
outcome: string | undefined,
|
outcome: string | undefined,
|
||||||
resolutions: {
|
|
||||||
[outcome: string]: number
|
|
||||||
},
|
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
liquidities: LiquidityProvision[],
|
liquidities: LiquidityProvision[],
|
||||||
|
resolutions?: {
|
||||||
|
[outcome: string]: number
|
||||||
|
},
|
||||||
resolutionProbability?: number
|
resolutionProbability?: number
|
||||||
): PayoutInfo => {
|
): PayoutInfo => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -76,9 +76,9 @@ export const getPayouts = (
|
||||||
}
|
}
|
||||||
return getDpmPayouts(
|
return getDpmPayouts(
|
||||||
outcome,
|
outcome,
|
||||||
resolutions,
|
|
||||||
contract,
|
contract,
|
||||||
bets,
|
bets,
|
||||||
|
resolutions,
|
||||||
resolutionProbability
|
resolutionProbability
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -109,11 +109,11 @@ export const getFixedPayouts = (
|
||||||
|
|
||||||
export const getDpmPayouts = (
|
export const getDpmPayouts = (
|
||||||
outcome: string | undefined,
|
outcome: string | undefined,
|
||||||
resolutions: {
|
|
||||||
[outcome: string]: number
|
|
||||||
},
|
|
||||||
contract: DPMContract,
|
contract: DPMContract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
|
resolutions?: {
|
||||||
|
[outcome: string]: number
|
||||||
|
},
|
||||||
resolutionProbability?: number
|
resolutionProbability?: number
|
||||||
): PayoutInfo => {
|
): PayoutInfo => {
|
||||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||||
|
|
@ -124,8 +124,8 @@ export const getDpmPayouts = (
|
||||||
return getDpmStandardPayouts(outcome, contract, openBets)
|
return getDpmStandardPayouts(outcome, contract, openBets)
|
||||||
|
|
||||||
case 'MKT':
|
case 'MKT':
|
||||||
return contract.outcomeType === 'FREE_RESPONSE'
|
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
? getPayoutsMultiOutcome(resolutions, contract, openBets)
|
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
||||||
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
||||||
case 'CANCEL':
|
case 'CANCEL':
|
||||||
case undefined:
|
case undefined:
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
||||||
)
|
)
|
||||||
const { payouts: resolvePayouts } = getPayouts(
|
const { payouts: resolvePayouts } = getPayouts(
|
||||||
resolution as string,
|
resolution as string,
|
||||||
{},
|
|
||||||
contract,
|
contract,
|
||||||
openBets,
|
openBets,
|
||||||
[],
|
[],
|
||||||
|
{},
|
||||||
resolutionProb
|
resolutionProb
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "../",
|
"baseUrl": "../",
|
||||||
|
"composite": true,
|
||||||
|
"module": "commonjs",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"outDir": "lib",
|
"outDir": "lib",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
|
||||||
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
|
||||||
type AnyTxnType = Donation | Tip | Manalink
|
type AnyTxnType = Donation | Tip | Manalink | Referral
|
||||||
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||||
|
|
||||||
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
|
|
@ -16,7 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
|
||||||
amount: number
|
amount: number
|
||||||
token: 'M$' // | 'USD' | MarketOutcome
|
token: 'M$' // | 'USD' | MarketOutcome
|
||||||
|
|
||||||
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
|
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET'
|
||||||
// Any extra data
|
// Any extra data
|
||||||
data?: { [key: string]: any }
|
data?: { [key: string]: any }
|
||||||
|
|
||||||
|
|
@ -46,6 +46,13 @@ type Manalink = {
|
||||||
category: 'MANALINK'
|
category: 'MANALINK'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Referral = {
|
||||||
|
fromType: 'BANK'
|
||||||
|
toType: 'USER'
|
||||||
|
category: 'REFERRAL'
|
||||||
|
}
|
||||||
|
|
||||||
export type DonationTxn = Txn & Donation
|
export type DonationTxn = Txn & Donation
|
||||||
export type TipTxn = Txn & Tip
|
export type TipTxn = Txn & Tip
|
||||||
export type ManalinkTxn = Txn & Manalink
|
export type ManalinkTxn = Txn & Manalink
|
||||||
|
export type ReferralTxn = Txn & Referral
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,14 @@ export type User = {
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
|
|
||||||
|
referredByUserId?: string
|
||||||
|
referredByContractId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STARTING_BALANCE = 1000
|
export const STARTING_BALANCE = 1000
|
||||||
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
|
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
|
||||||
|
export const REFERRAL_AMOUNT = 500
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
|
|
||||||
|
|
@ -456,7 +456,6 @@ Requires no authorization.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### `POST /v0/bet`
|
### `POST /v0/bet`
|
||||||
|
|
||||||
Places a new bet on behalf of the authorized user.
|
Places a new bet on behalf of the authorized user.
|
||||||
|
|
@ -514,6 +513,60 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
|
||||||
"initialProb":25}'
|
"initialProb":25}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `POST /v0/market/[marketId]/resolve`
|
||||||
|
|
||||||
|
Resolves a market on behalf of the authorized user.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
For binary markets:
|
||||||
|
|
||||||
|
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
|
||||||
|
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
|
||||||
|
|
||||||
|
For free response markets:
|
||||||
|
|
||||||
|
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
|
||||||
|
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
|
||||||
|
|
||||||
|
For numeric markets:
|
||||||
|
|
||||||
|
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
|
||||||
|
- `value`: The value that the market may resolves to.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Resolve a binary market
|
||||||
|
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Key {...}' \
|
||||||
|
--data-raw '{"outcome": "YES"}'
|
||||||
|
|
||||||
|
# Resolve a binary market with a specified probability
|
||||||
|
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Key {...}' \
|
||||||
|
--data-raw '{"outcome": "MKT", \
|
||||||
|
"probabilityInt": 75}'
|
||||||
|
|
||||||
|
# Resolve a free response market with a single answer chosen
|
||||||
|
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Key {...}' \
|
||||||
|
--data-raw '{"outcome": 2}'
|
||||||
|
|
||||||
|
# Resolve a free response market with multiple answers chosen
|
||||||
|
$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'Authorization: Key {...}' \
|
||||||
|
--data-raw '{"outcome": "MKT", \
|
||||||
|
"resolutions": [ \
|
||||||
|
{"answer": 0, "pct": 50}, \
|
||||||
|
{"answer": 2, "pct": 50} \
|
||||||
|
]}'
|
||||||
|
```
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
- 2022-06-08: Add paging to markets endpoint
|
- 2022-06-08: Add paging to markets endpoint
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ for the pool to be sorted into.
|
||||||
- Users can create a market on any question they want.
|
- Users can create a market on any question they want.
|
||||||
- When a user creates a market, they must choose a close date, after which trading will halt.
|
- When a user creates a market, they must choose a close date, after which trading will halt.
|
||||||
- They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market.
|
- They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market.
|
||||||
- The creation fee for the first market created each day is provided by Manifold.
|
|
||||||
- The market creator will earn a commission on all bets placed in the market.
|
- The market creator will earn a commission on all bets placed in the market.
|
||||||
- The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution.
|
- The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution.
|
||||||
- Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares.
|
- Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"functions": {
|
"functions": {
|
||||||
"predeploy": "npm --prefix \"$RESOURCE_DIR\" run build",
|
"predeploy": "cd functions && yarn build",
|
||||||
"runtime": "nodejs16",
|
"runtime": "nodejs16",
|
||||||
"source": "functions"
|
"source": "functions/dist"
|
||||||
},
|
},
|
||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']);
|
||||||
|
// only one referral allowed per user
|
||||||
|
allow update: if resource.data.id == request.auth.uid
|
||||||
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
.hasOnly(['referredByUserId'])
|
||||||
|
&& !("referredByUserId" in resource.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
|
||||||
|
|
|
||||||
6
functions/.gitignore
vendored
6
functions/.gitignore
vendored
|
|
@ -2,9 +2,11 @@
|
||||||
.env*
|
.env*
|
||||||
.runtimeconfig.json
|
.runtimeconfig.json
|
||||||
|
|
||||||
|
# GCP deployment artifact
|
||||||
|
dist/
|
||||||
|
|
||||||
# Compiled JavaScript files
|
# Compiled JavaScript files
|
||||||
lib/**/*.js
|
lib/
|
||||||
lib/**/*.js.map
|
|
||||||
|
|
||||||
# TypeScript v1 declaration files
|
# TypeScript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"firestore": "dev-mantic-markets.appspot.com"
|
"firestore": "dev-mantic-markets.appspot.com"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"watch": "tsc -w",
|
||||||
"shell": "yarn build && firebase functions:shell",
|
"shell": "yarn build && firebase functions:shell",
|
||||||
"start": "yarn shell",
|
"start": "yarn shell",
|
||||||
|
|
@ -16,9 +17,10 @@
|
||||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||||
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
||||||
"verify": "(cd .. && yarn verify)"
|
"verify": "(cd .. && yarn verify)",
|
||||||
|
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
|
||||||
},
|
},
|
||||||
"main": "lib/functions/src/index.js",
|
"main": "functions/src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/node": "1.10.0",
|
"@amplitude/node": "1.10.0",
|
||||||
"fetch": "1.1.0",
|
"fetch": "1.1.0",
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTS: HttpsOptions = {
|
interface EndpointOptions extends HttpsOptions {
|
||||||
|
methods?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTS = {
|
||||||
|
methods: ['POST'],
|
||||||
minInstances: 1,
|
minInstances: 1,
|
||||||
concurrency: 100,
|
concurrency: 100,
|
||||||
memory: '2GiB',
|
memory: '2GiB',
|
||||||
|
|
@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = {
|
||||||
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newEndpoint = (methods: [string], fn: Handler) =>
|
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||||
onRequest(DEFAULT_OPTS, async (req, res) => {
|
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
|
||||||
|
return onRequest(opts, async (req, res) => {
|
||||||
log('Request processing started.')
|
log('Request processing started.')
|
||||||
try {
|
try {
|
||||||
if (!methods.includes(req.method)) {
|
if (!opts.methods.includes(req.method)) {
|
||||||
const allowed = methods.join(', ')
|
const allowed = opts.methods.join(', ')
|
||||||
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
||||||
}
|
}
|
||||||
const authedUser = await lookupUser(await parseCredentials(req))
|
const authedUser = await lookupUser(await parseCredentials(req))
|
||||||
|
|
@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ const numericSchema = z.object({
|
||||||
isLogScale: z.boolean().optional(),
|
isLogScale: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||||
validate(bodySchema, req.body)
|
validate(bodySchema, req.body)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const bodySchema = z.object({
|
||||||
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
|
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const creategroup = newEndpoint(['POST'], async (req, auth) => {
|
export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||||
const { name, about, memberIds, anyoneCanJoin } = validate(
|
const { name, about, memberIds, anyoneCanJoin } = validate(
|
||||||
bodySchema,
|
bodySchema,
|
||||||
req.body
|
req.body
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export const createNotification = async (
|
||||||
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
sourceContractCreatorUsername: sourceContract?.creatorUsername,
|
||||||
// TODO: move away from sourceContractTitle to sourceTitle
|
// TODO: move away from sourceContractTitle to sourceTitle
|
||||||
sourceContractTitle: sourceContract?.question,
|
sourceContractTitle: sourceContract?.question,
|
||||||
|
// TODO: move away from sourceContractSlug to sourceSlug
|
||||||
sourceContractSlug: sourceContract?.slug,
|
sourceContractSlug: sourceContract?.slug,
|
||||||
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
|
||||||
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
|
||||||
|
|
@ -252,44 +253,62 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notifyUserReceivedReferralBonus = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts,
|
||||||
|
relatedUserId: string
|
||||||
|
) => {
|
||||||
|
if (shouldGetNotification(relatedUserId, userToReasonTexts))
|
||||||
|
userToReasonTexts[relatedUserId] = {
|
||||||
|
// If the referrer is the market creator, just tell them they joined to bet on their market
|
||||||
|
reason:
|
||||||
|
sourceContract?.creatorId === relatedUserId
|
||||||
|
? 'user_joined_to_bet_on_your_market'
|
||||||
|
: 'you_referred_user',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getUsersToNotify = async () => {
|
const getUsersToNotify = async () => {
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
if (sourceContract) {
|
if (sourceType === 'follow' && relatedUserId) {
|
||||||
if (
|
|
||||||
sourceType === 'comment' ||
|
|
||||||
sourceType === 'answer' ||
|
|
||||||
(sourceType === 'contract' &&
|
|
||||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
|
||||||
) {
|
|
||||||
if (sourceType === 'comment') {
|
|
||||||
if (relatedUserId && relatedSourceType)
|
|
||||||
await notifyRepliedUsers(
|
|
||||||
userToReasonTexts,
|
|
||||||
relatedUserId,
|
|
||||||
relatedSourceType
|
|
||||||
)
|
|
||||||
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
|
||||||
}
|
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
|
||||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
|
||||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
|
||||||
}
|
|
||||||
} else if (sourceType === 'follow' && relatedUserId) {
|
|
||||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
||||||
} else if (sourceType === 'group' && relatedUserId) {
|
} else if (sourceType === 'group' && relatedUserId) {
|
||||||
if (sourceUpdateType === 'created')
|
if (sourceUpdateType === 'created')
|
||||||
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
|
||||||
|
} else if (sourceType === 'user' && relatedUserId) {
|
||||||
|
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following functions need sourceContract to be defined.
|
||||||
|
if (!sourceContract) return userToReasonTexts
|
||||||
|
if (
|
||||||
|
sourceType === 'comment' ||
|
||||||
|
sourceType === 'answer' ||
|
||||||
|
(sourceType === 'contract' &&
|
||||||
|
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||||
|
) {
|
||||||
|
if (sourceType === 'comment') {
|
||||||
|
if (relatedUserId && relatedSourceType)
|
||||||
|
await notifyRepliedUsers(
|
||||||
|
userToReasonTexts,
|
||||||
|
relatedUserId,
|
||||||
|
relatedSourceType
|
||||||
|
)
|
||||||
|
if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText)
|
||||||
|
}
|
||||||
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
|
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||||
|
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||||
|
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||||
|
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||||
|
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||||
|
await notifyUsersFollowers(userToReasonTexts)
|
||||||
|
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||||
|
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||||
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
}
|
}
|
||||||
return userToReasonTexts
|
return userToReasonTexts
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { newEndpoint } from './api'
|
import { newEndpoint } from './api'
|
||||||
|
|
||||||
export const health = newEndpoint(['GET'], async (_req, auth) => {
|
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
|
||||||
return {
|
return {
|
||||||
message: 'Server is working.',
|
message: 'Server is working.',
|
||||||
uid: auth.uid,
|
uid: auth.uid,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ admin.initializeApp()
|
||||||
// export * from './keep-awake'
|
// export * from './keep-awake'
|
||||||
export * from './claim-manalink'
|
export * from './claim-manalink'
|
||||||
export * from './transact'
|
export * from './transact'
|
||||||
export * from './resolve-market'
|
|
||||||
export * from './stripe'
|
export * from './stripe'
|
||||||
export * from './create-user'
|
export * from './create-user'
|
||||||
export * from './create-answer'
|
export * from './create-answer'
|
||||||
|
|
@ -28,6 +27,7 @@ export * from './on-unfollow-user'
|
||||||
export * from './on-create-liquidity-provision'
|
export * from './on-create-liquidity-provision'
|
||||||
export * from './on-update-group'
|
export * from './on-update-group'
|
||||||
export * from './on-create-group'
|
export * from './on-create-group'
|
||||||
|
export * from './on-update-user'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
|
@ -37,3 +37,4 @@ export * from './sell-shares'
|
||||||
export * from './create-contract'
|
export * from './create-contract'
|
||||||
export * from './withdraw-liquidity'
|
export * from './withdraw-liquidity'
|
||||||
export * from './create-group'
|
export * from './create-group'
|
||||||
|
export * from './resolve-market'
|
||||||
|
|
|
||||||
111
functions/src/on-update-user.ts
Normal file
111
functions/src/on-update-user.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { REFERRAL_AMOUNT, User } from '../../common/user'
|
||||||
|
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
|
||||||
|
import { createNotification } from './create-notification'
|
||||||
|
import { ReferralTxn } from '../../common/txn'
|
||||||
|
import { Contract } from '../../common/contract'
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export const onUpdateUser = functions.firestore
|
||||||
|
.document('users/{userId}')
|
||||||
|
.onUpdate(async (change, context) => {
|
||||||
|
const prevUser = change.before.data() as User
|
||||||
|
const user = change.after.data() as User
|
||||||
|
const { eventId } = context
|
||||||
|
|
||||||
|
if (prevUser.referredByUserId !== user.referredByUserId) {
|
||||||
|
await handleUserUpdatedReferral(user, eventId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleUserUpdatedReferral(user: User, eventId: string) {
|
||||||
|
// Only create a referral txn if the user has a referredByUserId
|
||||||
|
if (!user.referredByUserId) {
|
||||||
|
console.log(`Not set: referredByUserId ${user.referredByUserId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const referredByUserId = user.referredByUserId
|
||||||
|
|
||||||
|
await firestore.runTransaction(async (transaction) => {
|
||||||
|
// get user that referred this user
|
||||||
|
const referredByUserDoc = firestore.doc(`users/${referredByUserId}`)
|
||||||
|
const referredByUserSnap = await transaction.get(referredByUserDoc)
|
||||||
|
if (!referredByUserSnap.exists) {
|
||||||
|
console.log(`User ${referredByUserId} not found`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const referredByUser = referredByUserSnap.data() as User
|
||||||
|
|
||||||
|
let referredByContract: Contract | undefined = undefined
|
||||||
|
if (user.referredByContractId) {
|
||||||
|
const referredByContractDoc = firestore.doc(
|
||||||
|
`contracts/${user.referredByContractId}`
|
||||||
|
)
|
||||||
|
referredByContract = await transaction
|
||||||
|
.get(referredByContractDoc)
|
||||||
|
.then((snap) => snap.data() as Contract)
|
||||||
|
}
|
||||||
|
console.log(`referredByContract: ${referredByContract}`)
|
||||||
|
|
||||||
|
const txns = (
|
||||||
|
await firestore
|
||||||
|
.collection('txns')
|
||||||
|
.where('toId', '==', referredByUserId)
|
||||||
|
.where('category', '==', 'REFERRAL')
|
||||||
|
.get()
|
||||||
|
).docs.map((txn) => txn.ref)
|
||||||
|
if (txns.length > 0) {
|
||||||
|
const referralTxns = await transaction.getAll(...txns).catch((err) => {
|
||||||
|
console.error('error getting txns:', err)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
// If the referring user already has a referral txn due to referring this user, halt
|
||||||
|
if (
|
||||||
|
referralTxns.map((txn) => txn.data()?.description).includes(user.id)
|
||||||
|
) {
|
||||||
|
console.log('found referral txn with the same details, aborting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('creating referral txns')
|
||||||
|
const fromId = HOUSE_LIQUIDITY_PROVIDER_ID
|
||||||
|
|
||||||
|
// if they're updating their referredId, create a txn for both
|
||||||
|
const txn: ReferralTxn = {
|
||||||
|
id: eventId,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
fromId,
|
||||||
|
fromType: 'BANK',
|
||||||
|
toId: referredByUserId,
|
||||||
|
toType: 'USER',
|
||||||
|
amount: REFERRAL_AMOUNT,
|
||||||
|
token: 'M$',
|
||||||
|
category: 'REFERRAL',
|
||||||
|
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
|
||||||
|
await transaction.set(txnDoc, txn)
|
||||||
|
console.log('created referral with txn id:', txn.id)
|
||||||
|
// We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
|
||||||
|
transaction.update(referredByUserDoc, {
|
||||||
|
balance: referredByUser.balance + REFERRAL_AMOUNT,
|
||||||
|
totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
user.id,
|
||||||
|
'user',
|
||||||
|
'updated',
|
||||||
|
user,
|
||||||
|
eventId,
|
||||||
|
txn.amount.toString(),
|
||||||
|
referredByContract,
|
||||||
|
'user',
|
||||||
|
referredByUser.id,
|
||||||
|
referredByContract?.slug,
|
||||||
|
referredByContract?.question
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ const numericSchema = z.object({
|
||||||
value: z.number(),
|
value: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
log('Inside endpoint handler.')
|
log('Inside endpoint handler.')
|
||||||
const { amount, contractId } = validate(bodySchema, req.body)
|
const { amount, contractId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
import { z } from 'zod'
|
||||||
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { Contract, resolution, RESOLUTIONS } from '../../common/contract'
|
import {
|
||||||
|
Contract,
|
||||||
|
FreeResponseContract,
|
||||||
|
RESOLUTIONS,
|
||||||
|
} from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { getUser, isProd, payUser } from './utils'
|
import { getUser, isProd, payUser } from './utils'
|
||||||
|
|
@ -15,162 +19,156 @@ import {
|
||||||
} from '../../common/payouts'
|
} from '../../common/payouts'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
|
||||||
export const resolveMarket = functions
|
const bodySchema = z.object({
|
||||||
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] })
|
contractId: z.string(),
|
||||||
.https.onCall(
|
})
|
||||||
async (
|
|
||||||
data: {
|
|
||||||
outcome: resolution
|
|
||||||
value?: number
|
|
||||||
contractId: string
|
|
||||||
probabilityInt?: number
|
|
||||||
resolutions?: { [outcome: string]: number }
|
|
||||||
},
|
|
||||||
context
|
|
||||||
) => {
|
|
||||||
const userId = context?.auth?.uid
|
|
||||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
|
||||||
|
|
||||||
const { outcome, contractId, probabilityInt, resolutions, value } = data
|
const binarySchema = z.object({
|
||||||
|
outcome: z.enum(RESOLUTIONS),
|
||||||
|
probabilityInt: z.number().gte(0).lte(100).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const freeResponseSchema = z.union([
|
||||||
const contractSnap = await contractDoc.get()
|
z.object({
|
||||||
if (!contractSnap.exists)
|
outcome: z.literal('CANCEL'),
|
||||||
return { status: 'error', message: 'Invalid contract' }
|
}),
|
||||||
const contract = contractSnap.data() as Contract
|
z.object({
|
||||||
const { creatorId, outcomeType, closeTime } = contract
|
outcome: z.literal('MKT'),
|
||||||
|
resolutions: z.array(
|
||||||
|
z.object({
|
||||||
|
answer: z.number().int().nonnegative(),
|
||||||
|
pct: z.number().gte(0).lte(100),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
outcome: z.number().int().nonnegative(),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
if (outcomeType === 'BINARY') {
|
const numericSchema = z.object({
|
||||||
if (!RESOLUTIONS.includes(outcome))
|
outcome: z.union([z.literal('CANCEL'), z.string()]),
|
||||||
return { status: 'error', message: 'Invalid outcome' }
|
value: z.number().optional(),
|
||||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
})
|
||||||
if (
|
|
||||||
isNaN(+outcome) &&
|
|
||||||
!(outcome === 'MKT' && resolutions) &&
|
|
||||||
outcome !== 'CANCEL'
|
|
||||||
)
|
|
||||||
return { status: 'error', message: 'Invalid outcome' }
|
|
||||||
} else if (outcomeType === 'NUMERIC') {
|
|
||||||
if (isNaN(+outcome) && outcome !== 'CANCEL')
|
|
||||||
return { status: 'error', message: 'Invalid outcome' }
|
|
||||||
} else if (outcomeType === 'PSEUDO_NUMERIC') {
|
|
||||||
if (probabilityInt === undefined && outcome !== 'CANCEL')
|
|
||||||
return { status: 'error', message: 'Invalid outcome' }
|
|
||||||
} else {
|
|
||||||
return { status: 'error', message: 'Invalid contract outcomeType' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined && !isFinite(value))
|
const pseudoNumericSchema = z.object({
|
||||||
return { status: 'error', message: 'Invalid value' }
|
outcome: z.union([z.literal('CANCEL'), z.literal('MKT')]),
|
||||||
|
value: z.number(),
|
||||||
|
probabilityInt: z.number().gte(0).lte(100),
|
||||||
|
})
|
||||||
|
|
||||||
if (
|
const opts = { secrets: ['MAILGUN_KEY'] }
|
||||||
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
probabilityInt !== undefined &&
|
const { contractId } = validate(bodySchema, req.body)
|
||||||
(probabilityInt < 0 ||
|
const userId = auth.uid
|
||||||
probabilityInt > 100 ||
|
|
||||||
!isFinite(probabilityInt))
|
|
||||||
)
|
|
||||||
return { status: 'error', message: 'Invalid probability' }
|
|
||||||
|
|
||||||
if (creatorId !== userId)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
return { status: 'error', message: 'User not creator of contract' }
|
const contractSnap = await contractDoc.get()
|
||||||
|
if (!contractSnap.exists)
|
||||||
|
throw new APIError(404, 'No contract exists with the provided ID')
|
||||||
|
const contract = contractSnap.data() as Contract
|
||||||
|
const { creatorId, closeTime } = contract
|
||||||
|
|
||||||
if (contract.resolution)
|
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||||
return { status: 'error', message: 'Contract already resolved' }
|
contract,
|
||||||
|
req.body
|
||||||
const creator = await getUser(creatorId)
|
|
||||||
if (!creator) return { status: 'error', message: 'Creator not found' }
|
|
||||||
|
|
||||||
const resolutionProbability =
|
|
||||||
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
|
||||||
|
|
||||||
const resolutionTime = Date.now()
|
|
||||||
const newCloseTime = closeTime
|
|
||||||
? Math.min(closeTime, resolutionTime)
|
|
||||||
: closeTime
|
|
||||||
|
|
||||||
const betsSnap = await firestore
|
|
||||||
.collection(`contracts/${contractId}/bets`)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
|
||||||
|
|
||||||
const liquiditiesSnap = await firestore
|
|
||||||
.collection(`contracts/${contractId}/liquidity`)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
const liquidities = liquiditiesSnap.docs.map(
|
|
||||||
(doc) => doc.data() as LiquidityProvision
|
|
||||||
)
|
|
||||||
|
|
||||||
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
|
||||||
getPayouts(
|
|
||||||
outcome,
|
|
||||||
resolutions ?? {},
|
|
||||||
contract,
|
|
||||||
bets,
|
|
||||||
liquidities,
|
|
||||||
resolutionProbability
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolutionInfo =
|
|
||||||
outcome !== 'CANCEL'
|
|
||||||
? { resolutionValue: value, resolutionProbability, resolutions }
|
|
||||||
: {}
|
|
||||||
|
|
||||||
await contractDoc.update(
|
|
||||||
removeUndefinedProps({
|
|
||||||
isResolved: true,
|
|
||||||
resolution: outcome,
|
|
||||||
resolutionTime,
|
|
||||||
closeTime: newCloseTime,
|
|
||||||
collectedFees,
|
|
||||||
...resolutionInfo,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('contract ', contractId, 'resolved to:', outcome)
|
|
||||||
|
|
||||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
|
||||||
const loanPayouts = getLoanPayouts(openBets)
|
|
||||||
|
|
||||||
if (!isProd())
|
|
||||||
console.log(
|
|
||||||
'payouts:',
|
|
||||||
payouts,
|
|
||||||
'creator payout:',
|
|
||||||
creatorPayout,
|
|
||||||
'liquidity payout:'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (creatorPayout)
|
|
||||||
await processPayouts(
|
|
||||||
[{ userId: creatorId, payout: creatorPayout }],
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
await processPayouts(liquidityPayouts, true)
|
|
||||||
|
|
||||||
const result = await processPayouts([...payouts, ...loanPayouts])
|
|
||||||
|
|
||||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
|
||||||
|
|
||||||
await sendResolutionEmails(
|
|
||||||
openBets,
|
|
||||||
userPayoutsWithoutLoans,
|
|
||||||
creator,
|
|
||||||
creatorPayout,
|
|
||||||
contract,
|
|
||||||
outcome,
|
|
||||||
resolutionProbability,
|
|
||||||
resolutions
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (creatorId !== userId)
|
||||||
|
throw new APIError(403, 'User is not creator of contract')
|
||||||
|
|
||||||
|
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||||
|
|
||||||
|
const creator = await getUser(creatorId)
|
||||||
|
if (!creator) throw new APIError(500, 'Creator not found')
|
||||||
|
|
||||||
|
const resolutionProbability =
|
||||||
|
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
||||||
|
|
||||||
|
const resolutionTime = Date.now()
|
||||||
|
const newCloseTime = closeTime
|
||||||
|
? Math.min(closeTime, resolutionTime)
|
||||||
|
: closeTime
|
||||||
|
|
||||||
|
const betsSnap = await firestore
|
||||||
|
.collection(`contracts/${contractId}/bets`)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
|
|
||||||
|
const liquiditiesSnap = await firestore
|
||||||
|
.collection(`contracts/${contractId}/liquidity`)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const liquidities = liquiditiesSnap.docs.map(
|
||||||
|
(doc) => doc.data() as LiquidityProvision
|
||||||
|
)
|
||||||
|
|
||||||
|
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
||||||
|
getPayouts(
|
||||||
|
outcome,
|
||||||
|
contract,
|
||||||
|
bets,
|
||||||
|
liquidities,
|
||||||
|
resolutions,
|
||||||
|
resolutionProbability
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedContract = {
|
||||||
|
...contract,
|
||||||
|
...removeUndefinedProps({
|
||||||
|
isResolved: true,
|
||||||
|
resolution: outcome,
|
||||||
|
resolutionValue: value,
|
||||||
|
resolutionTime,
|
||||||
|
closeTime: newCloseTime,
|
||||||
|
resolutionProbability,
|
||||||
|
resolutions,
|
||||||
|
collectedFees,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
await contractDoc.update(updatedContract)
|
||||||
|
|
||||||
|
console.log('contract ', contractId, 'resolved to:', outcome)
|
||||||
|
|
||||||
|
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||||
|
const loanPayouts = getLoanPayouts(openBets)
|
||||||
|
|
||||||
|
if (!isProd())
|
||||||
|
console.log(
|
||||||
|
'payouts:',
|
||||||
|
payouts,
|
||||||
|
'creator payout:',
|
||||||
|
creatorPayout,
|
||||||
|
'liquidity payout:'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (creatorPayout)
|
||||||
|
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
|
||||||
|
|
||||||
|
await processPayouts(liquidityPayouts, true)
|
||||||
|
|
||||||
|
await processPayouts([...payouts, ...loanPayouts])
|
||||||
|
|
||||||
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
|
await sendResolutionEmails(
|
||||||
|
openBets,
|
||||||
|
userPayoutsWithoutLoans,
|
||||||
|
creator,
|
||||||
|
creatorPayout,
|
||||||
|
contract,
|
||||||
|
outcome,
|
||||||
|
resolutionProbability,
|
||||||
|
resolutions
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedContract
|
||||||
|
})
|
||||||
|
|
||||||
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
|
||||||
const userPayouts = groupPayoutsByUser(payouts)
|
const userPayouts = groupPayoutsByUser(payouts)
|
||||||
|
|
||||||
|
|
@ -227,4 +225,72 @@ const sendResolutionEmails = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getResolutionParams(contract: Contract, body: string) {
|
||||||
|
const { outcomeType } = contract
|
||||||
|
|
||||||
|
if (outcomeType === 'NUMERIC') {
|
||||||
|
return {
|
||||||
|
...validate(numericSchema, body),
|
||||||
|
resolutions: undefined,
|
||||||
|
probabilityInt: undefined,
|
||||||
|
}
|
||||||
|
} else if (outcomeType === 'PSEUDO_NUMERIC') {
|
||||||
|
return {
|
||||||
|
...validate(pseudoNumericSchema, body),
|
||||||
|
resolutions: undefined,
|
||||||
|
}
|
||||||
|
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||||
|
const freeResponseParams = validate(freeResponseSchema, body)
|
||||||
|
const { outcome } = freeResponseParams
|
||||||
|
switch (outcome) {
|
||||||
|
case 'CANCEL':
|
||||||
|
return {
|
||||||
|
outcome: outcome.toString(),
|
||||||
|
resolutions: undefined,
|
||||||
|
value: undefined,
|
||||||
|
probabilityInt: undefined,
|
||||||
|
}
|
||||||
|
case 'MKT': {
|
||||||
|
const { resolutions } = freeResponseParams
|
||||||
|
resolutions.forEach(({ answer }) => validateAnswer(contract, answer))
|
||||||
|
const pctSum = sumBy(resolutions, ({ pct }) => pct)
|
||||||
|
if (Math.abs(pctSum - 100) > 0.1) {
|
||||||
|
throw new APIError(400, 'Resolution percentages must sum to 100')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
outcome: outcome.toString(),
|
||||||
|
resolutions: Object.fromEntries(
|
||||||
|
resolutions.map((r) => [r.answer, r.pct])
|
||||||
|
),
|
||||||
|
value: undefined,
|
||||||
|
probabilityInt: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
validateAnswer(contract, outcome)
|
||||||
|
return {
|
||||||
|
outcome: outcome.toString(),
|
||||||
|
resolutions: undefined,
|
||||||
|
value: undefined,
|
||||||
|
probabilityInt: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (outcomeType === 'BINARY') {
|
||||||
|
return {
|
||||||
|
...validate(binarySchema, body),
|
||||||
|
value: undefined,
|
||||||
|
resolutions: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAnswer(contract: FreeResponseContract, answer: number) {
|
||||||
|
const validIds = contract.answers.map((a) => a.id)
|
||||||
|
if (!validIds.includes(answer.toString())) {
|
||||||
|
throw new APIError(400, `${answer} is not a valid answer ID`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
|
||||||
|
|
||||||
const { payouts } = getPayouts(
|
const { payouts } = getPayouts(
|
||||||
resolution,
|
resolution,
|
||||||
resolutions,
|
|
||||||
contract,
|
contract,
|
||||||
openBets,
|
openBets,
|
||||||
[],
|
[],
|
||||||
|
resolutions,
|
||||||
resolutionProbability
|
resolutionProbability
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const bodySchema = z.object({
|
||||||
betId: z.string(),
|
betId: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
|
export const sellbet = newEndpoint({}, async (req, auth) => {
|
||||||
const { contractId, betId } = validate(bodySchema, req.body)
|
const { contractId, betId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// run as transaction to prevent race conditions
|
// run as transaction to prevent race conditions
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const bodySchema = z.object({
|
||||||
outcome: z.enum(['YES', 'NO']),
|
outcome: z.enum(['YES', 'NO']),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// Run as transaction to prevent race conditions.
|
// Run as transaction to prevent race conditions.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "../",
|
"baseUrl": "../",
|
||||||
|
"composite": true,
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"outDir": "lib",
|
"outDir": "lib",
|
||||||
|
|
@ -8,6 +9,11 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es2017"
|
"target": "es2017"
|
||||||
},
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../common"
|
||||||
|
}
|
||||||
|
],
|
||||||
"compileOnSave": true,
|
"compileOnSave": true,
|
||||||
"include": ["src", "../common/**/*.ts"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"web"
|
"web"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)"
|
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sum, mapValues } from 'lodash'
|
import { sum } from 'lodash'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Contract, FreeResponse } from 'common/contract'
|
import { Contract, FreeResponse } from 'common/contract'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { ChooseCancelSelector } from '../yes-no-selector'
|
import { ChooseCancelSelector } from '../yes-no-selector'
|
||||||
import { ResolveConfirmationButton } from '../confirmation-button'
|
import { ResolveConfirmationButton } from '../confirmation-button'
|
||||||
|
|
@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const totalProb = sum(Object.values(chosenAnswers))
|
const totalProb = sum(Object.values(chosenAnswers))
|
||||||
const normalizedProbs = mapValues(
|
const resolutions = Object.entries(chosenAnswers).map(([i, p]) => {
|
||||||
chosenAnswers,
|
return { answer: parseInt(i), pct: (100 * p) / totalProb }
|
||||||
(prob) => (100 * prob) / totalProb
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const resolutionProps = removeUndefinedProps({
|
const resolutionProps = removeUndefinedProps({
|
||||||
outcome:
|
outcome:
|
||||||
resolveOption === 'CHOOSE'
|
resolveOption === 'CHOOSE'
|
||||||
? answers[0]
|
? parseInt(answers[0])
|
||||||
: resolveOption === 'CHOOSE_MULTIPLE'
|
: resolveOption === 'CHOOSE_MULTIPLE'
|
||||||
? 'MKT'
|
? 'MKT'
|
||||||
: 'CANCEL',
|
: 'CANCEL',
|
||||||
resolutions:
|
resolutions:
|
||||||
resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined,
|
resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined,
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await resolveMarket(resolutionProps).then((r) => r.data)
|
try {
|
||||||
|
const result = await resolveMarket(resolutionProps)
|
||||||
console.log('resolved', resolutionProps, 'result:', result)
|
console.log('resolved', resolutionProps, 'result:', result)
|
||||||
|
} catch (e) {
|
||||||
if (result?.status !== 'success') {
|
if (e instanceof APIError) {
|
||||||
setError(result?.message || 'Error resolving market')
|
setError(e.toString())
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Error resolving market')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setResolveOption(undefined)
|
setResolveOption(undefined)
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
setText('')
|
setText('')
|
||||||
setBetAmount(10)
|
setBetAmount(10)
|
||||||
setAmountError(undefined)
|
setAmountError(undefined)
|
||||||
|
setPossibleDuplicateAnswer(undefined)
|
||||||
} else setAmountError(result.message)
|
} else setAmountError(result.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export function ContractSearch(props: {
|
||||||
|
|
||||||
const indexName = `${indexPrefix}contracts-${sort}`
|
const indexName = `${indexPrefix}contracts-${sort}`
|
||||||
|
|
||||||
if (IS_PRIVATE_MANIFOLD) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return (
|
return (
|
||||||
<ContractSearchFirestore
|
<ContractSearchFirestore
|
||||||
querySortOptions={querySortOptions}
|
querySortOptions={querySortOptions}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
import { useGroupsWithContract } from 'web/hooks/use-group'
|
||||||
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
|
@ -130,6 +132,7 @@ export function ContractDetails(props: {
|
||||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||||
// Find a group that this contract id is in
|
// Find a group that this contract id is in
|
||||||
const groups = useGroupsWithContract(contract.id)
|
const groups = useGroupsWithContract(contract.id)
|
||||||
|
const user = useUser()
|
||||||
return (
|
return (
|
||||||
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
|
|
@ -192,6 +195,11 @@ export function ContractDetails(props: {
|
||||||
|
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
<ShareIconButton
|
||||||
|
contract={contract}
|
||||||
|
toastClassName={'sm:-left-40 -left-24 min-w-[250%]'}
|
||||||
|
username={user?.username}
|
||||||
|
/>
|
||||||
|
|
||||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
getBinaryProbPercent,
|
getBinaryProbPercent,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { LiquidityPanel } from '../liquidity-panel'
|
import { LiquidityPanel } from '../liquidity-panel'
|
||||||
import { CopyLinkButton } from '../copy-link-button'
|
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
import { Modal } from '../layout/modal'
|
import { Modal } from '../layout/modal'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
|
|
@ -23,6 +22,9 @@ import { TweetButton } from '../tweet-button'
|
||||||
import { InfoTooltip } from '../info-tooltip'
|
import { InfoTooltip } from '../info-tooltip'
|
||||||
import { TagsInput } from 'web/components/tags-input'
|
import { TagsInput } from 'web/components/tags-input'
|
||||||
|
|
||||||
|
export const contractDetailsButtonClassName =
|
||||||
|
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||||
|
|
||||||
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const { contract, bets } = props
|
const { contract, bets } = props
|
||||||
|
|
||||||
|
|
@ -48,13 +50,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100"
|
className={contractDetailsButtonClassName}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon
|
<DotsHorizontalIcon
|
||||||
className={clsx(
|
className={clsx('h-6 w-6 flex-shrink-0')}
|
||||||
'h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500'
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -66,10 +66,6 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
<div>Share</div>
|
<div>Share</div>
|
||||||
|
|
||||||
<Row className="justify-start gap-4">
|
<Row className="justify-start gap-4">
|
||||||
<CopyLinkButton
|
|
||||||
contract={contract}
|
|
||||||
toastClassName={'sm:-left-10 -left-4 min-w-[250%]'}
|
|
||||||
/>
|
|
||||||
<TweetButton
|
<TweetButton
|
||||||
className="self-start"
|
className="self-start"
|
||||||
tweetText={getTweetText(contract, false)}
|
tweetText={getTweetText(contract, false)}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
<div className={clsx('flex p-1', className)}>
|
<div className={clsx('flex p-1', className)}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'btn-ghost cursor-pointer whitespace-nowrap rounded-full text-sm text-white'
|
'btn-ghost cursor-pointer whitespace-nowrap rounded-md p-1 text-sm text-gray-700'
|
||||||
)}
|
)}
|
||||||
onClick={() => updateOpen(!open)}
|
onClick={() => updateOpen(!open)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ export function GroupChat(props: {
|
||||||
setReplyToUsername('')
|
setReplyToUsername('')
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
|
function focusInput() {
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={'flex-1'}>
|
<Col className={'flex-1'}>
|
||||||
|
|
@ -117,7 +120,13 @@ export function GroupChat(props: {
|
||||||
))}
|
))}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="p-2 text-gray-500">
|
<div className="p-2 text-gray-500">
|
||||||
No messages yet. 🦗... Why not say something?
|
No messages yet. Why not{' '}
|
||||||
|
<button
|
||||||
|
className={'cursor-pointer font-bold text-gray-700'}
|
||||||
|
onClick={() => focusInput()}
|
||||||
|
>
|
||||||
|
add one?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function GroupSelector(props: {
|
||||||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const memberGroups = useMemberGroups(creator)
|
const memberGroups = useMemberGroups(creator?.id)
|
||||||
const filteredGroups = memberGroups
|
const filteredGroups = memberGroups
|
||||||
? query === ''
|
? query === ''
|
||||||
? memberGroups
|
? memberGroups
|
||||||
|
|
|
||||||
144
web/components/groups/groups-button.tsx
Normal file
144
web/components/groups/groups-button.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
|
import { TextButton } from 'web/components/text-button'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
||||||
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
|
import { GroupLink } from 'web/pages/groups'
|
||||||
|
|
||||||
|
export function GroupsButton(props: { user: User }) {
|
||||||
|
const { user } = props
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const groups = useMemberGroups(user.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextButton onClick={() => setIsOpen(true)}>
|
||||||
|
<span className="font-semibold">{groups?.length ?? ''}</span> Groups
|
||||||
|
</TextButton>
|
||||||
|
|
||||||
|
<GroupsDialog
|
||||||
|
user={user}
|
||||||
|
groups={groups ?? []}
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsDialog(props: {
|
||||||
|
user: User
|
||||||
|
groups: Group[]
|
||||||
|
isOpen: boolean
|
||||||
|
setIsOpen: (isOpen: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { user, groups, isOpen, setIsOpen } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||||
|
<Col className="rounded bg-white p-6">
|
||||||
|
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||||
|
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||||
|
<GroupsList groups={groups} />
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsList(props: { groups: Group[] }) {
|
||||||
|
const { groups } = props
|
||||||
|
return (
|
||||||
|
<Col className="gap-2">
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div className="text-gray-500">No groups yet...</div>
|
||||||
|
)}
|
||||||
|
{groups
|
||||||
|
.sort((group1, group2) => group2.createdTime - group1.createdTime)
|
||||||
|
.map((group) => (
|
||||||
|
<GroupItem key={group.id} group={group} />
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupItem(props: { group: Group; className?: string }) {
|
||||||
|
const { group, className } = props
|
||||||
|
return (
|
||||||
|
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||||
|
<Row className="line-clamp-1 items-center gap-2">
|
||||||
|
<GroupLink group={group} />
|
||||||
|
</Row>
|
||||||
|
<JoinOrLeaveGroupButton group={group} />
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinOrLeaveGroupButton(props: {
|
||||||
|
group: Group
|
||||||
|
small?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { group, small, className } = props
|
||||||
|
const currentUser = useUser()
|
||||||
|
const isFollowing = currentUser
|
||||||
|
? group.memberIds.includes(currentUser.id)
|
||||||
|
: false
|
||||||
|
const onJoinGroup = () => {
|
||||||
|
if (!currentUser) return
|
||||||
|
joinGroup(group, currentUser.id)
|
||||||
|
}
|
||||||
|
const onLeaveGroup = () => {
|
||||||
|
if (!currentUser) return
|
||||||
|
leaveGroup(group, currentUser.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallStyle =
|
||||||
|
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
||||||
|
|
||||||
|
if (!currentUser || isFollowing === undefined) {
|
||||||
|
if (!group.anyoneCanJoin)
|
||||||
|
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={firebaseLogin}
|
||||||
|
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||||
|
>
|
||||||
|
Login to Join
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'btn btn-outline btn-sm',
|
||||||
|
small && smallStyle,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={withTracking(onLeaveGroup, 'leave group')}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.anyoneCanJoin)
|
||||||
|
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||||
|
onClick={withTracking(onJoinGroup, 'join group')}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
MenuAlt3Icon,
|
MenuAlt3Icon,
|
||||||
PresentationChartLineIcon,
|
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
|
|
@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
function getNavigation(username: string) {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{
|
|
||||||
name: 'Portfolio',
|
|
||||||
href: `/${username}?tab=bets`,
|
|
||||||
icon: PresentationChartLineIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
href: `/notifications`,
|
href: `/notifications`,
|
||||||
|
|
@ -55,38 +49,39 @@ export function BottomNavBar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationOptions =
|
const navigationOptions =
|
||||||
user === null
|
user === null ? signedOutNavigation : getNavigation()
|
||||||
? signedOutNavigation
|
|
||||||
: getNavigation(user?.username || 'error')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
||||||
{navigationOptions.map((item) => (
|
{navigationOptions.map((item) => (
|
||||||
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
|
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<NavBarItem
|
||||||
|
key={'profile'}
|
||||||
|
currentPage={currentPage}
|
||||||
|
item={{
|
||||||
|
name: formatMoney(user.balance),
|
||||||
|
href: `/${user.username}?tab=bets`,
|
||||||
|
icon: () => (
|
||||||
|
<Avatar
|
||||||
|
className="mx-auto my-1"
|
||||||
|
size="xs"
|
||||||
|
username={user.username}
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
noLink
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
|
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
{user === null ? (
|
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||||
<>
|
More
|
||||||
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
|
||||||
More
|
|
||||||
</>
|
|
||||||
) : user ? (
|
|
||||||
<>
|
|
||||||
<Avatar
|
|
||||||
className="mx-auto my-1"
|
|
||||||
size="xs"
|
|
||||||
username={user.username}
|
|
||||||
avatarUrl={user.avatarUrl}
|
|
||||||
noLink
|
|
||||||
/>
|
|
||||||
{formatMoney(user.balance)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
|
|
@ -109,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||||
)}
|
)}
|
||||||
onClick={trackCallback('navbar: ' + item.name)}
|
onClick={trackCallback('navbar: ' + item.name)}
|
||||||
>
|
>
|
||||||
<item.icon className="my-1 mx-auto h-6 w-6" />
|
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
|
||||||
export function ProfileSummary(props: { user: User }) {
|
export function ProfileSummary(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.username}`}>
|
<Link href={`/${user.username}?tab=bets`}>
|
||||||
<a
|
<a
|
||||||
onClick={trackCallback('sidebar: profile')}
|
onClick={trackCallback('sidebar: profile')}
|
||||||
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ import {
|
||||||
DotsHorizontalIcon,
|
DotsHorizontalIcon,
|
||||||
CashIcon,
|
CashIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
PresentationChartLineIcon,
|
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
ChevronDownIcon,
|
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
|
ChatIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
@ -26,15 +25,11 @@ import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
|
import { Spacer } from '../layout/spacer'
|
||||||
|
|
||||||
function getNavigation(username: string) {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{
|
|
||||||
name: 'Portfolio',
|
|
||||||
href: `/${username}?tab=bets`,
|
|
||||||
icon: PresentationChartLineIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
href: `/notifications`,
|
href: `/notifications`,
|
||||||
|
|
@ -63,12 +58,10 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
|
||||||
{ name: 'Statistics', href: '/stats' },
|
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
{
|
{
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
|
|
@ -90,8 +83,20 @@ const signedOutNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedOutMobileNavigation = [
|
const signedOutMobileNavigation = [
|
||||||
|
{
|
||||||
|
name: 'About',
|
||||||
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
icon: BookOpenIcon,
|
||||||
|
},
|
||||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||||
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
const signedInMobileNavigation = [
|
||||||
|
...(IS_PRIVATE_MANIFOLD
|
||||||
|
? []
|
||||||
|
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
|
@ -99,17 +104,24 @@ const signedOutMobileNavigation = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedInMobileNavigation = [
|
function getMoreMobileNav() {
|
||||||
...(IS_PRIVATE_MANIFOLD
|
return [
|
||||||
? []
|
{ name: 'Send M$', href: '/links' },
|
||||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
{ name: 'Charity', href: '/charity' },
|
||||||
...signedOutMobileNavigation,
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
]
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
|
{
|
||||||
|
name: 'Sign out',
|
||||||
|
href: '#',
|
||||||
|
onClick: withTracking(firebaseLogout, 'sign out'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
name: string
|
name: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem(props: { item: Item; currentPage: string }) {
|
function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||||
|
|
@ -126,15 +138,17 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||||
)}
|
)}
|
||||||
aria-current={item.href == currentPage ? 'page' : undefined}
|
aria-current={item.href == currentPage ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<item.icon
|
{item.icon && (
|
||||||
className={clsx(
|
<item.icon
|
||||||
item.href == currentPage
|
className={clsx(
|
||||||
? 'text-gray-500'
|
item.href == currentPage
|
||||||
: 'text-gray-400 group-hover:text-gray-500',
|
? 'text-gray-500'
|
||||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
: 'text-gray-400 group-hover:text-gray-500',
|
||||||
)}
|
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||||
aria-hidden="true"
|
)}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -163,27 +177,17 @@ function MoreButton() {
|
||||||
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
|
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupsButton() {
|
|
||||||
return (
|
|
||||||
<SidebarButton icon={UserGroupIcon} text={'Groups'}>
|
|
||||||
<ChevronDownIcon className=" mt-0.5 ml-2 h-5 w-5" aria-hidden="true" />
|
|
||||||
</SidebarButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar(props: { className?: string }) {
|
export default function Sidebar(props: { className?: string }) {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const currentPage = router.pathname
|
const currentPage = router.pathname
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const navigationOptions = !user
|
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||||
? signedOutNavigation
|
|
||||||
: getNavigation(user?.username || 'error')
|
|
||||||
const mobileNavigationOptions = !user
|
const mobileNavigationOptions = !user
|
||||||
? signedOutMobileNavigation
|
? signedOutMobileNavigation
|
||||||
: signedInMobileNavigation
|
: signedInMobileNavigation
|
||||||
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
|
const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
href: groupPath(group.slug),
|
href: groupPath(group.slug),
|
||||||
}))
|
}))
|
||||||
|
|
@ -191,95 +195,73 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Sidebar" className={className}>
|
<nav aria-label="Sidebar" className={className}>
|
||||||
<ManifoldLogo className="pb-6" twoLine />
|
<ManifoldLogo className="pb-6" twoLine />
|
||||||
|
|
||||||
|
<CreateQuestionButton user={user} />
|
||||||
|
<Spacer h={4} />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="mb-2" style={{ minHeight: 80 }}>
|
<div className="w-full" style={{ minHeight: 80 }}>
|
||||||
<ProfileSummary user={user} />
|
<ProfileSummary user={user} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile navigation */}
|
{/* Mobile navigation */}
|
||||||
<div className="space-y-1 lg:hidden">
|
<div className="space-y-1 lg:hidden">
|
||||||
{user && (
|
|
||||||
<MenuButton
|
|
||||||
buttonContent={<GroupsButton />}
|
|
||||||
menuItems={[{ name: 'Explore', href: '/groups' }, ...memberItems]}
|
|
||||||
className={'relative z-50 flex-shrink-0'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{mobileNavigationOptions.map((item) => (
|
{mobileNavigationOptions.map((item) => (
|
||||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
))}
|
))}
|
||||||
{!user && (
|
|
||||||
<SidebarItem
|
|
||||||
key={'Groups'}
|
|
||||||
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
|
||||||
currentPage={currentPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MenuButton
|
<MenuButton
|
||||||
menuItems={[
|
menuItems={getMoreMobileNav()}
|
||||||
{
|
|
||||||
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: '#',
|
|
||||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GroupsList currentPage={currentPage} memberItems={memberItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop navigation */}
|
{/* Desktop navigation */}
|
||||||
<div className="hidden space-y-1 lg:block">
|
<div className="hidden space-y-1 lg:block">
|
||||||
{navigationOptions.map((item) =>
|
{navigationOptions.map((item) => (
|
||||||
item.name === 'Notifications' ? (
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
<div key={item.href}>
|
))}
|
||||||
<SidebarItem item={item} currentPage={currentPage} />
|
|
||||||
{user && (
|
|
||||||
<MenuButton
|
|
||||||
key={'groupsdropdown'}
|
|
||||||
buttonContent={<GroupsButton />}
|
|
||||||
menuItems={[
|
|
||||||
{ name: 'Explore', href: '/groups' },
|
|
||||||
...memberItems,
|
|
||||||
]}
|
|
||||||
className={'relative z-50 flex-shrink-0'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SidebarItem
|
|
||||||
key={item.href}
|
|
||||||
item={item}
|
|
||||||
currentPage={currentPage}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MenuButton
|
<MenuButton
|
||||||
menuItems={getMoreNavigation(user)}
|
menuItems={getMoreNavigation(user)}
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Spacer if there are any groups */}
|
||||||
|
{memberItems.length > 0 && (
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="h-[1px] bg-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GroupsList currentPage={currentPage} memberItems={memberItems} />
|
||||||
</div>
|
</div>
|
||||||
<CreateQuestionButton user={user} />
|
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||||
|
const { currentPage, memberItems } = props
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarItem
|
||||||
|
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
||||||
|
currentPage={currentPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{memberItems.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span className="truncate"> {item.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { User } from 'web/lib/firebase/users'
|
||||||
import { NumberCancelSelector } from './yes-no-selector'
|
import { NumberCancelSelector } from './yes-no-selector'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ResolveConfirmationButton } from './confirmation-button'
|
import { ResolveConfirmationButton } from './confirmation-button'
|
||||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
|
||||||
import { NumericContract, PseudoNumericContract } from 'common/contract'
|
import { NumericContract, PseudoNumericContract } from 'common/contract'
|
||||||
|
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||||
import { BucketInput } from './bucket-input'
|
import { BucketInput } from './bucket-input'
|
||||||
import { getPseudoProbability } from 'common/pseudo-numeric'
|
import { getPseudoProbability } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
|
|
@ -55,18 +55,23 @@ export function NumericResolutionPanel(props: {
|
||||||
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
|
outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await resolveMarket({
|
try {
|
||||||
outcome: finalOutcome,
|
const result = await resolveMarket({
|
||||||
value,
|
outcome: finalOutcome,
|
||||||
contractId: contract.id,
|
value,
|
||||||
probabilityInt,
|
probabilityInt,
|
||||||
}).then((r) => r.data)
|
contractId: contract.id,
|
||||||
|
})
|
||||||
console.log('resolved', outcome, 'result:', result)
|
console.log('resolved', outcome, 'result:', result)
|
||||||
|
} catch (e) {
|
||||||
if (result?.status !== 'success') {
|
if (e instanceof APIError) {
|
||||||
setError(result?.message || 'Error resolving market')
|
setError(e.toString())
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Error resolving market')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export function FreeResponseOutcomeLabel(props: {
|
||||||
if (resolution === 'CANCEL') return <CancelLabel />
|
if (resolution === 'CANCEL') return <CancelLabel />
|
||||||
if (resolution === 'MKT') return <MultiLabel />
|
if (resolution === 'MKT') return <MultiLabel />
|
||||||
|
|
||||||
const chosen = contract.answers.find((answer) => answer.id === resolution)
|
const chosen = contract.answers?.find((answer) => answer.id === resolution)
|
||||||
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
if (!chosen) return <AnswerNumberLabel number={resolution} />
|
||||||
return (
|
return (
|
||||||
<FreeResponseAnswerToolTip text={chosen.text}>
|
<FreeResponseAnswerToolTip text={chosen.text}>
|
||||||
|
|
|
||||||
93
web/components/referrals-button.tsx
Normal file
93
web/components/referrals-button.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { prefetchUsers, useUserById } from 'web/hooks/use-user'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Modal } from './layout/modal'
|
||||||
|
import { Tabs } from './layout/tabs'
|
||||||
|
import { TextButton } from './text-button'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Avatar } from 'web/components/avatar'
|
||||||
|
import { UserLink } from 'web/components/user-page'
|
||||||
|
import { useReferrals } from 'web/hooks/use-referrals'
|
||||||
|
|
||||||
|
export function ReferralsButton(props: { user: User }) {
|
||||||
|
const { user } = props
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const referralIds = useReferrals(user.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextButton onClick={() => setIsOpen(true)}>
|
||||||
|
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
|
||||||
|
Referrals
|
||||||
|
</TextButton>
|
||||||
|
|
||||||
|
<ReferralsDialog
|
||||||
|
user={user}
|
||||||
|
referralIds={referralIds ?? []}
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferralsDialog(props: {
|
||||||
|
user: User
|
||||||
|
referralIds: string[]
|
||||||
|
isOpen: boolean
|
||||||
|
setIsOpen: (isOpen: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { user, referralIds, isOpen, setIsOpen } = props
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchUsers(referralIds)
|
||||||
|
}, [referralIds])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||||
|
<Col className="rounded bg-white p-6">
|
||||||
|
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||||
|
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||||
|
<Tabs
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
title: 'Referrals',
|
||||||
|
content: <ReferralsList userIds={referralIds} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferralsList(props: { userIds: string[] }) {
|
||||||
|
const { userIds } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col className="gap-2">
|
||||||
|
{userIds.length === 0 && (
|
||||||
|
<div className="text-gray-500">No users yet...</div>
|
||||||
|
)}
|
||||||
|
{userIds.map((userId) => (
|
||||||
|
<UserReferralItem key={userId} userId={userId} />
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserReferralItem(props: { userId: string; className?: string }) {
|
||||||
|
const { userId, className } = props
|
||||||
|
const user = useUserById(userId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||||
|
<Row className="items-center gap-2">
|
||||||
|
<Avatar username={user?.username} avatarUrl={user?.avatarUrl} />
|
||||||
|
{user && <UserLink name={user.name} username={user.username} />}
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users'
|
||||||
import { YesNoCancelSelector } from './yes-no-selector'
|
import { YesNoCancelSelector } from './yes-no-selector'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ResolveConfirmationButton } from './confirmation-button'
|
import { ResolveConfirmationButton } from './confirmation-button'
|
||||||
import { resolveMarket } from 'web/lib/firebase/fn-call'
|
import { APIError, resolveMarket } from 'web/lib/firebase/api-call'
|
||||||
import { ProbabilitySelector } from './probability-selector'
|
import { ProbabilitySelector } from './probability-selector'
|
||||||
import { DPM_CREATOR_FEE } from 'common/fees'
|
import { DPM_CREATOR_FEE } from 'common/fees'
|
||||||
import { getProbability } from 'common/calculate'
|
import { getProbability } from 'common/calculate'
|
||||||
|
|
@ -42,17 +42,22 @@ export function ResolutionPanel(props: {
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const result = await resolveMarket({
|
try {
|
||||||
outcome,
|
const result = await resolveMarket({
|
||||||
contractId: contract.id,
|
outcome,
|
||||||
probabilityInt: prob,
|
contractId: contract.id,
|
||||||
}).then((r) => r.data)
|
probabilityInt: prob,
|
||||||
|
})
|
||||||
console.log('resolved', outcome, 'result:', result)
|
console.log('resolved', outcome, 'result:', result)
|
||||||
|
} catch (e) {
|
||||||
if (result?.status !== 'success') {
|
if (e instanceof APIError) {
|
||||||
setError(result?.message || 'Error resolving market')
|
setError(e.toString())
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
setError('Error resolving market')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
70
web/components/share-icon-button.tsx
Normal file
70
web/components/share-icon-button.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ShareIcon } from '@heroicons/react/outline'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
|
import { contractPath } from 'web/lib/firebase/contracts'
|
||||||
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { contractDetailsButtonClassName } from 'web/components/contract/contract-info-dialog'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
import { groupPath } from 'web/lib/firebase/groups'
|
||||||
|
|
||||||
|
function copyContractWithReferral(contract: Contract, username?: string) {
|
||||||
|
const postFix =
|
||||||
|
username && contract.creatorUsername !== username
|
||||||
|
? '?referrer=' + username
|
||||||
|
: ''
|
||||||
|
copyToClipboard(
|
||||||
|
`https://${ENV_CONFIG.domain}${contractPath(contract)}${postFix}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: if a user arrives at a /group endpoint with a ?referral= query, they'll be added to the group automatically
|
||||||
|
function copyGroupWithReferral(group: Group, username?: string) {
|
||||||
|
const postFix = username ? '?referrer=' + username : ''
|
||||||
|
copyToClipboard(
|
||||||
|
`https://${ENV_CONFIG.domain}${groupPath(group.slug)}${postFix}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareIconButton(props: {
|
||||||
|
contract?: Contract
|
||||||
|
group?: Group
|
||||||
|
buttonClassName?: string
|
||||||
|
toastClassName?: string
|
||||||
|
username?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
contract,
|
||||||
|
buttonClassName,
|
||||||
|
toastClassName,
|
||||||
|
username,
|
||||||
|
group,
|
||||||
|
children,
|
||||||
|
} = props
|
||||||
|
const [showToast, setShowToast] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
className={clsx(contractDetailsButtonClassName, buttonClassName)}
|
||||||
|
onClick={() => {
|
||||||
|
if (contract) copyContractWithReferral(contract, username)
|
||||||
|
if (group) copyGroupWithReferral(group, username)
|
||||||
|
track('copy share link')
|
||||||
|
setShowToast(true)
|
||||||
|
setTimeout(() => setShowToast(false), 2000)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShareIcon className="h-[24px] w-5" aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showToast && <ToastClipboard className={toastClassName} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,8 @@ import { FollowersButton, FollowingButton } from './following-button'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { FollowButton } from './follow-button'
|
import { FollowButton } from './follow-button'
|
||||||
import { PortfolioMetrics } from 'common/user'
|
import { PortfolioMetrics } from 'common/user'
|
||||||
|
import { ReferralsButton } from 'web/components/referrals-button'
|
||||||
|
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -193,10 +195,12 @@ export function UserPage(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Col className="gap-2 sm:flex-row sm:items-center sm:gap-4">
|
<Col className="flex-wrap gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
|
<ReferralsButton user={user} />
|
||||||
|
<GroupsButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{user.website && (
|
{user.website && (
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ import { useEffect } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
contractDocRef,
|
contracts,
|
||||||
listenForContract,
|
listenForContract,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
|
|
||||||
export const useContract = (contractId: string) => {
|
export const useContract = (contractId: string) => {
|
||||||
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
||||||
['contracts', contractId],
|
['contracts', contractId],
|
||||||
contractDocRef(contractId),
|
doc(contracts, contractId),
|
||||||
{ subscribe: true, includeMetadataChanges: true }
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ export const useGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (user: User | null | undefined) => {
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) return listenForMemberGroups(user.id, setMemberGroups)
|
if (userId) return listenForMemberGroups(userId, setMemberGroups)
|
||||||
}, [user])
|
}, [userId])
|
||||||
return memberGroups
|
return memberGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +74,9 @@ export function useMembers(group: Group) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMembers(group: Group) {
|
export async function listMembers(group: Group) {
|
||||||
return await Promise.all(group.memberIds.map(getUser))
|
return (await Promise.all(group.memberIds.map(getUser))).filter(
|
||||||
|
(user) => user
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGroupsWithContract = (contractId: string | undefined) => {
|
export const useGroupsWithContract = (contractId: string | undefined) => {
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ function getAppropriateNotifications(
|
||||||
return notifications.filter(
|
return notifications.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
n.reason &&
|
n.reason &&
|
||||||
// Show all contract notifications
|
// Show all contract notifications and any that aren't in the above list:
|
||||||
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
||||||
)
|
)
|
||||||
if (notificationPreferences === 'none') return []
|
if (notificationPreferences === 'none') return []
|
||||||
|
|
|
||||||
12
web/hooks/use-referrals.ts
Normal file
12
web/hooks/use-referrals.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listenForReferrals } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
|
export const useReferrals = (userId: string | null | undefined) => {
|
||||||
|
const [referralIds, setReferralIds] = useState<string[] | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) return listenForReferrals(userId, setReferralIds)
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
return referralIds
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
listenForPrivateUser,
|
listenForPrivateUser,
|
||||||
listenForUser,
|
listenForUser,
|
||||||
User,
|
User,
|
||||||
userDocRef,
|
users,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||||
|
|
@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => {
|
||||||
export const useUserById = (userId: string) => {
|
export const useUserById = (userId: string) => {
|
||||||
const result = useFirestoreDocumentData<DocumentData, User>(
|
const result = useFirestoreDocumentData<DocumentData, User>(
|
||||||
['users', userId],
|
['users', userId],
|
||||||
userDocRef(userId),
|
doc(users, userId),
|
||||||
{ subscribe: true, includeMetadataChanges: true }
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => {
|
||||||
'Origin',
|
'Origin',
|
||||||
])
|
])
|
||||||
const hasBody = req.method != 'HEAD' && req.method != 'GET'
|
const hasBody = req.method != 'HEAD' && req.method != 'GET'
|
||||||
const opts = { headers, method: req.method, body: hasBody ? req : undefined }
|
const body = req.body ? JSON.stringify(req.body) : req
|
||||||
|
const opts = {
|
||||||
|
headers,
|
||||||
|
method: req.method,
|
||||||
|
body: hasBody ? body : undefined,
|
||||||
|
}
|
||||||
return fetch(url, opts)
|
return fetch(url, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,23 @@ export async function call(url: string, method: string, params: any) {
|
||||||
// one less hop
|
// one less hop
|
||||||
|
|
||||||
export function getFunctionUrl(name: string) {
|
export function getFunctionUrl(name: string) {
|
||||||
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
|
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
|
const { projectId, region } = ENV_CONFIG.firebaseConfig
|
||||||
|
return `http://localhost:5001/${projectId}/${region}/${name}`
|
||||||
|
} else {
|
||||||
|
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
|
||||||
|
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMarket(params: any) {
|
export function createMarket(params: any) {
|
||||||
return call(getFunctionUrl('createmarket'), 'POST', params)
|
return call(getFunctionUrl('createmarket'), 'POST', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMarket(params: any) {
|
||||||
|
return call(getFunctionUrl('resolvemarket'), 'POST', params)
|
||||||
|
}
|
||||||
|
|
||||||
export function placeBet(params: any) {
|
export function placeBet(params: any) {
|
||||||
return call(getFunctionUrl('placebet'), 'POST', params)
|
return call(getFunctionUrl('placebet'), 'POST', params)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
|
|
@ -16,8 +15,7 @@ import {
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, sum } from 'lodash'
|
import { sortBy, sum } from 'lodash'
|
||||||
|
|
||||||
import { app } from './init'
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { getValues, listenForValue, listenForValues } from './utils'
|
|
||||||
import { BinaryContract, Contract } from 'common/contract'
|
import { BinaryContract, Contract } from 'common/contract'
|
||||||
import { getDpmProbability } from 'common/calculate-dpm'
|
import { getDpmProbability } from 'common/calculate-dpm'
|
||||||
import { createRNG, shuffle } from 'common/util/random'
|
import { createRNG, shuffle } from 'common/util/random'
|
||||||
|
|
@ -28,6 +26,9 @@ import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
|
||||||
|
export const contracts = coll<Contract>('contracts')
|
||||||
|
|
||||||
export type { Contract }
|
export type { Contract }
|
||||||
|
|
||||||
export function contractPath(contract: Contract) {
|
export function contractPath(contract: Contract) {
|
||||||
|
|
@ -86,83 +87,72 @@ export function tradingAllowed(contract: Contract) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getFirestore(app)
|
|
||||||
export const contractCollection = collection(db, 'contracts')
|
|
||||||
export const contractDocRef = (contractId: string) =>
|
|
||||||
doc(db, 'contracts', contractId)
|
|
||||||
|
|
||||||
// Push contract to Firestore
|
// Push contract to Firestore
|
||||||
export async function setContract(contract: Contract) {
|
export async function setContract(contract: Contract) {
|
||||||
const docRef = doc(db, 'contracts', contract.id)
|
await setDoc(doc(contracts, contract.id), contract)
|
||||||
await setDoc(docRef, contract)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContract(
|
export async function updateContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
update: Partial<Contract>
|
update: Partial<Contract>
|
||||||
) {
|
) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
await updateDoc(doc(contracts, contractId), update)
|
||||||
await updateDoc(docRef, update)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractFromId(contractId: string) {
|
export async function getContractFromId(contractId: string) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
const result = await getDoc(doc(contracts, contractId))
|
||||||
const result = await getDoc(docRef)
|
return result.exists() ? result.data() : undefined
|
||||||
|
|
||||||
return result.exists() ? (result.data() as Contract) : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractFromSlug(slug: string) {
|
export async function getContractFromSlug(slug: string) {
|
||||||
const q = query(contractCollection, where('slug', '==', slug))
|
const q = query(contracts, where('slug', '==', slug))
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
|
return snapshot.empty ? undefined : snapshot.docs[0].data()
|
||||||
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContract(contractId: string) {
|
export async function deleteContract(contractId: string) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
await deleteDoc(doc(contracts, contractId))
|
||||||
await deleteDoc(docRef)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('creatorId', '==', creatorId),
|
where('creatorId', '==', creatorId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTaggedContractsCaseInsensitive(
|
export async function listTaggedContractsCaseInsensitive(
|
||||||
tag: string
|
tag: string
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllContracts(
|
export async function listAllContracts(
|
||||||
n: number,
|
n: number,
|
||||||
before?: string
|
before?: string
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n))
|
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
|
||||||
if (before != null) {
|
if (before != null) {
|
||||||
const snap = await getDoc(doc(db, 'contracts', before))
|
const snap = await getDoc(doc(contracts, before))
|
||||||
q = query(q, startAfter(snap))
|
q = query(q, startAfter(snap))
|
||||||
}
|
}
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForContracts(
|
export function listenForContracts(
|
||||||
setContracts: (contracts: Contract[]) => void
|
setContracts: (contracts: Contract[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(contractCollection, orderBy('createdTime', 'desc'))
|
const q = query(contracts, orderBy('createdTime', 'desc'))
|
||||||
return listenForValues<Contract>(q, setContracts)
|
return listenForValues<Contract>(q, setContracts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +161,7 @@ export function listenForUserContracts(
|
||||||
setContracts: (contracts: Contract[]) => void
|
setContracts: (contracts: Contract[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('creatorId', '==', creatorId),
|
where('creatorId', '==', creatorId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
|
|
@ -179,7 +169,7 @@ export function listenForUserContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeContractsQuery = query(
|
const activeContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
where('volume7Days', '>', 0)
|
where('volume7Days', '>', 0)
|
||||||
|
|
@ -196,7 +186,7 @@ export function listenForActiveContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const inactiveContractsQuery = query(
|
const inactiveContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('closeTime', '>', Date.now()),
|
where('closeTime', '>', Date.now()),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
|
|
@ -214,7 +204,7 @@ export function listenForInactiveContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const newContractsQuery = query(
|
const newContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('volume7Days', '==', 0),
|
where('volume7Days', '==', 0),
|
||||||
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||||
|
|
@ -230,7 +220,7 @@ export function listenForContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setContract: (contract: Contract | null) => void
|
setContract: (contract: Contract | null) => void
|
||||||
) {
|
) {
|
||||||
const contractRef = doc(contractCollection, contractId)
|
const contractRef = doc(contracts, contractId)
|
||||||
return listenForValue<Contract>(contractRef, setContract)
|
return listenForValue<Contract>(contractRef, setContract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotContractsQuery = query(
|
const hotContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
orderBy('volume24Hours', 'desc'),
|
orderBy('volume24Hours', 'desc'),
|
||||||
|
|
@ -262,22 +252,22 @@ export function listenForHotContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHotContracts() {
|
export async function getHotContracts() {
|
||||||
const contracts = await getValues<Contract>(hotContractsQuery)
|
const data = await getValues<Contract>(hotContractsQuery)
|
||||||
return sortBy(
|
return sortBy(
|
||||||
chooseRandomSubset(contracts, 10),
|
chooseRandomSubset(data, 10),
|
||||||
(contract) => -1 * contract.volume24Hours
|
(contract) => -1 * contract.volume24Hours
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsBySlugs(slugs: string[]) {
|
export async function getContractsBySlugs(slugs: string[]) {
|
||||||
const q = query(contractCollection, where('slug', 'in', slugs))
|
const q = query(contracts, where('slug', 'in', slugs))
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
|
const data = snapshot.docs.map((doc) => doc.data())
|
||||||
return sortBy(contracts, (contract) => -1 * contract.volume24Hours)
|
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
const topWeeklyQuery = query(
|
const topWeeklyQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
orderBy('volume7Days', 'desc'),
|
orderBy('volume7Days', 'desc'),
|
||||||
limit(MAX_FEED_CONTRACTS)
|
limit(MAX_FEED_CONTRACTS)
|
||||||
|
|
@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const closingSoonQuery = query(
|
const closingSoonQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
where('closeTime', '>', Date.now()),
|
where('closeTime', '>', Date.now()),
|
||||||
|
|
@ -296,15 +286,12 @@ const closingSoonQuery = query(
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function getClosingSoonContracts() {
|
export async function getClosingSoonContracts() {
|
||||||
const contracts = await getValues<Contract>(closingSoonQuery)
|
const data = await getValues<Contract>(closingSoonQuery)
|
||||||
return sortBy(
|
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
||||||
chooseRandomSubset(contracts, 2),
|
|
||||||
(contract) => contract.closeTime
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentBetsAndComments(contract: Contract) {
|
export async function getRecentBetsAndComments(contract: Contract) {
|
||||||
const contractDoc = doc(db, 'contracts', contract.id)
|
const contractDoc = doc(contracts, contract.id)
|
||||||
|
|
||||||
const [recentBets, recentComments] = await Promise.all([
|
const [recentBets, recentComments] = await Promise.all([
|
||||||
getValues<Bet>(
|
getValues<Bet>(
|
||||||
|
|
|
||||||
|
|
@ -29,17 +29,6 @@ export const createAnswer = cloudFunction<
|
||||||
}
|
}
|
||||||
>('createAnswer')
|
>('createAnswer')
|
||||||
|
|
||||||
export const resolveMarket = cloudFunction<
|
|
||||||
{
|
|
||||||
outcome: string
|
|
||||||
value?: number
|
|
||||||
contractId: string
|
|
||||||
probabilityInt?: number
|
|
||||||
resolutions?: { [outcome: string]: number }
|
|
||||||
},
|
|
||||||
{ status: 'error' | 'success'; message?: string }
|
|
||||||
>('resolveMarket')
|
|
||||||
|
|
||||||
export const createUser: () => Promise<User | null> = () => {
|
export const createUser: () => Promise<User | null> = () => {
|
||||||
const local = safeLocalStorage()
|
const local = safeLocalStorage()
|
||||||
let deviceToken = local?.getItem('device-token')
|
let deviceToken = local?.getItem('device-token')
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
import {
|
import {
|
||||||
collection,
|
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
doc,
|
doc,
|
||||||
|
getDocs,
|
||||||
query,
|
query,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
where,
|
where,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy } from 'lodash'
|
import { sortBy, uniq } from 'lodash'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { getContractFromId } from './contracts'
|
import { getContractFromId } from './contracts'
|
||||||
import { db } from './init'
|
import {
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
coll,
|
||||||
|
getValue,
|
||||||
|
getValues,
|
||||||
|
listenForValue,
|
||||||
|
listenForValues,
|
||||||
|
} from './utils'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
|
|
||||||
const groupCollection = collection(db, 'groups')
|
export const groups = coll<Group>('groups')
|
||||||
|
|
||||||
export function groupPath(
|
export function groupPath(
|
||||||
groupSlug: string,
|
groupSlug: string,
|
||||||
|
|
@ -23,30 +28,29 @@ export function groupPath(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateGroup(group: Group, updates: Partial<Group>) {
|
export function updateGroup(group: Group, updates: Partial<Group>) {
|
||||||
return updateDoc(doc(groupCollection, group.id), updates)
|
return updateDoc(doc(groups, group.id), updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteGroup(group: Group) {
|
export function deleteGroup(group: Group) {
|
||||||
return deleteDoc(doc(groupCollection, group.id))
|
return deleteDoc(doc(groups, group.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllGroups() {
|
export async function listAllGroups() {
|
||||||
return getValues<Group>(groupCollection)
|
return getValues<Group>(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groupCollection, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGroup(groupId: string) {
|
export function getGroup(groupId: string) {
|
||||||
return getValue<Group>(doc(groupCollection, groupId))
|
return getValue<Group>(doc(groups, groupId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupBySlug(slug: string) {
|
export async function getGroupBySlug(slug: string) {
|
||||||
const q = query(groupCollection, where('slug', '==', slug))
|
const q = query(groups, where('slug', '==', slug))
|
||||||
const groups = await getValues<Group>(q)
|
const docs = (await getDocs(q)).docs
|
||||||
|
return docs.length === 0 ? null : docs[0].data()
|
||||||
return groups.length === 0 ? null : groups[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupContracts(group: Group) {
|
export async function getGroupContracts(group: Group) {
|
||||||
|
|
@ -68,14 +72,14 @@ export function listenForGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
setGroup: (group: Group | null) => void
|
setGroup: (group: Group | null) => void
|
||||||
) {
|
) {
|
||||||
return listenForValue(doc(groupCollection, groupId), setGroup)
|
return listenForValue(doc(groups, groupId), setGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForMemberGroups(
|
export function listenForMemberGroups(
|
||||||
userId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(groupCollection, where('memberIds', 'array-contains', userId))
|
const q = query(groups, where('memberIds', 'array-contains', userId))
|
||||||
|
|
||||||
return listenForValues<Group>(q, (groups) => {
|
return listenForValues<Group>(q, (groups) => {
|
||||||
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
||||||
|
|
@ -87,10 +91,37 @@ export async function getGroupsWithContractId(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(
|
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
||||||
groupCollection,
|
setGroups(await getValues<Group>(q))
|
||||||
where('contractIds', 'array-contains', contractId)
|
}
|
||||||
)
|
|
||||||
const groups = await getValues<Group>(q)
|
export async function addUserToGroupViaSlug(groupSlug: string, userId: string) {
|
||||||
setGroups(groups)
|
// get group to get the member ids
|
||||||
|
const group = await getGroupBySlug(groupSlug)
|
||||||
|
if (!group) {
|
||||||
|
console.error(`Group not found: ${groupSlug}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return await joinGroup(group, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinGroup(group: Group, userId: string): Promise<Group> {
|
||||||
|
const { memberIds } = group
|
||||||
|
if (memberIds.includes(userId)) {
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
const newMemberIds = [...memberIds, userId]
|
||||||
|
const newGroup = { ...group, memberIds: newMemberIds }
|
||||||
|
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
|
||||||
|
return newGroup
|
||||||
|
}
|
||||||
|
export async function leaveGroup(group: Group, userId: string): Promise<Group> {
|
||||||
|
const { memberIds } = group
|
||||||
|
if (!memberIds.includes(userId)) {
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
const newMemberIds = memberIds.filter((id) => id !== userId)
|
||||||
|
const newGroup = { ...group, memberIds: newMemberIds }
|
||||||
|
await updateGroup(newGroup, { memberIds: uniq(newMemberIds) })
|
||||||
|
return newGroup
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
import {
|
import { getDoc, orderBy, query, setDoc, where } from 'firebase/firestore'
|
||||||
collection,
|
|
||||||
getDoc,
|
|
||||||
orderBy,
|
|
||||||
query,
|
|
||||||
setDoc,
|
|
||||||
where,
|
|
||||||
} from 'firebase/firestore'
|
|
||||||
import { doc } from 'firebase/firestore'
|
import { doc } from 'firebase/firestore'
|
||||||
import { Manalink } from '../../../common/manalink'
|
import { Manalink } from '../../../common/manalink'
|
||||||
import { db } from './init'
|
|
||||||
import { customAlphabet } from 'nanoid'
|
import { customAlphabet } from 'nanoid'
|
||||||
import { listenForValues } from './utils'
|
import { coll, listenForValues } from './utils'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export const manalinks = coll<Manalink>('manalinks')
|
||||||
|
|
||||||
export async function createManalink(data: {
|
export async function createManalink(data: {
|
||||||
fromId: string
|
fromId: string
|
||||||
amount: number
|
amount: number
|
||||||
|
|
@ -45,29 +39,25 @@ export async function createManalink(data: {
|
||||||
message,
|
message,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = doc(db, 'manalinks', slug)
|
await setDoc(doc(manalinks, slug), manalink)
|
||||||
await setDoc(ref, manalink)
|
|
||||||
return slug
|
return slug
|
||||||
}
|
}
|
||||||
|
|
||||||
const manalinkCol = collection(db, 'manalinks')
|
|
||||||
|
|
||||||
// TODO: This required an index, make sure to also set up in prod
|
// TODO: This required an index, make sure to also set up in prod
|
||||||
function listUserManalinks(fromId?: string) {
|
function listUserManalinks(fromId?: string) {
|
||||||
return query(
|
return query(
|
||||||
manalinkCol,
|
manalinks,
|
||||||
where('fromId', '==', fromId),
|
where('fromId', '==', fromId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getManalink(slug: string) {
|
export async function getManalink(slug: string) {
|
||||||
const docSnap = await getDoc(doc(db, 'manalinks', slug))
|
return (await getDoc(doc(manalinks, slug))).data()
|
||||||
return docSnap.data() as Manalink
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useManalink(slug: string) {
|
export function useManalink(slug: string) {
|
||||||
const [manalink, setManalink] = useState<Manalink | null>(null)
|
const [manalink, setManalink] = useState<Manalink | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slug) {
|
if (slug) {
|
||||||
getManalink(slug).then(setManalink)
|
getManalink(slug).then(setManalink)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn'
|
import { ManalinkTxn, DonationTxn, TipTxn, Txn } from 'common/txn'
|
||||||
import { collection, orderBy, query, where } from 'firebase/firestore'
|
import { orderBy, query, where } from 'firebase/firestore'
|
||||||
import { db } from './init'
|
import { coll, getValues, listenForValues } from './utils'
|
||||||
import { getValues, listenForValues } from './utils'
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { orderBy as _orderBy } from 'lodash'
|
import { orderBy as _orderBy } from 'lodash'
|
||||||
|
|
||||||
const txnCollection = collection(db, 'txns')
|
export const txns = coll<Txn>('txns')
|
||||||
|
|
||||||
const getCharityQuery = (charityId: string) =>
|
const getCharityQuery = (charityId: string) =>
|
||||||
query(
|
query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('toType', '==', 'CHARITY'),
|
where('toType', '==', 'CHARITY'),
|
||||||
where('toId', '==', charityId),
|
where('toId', '==', charityId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
|
|
@ -22,7 +21,7 @@ export function listenForCharityTxns(
|
||||||
return listenForValues<DonationTxn>(getCharityQuery(charityId), setTxns)
|
return listenForValues<DonationTxn>(getCharityQuery(charityId), setTxns)
|
||||||
}
|
}
|
||||||
|
|
||||||
const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY'))
|
const charitiesQuery = query(txns, where('toType', '==', 'CHARITY'))
|
||||||
|
|
||||||
export function getAllCharityTxns() {
|
export function getAllCharityTxns() {
|
||||||
return getValues<DonationTxn>(charitiesQuery)
|
return getValues<DonationTxn>(charitiesQuery)
|
||||||
|
|
@ -30,7 +29,7 @@ export function getAllCharityTxns() {
|
||||||
|
|
||||||
const getTipsQuery = (contractId: string) =>
|
const getTipsQuery = (contractId: string) =>
|
||||||
query(
|
query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('category', '==', 'TIP'),
|
where('category', '==', 'TIP'),
|
||||||
where('data.contractId', '==', contractId)
|
where('data.contractId', '==', contractId)
|
||||||
)
|
)
|
||||||
|
|
@ -50,13 +49,13 @@ export function useManalinkTxns(userId: string) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: Need to instantiate these indexes too
|
// TODO: Need to instantiate these indexes too
|
||||||
const fromQuery = query(
|
const fromQuery = query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('fromId', '==', userId),
|
where('fromId', '==', userId),
|
||||||
where('category', '==', 'MANALINK'),
|
where('category', '==', 'MANALINK'),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const toQuery = query(
|
const toQuery = query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('toId', '==', userId),
|
where('toId', '==', userId),
|
||||||
where('category', '==', 'MANALINK'),
|
where('category', '==', 'MANALINK'),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
getDoc,
|
getDoc,
|
||||||
|
|
@ -23,58 +22,66 @@ import {
|
||||||
} from 'firebase/auth'
|
} from 'firebase/auth'
|
||||||
import { throttle, zip } from 'lodash'
|
import { throttle, zip } from 'lodash'
|
||||||
|
|
||||||
import { app } from './init'
|
import { app, db } from './init'
|
||||||
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||||
import { createUser } from './fn-call'
|
import { createUser } from './fn-call'
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
import {
|
||||||
|
coll,
|
||||||
|
getValue,
|
||||||
|
getValues,
|
||||||
|
listenForValue,
|
||||||
|
listenForValues,
|
||||||
|
} from './utils'
|
||||||
import { feed } from 'common/feed'
|
import { feed } from 'common/feed'
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
import { CATEGORY_LIST } from 'common/categories'
|
||||||
import { safeLocalStorage } from '../util/local'
|
import { safeLocalStorage } from '../util/local'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import { addUserToGroupViaSlug } from 'web/lib/firebase/groups'
|
||||||
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
|
||||||
|
export const users = coll<User>('users')
|
||||||
|
export const privateUsers = coll<PrivateUser>('private-users')
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
||||||
|
|
||||||
const db = getFirestore(app)
|
|
||||||
export const auth = getAuth(app)
|
export const auth = getAuth(app)
|
||||||
|
|
||||||
export const userDocRef = (userId: string) => doc(db, 'users', userId)
|
|
||||||
|
|
||||||
export async function getUser(userId: string) {
|
export async function getUser(userId: string) {
|
||||||
const docSnap = await getDoc(userDocRef(userId))
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
return docSnap.data() as User
|
return (await getDoc(doc(users, userId))).data()!
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByUsername(username: string) {
|
export async function getUserByUsername(username: string) {
|
||||||
// Find a user whose username matches the given username, or null if no such user exists.
|
// Find a user whose username matches the given username, or null if no such user exists.
|
||||||
const userCollection = collection(db, 'users')
|
const q = query(users, where('username', '==', username), limit(1))
|
||||||
const q = query(userCollection, where('username', '==', username), limit(1))
|
const docs = (await getDocs(q)).docs
|
||||||
const docs = await getDocs(q)
|
return docs.length > 0 ? docs[0].data() : null
|
||||||
const users = docs.docs.map((doc) => doc.data() as User)
|
|
||||||
return users[0] || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setUser(userId: string, user: User) {
|
export async function setUser(userId: string, user: User) {
|
||||||
await setDoc(doc(db, 'users', userId), user)
|
await setDoc(doc(users, userId), user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, update: Partial<User>) {
|
export async function updateUser(userId: string, update: Partial<User>) {
|
||||||
await updateDoc(doc(db, 'users', userId), { ...update })
|
await updateDoc(doc(users, userId), { ...update })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePrivateUser(
|
export async function updatePrivateUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
update: Partial<PrivateUser>
|
update: Partial<PrivateUser>
|
||||||
) {
|
) {
|
||||||
await updateDoc(doc(db, 'private-users', userId), { ...update })
|
await updateDoc(doc(privateUsers, userId), { ...update })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForUser(
|
export function listenForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
setUser: (user: User | null) => void
|
setUser: (user: User | null) => void
|
||||||
) {
|
) {
|
||||||
const userRef = doc(db, 'users', userId)
|
const userRef = doc(users, userId)
|
||||||
return listenForValue<User>(userRef, setUser)
|
return listenForValue<User>(userRef, setUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,17 +89,97 @@ export function listenForPrivateUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
setPrivateUser: (privateUser: PrivateUser | null) => void
|
setPrivateUser: (privateUser: PrivateUser | null) => void
|
||||||
) {
|
) {
|
||||||
const userRef = doc(db, 'private-users', userId)
|
const userRef = doc(privateUsers, userId)
|
||||||
return listenForValue<PrivateUser>(userRef, setPrivateUser)
|
return listenForValue<PrivateUser>(userRef, setPrivateUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHED_USER_KEY = 'CACHED_USER_KEY'
|
const CACHED_USER_KEY = 'CACHED_USER_KEY'
|
||||||
|
const CACHED_REFERRAL_USERNAME_KEY = 'CACHED_REFERRAL_KEY'
|
||||||
|
const CACHED_REFERRAL_CONTRACT_ID_KEY = 'CACHED_REFERRAL_CONTRACT_KEY'
|
||||||
|
const CACHED_REFERRAL_GROUP_SLUG_KEY = 'CACHED_REFERRAL_GROUP_KEY'
|
||||||
|
|
||||||
// used to avoid weird race condition
|
// used to avoid weird race condition
|
||||||
let createUserPromise: Promise<User | null> | undefined = undefined
|
let createUserPromise: Promise<User | null> | undefined = undefined
|
||||||
|
|
||||||
const warmUpCreateUser = throttle(createUser, 5000 /* ms */)
|
const warmUpCreateUser = throttle(createUser, 5000 /* ms */)
|
||||||
|
|
||||||
|
export function writeReferralInfo(
|
||||||
|
defaultReferrerUsername: string,
|
||||||
|
contractId?: string,
|
||||||
|
referralUsername?: string,
|
||||||
|
groupSlug?: string
|
||||||
|
) {
|
||||||
|
const local = safeLocalStorage()
|
||||||
|
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
|
||||||
|
// Write the first referral username we see.
|
||||||
|
if (!cachedReferralUser)
|
||||||
|
local?.setItem(
|
||||||
|
CACHED_REFERRAL_USERNAME_KEY,
|
||||||
|
referralUsername || defaultReferrerUsername
|
||||||
|
)
|
||||||
|
|
||||||
|
// If an explicit referral query is passed, overwrite the cached referral username.
|
||||||
|
if (referralUsername)
|
||||||
|
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername)
|
||||||
|
|
||||||
|
// Always write the most recent explicit group invite query value
|
||||||
|
if (groupSlug) local?.setItem(CACHED_REFERRAL_GROUP_SLUG_KEY, groupSlug)
|
||||||
|
|
||||||
|
// Write the first contract id that we see.
|
||||||
|
const cachedReferralContract = local?.getItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
|
||||||
|
if (!cachedReferralContract && contractId)
|
||||||
|
local?.setItem(CACHED_REFERRAL_CONTRACT_ID_KEY, contractId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCachedReferralInfoForUser(user: User | null) {
|
||||||
|
if (!user || user.referredByUserId) return
|
||||||
|
// if the user wasn't created in the last minute, don't bother
|
||||||
|
const now = dayjs().utc()
|
||||||
|
const userCreatedTime = dayjs(user.createdTime)
|
||||||
|
if (now.diff(userCreatedTime, 'minute') > 1) return
|
||||||
|
|
||||||
|
const local = safeLocalStorage()
|
||||||
|
const cachedReferralUsername = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
|
||||||
|
const cachedReferralContractId = local?.getItem(
|
||||||
|
CACHED_REFERRAL_CONTRACT_ID_KEY
|
||||||
|
)
|
||||||
|
const cachedReferralGroupSlug = local?.getItem(CACHED_REFERRAL_GROUP_SLUG_KEY)
|
||||||
|
|
||||||
|
// get user via username
|
||||||
|
if (cachedReferralUsername)
|
||||||
|
getUserByUsername(cachedReferralUsername).then((referredByUser) => {
|
||||||
|
if (!referredByUser) return
|
||||||
|
// update user's referralId
|
||||||
|
updateUser(
|
||||||
|
user.id,
|
||||||
|
removeUndefinedProps({
|
||||||
|
referredByUserId: referredByUser.id,
|
||||||
|
referredByContractId: cachedReferralContractId
|
||||||
|
? cachedReferralContractId
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('error setting referral details', err)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
track('Referral', {
|
||||||
|
userId: user.id,
|
||||||
|
referredByUserId: referredByUser.id,
|
||||||
|
referredByContractId: cachedReferralContractId,
|
||||||
|
referredByGroupSlug: cachedReferralGroupSlug,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cachedReferralGroupSlug)
|
||||||
|
addUserToGroupViaSlug(cachedReferralGroupSlug, user.id)
|
||||||
|
|
||||||
|
local?.removeItem(CACHED_REFERRAL_GROUP_SLUG_KEY)
|
||||||
|
local?.removeItem(CACHED_REFERRAL_USERNAME_KEY)
|
||||||
|
local?.removeItem(CACHED_REFERRAL_CONTRACT_ID_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
export function listenForLogin(onUser: (user: User | null) => void) {
|
export function listenForLogin(onUser: (user: User | null) => void) {
|
||||||
const local = safeLocalStorage()
|
const local = safeLocalStorage()
|
||||||
const cachedUser = local?.getItem(CACHED_USER_KEY)
|
const cachedUser = local?.getItem(CACHED_USER_KEY)
|
||||||
|
|
@ -116,6 +203,7 @@ export function listenForLogin(onUser: (user: User | null) => void) {
|
||||||
// Persist to local storage, to reduce login blink next time.
|
// Persist to local storage, to reduce login blink next time.
|
||||||
// Note: Cap on localStorage size is ~5mb
|
// Note: Cap on localStorage size is ~5mb
|
||||||
local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
|
local?.setItem(CACHED_USER_KEY, JSON.stringify(user))
|
||||||
|
setCachedReferralInfoForUser(user)
|
||||||
} else {
|
} else {
|
||||||
// User logged out; reset to null
|
// User logged out; reset to null
|
||||||
onUser(null)
|
onUser(null)
|
||||||
|
|
@ -152,36 +240,29 @@ export async function listUsers(userIds: string[]) {
|
||||||
if (userIds.length > 10) {
|
if (userIds.length > 10) {
|
||||||
throw new Error('Too many users requested at once; Firestore limits to 10')
|
throw new Error('Too many users requested at once; Firestore limits to 10')
|
||||||
}
|
}
|
||||||
const userCollection = collection(db, 'users')
|
const q = query(users, where('id', 'in', userIds))
|
||||||
const q = query(userCollection, where('id', 'in', userIds))
|
const docs = (await getDocs(q)).docs
|
||||||
const docs = await getDocs(q)
|
return docs.map((doc) => doc.data())
|
||||||
return docs.docs.map((doc) => doc.data() as User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllUsers() {
|
export async function listAllUsers() {
|
||||||
const userCollection = collection(db, 'users')
|
const docs = (await getDocs(users)).docs
|
||||||
const q = query(userCollection)
|
return docs.map((doc) => doc.data())
|
||||||
const docs = await getDocs(q)
|
|
||||||
return docs.docs.map((doc) => doc.data() as User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForAllUsers(setUsers: (users: User[]) => void) {
|
export function listenForAllUsers(setUsers: (users: User[]) => void) {
|
||||||
const userCollection = collection(db, 'users')
|
listenForValues(users, setUsers)
|
||||||
const q = query(userCollection)
|
|
||||||
listenForValues(q, setUsers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForPrivateUsers(
|
export function listenForPrivateUsers(
|
||||||
setUsers: (users: PrivateUser[]) => void
|
setUsers: (users: PrivateUser[]) => void
|
||||||
) {
|
) {
|
||||||
const userCollection = collection(db, 'private-users')
|
listenForValues(privateUsers, setUsers)
|
||||||
const q = query(userCollection)
|
|
||||||
listenForValues(q, setUsers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopTraders(period: Period) {
|
export function getTopTraders(period: Period) {
|
||||||
const topTraders = query(
|
const topTraders = query(
|
||||||
collection(db, 'users'),
|
users,
|
||||||
orderBy('profitCached.' + period, 'desc'),
|
orderBy('profitCached.' + period, 'desc'),
|
||||||
limit(20)
|
limit(20)
|
||||||
)
|
)
|
||||||
|
|
@ -191,7 +272,7 @@ export function getTopTraders(period: Period) {
|
||||||
|
|
||||||
export function getTopCreators(period: Period) {
|
export function getTopCreators(period: Period) {
|
||||||
const topCreators = query(
|
const topCreators = query(
|
||||||
collection(db, 'users'),
|
users,
|
||||||
orderBy('creatorVolumeCached.' + period, 'desc'),
|
orderBy('creatorVolumeCached.' + period, 'desc'),
|
||||||
limit(20)
|
limit(20)
|
||||||
)
|
)
|
||||||
|
|
@ -199,22 +280,21 @@ export function getTopCreators(period: Period) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopFollowed() {
|
export async function getTopFollowed() {
|
||||||
const users = await getValues<User>(topFollowedQuery)
|
return (await getValues<User>(topFollowedQuery)).slice(0, 20)
|
||||||
return users.slice(0, 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topFollowedQuery = query(
|
const topFollowedQuery = query(
|
||||||
collection(db, 'users'),
|
users,
|
||||||
orderBy('followerCountCached', 'desc'),
|
orderBy('followerCountCached', 'desc'),
|
||||||
limit(20)
|
limit(20)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function getUsers() {
|
export function getUsers() {
|
||||||
return getValues<User>(collection(db, 'users'))
|
return getValues<User>(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserFeed(userId: string) {
|
export async function getUserFeed(userId: string) {
|
||||||
const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed')
|
const feedDoc = doc(privateUsers, userId, 'cache', 'feed')
|
||||||
const userFeed = await getValue<{
|
const userFeed = await getValue<{
|
||||||
feed: feed
|
feed: feed
|
||||||
}>(feedDoc)
|
}>(feedDoc)
|
||||||
|
|
@ -222,7 +302,7 @@ export async function getUserFeed(userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCategoryFeeds(userId: string) {
|
export async function getCategoryFeeds(userId: string) {
|
||||||
const cacheCollection = collection(db, 'private-users', userId, 'cache')
|
const cacheCollection = collection(privateUsers, userId, 'cache')
|
||||||
const feedData = await Promise.all(
|
const feedData = await Promise.all(
|
||||||
CATEGORY_LIST.map((category) =>
|
CATEGORY_LIST.map((category) =>
|
||||||
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
|
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
|
||||||
|
|
@ -233,7 +313,7 @@ export async function getCategoryFeeds(userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function follow(userId: string, followedUserId: string) {
|
export async function follow(userId: string, followedUserId: string) {
|
||||||
const followDoc = doc(db, 'users', userId, 'follows', followedUserId)
|
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
|
||||||
await setDoc(followDoc, {
|
await setDoc(followDoc, {
|
||||||
userId: followedUserId,
|
userId: followedUserId,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -241,7 +321,7 @@ export async function follow(userId: string, followedUserId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unfollow(userId: string, unfollowedUserId: string) {
|
export async function unfollow(userId: string, unfollowedUserId: string) {
|
||||||
const followDoc = doc(db, 'users', userId, 'follows', unfollowedUserId)
|
const followDoc = doc(collection(users, userId, 'follows'), unfollowedUserId)
|
||||||
await deleteDoc(followDoc)
|
await deleteDoc(followDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,7 +339,7 @@ export function listenForFollows(
|
||||||
userId: string,
|
userId: string,
|
||||||
setFollowIds: (followIds: string[]) => void
|
setFollowIds: (followIds: string[]) => void
|
||||||
) {
|
) {
|
||||||
const follows = collection(db, 'users', userId, 'follows')
|
const follows = collection(users, userId, 'follows')
|
||||||
return listenForValues<{ userId: string }>(follows, (docs) =>
|
return listenForValues<{ userId: string }>(follows, (docs) =>
|
||||||
setFollowIds(docs.map(({ userId }) => userId))
|
setFollowIds(docs.map(({ userId }) => userId))
|
||||||
)
|
)
|
||||||
|
|
@ -284,3 +364,22 @@ export function listenForFollowers(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
export function listenForReferrals(
|
||||||
|
userId: string,
|
||||||
|
setReferralIds: (referralIds: string[]) => void
|
||||||
|
) {
|
||||||
|
const referralsQuery = query(
|
||||||
|
collection(db, 'users'),
|
||||||
|
where('referredByUserId', '==', userId)
|
||||||
|
)
|
||||||
|
return onSnapshot(
|
||||||
|
referralsQuery,
|
||||||
|
{ includeMetadataChanges: true },
|
||||||
|
(snapshot) => {
|
||||||
|
if (snapshot.metadata.fromCache) return
|
||||||
|
|
||||||
|
const values = snapshot.docs.map((doc) => doc.ref.id)
|
||||||
|
setReferralIds(filterDefined(values))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
collection,
|
||||||
getDoc,
|
getDoc,
|
||||||
getDocs,
|
getDocs,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
Query,
|
Query,
|
||||||
|
CollectionReference,
|
||||||
DocumentReference,
|
DocumentReference,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
|
import { db } from './init'
|
||||||
|
|
||||||
|
export const coll = <T>(path: string, ...rest: string[]) => {
|
||||||
|
return collection(db, path, ...rest) as CollectionReference<T>
|
||||||
|
}
|
||||||
|
|
||||||
export const getValue = async <T>(doc: DocumentReference) => {
|
export const getValue = async <T>(doc: DocumentReference) => {
|
||||||
const snap = await getDoc(doc)
|
const snap = await getDoc(doc)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"postbuild": "next-sitemap",
|
"postbuild": "next-sitemap",
|
||||||
"verify": "(cd .. && yarn verify)"
|
"verify": "(cd .. && yarn verify)",
|
||||||
|
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/analytics-browser": "0.4.1",
|
"@amplitude/analytics-browser": "0.4.1",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useUser } from 'web/hooks/use-user'
|
||||||
import { ResolutionPanel } from 'web/components/resolution-panel'
|
import { ResolutionPanel } from 'web/components/resolution-panel'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { listUsers, User } from 'web/lib/firebase/users'
|
import { listUsers, User, writeReferralInfo } from 'web/lib/firebase/users'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
getContractFromSlug,
|
getContractFromSlug,
|
||||||
|
|
@ -42,6 +42,7 @@ import { useBets } from 'web/hooks/use-bets'
|
||||||
import { AlertBox } from 'web/components/alert-box'
|
import { AlertBox } from 'web/components/alert-box'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
|
|
@ -151,6 +152,16 @@ export function ContractPageContent(
|
||||||
|
|
||||||
const ogCardProps = getOpenGraphProps(contract)
|
const ogCardProps = getOpenGraphProps(contract)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { referrer } = router.query as {
|
||||||
|
referrer?: string
|
||||||
|
}
|
||||||
|
if (!user && router.isReady)
|
||||||
|
writeReferralInfo(contract.creatorUsername, contract.id, referrer)
|
||||||
|
}, [user, contract, router])
|
||||||
|
|
||||||
const rightSidebar = hasSidePanel ? (
|
const rightSidebar = hasSidePanel ? (
|
||||||
<Col className="gap-4">
|
<Col className="gap-4">
|
||||||
{allowTrade &&
|
{allowTrade &&
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { listAllBets } from 'web/lib/firebase/bets'
|
||||||
import { listAllComments } from 'web/lib/firebase/comments'
|
import { listAllComments } from 'web/lib/firebase/comments'
|
||||||
import { getContractFromId } from 'web/lib/firebase/contracts'
|
import { getContractFromId } from 'web/lib/firebase/contracts'
|
||||||
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
||||||
import { FullMarket, ApiError, toFullMarket } from '../_types'
|
import { FullMarket, ApiError, toFullMarket } from '../../_types'
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
28
web/pages/api/v0/market/[id]/resolve.ts
Normal file
28
web/pages/api/v0/market/[id]/resolve.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
import {
|
||||||
|
CORS_ORIGIN_MANIFOLD,
|
||||||
|
CORS_ORIGIN_LOCALHOST,
|
||||||
|
} from 'common/envs/constants'
|
||||||
|
import { applyCorsHeaders } from 'web/lib/api/cors'
|
||||||
|
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
|
||||||
|
|
||||||
|
export const config = { api: { bodyParser: true } }
|
||||||
|
|
||||||
|
export default async function route(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
await applyCorsHeaders(req, res, {
|
||||||
|
origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
|
||||||
|
methods: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { id } = req.query
|
||||||
|
const contractId = id as string
|
||||||
|
|
||||||
|
if (req.body) req.body.contractId = contractId
|
||||||
|
try {
|
||||||
|
const backendRes = await fetchBackend(req, 'resolvemarket')
|
||||||
|
await forwardResponse(res, backendRes)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error talking to cloud function: ', err)
|
||||||
|
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
useInitialQueryAndSort,
|
useInitialQueryAndSort,
|
||||||
} from 'web/hooks/use-sort-and-query-params'
|
} from 'web/hooks/use-sort-and-query-params'
|
||||||
|
|
||||||
|
const MAX_CONTRACTS_RENDERED = 100
|
||||||
|
|
||||||
export default function ContractSearchFirestore(props: {
|
export default function ContractSearchFirestore(props: {
|
||||||
querySortOptions?: {
|
querySortOptions?: {
|
||||||
defaultSort: Sort
|
defaultSort: Sort
|
||||||
|
|
@ -80,6 +82,8 @@ export default function ContractSearchFirestore(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
matches = matches.slice(0, MAX_CONTRACTS_RENDERED)
|
||||||
|
|
||||||
const showTime = ['close-date', 'closed'].includes(sort)
|
const showTime = ['close-date', 'closed'].includes(sort)
|
||||||
? 'close-date'
|
? 'close-date'
|
||||||
: sort === 'resolve-date'
|
: sort === 'resolve-date'
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||||
import { CATEGORIES } from 'common/categories'
|
import { CATEGORIES } from 'common/categories'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
|
||||||
export default function Create() {
|
export default function Create() {
|
||||||
const [question, setQuestion] = useState('')
|
const [question, setQuestion] = useState('')
|
||||||
|
|
@ -33,7 +34,13 @@ export default function Create() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { groupId } = router.query as { groupId: string }
|
const { groupId } = router.query as { groupId: string }
|
||||||
useTracking('view create page')
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|
@ -58,7 +65,11 @@ export default function Create() {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
<NewContract question={question} groupId={groupId} />
|
<NewContract
|
||||||
|
question={question}
|
||||||
|
groupId={groupId}
|
||||||
|
creator={creator}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
@ -66,14 +77,12 @@ export default function Create() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow user to create a new contract
|
// Allow user to create a new contract
|
||||||
export function NewContract(props: { question: string; groupId?: string }) {
|
export function NewContract(props: {
|
||||||
const { question, groupId } = props
|
creator: User
|
||||||
const creator = useUser()
|
question: string
|
||||||
|
groupId?: string
|
||||||
useEffect(() => {
|
}) {
|
||||||
if (creator === null) router.push('/')
|
const { creator, question, groupId } = props
|
||||||
}, [creator])
|
|
||||||
|
|
||||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||||
const [initialProb] = useState(50)
|
const [initialProb] = useState(50)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ import {
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import {
|
||||||
|
firebaseLogin,
|
||||||
|
getUser,
|
||||||
|
User,
|
||||||
|
writeReferralInfo,
|
||||||
|
} from 'web/lib/firebase/users'
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
|
@ -40,6 +45,9 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import { useCommentsOnGroup } from 'web/hooks/use-comments'
|
import { useCommentsOnGroup } from 'web/hooks/use-comments'
|
||||||
import ShortToggle from 'web/components/widgets/short-toggle'
|
import ShortToggle from 'web/components/widgets/short-toggle'
|
||||||
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
|
import { REFERRAL_AMOUNT } from 'common/user'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
|
|
@ -150,6 +158,14 @@ export default function GroupPage(props: {
|
||||||
}, [group])
|
}, [group])
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
useEffect(() => {
|
||||||
|
const { referrer } = router.query as {
|
||||||
|
referrer?: string
|
||||||
|
}
|
||||||
|
if (!user && router.isReady)
|
||||||
|
writeReferralInfo(creator.username, undefined, referrer, group?.slug)
|
||||||
|
}, [user, creator, group, router])
|
||||||
|
|
||||||
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +273,13 @@ export default function GroupPage(props: {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 text-gray-500">
|
<div className="p-2 text-gray-500">
|
||||||
No questions yet. 🦗... Why not add one?
|
No questions yet. Why not{' '}
|
||||||
|
<SiteLink
|
||||||
|
href={`/create/?groupId=${group.id}`}
|
||||||
|
className={'font-bold text-gray-700'}
|
||||||
|
>
|
||||||
|
add one?
|
||||||
|
</SiteLink>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -321,18 +343,17 @@ function GroupOverview(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col>
|
<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.name}</Row>
|
|
||||||
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
|
|
||||||
</Row>
|
|
||||||
<Col className="gap-2 rounded-b bg-white p-4">
|
<Col className="gap-2 rounded-b bg-white p-4">
|
||||||
<Row>
|
<Row className={'flex-wrap justify-between'}>
|
||||||
<div className="mr-1 text-gray-500">Created by</div>
|
<div className={'inline-flex items-center'}>
|
||||||
<UserLink
|
<div className="mr-1 text-gray-500">Created by</div>
|
||||||
className="text-neutral"
|
<UserLink
|
||||||
name={creator.name}
|
className="text-neutral"
|
||||||
username={creator.username}
|
name={creator.name}
|
||||||
/>
|
username={creator.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
|
||||||
</Row>
|
</Row>
|
||||||
<Row className={'items-center gap-1'}>
|
<Row className={'items-center gap-1'}>
|
||||||
<span className={'text-gray-500'}>Membership</span>
|
<span className={'text-gray-500'}>Membership</span>
|
||||||
|
|
@ -352,6 +373,20 @@ function GroupOverview(props: {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
{anyoneCanJoin && user && (
|
||||||
|
<Row className={'flex-wrap items-center gap-1'}>
|
||||||
|
<span className={'text-gray-500'}>Sharing</span>
|
||||||
|
<ShareIconButton
|
||||||
|
group={group}
|
||||||
|
username={user.username}
|
||||||
|
buttonClassName={'hover:bg-gray-300 mt-1 !text-gray-700'}
|
||||||
|
>
|
||||||
|
<span className={'mx-2'}>
|
||||||
|
Invite a friend and get M${REFERRAL_AMOUNT} if they sign up!
|
||||||
|
</span>
|
||||||
|
</ShareIconButton>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { Tabs } from 'web/components/layout/tabs'
|
||||||
import { GroupMembersList } from 'web/pages/group/[...slugs]'
|
import { GroupMembersList } from 'web/pages/group/[...slugs]'
|
||||||
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
|
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const groups = await listAllGroups().catch((_) => [])
|
const groups = await listAllGroups().catch((_) => [])
|
||||||
|
|
@ -105,7 +107,7 @@ export default function Groups(props: {
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
...(user
|
...(user && memberGroupIds.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: 'My Groups',
|
title: 'My Groups',
|
||||||
|
|
@ -202,3 +204,16 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupLink(props: { group: Group; className?: string }) {
|
||||||
|
const { group, className } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLink
|
||||||
|
href={groupPath(group.slug)}
|
||||||
|
className={clsx('z-10 truncate', className)}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default function ClaimPage() {
|
||||||
url="/send"
|
url="/send"
|
||||||
/>
|
/>
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-xl">
|
||||||
<Title text={`Claim ${manalink.amount} mana`} />
|
<Title text={`Claim M$${manalink.amount} mana`} />
|
||||||
<ManalinkCard
|
<ManalinkCard
|
||||||
defaultMessage={fromUser?.name || 'Enjoy this mana!'}
|
defaultMessage={fromUser?.name || 'Enjoy this mana!'}
|
||||||
info={info}
|
info={info}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ import { Title } from 'web/components/title'
|
||||||
import { doc, updateDoc } from 'firebase/firestore'
|
import { doc, updateDoc } from 'firebase/firestore'
|
||||||
import { db } from 'web/lib/firebase/init'
|
import { db } from 'web/lib/firebase/init'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { Answer } from 'common/answer'
|
|
||||||
import { Comment } from 'web/lib/firebase/comments'
|
|
||||||
import { getValue } from 'web/lib/firebase/utils'
|
|
||||||
import Custom404 from 'web/pages/404'
|
import Custom404 from 'web/pages/404'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
||||||
|
|
@ -38,7 +35,6 @@ import {
|
||||||
NotificationGroup,
|
NotificationGroup,
|
||||||
usePreferredGroupedNotifications,
|
usePreferredGroupedNotifications,
|
||||||
} from 'web/hooks/use-notifications'
|
} from 'web/hooks/use-notifications'
|
||||||
import { getContractFromId } from 'web/lib/firebase/contracts'
|
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
@ -182,7 +178,7 @@ function NotificationGroupItem(props: {
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { notificationGroup, className } = props
|
const { notificationGroup, className } = props
|
||||||
const { sourceContractId, notifications } = notificationGroup
|
const { notifications } = notificationGroup
|
||||||
const {
|
const {
|
||||||
sourceContractTitle,
|
sourceContractTitle,
|
||||||
sourceContractSlug,
|
sourceContractSlug,
|
||||||
|
|
@ -191,28 +187,6 @@ function NotificationGroupItem(props: {
|
||||||
const numSummaryLines = 3
|
const numSummaryLines = 3
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [contract, setContract] = useState<Contract | undefined>(undefined)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
sourceContractTitle &&
|
|
||||||
sourceContractSlug &&
|
|
||||||
sourceContractCreatorUsername
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if (sourceContractId) {
|
|
||||||
getContractFromId(sourceContractId)
|
|
||||||
.then((contract) => {
|
|
||||||
if (contract) setContract(contract)
|
|
||||||
})
|
|
||||||
.catch((e) => console.log(e))
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
sourceContractCreatorUsername,
|
|
||||||
sourceContractId,
|
|
||||||
sourceContractSlug,
|
|
||||||
sourceContractTitle,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotificationsAsSeen(notifications)
|
setNotificationsAsSeen(notifications)
|
||||||
|
|
@ -240,20 +214,20 @@ function NotificationGroupItem(props: {
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
|
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
|
||||||
>
|
>
|
||||||
{sourceContractTitle || contract ? (
|
{sourceContractTitle ? (
|
||||||
<span>
|
<span>
|
||||||
{'Activity on '}
|
{'Activity on '}
|
||||||
<a
|
<a
|
||||||
href={
|
href={
|
||||||
sourceContractCreatorUsername
|
sourceContractCreatorUsername
|
||||||
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||||
: `/${contract?.creatorUsername}/${contract?.slug}`
|
: ''
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{sourceContractTitle || contract?.question}
|
{sourceContractTitle}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -306,6 +280,7 @@ function NotificationGroupItem(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: where should we put referral bonus notifications?
|
||||||
function NotificationSettings() {
|
function NotificationSettings() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [notificationSettings, setNotificationSettings] =
|
const [notificationSettings, setNotificationSettings] =
|
||||||
|
|
@ -455,6 +430,10 @@ function NotificationSettings() {
|
||||||
highlight={notificationSettings !== 'none'}
|
highlight={notificationSettings !== 'none'}
|
||||||
label={"Activity on questions you're betting on"}
|
label={"Activity on questions you're betting on"}
|
||||||
/>
|
/>
|
||||||
|
<NotificationSettingLine
|
||||||
|
highlight={notificationSettings !== 'none'}
|
||||||
|
label={"Referral bonuses you've received"}
|
||||||
|
/>
|
||||||
<NotificationSettingLine
|
<NotificationSettingLine
|
||||||
label={"Activity on questions you've ever bet or commented on"}
|
label={"Activity on questions you've ever bet or commented on"}
|
||||||
highlight={notificationSettings === 'all'}
|
highlight={notificationSettings === 'all'}
|
||||||
|
|
@ -515,7 +494,6 @@ function NotificationItem(props: {
|
||||||
const { notification, justSummary } = props
|
const { notification, justSummary } = props
|
||||||
const {
|
const {
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceContractId,
|
|
||||||
sourceId,
|
sourceId,
|
||||||
sourceUserName,
|
sourceUserName,
|
||||||
sourceUserAvatarUrl,
|
sourceUserAvatarUrl,
|
||||||
|
|
@ -534,60 +512,15 @@ function NotificationItem(props: {
|
||||||
|
|
||||||
const [defaultNotificationText, setDefaultNotificationText] =
|
const [defaultNotificationText, setDefaultNotificationText] =
|
||||||
useState<string>('')
|
useState<string>('')
|
||||||
const [contract, setContract] = useState<Contract | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!sourceContractId ||
|
|
||||||
(sourceContractSlug && sourceContractCreatorUsername)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
getContractFromId(sourceContractId)
|
|
||||||
.then((contract) => {
|
|
||||||
if (contract) setContract(contract)
|
|
||||||
})
|
|
||||||
.catch((e) => console.log(e))
|
|
||||||
}, [
|
|
||||||
sourceContractCreatorUsername,
|
|
||||||
sourceContractId,
|
|
||||||
sourceContractSlug,
|
|
||||||
sourceContractTitle,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sourceText) {
|
if (sourceText) {
|
||||||
setDefaultNotificationText(sourceText)
|
setDefaultNotificationText(sourceText)
|
||||||
} else if (!contract || !sourceContractId || !sourceId) return
|
|
||||||
else if (
|
|
||||||
sourceType === 'answer' ||
|
|
||||||
sourceType === 'comment' ||
|
|
||||||
sourceType === 'contract'
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
parseOldStyleNotificationText(
|
|
||||||
sourceId,
|
|
||||||
sourceContractId,
|
|
||||||
sourceType,
|
|
||||||
sourceUpdateType,
|
|
||||||
setDefaultNotificationText,
|
|
||||||
contract
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
} else if (reasonText) {
|
} else if (reasonText) {
|
||||||
// Handle arbitrary notifications with reason text here.
|
// Handle arbitrary notifications with reason text here.
|
||||||
setDefaultNotificationText(reasonText)
|
setDefaultNotificationText(reasonText)
|
||||||
}
|
}
|
||||||
}, [
|
}, [reasonText, sourceText])
|
||||||
contract,
|
|
||||||
reasonText,
|
|
||||||
sourceContractId,
|
|
||||||
sourceId,
|
|
||||||
sourceText,
|
|
||||||
sourceType,
|
|
||||||
sourceUpdateType,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotificationsAsSeen([notification])
|
setNotificationsAsSeen([notification])
|
||||||
|
|
@ -596,14 +529,16 @@ function NotificationItem(props: {
|
||||||
function getSourceUrl() {
|
function getSourceUrl() {
|
||||||
if (sourceType === 'follow') return `/${sourceUserUsername}`
|
if (sourceType === 'follow') return `/${sourceUserUsername}`
|
||||||
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
|
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
|
||||||
|
if (
|
||||||
|
sourceContractCreatorUsername &&
|
||||||
|
sourceContractSlug &&
|
||||||
|
sourceType === 'user'
|
||||||
|
)
|
||||||
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
sourceId ?? ''
|
sourceId ?? ''
|
||||||
)}`
|
)}`
|
||||||
if (!contract) return ''
|
|
||||||
return `/${contract.creatorUsername}/${
|
|
||||||
contract.slug
|
|
||||||
}#${getSourceIdForLinkComponent(sourceId ?? '')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceIdForLinkComponent(sourceId: string) {
|
function getSourceIdForLinkComponent(sourceId: string) {
|
||||||
|
|
@ -619,38 +554,6 @@ function NotificationItem(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseOldStyleNotificationText(
|
|
||||||
sourceId: string,
|
|
||||||
sourceContractId: string,
|
|
||||||
sourceType: 'answer' | 'comment' | 'contract',
|
|
||||||
sourceUpdateType: notification_source_update_types | undefined,
|
|
||||||
setText: (text: string) => void,
|
|
||||||
contract: Contract
|
|
||||||
) {
|
|
||||||
if (sourceType === 'contract') {
|
|
||||||
if (
|
|
||||||
isNotificationAboutContractResolution(
|
|
||||||
sourceType,
|
|
||||||
sourceUpdateType,
|
|
||||||
contract
|
|
||||||
) &&
|
|
||||||
contract.resolution
|
|
||||||
)
|
|
||||||
setText(contract.resolution)
|
|
||||||
else setText(contract.question)
|
|
||||||
} else if (sourceType === 'answer') {
|
|
||||||
const answer = await getValue<Answer>(
|
|
||||||
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
|
|
||||||
)
|
|
||||||
setText(answer?.text ?? '')
|
|
||||||
} else {
|
|
||||||
const comment = await getValue<Comment>(
|
|
||||||
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
|
|
||||||
)
|
|
||||||
setText(comment?.text ?? '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (justSummary) {
|
if (justSummary) {
|
||||||
return (
|
return (
|
||||||
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
||||||
|
|
@ -669,13 +572,13 @@ function NotificationItem(props: {
|
||||||
sourceType,
|
sourceType,
|
||||||
reason,
|
reason,
|
||||||
sourceUpdateType,
|
sourceUpdateType,
|
||||||
contract,
|
undefined,
|
||||||
true
|
true
|
||||||
).replace(' on', '')}
|
).replace(' on', '')}
|
||||||
</span>
|
</span>
|
||||||
<div className={'ml-1 text-black'}>
|
<div className={'ml-1 text-black'}>
|
||||||
<NotificationTextLabel
|
<NotificationTextLabel
|
||||||
contract={contract}
|
contract={null}
|
||||||
defaultText={defaultNotificationText}
|
defaultText={defaultNotificationText}
|
||||||
className={'line-clamp-1'}
|
className={'line-clamp-1'}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
|
|
@ -717,7 +620,9 @@ function NotificationItem(props: {
|
||||||
sourceType,
|
sourceType,
|
||||||
reason,
|
reason,
|
||||||
sourceUpdateType,
|
sourceUpdateType,
|
||||||
contract
|
undefined,
|
||||||
|
false,
|
||||||
|
sourceSlug
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={
|
href={
|
||||||
|
|
@ -725,13 +630,13 @@ function NotificationItem(props: {
|
||||||
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
|
||||||
: sourceType === 'group' && sourceSlug
|
: sourceType === 'group' && sourceSlug
|
||||||
? `${groupPath(sourceSlug)}`
|
? `${groupPath(sourceSlug)}`
|
||||||
: `/${contract?.creatorUsername}/${contract?.slug}`
|
: ''
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{contract?.question || sourceContractTitle || sourceTitle}
|
{sourceContractTitle || sourceTitle}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -752,7 +657,7 @@ function NotificationItem(props: {
|
||||||
</Row>
|
</Row>
|
||||||
<div className={'mt-1 ml-1 md:text-base'}>
|
<div className={'mt-1 ml-1 md:text-base'}>
|
||||||
<NotificationTextLabel
|
<NotificationTextLabel
|
||||||
contract={contract}
|
contract={null}
|
||||||
defaultText={defaultNotificationText}
|
defaultText={defaultNotificationText}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
/>
|
/>
|
||||||
|
|
@ -811,6 +716,16 @@ function NotificationTextLabel(props: {
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (sourceType === 'user' && sourceText) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
As a thank you, we sent you{' '}
|
||||||
|
<span className="text-primary">
|
||||||
|
{formatMoney(parseInt(sourceText))}
|
||||||
|
</span>
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
)
|
||||||
} else if (sourceType === 'liquidity' && sourceText) {
|
} else if (sourceType === 'liquidity' && sourceText) {
|
||||||
return (
|
return (
|
||||||
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
|
||||||
|
|
@ -829,7 +744,8 @@ function getReasonForShowingNotification(
|
||||||
reason: notification_reason_types,
|
reason: notification_reason_types,
|
||||||
sourceUpdateType: notification_source_update_types | undefined,
|
sourceUpdateType: notification_source_update_types | undefined,
|
||||||
contract: Contract | undefined | null,
|
contract: Contract | undefined | null,
|
||||||
simple?: boolean
|
simple?: boolean,
|
||||||
|
sourceSlug?: string
|
||||||
) {
|
) {
|
||||||
let reasonText: string
|
let reasonText: string
|
||||||
switch (source) {
|
switch (source) {
|
||||||
|
|
@ -883,6 +799,12 @@ function getReasonForShowingNotification(
|
||||||
case 'group':
|
case 'group':
|
||||||
reasonText = 'added you to the group'
|
reasonText = 'added you to the group'
|
||||||
break
|
break
|
||||||
|
case 'user':
|
||||||
|
if (sourceSlug && reason === 'user_joined_to_bet_on_your_market')
|
||||||
|
reasonText = 'joined to bet on your market'
|
||||||
|
else if (sourceSlug) reasonText = 'joined because you shared'
|
||||||
|
else reasonText = 'joined because of you'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
reasonText = ''
|
reasonText = ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true
|
"incremental": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"watchOptions": {
|
"watchOptions": {
|
||||||
"excludeDirectories": [".next"]
|
"excludeDirectories": [".next"]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user