Merge branch 'main' into search
This commit is contained in:
commit
85fa76200f
|
@ -5,12 +5,14 @@ import {
|
|||
CPMMBinaryContract,
|
||||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
} from './contract'
|
||||
import { User } from './user'
|
||||
import { LiquidityProvision } from './liquidity-provision'
|
||||
import { noFees } from './fees'
|
||||
import { ENV_CONFIG } from './envs/constants'
|
||||
import { Answer } from './answer'
|
||||
|
||||
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
|
||||
|
||||
|
@ -111,6 +113,50 @@ export function getFreeAnswerAnte(
|
|||
return anteBet
|
||||
}
|
||||
|
||||
export function getMultipleChoiceAntes(
|
||||
creator: User,
|
||||
contract: MultipleChoiceContract,
|
||||
answers: string[],
|
||||
betDocIds: string[]
|
||||
) {
|
||||
const { totalBets, totalShares } = contract
|
||||
const amount = totalBets['0']
|
||||
const shares = totalShares['0']
|
||||
const p = 1 / answers.length
|
||||
|
||||
const { createdTime } = contract
|
||||
|
||||
const bets: Bet[] = answers.map((answer, i) => ({
|
||||
id: betDocIds[i],
|
||||
userId: creator.id,
|
||||
contractId: contract.id,
|
||||
amount,
|
||||
shares,
|
||||
outcome: i.toString(),
|
||||
probBefore: p,
|
||||
probAfter: p,
|
||||
createdTime,
|
||||
isAnte: true,
|
||||
fees: noFees,
|
||||
}))
|
||||
|
||||
const { username, name, avatarUrl } = creator
|
||||
|
||||
const answerObjects: Answer[] = answers.map((answer, i) => ({
|
||||
id: i.toString(),
|
||||
number: i,
|
||||
contractId: contract.id,
|
||||
createdTime,
|
||||
userId: creator.id,
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
text: answer,
|
||||
}))
|
||||
|
||||
return { bets, answerObjects }
|
||||
}
|
||||
|
||||
export function getNumericAnte(
|
||||
anteBettorId: string,
|
||||
contract: NumericContract,
|
||||
|
|
|
@ -12,7 +12,9 @@ export class APIError extends Error {
|
|||
}
|
||||
|
||||
export function getFunctionUrl(name: string) {
|
||||
if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
if (process.env.NEXT_PUBLIC_FUNCTIONS_URL) {
|
||||
return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}`
|
||||
} else if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||
const { projectId, region } = ENV_CONFIG.firebaseConfig
|
||||
return `http://localhost:5001/${projectId}/${region}/${name}`
|
||||
} else {
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
BinaryContract,
|
||||
FreeResponseContract,
|
||||
PseudoNumericContract,
|
||||
MultipleChoiceContract,
|
||||
} from './contract'
|
||||
import { floatingEqual } from './util/math'
|
||||
|
||||
|
@ -200,7 +201,9 @@ export function getContractBetNullMetrics() {
|
|||
}
|
||||
}
|
||||
|
||||
export function getTopAnswer(contract: FreeResponseContract) {
|
||||
export function getTopAnswer(
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
) {
|
||||
const { answers } = contract
|
||||
const top = maxBy(
|
||||
answers?.map((answer) => ({
|
||||
|
|
|
@ -169,7 +169,7 @@ export const charities: Charity[] = [
|
|||
{
|
||||
name: "Founder's Pledge Climate Change Fund",
|
||||
website: 'https://founderspledge.com/funds/climate-change-fund',
|
||||
photo: 'https://i.imgur.com/ZAhzHu4.png',
|
||||
photo: 'https://i.imgur.com/9turaJW.png',
|
||||
preview:
|
||||
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.',
|
||||
description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally.
|
||||
|
@ -183,7 +183,7 @@ export const charities: Charity[] = [
|
|||
{
|
||||
name: "Founder's Pledge Patient Philanthropy Fund",
|
||||
website: 'https://founderspledge.com/funds/patient-philanthropy-fund',
|
||||
photo: 'https://i.imgur.com/ZAhzHu4.png',
|
||||
photo: 'https://i.imgur.com/LLR6CI6.png',
|
||||
preview:
|
||||
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity',
|
||||
description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future.
|
||||
|
@ -551,6 +551,20 @@ With an emphasis on approval voting, we bring better elections to people across
|
|||
|
||||
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.`,
|
||||
},
|
||||
{
|
||||
name: 'Founders Pledge Global Health and Development Fund',
|
||||
website: 'https://founderspledge.com/funds/global-health-and-development',
|
||||
photo: 'https://i.imgur.com/EXbxH7T.png',
|
||||
preview:
|
||||
'Tackling the vast global inequalities in health, wealth and opportunity',
|
||||
description: `Nearly half the world lives on less than $2.50 a day, yet giving by the world’s richest often overlooks the world’s poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally.
|
||||
|
||||
This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to:
|
||||
|
||||
Improve the lives of the world's most vulnerable people.
|
||||
Reduce the number of easily preventable deaths worldwide.
|
||||
Work towards sustainable, systemic change.`,
|
||||
},
|
||||
].map((charity) => {
|
||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||
return {
|
||||
|
|
|
@ -4,13 +4,19 @@ import { JSONContent } from '@tiptap/core'
|
|||
import { GroupLink } from 'common/group'
|
||||
|
||||
export type AnyMechanism = DPM | CPMM
|
||||
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric
|
||||
export type AnyOutcomeType =
|
||||
| Binary
|
||||
| MultipleChoice
|
||||
| PseudoNumeric
|
||||
| FreeResponse
|
||||
| Numeric
|
||||
export type AnyContractType =
|
||||
| (CPMM & Binary)
|
||||
| (CPMM & PseudoNumeric)
|
||||
| (DPM & Binary)
|
||||
| (DPM & FreeResponse)
|
||||
| (DPM & Numeric)
|
||||
| (DPM & MultipleChoice)
|
||||
|
||||
export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||
id: string
|
||||
|
@ -57,6 +63,7 @@ export type BinaryContract = Contract & Binary
|
|||
export type PseudoNumericContract = Contract & PseudoNumeric
|
||||
export type NumericContract = Contract & Numeric
|
||||
export type FreeResponseContract = Contract & FreeResponse
|
||||
export type MultipleChoiceContract = Contract & MultipleChoice
|
||||
export type DPMContract = Contract & DPM
|
||||
export type CPMMContract = Contract & CPMM
|
||||
export type DPMBinaryContract = BinaryContract & DPM
|
||||
|
@ -104,6 +111,13 @@ export type FreeResponse = {
|
|||
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
||||
}
|
||||
|
||||
export type MultipleChoice = {
|
||||
outcomeType: 'MULTIPLE_CHOICE'
|
||||
answers: Answer[]
|
||||
resolution?: string | 'MKT' | 'CANCEL'
|
||||
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
|
||||
}
|
||||
|
||||
export type Numeric = {
|
||||
outcomeType: 'NUMERIC'
|
||||
bucketCount: number
|
||||
|
@ -118,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
|
|||
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
|
||||
export const OUTCOME_TYPES = [
|
||||
'BINARY',
|
||||
'MULTIPLE_CHOICE',
|
||||
'FREE_RESPONSE',
|
||||
'PSEUDO_NUMERIC',
|
||||
'NUMERIC',
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
CPMMBinaryContract,
|
||||
DPMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from './contract'
|
||||
|
@ -322,7 +323,7 @@ export const getNewBinaryDpmBetInfo = (
|
|||
export const getNewMultiBetInfo = (
|
||||
outcome: string,
|
||||
amount: number,
|
||||
contract: FreeResponseContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
loanAmount: number
|
||||
) => {
|
||||
const { pool, totalShares, totalBets } = contract
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
CPMM,
|
||||
DPM,
|
||||
FreeResponse,
|
||||
MultipleChoice,
|
||||
Numeric,
|
||||
outcomeType,
|
||||
PseudoNumeric,
|
||||
|
@ -30,7 +31,10 @@ export function getNewContract(
|
|||
bucketCount: number,
|
||||
min: number,
|
||||
max: number,
|
||||
isLogScale: boolean
|
||||
isLogScale: boolean,
|
||||
|
||||
// for multiple choice
|
||||
answers: string[]
|
||||
) {
|
||||
const tags = parseTags(
|
||||
[
|
||||
|
@ -48,6 +52,8 @@ export function getNewContract(
|
|||
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
|
||||
: outcomeType === 'NUMERIC'
|
||||
? getNumericProps(ante, bucketCount, min, max)
|
||||
: outcomeType === 'MULTIPLE_CHOICE'
|
||||
? getMultipleChoiceProps(ante, answers)
|
||||
: getFreeAnswerProps(ante)
|
||||
|
||||
const contract: Contract = removeUndefinedProps({
|
||||
|
@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
|
|||
return system
|
||||
}
|
||||
|
||||
const getMultipleChoiceProps = (ante: number, answers: string[]) => {
|
||||
const numAnswers = answers.length
|
||||
const betAnte = ante / numAnswers
|
||||
const betShares = Math.sqrt(ante ** 2 / numAnswers)
|
||||
|
||||
const defaultValues = (x: any) =>
|
||||
Object.fromEntries(range(0, numAnswers).map((k) => [k, x]))
|
||||
|
||||
const system: DPM & MultipleChoice = {
|
||||
mechanism: 'dpm-2',
|
||||
outcomeType: 'MULTIPLE_CHOICE',
|
||||
pool: defaultValues(betAnte),
|
||||
totalShares: defaultValues(betShares),
|
||||
totalBets: defaultValues(betAnte),
|
||||
answers: [],
|
||||
}
|
||||
|
||||
return system
|
||||
}
|
||||
|
||||
const getNumericProps = (
|
||||
ante: number,
|
||||
bucketCount: number,
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
|
|
|
@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
|
|||
|
||||
import { Bet, NumericBet } from './bet'
|
||||
import { deductDpmFees, getDpmProbability } from './calculate-dpm'
|
||||
import { DPMContract, FreeResponseContract } from './contract'
|
||||
import {
|
||||
DPMContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
} from './contract'
|
||||
import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
|
||||
import { addObjects } from './util/object'
|
||||
|
||||
|
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
|
|||
|
||||
export const getPayoutsMultiOutcome = (
|
||||
resolutions: { [outcome: string]: number },
|
||||
contract: FreeResponseContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const poolTotal = sum(Object.values(contract.pool))
|
||||
|
|
|
@ -117,6 +117,7 @@ export const getDpmPayouts = (
|
|||
resolutionProbability?: number
|
||||
): PayoutInfo => {
|
||||
const openBets = bets.filter((b) => !b.isSold && !b.sale)
|
||||
const { outcomeType } = contract
|
||||
|
||||
switch (outcome) {
|
||||
case 'YES':
|
||||
|
@ -124,7 +125,8 @@ export const getDpmPayouts = (
|
|||
return getDpmStandardPayouts(outcome, contract, openBets)
|
||||
|
||||
case 'MKT':
|
||||
return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
? getPayoutsMultiOutcome(resolutions!, contract, openBets)
|
||||
: getDpmMktPayouts(contract, openBets, resolutionProbability)
|
||||
case 'CANCEL':
|
||||
|
@ -132,7 +134,7 @@ export const getDpmPayouts = (
|
|||
return getDpmCancelPayouts(contract, openBets)
|
||||
|
||||
default:
|
||||
if (contract.outcomeType === 'NUMERIC')
|
||||
if (outcomeType === 'NUMERIC')
|
||||
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
|
||||
|
||||
// Outcome is a free response answer id.
|
||||
|
|
|
@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text'
|
|||
// other tiptap extensions
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
import Iframe from './tiptap-iframe'
|
||||
|
||||
export function parseTags(text: string) {
|
||||
|
@ -81,9 +82,9 @@ export const exhibitExts = [
|
|||
|
||||
Image,
|
||||
Link,
|
||||
Mention,
|
||||
Iframe,
|
||||
]
|
||||
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
|
||||
|
||||
export function richTextToString(text?: JSONContent) {
|
||||
return !text ? '' : generateText(text, exhibitExts)
|
||||
|
|
43
dev.sh
Executable file
43
dev.sh
Executable file
|
@ -0,0 +1,43 @@
|
|||
#!/bin/bash
|
||||
|
||||
ENV=${1:-dev}
|
||||
case $ENV in
|
||||
dev)
|
||||
FIREBASE_PROJECT=dev
|
||||
NEXT_ENV=DEV ;;
|
||||
prod)
|
||||
FIREBASE_PROJECT=prod
|
||||
NEXT_ENV=PROD ;;
|
||||
localdb)
|
||||
FIREBASE_PROJECT=dev
|
||||
NEXT_ENV=DEV
|
||||
EMULATOR=true ;;
|
||||
*)
|
||||
echo "Invalid environment; must be dev, prod, or localdb."
|
||||
exit 1
|
||||
esac
|
||||
|
||||
firebase use $FIREBASE_PROJECT
|
||||
|
||||
if [ ! -z $EMULATOR ]
|
||||
then
|
||||
npx concurrently \
|
||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||
-c green,white,magenta,cyan \
|
||||
"yarn --cwd=functions firestore" \
|
||||
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
|
||||
yarn --cwd=web serve" \
|
||||
"cross-env yarn --cwd=web ts-watch"
|
||||
else
|
||||
npx concurrently \
|
||||
-n FUNCTIONS,NEXT,TS \
|
||||
-c white,magenta,cyan \
|
||||
"yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \
|
||||
yarn --cwd=web serve" \
|
||||
"cross-env yarn --cwd=web ts-watch"
|
||||
fi
|
|
@ -579,6 +579,26 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
|||
]}'
|
||||
```
|
||||
|
||||
### `POST /v0/market/[marketId]/sell`
|
||||
|
||||
Sells some quantity of shares in a market on behalf of the authorized user.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric
|
||||
bucket ID, depending on the market type.
|
||||
- `shares`: Optional. The amount of shares to sell of the outcome given
|
||||
above. If not provided, all the shares you own will be sold.
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Key {...}' \
|
||||
--data-raw '{"outcome": "YES", "shares": 10}'
|
||||
```
|
||||
|
||||
### `GET /v0/bets`
|
||||
|
||||
Gets a list of bets, ordered by creation date descending.
|
||||
|
|
|
@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
|||
|
||||
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
|
||||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||
|
||||
## Bots
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
"start": "yarn shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log",
|
||||
"dev": "nodemon src/serve.ts",
|
||||
"firestore": "firebase emulators:start --only firestore --import=./firestore_export",
|
||||
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export",
|
||||
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
|
||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||
|
@ -27,7 +29,10 @@
|
|||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"cors": "2.8.5",
|
||||
"express": "4.18.1",
|
||||
"firebase-admin": "10.0.0",
|
||||
"firebase-functions": "3.21.2",
|
||||
"lodash": "4.17.21",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { logger } from 'firebase-functions/v2'
|
||||
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https'
|
||||
import { Request, RequestHandler, Response } from 'express'
|
||||
import { error } from 'firebase-functions/logger'
|
||||
import { HttpsOptions } from 'firebase-functions/v2/https'
|
||||
import { log } from './utils'
|
||||
import { z } from 'zod'
|
||||
import { APIError } from '../../common/api'
|
||||
|
@ -45,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
|||
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
||||
} catch (err) {
|
||||
// This is somewhat suspicious, so get it into the firebase console
|
||||
logger.error('Error verifying Firebase JWT: ', err)
|
||||
error('Error verifying Firebase JWT: ', err)
|
||||
throw new APIError(403, 'Error validating token.')
|
||||
}
|
||||
case 'Key':
|
||||
|
@ -83,6 +84,11 @@ export const zTimestamp = () => {
|
|||
}, z.date())
|
||||
}
|
||||
|
||||
export type EndpointDefinition = {
|
||||
opts: EndpointOptions & { method: string }
|
||||
handler: RequestHandler
|
||||
}
|
||||
|
||||
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||
const result = schema.safeParse(val)
|
||||
if (!result.success) {
|
||||
|
@ -99,12 +105,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||
}
|
||||
}
|
||||
|
||||
interface EndpointOptions extends HttpsOptions {
|
||||
methods?: string[]
|
||||
export interface EndpointOptions extends HttpsOptions {
|
||||
method?: string
|
||||
}
|
||||
|
||||
const DEFAULT_OPTS = {
|
||||
methods: ['POST'],
|
||||
method: 'POST',
|
||||
minInstances: 1,
|
||||
concurrency: 100,
|
||||
memory: '2GiB',
|
||||
|
@ -113,28 +119,29 @@ const DEFAULT_OPTS = {
|
|||
}
|
||||
|
||||
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
|
||||
const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
|
||||
return onRequest(opts, async (req, res) => {
|
||||
log('Request processing started.')
|
||||
try {
|
||||
if (!opts.methods.includes(req.method)) {
|
||||
const allowed = opts.methods.join(', ')
|
||||
throw new APIError(405, `This endpoint supports only ${allowed}.`)
|
||||
}
|
||||
const authedUser = await lookupUser(await parseCredentials(req))
|
||||
log('User credentials processed.')
|
||||
res.status(200).json(await fn(req, authedUser))
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
const output: { [k: string]: unknown } = { message: e.message }
|
||||
if (e.details != null) {
|
||||
output.details = e.details
|
||||
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
|
||||
return {
|
||||
opts,
|
||||
handler: async (req: Request, res: Response) => {
|
||||
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
|
||||
try {
|
||||
if (opts.method !== req.method) {
|
||||
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
|
||||
}
|
||||
const authedUser = await lookupUser(await parseCredentials(req))
|
||||
res.status(200).json(await fn(req, authedUser))
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
const output: { [k: string]: unknown } = { message: e.message }
|
||||
if (e.details != null) {
|
||||
output.details = e.details
|
||||
}
|
||||
res.status(e.code).json(output)
|
||||
} else {
|
||||
error(e)
|
||||
res.status(500).json({ message: 'An unknown error occurred.' })
|
||||
}
|
||||
res.status(e.code).json(output)
|
||||
} else {
|
||||
logger.error(e)
|
||||
res.status(500).json({ message: 'An unknown error occurred.' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
} as EndpointDefinition
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ const bodySchema = z.object({
|
|||
export const cancelbet = newEndpoint({}, async (req, auth) => {
|
||||
const { betId } = validate(bodySchema, req.body)
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
return await firestore.runTransaction(async (trans) => {
|
||||
const snap = await trans.get(
|
||||
firestore.collectionGroup('bets').where('id', '==', betId)
|
||||
)
|
||||
|
@ -28,8 +28,6 @@ export const cancelbet = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
return { ...bet, isCancelled: true }
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -2,11 +2,12 @@ import * as admin from 'firebase-admin'
|
|||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
CPMMBinaryContract,
|
||||
Contract,
|
||||
CPMMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MAX_QUESTION_LENGTH,
|
||||
MAX_TAG_LENGTH,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
OUTCOME_TYPES,
|
||||
} from '../../common/contract'
|
||||
|
@ -20,15 +21,18 @@ import {
|
|||
FIXED_ANTE,
|
||||
getCpmmInitialLiquidity,
|
||||
getFreeAnswerAnte,
|
||||
getMultipleChoiceAntes,
|
||||
getNumericAnte,
|
||||
} from '../../common/antes'
|
||||
import { getNoneAnswer } from '../../common/answer'
|
||||
import { Answer, getNoneAnswer } from '../../common/answer'
|
||||
import { getNewContract } from '../../common/new-contract'
|
||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||
import { User } from '../../common/user'
|
||||
import { Group, MAX_ID_LENGTH } from '../../common/group'
|
||||
import { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { zip } from 'lodash'
|
||||
import { Bet } from 'common/bet'
|
||||
|
||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
|
@ -79,11 +83,15 @@ const numericSchema = z.object({
|
|||
isLogScale: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const multipleChoiceSchema = z.object({
|
||||
answers: z.string().trim().min(1).array().min(2),
|
||||
})
|
||||
|
||||
export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||
const { question, description, tags, closeTime, outcomeType, groupId } =
|
||||
validate(bodySchema, req.body)
|
||||
|
||||
let min, max, initialProb, isLogScale
|
||||
let min, max, initialProb, isLogScale, answers
|
||||
|
||||
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
|
||||
let initialValue
|
||||
|
@ -97,12 +105,22 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
|
||||
|
||||
if (initialProb < 1 || initialProb > 99)
|
||||
throw new APIError(400, 'Invalid initial value.')
|
||||
if (outcomeType === 'PSEUDO_NUMERIC')
|
||||
throw new APIError(
|
||||
400,
|
||||
`Initial value is too ${initialProb < 1 ? 'low' : 'high'}`
|
||||
)
|
||||
else throw new APIError(400, 'Invalid initial probability.')
|
||||
}
|
||||
|
||||
if (outcomeType === 'BINARY') {
|
||||
;({ initialProb } = validate(binarySchema, req.body))
|
||||
}
|
||||
|
||||
if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
;({ answers } = validate(multipleChoiceSchema, req.body))
|
||||
}
|
||||
|
||||
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||
if (!userDoc.exists) {
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
|
@ -120,7 +138,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
let group = null
|
||||
if (groupId) {
|
||||
const groupDocRef = await firestore.collection('groups').doc(groupId)
|
||||
const groupDocRef = firestore.collection('groups').doc(groupId)
|
||||
const groupDoc = await groupDocRef.get()
|
||||
if (!groupDoc.exists) {
|
||||
throw new APIError(400, 'No group exists with the given group ID.')
|
||||
|
@ -162,7 +180,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
NUMERIC_BUCKET_COUNT,
|
||||
min ?? 0,
|
||||
max ?? 0,
|
||||
isLogScale ?? false
|
||||
isLogScale ?? false,
|
||||
answers ?? []
|
||||
)
|
||||
|
||||
if (ante) await chargeUser(user.id, ante, true)
|
||||
|
@ -184,6 +203,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
)
|
||||
|
||||
await liquidityDoc.set(lp)
|
||||
} else if (outcomeType === 'MULTIPLE_CHOICE') {
|
||||
const betCol = firestore.collection(`contracts/${contract.id}/bets`)
|
||||
const betDocs = (answers ?? []).map(() => betCol.doc())
|
||||
|
||||
const answerCol = firestore.collection(`contracts/${contract.id}/answers`)
|
||||
const answerDocs = (answers ?? []).map((_, i) =>
|
||||
answerCol.doc(i.toString())
|
||||
)
|
||||
|
||||
const { bets, answerObjects } = getMultipleChoiceAntes(
|
||||
user,
|
||||
contract as MultipleChoiceContract,
|
||||
answers ?? [],
|
||||
betDocs.map((bd) => bd.id)
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet))
|
||||
)
|
||||
await Promise.all(
|
||||
zip(answerObjects, answerDocs).map(([answer, doc]) =>
|
||||
doc?.create(answer as Answer)
|
||||
)
|
||||
)
|
||||
await contractRef.update({ answers: answerObjects })
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
const noneAnswerDoc = firestore
|
||||
.collection(`contracts/${contract.id}/answers`)
|
||||
|
|
|
@ -63,10 +63,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
|||
const deviceUsedBefore =
|
||||
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
|
||||
|
||||
const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0
|
||||
|
||||
const balance =
|
||||
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
|
||||
const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE
|
||||
|
||||
const user: User = {
|
||||
id: auth.uid,
|
||||
|
@ -113,7 +110,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
|
|||
return !snap.empty
|
||||
}
|
||||
|
||||
const numberUsersWithIp = async (ipAddress: string) => {
|
||||
export const numberUsersWithIp = async (ipAddress: string) => {
|
||||
const snap = await firestore
|
||||
.collection('private-users')
|
||||
.where('initialIpAddress', '==', ipAddress)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { newEndpoint } from './api'
|
||||
|
||||
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
|
||||
export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => {
|
||||
return {
|
||||
message: 'Server is working.',
|
||||
uid: auth.uid,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { onRequest } from 'firebase-functions/v2/https'
|
||||
import { EndpointDefinition } from './api'
|
||||
|
||||
admin.initializeApp()
|
||||
|
||||
|
@ -25,20 +27,63 @@ export * from './on-delete-group'
|
|||
export * from './score-contracts'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
export * from './transact'
|
||||
export * from './change-user-info'
|
||||
export * from './create-user'
|
||||
export * from './create-answer'
|
||||
export * from './place-bet'
|
||||
export * from './cancel-bet'
|
||||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './claim-manalink'
|
||||
export * from './create-contract'
|
||||
export * from './add-liquidity'
|
||||
export * from './withdraw-liquidity'
|
||||
export * from './create-group'
|
||||
export * from './resolve-market'
|
||||
export * from './unsubscribe'
|
||||
export * from './stripe'
|
||||
import { health } from './health'
|
||||
import { transact } from './transact'
|
||||
import { changeuserinfo } from './change-user-info'
|
||||
import { createuser } from './create-user'
|
||||
import { createanswer } from './create-answer'
|
||||
import { placebet } from './place-bet'
|
||||
import { cancelbet } from './cancel-bet'
|
||||
import { sellbet } from './sell-bet'
|
||||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-contract'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
import { resolvemarket } from './resolve-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
}
|
||||
const healthFunction = toCloudFunction(health)
|
||||
const transactFunction = toCloudFunction(transact)
|
||||
const changeUserInfoFunction = toCloudFunction(changeuserinfo)
|
||||
const createUserFunction = toCloudFunction(createuser)
|
||||
const createAnswerFunction = toCloudFunction(createanswer)
|
||||
const placeBetFunction = toCloudFunction(placebet)
|
||||
const cancelBetFunction = toCloudFunction(cancelbet)
|
||||
const sellBetFunction = toCloudFunction(sellbet)
|
||||
const sellSharesFunction = toCloudFunction(sellshares)
|
||||
const claimManalinkFunction = toCloudFunction(claimmanalink)
|
||||
const createMarketFunction = toCloudFunction(createmarket)
|
||||
const addLiquidityFunction = toCloudFunction(addliquidity)
|
||||
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
|
||||
const createGroupFunction = toCloudFunction(creategroup)
|
||||
const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
transactFunction as transact,
|
||||
changeUserInfoFunction as changeuserinfo,
|
||||
createUserFunction as createuser,
|
||||
createAnswerFunction as createanswer,
|
||||
placeBetFunction as placebet,
|
||||
cancelBetFunction as cancelbet,
|
||||
sellBetFunction as sellbet,
|
||||
sellSharesFunction as sellshares,
|
||||
claimManalinkFunction as claimmanalink,
|
||||
createMarketFunction as createmarket,
|
||||
addLiquidityFunction as addliquidity,
|
||||
withdrawLiquidityFunction as withdrawliquidity,
|
||||
createGroupFunction as creategroup,
|
||||
resolveMarketFunction as resolvemarket,
|
||||
unsubscribeFunction as unsubscribe,
|
||||
stripeWebhookFunction as stripewebhook,
|
||||
createCheckoutSessionFunction as createcheckoutsession,
|
||||
}
|
||||
|
|
|
@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore
|
|||
contractId: string
|
||||
}
|
||||
const { eventId } = context
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with answer')
|
||||
|
||||
const answer = change.data() as Answer
|
||||
// Ignore ante answer.
|
||||
if (answer.number === 0) return
|
||||
|
||||
const contract = await getContract(contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with answer')
|
||||
|
||||
const answerCreator = await getUser(answer.userId)
|
||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||
|
||||
|
|
|
@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore
|
|||
.onCreate(async (change, context) => {
|
||||
const liquidity = change.data() as LiquidityProvision
|
||||
const { eventId } = context
|
||||
const contract = await getContract(liquidity.contractId)
|
||||
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with liquidity')
|
||||
|
||||
// Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
|
||||
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return
|
||||
|
||||
const contract = await getContract(liquidity.contractId)
|
||||
if (!contract)
|
||||
throw new Error('Could not find contract corresponding with liquidity')
|
||||
|
||||
const liquidityProvider = await getUser(liquidity.userId)
|
||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||
|
||||
|
|
|
@ -103,8 +103,8 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
|
|||
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
|
||||
}
|
||||
|
||||
const txnDoc = await firestore.collection(`txns/`).doc(txn.id)
|
||||
await transaction.set(txnDoc, txn)
|
||||
const txnDoc = firestore.collection(`txns/`).doc(txn.id)
|
||||
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, {
|
||||
|
|
|
@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
limitProb,
|
||||
unfilledBets
|
||||
)
|
||||
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
|
||||
} else if (
|
||||
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
mechanism == 'dpm-2'
|
||||
) {
|
||||
const { outcome } = validate(freeResponseSchema, req.body)
|
||||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||
const answerSnap = await trans.get(answerDoc)
|
||||
|
|
|
@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
|||
import {
|
||||
Contract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
RESOLUTIONS,
|
||||
} from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
|
@ -245,7 +246,10 @@ function getResolutionParams(contract: Contract, body: string) {
|
|||
...validate(pseudoNumericSchema, body),
|
||||
resolutions: undefined,
|
||||
}
|
||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||
} else if (
|
||||
outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE'
|
||||
) {
|
||||
const freeResponseParams = validate(freeResponseSchema, body)
|
||||
const { outcome } = freeResponseParams
|
||||
switch (outcome) {
|
||||
|
@ -292,7 +296,10 @@ function getResolutionParams(contract: Contract, body: string) {
|
|||
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
||||
}
|
||||
|
||||
function validateAnswer(contract: FreeResponseContract, answer: number) {
|
||||
function validateAnswer(
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
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`)
|
||||
|
|
55
functions/src/scripts/backfill-comment-ids.ts
Normal file
55
functions/src/scripts/backfill-comment-ids.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
// We have some old comments without IDs and user IDs. Let's fill them in.
|
||||
// Luckily, this was back when all comments had associated bets, so it's possible
|
||||
// to retrieve the user IDs through the bets.
|
||||
|
||||
import * as admin from 'firebase-admin'
|
||||
import { QueryDocumentSnapshot } from 'firebase-admin/firestore'
|
||||
import { initAdmin } from './script-init'
|
||||
import { log, writeAsync } from '../utils'
|
||||
import { Bet } from '../../../common/bet'
|
||||
|
||||
initAdmin()
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => {
|
||||
const bets = await firestore.collectionGroup('bets').get()
|
||||
log(`Loaded ${bets.size} bets.`)
|
||||
const betsById = Object.fromEntries(
|
||||
bets.docs.map((b) => [b.id, b.data() as Bet])
|
||||
)
|
||||
return Object.fromEntries(
|
||||
comments.map((c) => [c.id, betsById[c.data().betId].userId])
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const commentsQuery = firestore.collectionGroup('comments')
|
||||
commentsQuery.get().then(async (commentSnaps) => {
|
||||
log(`Loaded ${commentSnaps.size} comments.`)
|
||||
const needsFilling = commentSnaps.docs.filter((ct) => {
|
||||
return !('id' in ct.data()) || !('userId' in ct.data())
|
||||
})
|
||||
log(`${needsFilling.length} comments need IDs.`)
|
||||
const userIdNeedsFilling = needsFilling.filter((ct) => {
|
||||
return !('userId' in ct.data())
|
||||
})
|
||||
log(`${userIdNeedsFilling.length} comments need user IDs.`)
|
||||
const userIdsByCommentId =
|
||||
userIdNeedsFilling.length > 0
|
||||
? await getUserIdsByCommentId(userIdNeedsFilling)
|
||||
: {}
|
||||
const updates = needsFilling.map((ct) => {
|
||||
const fields: { [k: string]: unknown } = {}
|
||||
if (!ct.data().id) {
|
||||
fields.id = ct.id
|
||||
}
|
||||
if (!ct.data().userId && userIdsByCommentId[ct.id]) {
|
||||
fields.userId = userIdsByCommentId[ct.id]
|
||||
}
|
||||
return { doc: ct.ref, fields }
|
||||
})
|
||||
log(`Updating ${updates.length} comments.`)
|
||||
await writeAsync(firestore, updates)
|
||||
log(`Updated all comments.`)
|
||||
})
|
||||
}
|
|
@ -66,10 +66,18 @@ export const getServiceAccountCredentials = (env?: string) => {
|
|||
}
|
||||
|
||||
export const initAdmin = (env?: string) => {
|
||||
const serviceAccount = getServiceAccountCredentials(env)
|
||||
console.log(`Initializing connection to ${serviceAccount.project_id}...`)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
})
|
||||
try {
|
||||
const serviceAccount = getServiceAccountCredentials(env)
|
||||
console.log(
|
||||
`Initializing connection to ${serviceAccount.project_id} Firebase...`
|
||||
)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
console.log(`Initializing connection to default Firebase...`)
|
||||
return admin.initializeApp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import { redeemShares } from './redeem-shares'
|
|||
|
||||
const bodySchema = z.object({
|
||||
contractId: z.string(),
|
||||
shares: z.number(),
|
||||
shares: z.number().optional(), // leave it out to sell all shares
|
||||
outcome: z.enum(['YES', 'NO']),
|
||||
})
|
||||
|
||||
|
@ -49,11 +49,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
|||
|
||||
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||
const sharesToSell = shares ?? maxShares
|
||||
|
||||
if (!floatingLesserEqual(shares, maxShares))
|
||||
if (!floatingLesserEqual(sharesToSell, maxShares))
|
||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||
|
||||
const soldShares = Math.min(shares, maxShares)
|
||||
const soldShares = Math.min(sharesToSell, maxShares)
|
||||
|
||||
const unfilledBetsSnap = await transaction.get(
|
||||
getUnfilledBetsQuery(contractDoc)
|
||||
|
|
68
functions/src/serve.ts
Normal file
68
functions/src/serve.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import * as cors from 'cors'
|
||||
import * as express from 'express'
|
||||
import { Express, Request, Response, NextFunction } from 'express'
|
||||
import { EndpointDefinition } from './api'
|
||||
|
||||
const PORT = 8088
|
||||
|
||||
import { initAdmin } from './scripts/script-init'
|
||||
initAdmin()
|
||||
|
||||
import { health } from './health'
|
||||
import { transact } from './transact'
|
||||
import { changeuserinfo } from './change-user-info'
|
||||
import { createuser } from './create-user'
|
||||
import { createanswer } from './create-answer'
|
||||
import { placebet } from './place-bet'
|
||||
import { cancelbet } from './cancel-bet'
|
||||
import { sellbet } from './sell-bet'
|
||||
import { sellshares } from './sell-shares'
|
||||
import { claimmanalink } from './claim-manalink'
|
||||
import { createmarket } from './create-contract'
|
||||
import { addliquidity } from './add-liquidity'
|
||||
import { withdrawliquidity } from './withdraw-liquidity'
|
||||
import { creategroup } from './create-group'
|
||||
import { resolvemarket } from './resolve-market'
|
||||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
||||
const addEndpointRoute = (
|
||||
path: string,
|
||||
endpoint: EndpointDefinition,
|
||||
...middlewares: Middleware[]
|
||||
) => {
|
||||
const method = endpoint.opts.method.toLowerCase() as keyof Express
|
||||
const corsMiddleware = cors({ origin: endpoint.opts.cors })
|
||||
const allMiddleware = [...middlewares, corsMiddleware]
|
||||
app.options(path, corsMiddleware) // preflight requests
|
||||
app[method](path, ...allMiddleware, endpoint.handler)
|
||||
}
|
||||
|
||||
const addJsonEndpointRoute = (name: string, endpoint: EndpointDefinition) => {
|
||||
addEndpointRoute(name, endpoint, express.json())
|
||||
}
|
||||
|
||||
addEndpointRoute('/health', health)
|
||||
addJsonEndpointRoute('/transact', transact)
|
||||
addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
|
||||
addJsonEndpointRoute('/createuser', createuser)
|
||||
addJsonEndpointRoute('/createanswer', createanswer)
|
||||
addJsonEndpointRoute('/placebet', placebet)
|
||||
addJsonEndpointRoute('/cancelbet', cancelbet)
|
||||
addJsonEndpointRoute('/sellbet', sellbet)
|
||||
addJsonEndpointRoute('/sellshares', sellshares)
|
||||
addJsonEndpointRoute('/claimmanalink', claimmanalink)
|
||||
addJsonEndpointRoute('/createmarket', createmarket)
|
||||
addJsonEndpointRoute('/addliquidity', addliquidity)
|
||||
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
|
||||
addJsonEndpointRoute('/creategroup', creategroup)
|
||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
|
@ -1,7 +1,7 @@
|
|||
import { onRequest } from 'firebase-functions/v2/https'
|
||||
import * as admin from 'firebase-admin'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import { EndpointDefinition } from './api'
|
||||
import { getPrivateUser, getUser, isProd, payUser } from './utils'
|
||||
import { sendThankYouEmail } from './emails'
|
||||
import { track } from './analytics'
|
||||
|
@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd()
|
|||
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
|
||||
}
|
||||
|
||||
export const createcheckoutsession = onRequest(
|
||||
{ minInstances: 1, secrets: ['STRIPE_APIKEY'] },
|
||||
async (req, res) => {
|
||||
export const createcheckoutsession: EndpointDefinition = {
|
||||
opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] },
|
||||
handler: async (req, res) => {
|
||||
const userId = req.query.userId?.toString()
|
||||
|
||||
const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
|
||||
|
@ -86,21 +86,24 @@ export const createcheckoutsession = onRequest(
|
|||
})
|
||||
|
||||
res.redirect(303, session.url || '')
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const stripewebhook = onRequest(
|
||||
{
|
||||
export const stripewebhook: EndpointDefinition = {
|
||||
opts: {
|
||||
method: 'POST',
|
||||
minInstances: 1,
|
||||
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
|
||||
},
|
||||
async (req, res) => {
|
||||
handler: async (req, res) => {
|
||||
const stripe = initStripe()
|
||||
let event
|
||||
|
||||
try {
|
||||
// Cloud Functions jam the raw body into a special `rawBody` property
|
||||
const rawBody = (req as any).rawBody ?? req.body
|
||||
event = stripe.webhooks.constructEvent(
|
||||
req.rawBody,
|
||||
rawBody,
|
||||
req.headers['stripe-signature'] as string,
|
||||
process.env.STRIPE_WEBHOOKSECRET as string
|
||||
)
|
||||
|
@ -116,8 +119,8 @@ export const stripewebhook = onRequest(
|
|||
}
|
||||
|
||||
res.status(200).send('success')
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const issueMoneys = async (session: StripeSession) => {
|
||||
const { id: sessionId } = session
|
||||
|
|
|
@ -1,66 +1,72 @@
|
|||
import { onRequest } from 'firebase-functions/v2/https'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { EndpointDefinition } from './api'
|
||||
import { getUser } from './utils'
|
||||
import { PrivateUser } from '../../common/user'
|
||||
|
||||
export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => {
|
||||
const id = req.query.id as string
|
||||
let type = req.query.type as string
|
||||
if (!id || !type) {
|
||||
res.status(400).send('Empty id or type parameter.')
|
||||
return
|
||||
}
|
||||
export const unsubscribe: EndpointDefinition = {
|
||||
opts: { method: 'GET', minInstances: 1 },
|
||||
handler: async (req, res) => {
|
||||
const id = req.query.id as string
|
||||
let type = req.query.type as string
|
||||
if (!id || !type) {
|
||||
res.status(400).send('Empty id or type parameter.')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'market-resolved') type = 'market-resolve'
|
||||
if (type === 'market-resolved') type = 'market-resolve'
|
||||
|
||||
if (
|
||||
!['market-resolve', 'market-comment', 'market-answer', 'generic'].includes(
|
||||
type
|
||||
)
|
||||
) {
|
||||
res.status(400).send('Invalid type parameter.')
|
||||
return
|
||||
}
|
||||
if (
|
||||
![
|
||||
'market-resolve',
|
||||
'market-comment',
|
||||
'market-answer',
|
||||
'generic',
|
||||
].includes(type)
|
||||
) {
|
||||
res.status(400).send('Invalid type parameter.')
|
||||
return
|
||||
}
|
||||
|
||||
const user = await getUser(id)
|
||||
const user = await getUser(id)
|
||||
|
||||
if (!user) {
|
||||
res.send('This user is not currently subscribed or does not exist.')
|
||||
return
|
||||
}
|
||||
if (!user) {
|
||||
res.send('This user is not currently subscribed or does not exist.')
|
||||
return
|
||||
}
|
||||
|
||||
const { name } = user
|
||||
const { name } = user
|
||||
|
||||
const update: Partial<PrivateUser> = {
|
||||
...(type === 'market-resolve' && {
|
||||
unsubscribedFromResolutionEmails: true,
|
||||
}),
|
||||
...(type === 'market-comment' && {
|
||||
unsubscribedFromCommentEmails: true,
|
||||
}),
|
||||
...(type === 'market-answer' && {
|
||||
unsubscribedFromAnswerEmails: true,
|
||||
}),
|
||||
...(type === 'generic' && {
|
||||
unsubscribedFromGenericEmails: true,
|
||||
}),
|
||||
}
|
||||
const update: Partial<PrivateUser> = {
|
||||
...(type === 'market-resolve' && {
|
||||
unsubscribedFromResolutionEmails: true,
|
||||
}),
|
||||
...(type === 'market-comment' && {
|
||||
unsubscribedFromCommentEmails: true,
|
||||
}),
|
||||
...(type === 'market-answer' && {
|
||||
unsubscribedFromAnswerEmails: true,
|
||||
}),
|
||||
...(type === 'generic' && {
|
||||
unsubscribedFromGenericEmails: true,
|
||||
}),
|
||||
}
|
||||
|
||||
await firestore.collection('private-users').doc(id).update(update)
|
||||
await firestore.collection('private-users').doc(id).update(update)
|
||||
|
||||
if (type === 'market-resolve')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'market-comment')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'market-answer')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||
)
|
||||
else res.send(`${name}, you have been unsubscribed.`)
|
||||
})
|
||||
if (type === 'market-resolve')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'market-comment')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
|
||||
)
|
||||
else if (type === 'market-answer')
|
||||
res.send(
|
||||
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
|
||||
)
|
||||
else res.send(`${name}, you have been unsubscribed.`)
|
||||
},
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -11,8 +11,6 @@ import { last } from 'lodash'
|
|||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const oneDay = 1000 * 60 * 60 * 24
|
||||
|
||||
const computeInvestmentValue = (
|
||||
bets: Bet[],
|
||||
contractsDict: { [k: string]: Contract }
|
||||
|
@ -59,8 +57,8 @@ export const updateMetricsCore = async () => {
|
|||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - oneDay),
|
||||
volume7Days: computeVolume(contractBets, now - oneDay * 7),
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
@ -14,10 +14,13 @@
|
|||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "5.25.0",
|
||||
"@typescript-eslint/parser": "5.25.0",
|
||||
"concurrently": "6.5.1",
|
||||
"eslint": "8.15.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"prettier": "2.5.0",
|
||||
"typescript": "4.6.4"
|
||||
"typescript": "4.6.4",
|
||||
"ts-node": "10.9.1",
|
||||
"nodemon": "2.0.19"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.43"
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
import { ExclamationIcon } from '@heroicons/react/solid'
|
||||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { Linkify } from './linkify'
|
||||
|
||||
export function AlertBox(props: { title: string; text: string }) {
|
||||
const { title, text } = props
|
||||
return (
|
||||
<div className="rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<Col className="rounded-md bg-yellow-50 p-4">
|
||||
<Row className="mb-2 flex-shrink-0">
|
||||
<ExclamationIcon
|
||||
className="h-5 w-5 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">{title}</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<div className="mt-2 whitespace-pre-line text-sm text-yellow-700">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { BuyAmountInput } from '../amount-input'
|
||||
import { Col } from '../layout/col'
|
||||
import { APIError, placeBet } from 'web/lib/firebase/api'
|
||||
|
@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device'
|
|||
|
||||
export function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
closePanel: () => void
|
||||
className?: string
|
||||
isModal?: boolean
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Avatar } from '../avatar'
|
||||
|
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
|
|||
|
||||
export function AnswerItem(props: {
|
||||
answer: Answer
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
showChoice: 'radio' | 'checkbox' | undefined
|
||||
chosenProb: number | undefined
|
||||
totalChosenProb?: number
|
||||
|
|
|
@ -2,7 +2,7 @@ import clsx from 'clsx'
|
|||
import { sum } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Contract, FreeResponse } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { APIError, resolveMarket } from 'web/lib/firebase/api'
|
||||
import { Row } from '../layout/row'
|
||||
|
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
|
|||
import { removeUndefinedProps } from 'common/util/object'
|
||||
|
||||
export function AnswerResolvePanel(props: {
|
||||
contract: Contract & FreeResponse
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||
setResolveOption: (
|
||||
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
|
||||
|
|
|
@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash'
|
|||
import { memo } from 'react'
|
||||
|
||||
import { Bet } from 'common/bet'
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { getOutcomeProbability } from 'common/calculate'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
const NUM_LINES = 6
|
||||
|
||||
export const AnswersGraph = memo(function AnswersGraph(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
bets: Bet[]
|
||||
height?: number
|
||||
}) {
|
||||
|
@ -178,15 +178,22 @@ function formatTime(
|
|||
return d.format(format)
|
||||
}
|
||||
|
||||
const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => {
|
||||
const { totalBets } = contract
|
||||
const computeProbsByOutcome = (
|
||||
bets: Bet[],
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
) => {
|
||||
const { totalBets, outcomeType } = contract
|
||||
|
||||
const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
|
||||
const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
|
||||
const maxProb = Math.max(
|
||||
...betsByOutcome[outcome].map((bet) => bet.probAfter)
|
||||
)
|
||||
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
|
||||
return (
|
||||
(outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
maxProb > 0.02 &&
|
||||
totalBets[outcome] > 0.000000001
|
||||
)
|
||||
})
|
||||
|
||||
const trackedOutcomes = sortBy(
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { sortBy, partition, sum, uniq } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { FreeResponseContract } from 'common/contract'
|
||||
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
|
||||
import { Col } from '../layout/col'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||
|
@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page'
|
|||
import { Linkify } from 'web/components/linkify'
|
||||
import { BuyButton } from 'web/components/yes-no-selector'
|
||||
|
||||
export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
||||
export function AnswersPanel(props: {
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
}) {
|
||||
const { contract } = props
|
||||
const { creatorId, resolution, resolutions, totalBets } = contract
|
||||
const { creatorId, resolution, resolutions, totalBets, outcomeType } =
|
||||
contract
|
||||
|
||||
const answers = useAnswers(contract.id) ?? contract.answers
|
||||
const [winningAnswers, losingAnswers] = partition(
|
||||
answers.filter(
|
||||
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
|
||||
(answer) =>
|
||||
(answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
totalBets[answer.id] > 0.000000001
|
||||
),
|
||||
(answer) =>
|
||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||
|
@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
|||
<div className="pb-4 text-gray-500">No answers yet...</div>
|
||||
)}
|
||||
|
||||
{tradingAllowed(contract) &&
|
||||
{outcomeType === 'FREE_RESPONSE' &&
|
||||
tradingAllowed(contract) &&
|
||||
(!resolveOption || resolveOption === 'CANCEL') && (
|
||||
<CreateAnswerPanel contract={contract} />
|
||||
)}
|
||||
|
@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
|
|||
}
|
||||
|
||||
function getAnswerItems(
|
||||
contract: FreeResponseContract,
|
||||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
answers: Answer[],
|
||||
user: User | undefined | null
|
||||
) {
|
||||
|
@ -178,7 +184,7 @@ function getAnswerItems(
|
|||
}
|
||||
|
||||
function OpenAnswer(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
answer: Answer
|
||||
items: ActivityItem[]
|
||||
type: string
|
||||
|
|
65
web/components/answers/multiple-choice-answers.tsx
Normal file
65
web/components/answers/multiple-choice-answers.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
|
||||
export function MultipleChoiceAnswers(props: {
|
||||
setAnswers: (answers: string[]) => void
|
||||
}) {
|
||||
const [answers, setInternalAnswers] = useState(['', '', ''])
|
||||
|
||||
const setAnswer = (i: number, answer: string) => {
|
||||
const newAnswers = setElement(answers, i, answer)
|
||||
setInternalAnswers(newAnswers)
|
||||
props.setAnswers(newAnswers)
|
||||
}
|
||||
|
||||
const removeAnswer = (i: number) => {
|
||||
const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1))
|
||||
setInternalAnswers(newAnswers)
|
||||
props.setAnswers(newAnswers)
|
||||
}
|
||||
|
||||
const addAnswer = () => setAnswer(answers.length, '')
|
||||
|
||||
return (
|
||||
<Col>
|
||||
{answers.map((answer, i) => (
|
||||
<Row className="mb-2 items-center align-middle">
|
||||
{i + 1}.{' '}
|
||||
<Textarea
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(i, e.target.value)}
|
||||
className="textarea textarea-bordered ml-2 w-full resize-none"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
/>
|
||||
{answers.length > 2 && (
|
||||
<button
|
||||
className="btn btn-xs btn-outline ml-2"
|
||||
onClick={() => removeAnswer(i)}
|
||||
>
|
||||
<XIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<Row className="justify-end">
|
||||
<button className="btn btn-outline btn-xs" onClick={addAnswer}>
|
||||
Add answer
|
||||
</button>
|
||||
</Row>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
const setElement = <T,>(array: T[], i: number, elem: T) => {
|
||||
const newArray = array.concat()
|
||||
newArray[i] = elem
|
||||
return newArray
|
||||
}
|
|
@ -42,6 +42,8 @@ import { useUnfilledBets } from 'web/hooks/use-bets'
|
|||
import { LimitBets } from './limit-bets'
|
||||
import { PillButton } from './buttons/pill-button'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
|
||||
import { AlertBox } from './alert-box'
|
||||
|
||||
export function BetPanel(props: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
|
@ -72,6 +74,7 @@ export function BetPanel(props: {
|
|||
<QuickOrLimitBet
|
||||
isLimitOrder={isLimitOrder}
|
||||
setIsLimitOrder={setIsLimitOrder}
|
||||
hideToggle={!user}
|
||||
/>
|
||||
<BuyPanel
|
||||
hidden={isLimitOrder}
|
||||
|
@ -85,9 +88,13 @@ export function BetPanel(props: {
|
|||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
|
||||
<SignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
</Col>
|
||||
{unfilledBets.length > 0 && (
|
||||
|
||||
{user && unfilledBets.length > 0 && (
|
||||
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
|
||||
)}
|
||||
</Col>
|
||||
|
@ -124,6 +131,7 @@ export function SimpleBetPanel(props: {
|
|||
<QuickOrLimitBet
|
||||
isLimitOrder={isLimitOrder}
|
||||
setIsLimitOrder={setIsLimitOrder}
|
||||
hideToggle={!user}
|
||||
/>
|
||||
<BuyPanel
|
||||
hidden={isLimitOrder}
|
||||
|
@ -140,7 +148,10 @@ export function SimpleBetPanel(props: {
|
|||
unfilledBets={unfilledBets}
|
||||
onBuySuccess={onBetSuccess}
|
||||
/>
|
||||
|
||||
<SignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
</Col>
|
||||
|
||||
{unfilledBets.length > 0 && (
|
||||
|
@ -254,6 +265,8 @@ function BuyPanel(props: {
|
|||
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
||||
return (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
<div className="my-3 text-left text-sm text-gray-500">
|
||||
|
@ -277,6 +290,22 @@ function BuyPanel(props: {
|
|||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
|
||||
{(betAmount ?? 0) > 10 &&
|
||||
bankrollFraction >= 0.5 &&
|
||||
bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
|
@ -688,32 +717,35 @@ function LimitOrderPanel(props: {
|
|||
function QuickOrLimitBet(props: {
|
||||
isLimitOrder: boolean
|
||||
setIsLimitOrder: (isLimitOrder: boolean) => void
|
||||
hideToggle?: boolean
|
||||
}) {
|
||||
const { isLimitOrder, setIsLimitOrder } = props
|
||||
const { isLimitOrder, setIsLimitOrder, hideToggle } = props
|
||||
|
||||
return (
|
||||
<Row className="align-center mb-4 justify-between">
|
||||
<div className="text-4xl">Bet</div>
|
||||
<Row className="mt-1 items-center gap-2">
|
||||
<PillButton
|
||||
selected={!isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(false)
|
||||
track('select quick order')
|
||||
}}
|
||||
>
|
||||
Quick
|
||||
</PillButton>
|
||||
<PillButton
|
||||
selected={isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(true)
|
||||
track('select limit order')
|
||||
}}
|
||||
>
|
||||
Limit
|
||||
</PillButton>
|
||||
</Row>
|
||||
{!hideToggle && (
|
||||
<Row className="mt-1 items-center gap-2">
|
||||
<PillButton
|
||||
selected={!isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(false)
|
||||
track('select quick order')
|
||||
}}
|
||||
>
|
||||
Quick
|
||||
</PillButton>
|
||||
<PillButton
|
||||
selected={isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(true)
|
||||
track('select limit order')
|
||||
}}
|
||||
>
|
||||
Limit
|
||||
</PillButton>
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -739,7 +771,9 @@ export function SellPanel(props: {
|
|||
const betDisabled = isSubmitting || !amount || error
|
||||
|
||||
// Sell all shares if remaining shares would be < 1
|
||||
const sellQuantity = amount === Math.floor(shares) ? shares : amount
|
||||
const isSellingAllShares = amount === Math.floor(shares)
|
||||
|
||||
const sellQuantity = isSellingAllShares ? shares : amount
|
||||
|
||||
async function submitSell() {
|
||||
if (!user || !amount) return
|
||||
|
@ -748,7 +782,7 @@ export function SellPanel(props: {
|
|||
setIsSubmitting(true)
|
||||
|
||||
await sellShares({
|
||||
shares: sellQuantity,
|
||||
shares: isSellingAllShares ? undefined : amount,
|
||||
outcome: sharesOutcome,
|
||||
contractId: contract.id,
|
||||
})
|
||||
|
|
|
@ -3,6 +3,7 @@ import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
|||
import dayjs from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Bet } from 'web/lib/firebase/bets'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
|
@ -277,13 +278,7 @@ function ContractBets(props: {
|
|||
bets
|
||||
)
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'collapse collapse-arrow relative bg-white p-4 pr-6',
|
||||
collapsed ? 'collapse-close' : 'collapse-open pb-2'
|
||||
)}
|
||||
>
|
||||
<div tabIndex={0} className="relative bg-white p-4 pr-6">
|
||||
<Row
|
||||
className="cursor-pointer flex-wrap gap-2"
|
||||
onClick={() => setCollapsed((collapsed) => !collapsed)}
|
||||
|
@ -300,10 +295,11 @@ function ContractBets(props: {
|
|||
</Link>
|
||||
|
||||
{/* Show carrot for collapsing. Hack the positioning. */}
|
||||
<div
|
||||
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
|
||||
style={{ top: -10, right: 0 }}
|
||||
/>
|
||||
{collapsed ? (
|
||||
<ChevronDownIcon className="absolute top-5 right-4 h-6 w-6" />
|
||||
) : (
|
||||
<ChevronUpIcon className="absolute top-5 right-4 h-6 w-6" />
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Row className="flex-1 items-center gap-2 text-sm text-gray-500">
|
||||
|
@ -335,55 +331,42 @@ function ContractBets(props: {
|
|||
</Row>
|
||||
</Col>
|
||||
|
||||
<Row className="mr-5 justify-end sm:mr-8">
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-right text-lg">
|
||||
{formatMoney(metric === 'profit' ? profit : payout)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ProfitBadge profitPercent={profitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Col className="mr-5 sm:mr-8">
|
||||
<div className="whitespace-nowrap text-right text-lg">
|
||||
{formatMoney(metric === 'profit' ? profit : payout)}
|
||||
</div>
|
||||
<ProfitBadge className="text-right" profitPercent={profitPercent} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div
|
||||
className="collapse-content !px-0"
|
||||
style={{ backgroundColor: 'white' }}
|
||||
>
|
||||
<Spacer h={8} />
|
||||
{!collapsed && (
|
||||
<div className="bg-white">
|
||||
<BetsSummary
|
||||
className="mt-8 mr-5 flex-1 sm:mr-8"
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
isYourBets={isYourBets}
|
||||
/>
|
||||
|
||||
<BetsSummary
|
||||
className="mr-5 flex-1 sm:mr-8"
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
isYourBets={isYourBets}
|
||||
/>
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
||||
<>
|
||||
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
|
||||
<div className="max-w-md">
|
||||
<div className="bg-gray-50 px-4 py-2">Limit orders</div>
|
||||
<div className="mt-4 bg-gray-50 px-4 py-2">Limit orders</div>
|
||||
<LimitOrderTable
|
||||
contract={contract}
|
||||
limitBets={limitBets}
|
||||
isYou={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
<Spacer h={4} />
|
||||
|
||||
<div className="bg-gray-50 px-4 py-2">Bets</div>
|
||||
<ContractBetsTable
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
isYourBets={isYourBets}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 bg-gray-50 px-4 py-2">Bets</div>
|
||||
<ContractBetsTable
|
||||
contract={contract}
|
||||
bets={bets}
|
||||
isYourBets={isYourBets}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -427,107 +410,92 @@ export function BetsSummary(props: {
|
|||
|
||||
return (
|
||||
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
|
||||
<Row className="flex-wrap gap-4 sm:gap-6">
|
||||
{!isCpmm && (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Invested
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
||||
</Col>
|
||||
)}
|
||||
{resolution ? (
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Payout</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(payout)}{' '}
|
||||
<ProfitBadge profitPercent={profitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
) : (
|
||||
<>
|
||||
{isBinary ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if <YesLabel />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(yesWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if <NoLabel />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(noWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
) : isPseudoNumeric ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(yesWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(noWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Current value
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||
</Col>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCpmm && (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Invested
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(invested)}</div>
|
||||
</Col>
|
||||
)}
|
||||
{resolution ? (
|
||||
<Col>
|
||||
<div className="text-sm text-gray-500">Payout</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
||||
{isYourBets &&
|
||||
isCpmm &&
|
||||
(isBinary || isPseudoNumeric) &&
|
||||
!isClosed &&
|
||||
!resolution &&
|
||||
hasShares &&
|
||||
sharesOutcome &&
|
||||
user && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm ml-2"
|
||||
onClick={() => setShowSellModal(true)}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
{showSellModal && (
|
||||
<SellSharesModal
|
||||
contract={contract}
|
||||
user={user}
|
||||
userBets={bets}
|
||||
shares={totalShares[sharesOutcome]}
|
||||
sharesOutcome={sharesOutcome}
|
||||
setOpen={setShowSellModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{formatMoney(payout)} <ProfitBadge profitPercent={profitPercent} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
) : isBinary ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if <YesLabel />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if <NoLabel />
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||
</Col>
|
||||
</>
|
||||
) : isPseudoNumeric ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(yesWinnings)}</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Current value
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||
</Col>
|
||||
)}
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">Profit</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} />
|
||||
{isYourBets &&
|
||||
isCpmm &&
|
||||
(isBinary || isPseudoNumeric) &&
|
||||
!isClosed &&
|
||||
!resolution &&
|
||||
hasShares &&
|
||||
sharesOutcome &&
|
||||
user && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm ml-2"
|
||||
onClick={() => setShowSellModal(true)}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
{showSellModal && (
|
||||
<SellSharesModal
|
||||
contract={contract}
|
||||
user={user}
|
||||
userBets={bets}
|
||||
shares={totalShares[sharesOutcome]}
|
||||
sharesOutcome={sharesOutcome}
|
||||
setOpen={setShowSellModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -689,7 +657,13 @@ function BetRow(props: {
|
|||
!isClosed &&
|
||||
!isSold &&
|
||||
!isAnte &&
|
||||
!isNumeric && <SellButton contract={contract} bet={bet} />}
|
||||
!isNumeric && (
|
||||
<SellButton
|
||||
contract={contract}
|
||||
bet={bet}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
|
||||
<td>
|
||||
|
@ -729,8 +703,12 @@ function BetRow(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function SellButton(props: { contract: Contract; bet: Bet }) {
|
||||
const { contract, bet } = props
|
||||
function SellButton(props: {
|
||||
contract: Contract
|
||||
bet: Bet
|
||||
unfilledBets: LimitBet[]
|
||||
}) {
|
||||
const { contract, bet, unfilledBets } = props
|
||||
const { outcome, shares, loanAmount } = bet
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
@ -740,8 +718,6 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
|||
outcome === 'NO' ? 'YES' : outcome
|
||||
)
|
||||
|
||||
const unfilledBets = useUnfilledBets(contract.id) ?? []
|
||||
|
||||
const outcomeProb = getProbabilityAfterSale(
|
||||
contract,
|
||||
outcome,
|
||||
|
@ -787,8 +763,8 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ProfitBadge(props: { profitPercent: number }) {
|
||||
const { profitPercent } = props
|
||||
function ProfitBadge(props: { profitPercent: number; className?: string }) {
|
||||
const { profitPercent, className } = props
|
||||
if (!profitPercent) return null
|
||||
const colors =
|
||||
profitPercent > 0
|
||||
|
@ -799,7 +775,8 @@ function ProfitBadge(props: { profitPercent: number }) {
|
|||
<span
|
||||
className={clsx(
|
||||
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium',
|
||||
colors
|
||||
colors,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}
|
||||
|
|
|
@ -17,58 +17,55 @@ export function UserCommentsList(props: {
|
|||
contractsById: { [id: string]: Contract }
|
||||
}) {
|
||||
const { comments, contractsById } = props
|
||||
const commentsByContract = groupBy(comments, 'contractId')
|
||||
|
||||
const contractCommentPairs = Object.entries(commentsByContract)
|
||||
.map(
|
||||
([contractId, comments]) => [contractsById[contractId], comments] as const
|
||||
)
|
||||
.filter(([contract]) => contract)
|
||||
// we don't show comments in groups here atm, just comments on contracts
|
||||
const contractComments = comments.filter((c) => c.contractId)
|
||||
const commentsByContract = groupBy(contractComments, 'contractId')
|
||||
|
||||
return (
|
||||
<Col className={'bg-white'}>
|
||||
{contractCommentPairs.map(([contract, comments]) => (
|
||||
<div key={contract.id} className={'border-width-1 border-b p-5'}>
|
||||
<div className={'mb-2 text-sm text-indigo-700'}>
|
||||
<SiteLink href={contractPath(contract)}>
|
||||
{Object.entries(commentsByContract).map(([contractId, comments]) => {
|
||||
const contract = contractsById[contractId]
|
||||
return (
|
||||
<div key={contractId} className={'border-width-1 border-b p-5'}>
|
||||
<SiteLink
|
||||
className={'mb-2 block text-sm text-indigo-700'}
|
||||
href={contractPath(contract)}
|
||||
>
|
||||
{contract.question}
|
||||
</SiteLink>
|
||||
{comments.map((comment) => (
|
||||
<ProfileComment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
className="relative flex items-start space-x-3 pb-6"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className={'relative pb-6'}>
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<ProfileComment comment={comment} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileComment(props: { comment: Comment }) {
|
||||
const { comment } = props
|
||||
function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||
const { comment, className } = props
|
||||
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||
// TODO: find and attach relevant bets by comment betId at some point
|
||||
return (
|
||||
<div>
|
||||
<Row className={'gap-4'}>
|
||||
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</p>
|
||||
</div>
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
<Row className={className}>
|
||||
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
<UserLink
|
||||
className="text-gray-500"
|
||||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</p>
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,10 @@ import algoliasearch from 'algoliasearch/lite'
|
|||
|
||||
import { Contract } from 'common/contract'
|
||||
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
||||
import { ContractsGrid } from './contract/contracts-list'
|
||||
import {
|
||||
ContractHighlightOptions,
|
||||
ContractsGrid,
|
||||
} from './contract/contracts-list'
|
||||
import { Row } from './layout/row'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Spacer } from './layout/spacer'
|
||||
|
@ -52,11 +55,15 @@ export function ContractSearch(props: {
|
|||
excludeContractIds?: string[]
|
||||
groupSlug?: string
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
onContractClick?: (contract: Contract) => void
|
||||
showPlaceHolder?: boolean
|
||||
hideOrderSelector?: boolean
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
cardHideOptions?: {
|
||||
hideGroupLink?: boolean
|
||||
hideQuickBet?: boolean
|
||||
}
|
||||
}) {
|
||||
const {
|
||||
querySortOptions,
|
||||
|
@ -65,7 +72,8 @@ export function ContractSearch(props: {
|
|||
overrideGridClassName,
|
||||
hideOrderSelector,
|
||||
showPlaceHolder,
|
||||
hideQuickBet,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
|
||||
const user = useUser()
|
||||
|
@ -327,7 +335,8 @@ export function ContractSearch(props: {
|
|||
showTime={showTime}
|
||||
onContractClick={onContractClick}
|
||||
overrideGridClassName={overrideGridClassName}
|
||||
hideQuickBet={hideQuickBet}
|
||||
highlightOptions={highlightOptions}
|
||||
cardHideOptions={cardHideOptions}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
|
|
|
@ -5,9 +5,10 @@ import { formatLargeNumber, formatPercent } from 'common/util/format'
|
|||
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
import {
|
||||
Contract,
|
||||
BinaryContract,
|
||||
Contract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
|
@ -24,7 +25,7 @@ import {
|
|||
} from 'common/calculate'
|
||||
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
|
||||
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
|
||||
import { QuickBet, ProbBar, getColor } from './quick-bet'
|
||||
import { getColor, ProbBar, QuickBet } from './quick-bet'
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
|
@ -38,8 +39,16 @@ export function ContractCard(props: {
|
|||
className?: string
|
||||
onClick?: () => void
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
}) {
|
||||
const { showHotVolume, showTime, className, onClick, hideQuickBet } = props
|
||||
const {
|
||||
showHotVolume,
|
||||
showTime,
|
||||
className,
|
||||
onClick,
|
||||
hideQuickBet,
|
||||
hideGroupLink,
|
||||
} = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const { question, outcomeType } = contract
|
||||
const { resolution } = contract
|
||||
|
@ -106,7 +115,8 @@ export function ContractCard(props: {
|
|||
{question}
|
||||
</p>
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' &&
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
(resolution ? (
|
||||
<FreeResponseOutcomeLabel
|
||||
contract={contract}
|
||||
|
@ -121,6 +131,7 @@ export function ContractCard(props: {
|
|||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
|
@ -148,7 +159,8 @@ export function ContractCard(props: {
|
|||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<FreeResponseResolutionOrChance
|
||||
className="self-end text-gray-600"
|
||||
contract={contract}
|
||||
|
@ -200,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
|
|||
}
|
||||
|
||||
function FreeResponseTopAnswer(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
|
@ -218,7 +230,7 @@ function FreeResponseTopAnswer(props: {
|
|||
}
|
||||
|
||||
export function FreeResponseResolutionOrChance(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
className?: string
|
||||
}) {
|
||||
|
|
|
@ -42,8 +42,9 @@ export function MiscDetails(props: {
|
|||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
hideGroupLink?: boolean
|
||||
}) {
|
||||
const { contract, showHotVolume, showTime } = props
|
||||
const { contract, showHotVolume, showTime, hideGroupLink } = props
|
||||
const {
|
||||
volume,
|
||||
volume24Hours,
|
||||
|
@ -80,7 +81,7 @@ export function MiscDetails(props: {
|
|||
<NewContractBadge />
|
||||
)}
|
||||
|
||||
{groupLinks && groupLinks.length > 0 && (
|
||||
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
||||
<SiteLink
|
||||
href={groupPath(groupLinks[0].slug)}
|
||||
className="text-sm text-gray-400"
|
||||
|
|
|
@ -41,6 +41,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
? 'YES / NO'
|
||||
: outcomeType === 'FREE_RESPONSE'
|
||||
? 'Free response'
|
||||
: outcomeType === 'MULTIPLE_CHOICE'
|
||||
? 'Multiple choice'
|
||||
: 'Numeric'
|
||||
|
||||
return (
|
||||
|
|
|
@ -85,7 +85,8 @@ export const ContractOverview = (props: {
|
|||
{tradingAllowed(contract) && <BetRow contract={contract} />}
|
||||
</Row>
|
||||
) : (
|
||||
outcomeType === 'FREE_RESPONSE' &&
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
resolution && (
|
||||
<FreeResponseResolutionOrChance
|
||||
contract={contract}
|
||||
|
@ -110,7 +111,8 @@ export const ContractOverview = (props: {
|
|||
{(isBinary || isPseudoNumeric) && (
|
||||
<ContractProbGraph contract={contract} bets={bets} />
|
||||
)}{' '}
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<AnswersGraph contract={contract} bets={bets} />
|
||||
)}
|
||||
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Tabs } from '../layout/tabs'
|
|||
import { Col } from '../layout/col'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
|
||||
export function ContractTabs(props: {
|
||||
contract: Contract
|
||||
|
@ -18,11 +19,15 @@ export function ContractTabs(props: {
|
|||
comments: Comment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, bets, comments, tips, liquidityProvisions } = props
|
||||
const { contract, user, bets, tips, liquidityProvisions } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||
|
||||
// Load comments here, so the badge count will be correct
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const betActivity = (
|
||||
<ContractActivity
|
||||
contract={contract}
|
||||
|
@ -89,8 +94,12 @@ export function ContractTabs(props: {
|
|||
<Tabs
|
||||
currentPageForAnalytics={'contract'}
|
||||
tabs={[
|
||||
{ title: 'Comments', content: commentActivity },
|
||||
{ title: 'Bets', content: betActivity },
|
||||
{
|
||||
title: 'Comments',
|
||||
content: commentActivity,
|
||||
badge: `${comments.length}`,
|
||||
},
|
||||
{ title: 'Bets', content: betActivity, badge: `${bets.length}` },
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Contract } from '../../lib/firebase/contracts'
|
||||
import { User } from '../../lib/firebase/users'
|
||||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { SiteLink } from '../site-link'
|
||||
import { ContractCard } from './contract-card'
|
||||
|
@ -9,6 +9,11 @@ import { useIsVisible } from 'web/hooks/use-is-visible'
|
|||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type ContractHighlightOptions = {
|
||||
contractIds?: string[]
|
||||
highlightClassName?: string
|
||||
}
|
||||
|
||||
export function ContractsGrid(props: {
|
||||
contracts: Contract[]
|
||||
loadMore: () => void
|
||||
|
@ -16,7 +21,11 @@ export function ContractsGrid(props: {
|
|||
showTime?: ShowTime
|
||||
onContractClick?: (contract: Contract) => void
|
||||
overrideGridClassName?: string
|
||||
hideQuickBet?: boolean
|
||||
cardHideOptions?: {
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
}) {
|
||||
const {
|
||||
contracts,
|
||||
|
@ -25,9 +34,12 @@ export function ContractsGrid(props: {
|
|||
loadMore,
|
||||
onContractClick,
|
||||
overrideGridClassName,
|
||||
hideQuickBet,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
} = props
|
||||
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
||||
|
||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||
const isBottomVisible = useIsVisible(elem)
|
||||
|
||||
|
@ -66,6 +78,12 @@ export function ContractsGrid(props: {
|
|||
onContractClick ? () => onContractClick(contract) : undefined
|
||||
}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
className={
|
||||
contractIds?.includes(contract.id)
|
||||
? highlightClassName
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
|
|||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||
}
|
||||
|
||||
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
||||
params.groupId = contract.groupLinks[0].groupId
|
||||
}
|
||||
|
||||
return (
|
||||
`/create?` +
|
||||
Object.entries(params)
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Mention } from '@tiptap/extension-mention'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Linkify } from './linkify'
|
||||
|
@ -19,6 +20,9 @@ import { useMutation } from 'react-query'
|
|||
import { exhibitExts } from 'common/util/parse'
|
||||
import { FileUploadButton } from './file-upload-button'
|
||||
import { linkClass } from './site-link'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||
import { DisplayMention } from './editor/mention'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
|
||||
import { Modal } from './layout/modal'
|
||||
|
@ -40,33 +44,41 @@ export function useTextEditor(props: {
|
|||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled } = props
|
||||
|
||||
const users = useUsers()
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
Image,
|
||||
Link.configure({
|
||||
HTMLAttributes: {
|
||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||
},
|
||||
}),
|
||||
Iframe,
|
||||
],
|
||||
content: defaultValue,
|
||||
})
|
||||
const editor = useEditor(
|
||||
{
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
Image,
|
||||
Link.configure({
|
||||
HTMLAttributes: {
|
||||
class: clsx('no-underline !text-indigo-700', linkClass),
|
||||
},
|
||||
}),
|
||||
DisplayMention.configure({
|
||||
suggestion: mentionSuggestion(users),
|
||||
}),
|
||||
Iframe,
|
||||
],
|
||||
content: defaultValue,
|
||||
},
|
||||
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
|
||||
)
|
||||
|
||||
const upload = useUploadMutation(editor)
|
||||
|
||||
|
@ -261,7 +273,11 @@ function RichContent(props: { content: JSONContent | string }) {
|
|||
const { content } = props
|
||||
const editor = useEditor({
|
||||
editorProps: { attributes: { class: proseClass } },
|
||||
extensions: exhibitExts,
|
||||
extensions: [
|
||||
// replace tiptap's Mention with ours, to add style and link
|
||||
...exhibitExts.filter((ex) => ex.name !== Mention.name),
|
||||
DisplayMention,
|
||||
],
|
||||
content,
|
||||
editable: false,
|
||||
})
|
||||
|
|
62
web/components/editor/mention-list.tsx
Normal file
62
web/components/editor/mention-list.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { SuggestionProps } from '@tiptap/suggestion'
|
||||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { Avatar } from '../avatar'
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
export const MentionList = forwardRef((props: SuggestionProps<User>, ref) => {
|
||||
const { items: users, command } = props
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
useEffect(() => setSelectedIndex(0), [users])
|
||||
|
||||
const submitUser = (index: number) => {
|
||||
const user = users[index]
|
||||
if (user) command({ id: user.id, label: user.username } as any)
|
||||
}
|
||||
|
||||
const onUp = () =>
|
||||
setSelectedIndex((i) => (i + users.length - 1) % users.length)
|
||||
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
|
||||
const onEnter = () => submitUser(selectedIndex)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: any) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
onUp()
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
onDown()
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
onEnter()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-42 absolute z-10 overflow-x-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{!users.length ? (
|
||||
<span className="m-1 whitespace-nowrap">No results...</span>
|
||||
) : (
|
||||
users.map((user, i) => (
|
||||
<button
|
||||
className={clsx(
|
||||
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
|
||||
selectedIndex === i ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)}
|
||||
onClick={() => submitUser(i)}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} size="xs" />
|
||||
{user.username}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
72
web/components/editor/mention-suggestion.ts
Normal file
72
web/components/editor/mention-suggestion.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { orderBy } from 'lodash'
|
||||
import tippy from 'tippy.js'
|
||||
import { MentionList } from './mention-list'
|
||||
|
||||
type Suggestion = MentionOptions['suggestion']
|
||||
|
||||
const beginsWith = (text: string, query: string) =>
|
||||
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||
items: ({ query }) =>
|
||||
orderBy(
|
||||
users.filter((u) => searchInAny(query, u.username, u.name)),
|
||||
[
|
||||
(u) => [u.name, u.username].some((s) => beginsWith(s, query)),
|
||||
'followerCountCached',
|
||||
],
|
||||
['desc', 'desc']
|
||||
).slice(0, 5),
|
||||
render: () => {
|
||||
let component: ReactRenderer
|
||||
let popup: ReturnType<typeof tippy>
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
return true
|
||||
}
|
||||
return (component.ref as any)?.onKeyDown(props)
|
||||
},
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
29
web/components/editor/mention.tsx
Normal file
29
web/components/editor/mention.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import Mention from '@tiptap/extension-mention'
|
||||
import {
|
||||
mergeAttributes,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from '@tiptap/react'
|
||||
import clsx from 'clsx'
|
||||
import { Linkify } from '../linkify'
|
||||
|
||||
const name = 'mention-component'
|
||||
|
||||
const MentionComponent = (props: any) => {
|
||||
return (
|
||||
<NodeViewWrapper className={clsx(name, 'not-prose inline text-indigo-700')}>
|
||||
<Linkify text={'@' + props.node.attrs.label} />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mention extension that renders React. See:
|
||||
* https://tiptap.dev/guide/custom-extensions#extend-existing-extensions
|
||||
* https://tiptap.dev/guide/node-views/react#render-a-react-component
|
||||
*/
|
||||
export const DisplayMention = Mention.extend({
|
||||
parseHTML: () => [{ tag: name }],
|
||||
renderHTML: ({ HTMLAttributes }) => [name, mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () => ReactNodeViewRenderer(MentionComponent),
|
||||
})
|
|
@ -2,7 +2,6 @@ import { Contract } from 'web/lib/firebase/contracts'
|
|||
import { Comment } from 'web/lib/firebase/comments'
|
||||
import { Bet } from 'common/bet'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { getSpecificContractActivityItems } from './activity-items'
|
||||
import { FeedItems } from './feed-items'
|
||||
import { User } from 'common/user'
|
||||
|
@ -26,10 +25,7 @@ export function ContractActivity(props: {
|
|||
props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const comments = props.comments
|
||||
const updatedBets = useBets(contract.id)
|
||||
const bets = (updatedBets ?? props.bets).filter(
|
||||
(bet) => !bet.isRedemption && bet.amount !== 0
|
||||
|
@ -50,6 +46,7 @@ export function ContractActivity(props: {
|
|||
items={items}
|
||||
className={className}
|
||||
betRowClassName={betRowClassName}
|
||||
user={user}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -36,14 +36,18 @@ import {
|
|||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { CPMMBinaryContract, NumericContract } from 'common/contract'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
import { SignUpPrompt } from '../sign-up-prompt'
|
||||
import { User } from 'common/user'
|
||||
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
||||
|
||||
export function FeedItems(props: {
|
||||
contract: Contract
|
||||
items: ActivityItem[]
|
||||
className?: string
|
||||
betRowClassName?: string
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { contract, items, className, betRowClassName } = props
|
||||
const { contract, items, className, betRowClassName, user } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||
|
@ -67,11 +71,20 @@ export function FeedItems(props: {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{outcomeType === 'BINARY' && tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract as CPMMBinaryContract}
|
||||
className={clsx('mb-2', betRowClassName)}
|
||||
/>
|
||||
|
||||
{!user ? (
|
||||
<Col className="mt-4 max-w-sm items-center xl:hidden">
|
||||
<SignUpPrompt />
|
||||
<PlayMoneyDisclaimer />
|
||||
</Col>
|
||||
) : (
|
||||
outcomeType === 'BINARY' &&
|
||||
tradingAllowed(contract) && (
|
||||
<BetRow
|
||||
contract={contract as CPMMBinaryContract}
|
||||
className={clsx('mb-2', betRowClassName)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -77,8 +77,7 @@ export function LiquidityStatusText(props: {
|
|||
) : (
|
||||
<span>{isSelf ? 'You' : 'A trader'}</span>
|
||||
)}{' '}
|
||||
{bought} {money}
|
||||
{' of liquidity'}
|
||||
{bought} a subsidy of {money}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -46,7 +46,7 @@ export function CreateGroupButton(props: {
|
|||
const newGroup = {
|
||||
name: groupName,
|
||||
memberIds: memberUsers.map((user) => user.id),
|
||||
anyoneCanJoin: false,
|
||||
anyoneCanJoin: true,
|
||||
}
|
||||
const result = await createGroup(newGroup).catch((e) => {
|
||||
const errorDetails = e.details[0]
|
||||
|
|
|
@ -20,7 +20,7 @@ export function InfoBox(props: {
|
|||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-black">{title}</h3>
|
||||
<div className="mt-2 text-sm text-black">
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<Linkify text={text} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -26,7 +26,7 @@ export function Modal(props: {
|
|||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
onClose={setOpen}
|
||||
>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
@ -57,7 +57,7 @@ export function Modal(props: {
|
|||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'my-8 mx-6 inline-block w-full transform overflow-hidden text-left align-bottom transition-all sm:align-middle',
|
||||
'my-8 mx-6 inline-block w-full transform overflow-hidden text-left align-bottom transition-all sm:self-center sm:align-middle',
|
||||
sizeClass,
|
||||
className
|
||||
)}
|
||||
|
|
|
@ -1,77 +1,121 @@
|
|||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, NextRouter } from 'next/router'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Row } from './row'
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
|
||||
type Tab = {
|
||||
title: string
|
||||
tabIcon?: ReactNode
|
||||
content: ReactNode
|
||||
// If set, change the url to this href when the tab is selected
|
||||
href?: string
|
||||
// If set, show a badge with this content
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export function Tabs(props: {
|
||||
type TabProps = {
|
||||
tabs: Tab[]
|
||||
defaultIndex?: number
|
||||
labelClassName?: string
|
||||
onClick?: (tabTitle: string, index: number) => void
|
||||
className?: string
|
||||
currentPageForAnalytics?: string
|
||||
}) {
|
||||
}
|
||||
|
||||
export function ControlledTabs(props: TabProps & { activeIndex: number }) {
|
||||
const {
|
||||
tabs,
|
||||
defaultIndex,
|
||||
activeIndex,
|
||||
labelClassName,
|
||||
onClick,
|
||||
className,
|
||||
currentPageForAnalytics,
|
||||
} = props
|
||||
const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
|
||||
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx('mb-4 border-b border-gray-200', className)}>
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab, i) => (
|
||||
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
|
||||
<a
|
||||
id={`tab-${i}`}
|
||||
key={tab.title}
|
||||
onClick={(e) => {
|
||||
track('Clicked Tab', {
|
||||
title: tab.title,
|
||||
href: tab.href,
|
||||
currentPage: currentPageForAnalytics,
|
||||
})
|
||||
if (!tab.href) {
|
||||
e.preventDefault()
|
||||
}
|
||||
setActiveIndex(i)
|
||||
onClick?.(tab.title, i)
|
||||
}}
|
||||
className={clsx(
|
||||
activeIndex === i
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
||||
'cursor-pointer whitespace-nowrap border-b-2 py-3 px-1 text-sm font-medium',
|
||||
labelClassName
|
||||
)}
|
||||
aria-current={activeIndex === i ? 'page' : undefined}
|
||||
>
|
||||
<Row className={'items-center justify-center gap-1'}>
|
||||
{tab.tabIcon && <span> {tab.tabIcon}</span>}
|
||||
{tab.title}
|
||||
</Row>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={clsx('mb-4 space-x-8 border-b border-gray-200', className)}
|
||||
aria-label="Tabs"
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<a
|
||||
href="#"
|
||||
key={tab.title}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
track('Clicked Tab', {
|
||||
title: tab.title,
|
||||
currentPage: currentPageForAnalytics,
|
||||
})
|
||||
onClick?.(tab.title, i)
|
||||
}}
|
||||
className={clsx(
|
||||
activeIndex === i
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
||||
'inline-flex cursor-pointer flex-row gap-1 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
|
||||
labelClassName
|
||||
)}
|
||||
aria-current={activeIndex === i ? 'page' : undefined}
|
||||
>
|
||||
{tab.tabIcon && <span>{tab.tabIcon}</span>}
|
||||
{tab.badge ? (
|
||||
<span className="px-0.5 font-bold">{tab.badge}</span>
|
||||
) : null}
|
||||
{tab.title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{activeTab?.content}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function UncontrolledTabs(props: TabProps & { defaultIndex?: number }) {
|
||||
const { defaultIndex, onClick, ...rest } = props
|
||||
const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
|
||||
return (
|
||||
<ControlledTabs
|
||||
{...rest}
|
||||
activeIndex={activeIndex}
|
||||
onClick={(title, i) => {
|
||||
setActiveIndex(i)
|
||||
onClick?.(title, i)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isTabSelected = (router: NextRouter, queryParam: string, tab: Tab) => {
|
||||
const selected = router.query[queryParam]
|
||||
if (typeof selected === 'string') {
|
||||
return tab.title.toLowerCase() === selected
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function QueryUncontrolledTabs(
|
||||
props: TabProps & { defaultIndex?: number }
|
||||
) {
|
||||
const { tabs, defaultIndex, onClick, ...rest } = props
|
||||
const router = useRouter()
|
||||
const selectedIdx = tabs.findIndex((t) => isTabSelected(router, 'tab', t))
|
||||
const activeIndex = selectedIdx !== -1 ? selectedIdx : defaultIndex ?? 0
|
||||
return (
|
||||
<ControlledTabs
|
||||
{...rest}
|
||||
tabs={tabs}
|
||||
activeIndex={activeIndex}
|
||||
onClick={(title, i) => {
|
||||
router.replace(
|
||||
{ query: { ...router.query, tab: title.toLowerCase() } },
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
)
|
||||
onClick?.(title, i)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// legacy code that didn't know about any other kind of tabs imports this
|
||||
export const Tabs = UncontrolledTabs
|
||||
|
|
|
@ -10,6 +10,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/solid'
|
|||
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
|
||||
import { useUserById } from 'web/hooks/use-user'
|
||||
import getManalinkUrl from 'web/get-manalink-url'
|
||||
import { QrcodeIcon } from '@heroicons/react/outline'
|
||||
export type ManalinkInfo = {
|
||||
expiresTime: number | null
|
||||
maxUses: number | null
|
||||
|
@ -78,7 +79,9 @@ export function ManalinkCardFromView(props: {
|
|||
const { className, link, highlightedSlug } = props
|
||||
const { message, amount, expiresTime, maxUses, claims } = link
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl(
|
||||
link.slug
|
||||
)}`
|
||||
return (
|
||||
<Col>
|
||||
<Col
|
||||
|
@ -127,6 +130,19 @@ export function ManalinkCardFromView(props: {
|
|||
>
|
||||
{formatMoney(amount)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => (window.location.href = qrUrl)}
|
||||
className={clsx(
|
||||
contractDetailsButtonClassName,
|
||||
showDetails
|
||||
? 'bg-gray-200 text-gray-600 hover:bg-gray-200 hover:text-gray-600'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<QrcodeIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<ShareIconButton
|
||||
toastClassName={'-left-48 min-w-[250%]'}
|
||||
buttonClassName={'transition-colors'}
|
||||
|
|
|
@ -12,6 +12,7 @@ import dayjs from 'dayjs'
|
|||
import { Button } from '../button'
|
||||
import { getManalinkUrl } from 'web/pages/links'
|
||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||
import { QRCode } from '../qr-code'
|
||||
|
||||
export function CreateLinksButton(props: {
|
||||
user: User
|
||||
|
@ -98,6 +99,8 @@ function CreateManalinkForm(props: {
|
|||
})
|
||||
}
|
||||
|
||||
const url = getManalinkUrl(highlightedSlug)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!finishedCreating && (
|
||||
|
@ -199,17 +202,17 @@ function CreateManalinkForm(props: {
|
|||
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
|
||||
)}
|
||||
>
|
||||
<div className="w-full select-text truncate">
|
||||
{getManalinkUrl(highlightedSlug)}
|
||||
</div>
|
||||
<div className="w-full select-text truncate">{url}</div>
|
||||
<DuplicateIcon
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getManalinkUrl(highlightedSlug))
|
||||
navigator.clipboard.writeText(url)
|
||||
setCopyPressed(true)
|
||||
}}
|
||||
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<QRCode url={url} className="self-center" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
BinaryContract,
|
||||
Contract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
resolution,
|
||||
} from 'common/contract'
|
||||
import { formatLargeNumber, formatPercent } from 'common/util/format'
|
||||
|
@ -77,7 +78,7 @@ export function BinaryContractOutcomeLabel(props: {
|
|||
}
|
||||
|
||||
export function FreeResponseOutcomeLabel(props: {
|
||||
contract: FreeResponseContract
|
||||
contract: FreeResponseContract | MultipleChoiceContract
|
||||
resolution: string | 'CANCEL' | 'MKT'
|
||||
truncate: 'short' | 'long' | 'none'
|
||||
answerClassName?: string
|
||||
|
|
9
web/components/play-money-disclaimer.tsx
Normal file
9
web/components/play-money-disclaimer.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { InfoBox } from './info-box'
|
||||
|
||||
export const PlayMoneyDisclaimer = () => (
|
||||
<InfoBox
|
||||
title="Play-money betting"
|
||||
className="mt-4 max-w-md"
|
||||
text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!"
|
||||
/>
|
||||
)
|
16
web/components/qr-code.tsx
Normal file
16
web/components/qr-code.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
export function QRCode(props: {
|
||||
url: string
|
||||
className?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}) {
|
||||
const { url, className, width, height } = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
...props,
|
||||
}
|
||||
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${width}x${height}&data=${url}`
|
||||
|
||||
return <img src={qrUrl} width={width} height={height} className={className} />
|
||||
}
|
|
@ -5,13 +5,13 @@ 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'
|
||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { getUser, updateUser } from 'web/lib/firebase/users'
|
||||
import { TextButton } from 'web/components/text-button'
|
||||
|
||||
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
|
@ -24,7 +24,6 @@ export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
|||
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
|
||||
Referrals
|
||||
</TextButton>
|
||||
|
||||
<ReferralsDialog
|
||||
user={user}
|
||||
referralIds={referralIds ?? []}
|
||||
|
|
|
@ -8,7 +8,7 @@ export function TextButton(props: {
|
|||
const { onClick, children, className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
className={clsx(
|
||||
className,
|
||||
'cursor-pointer gap-2 hover:underline hover:decoration-indigo-400 hover:decoration-2 focus:underline focus:decoration-indigo-400 focus:decoration-2'
|
||||
|
@ -17,6 +17,6 @@ export function TextButton(props: {
|
|||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,9 +8,9 @@ import Confetti from 'react-confetti'
|
|||
|
||||
import {
|
||||
follow,
|
||||
getPortfolioHistory,
|
||||
unfollow,
|
||||
User,
|
||||
getPortfolioHistory,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { CreatorContractsList } from './contract/contracts-list'
|
||||
import { SEO } from './SEO'
|
||||
|
@ -22,7 +22,7 @@ import { Linkify } from './linkify'
|
|||
import { Spacer } from './layout/spacer'
|
||||
import { Row } from './layout/row'
|
||||
import { genHash } from 'common/util/random'
|
||||
import { Tabs } from './layout/tabs'
|
||||
import { QueryUncontrolledTabs } from './layout/tabs'
|
||||
import { UserCommentsList } from './comments-list'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Comment, getUsersComments } from 'web/lib/firebase/comments'
|
||||
|
@ -40,6 +40,8 @@ import { filterDefined } from 'common/util/array'
|
|||
import { useUserBets } from 'web/hooks/use-user-bets'
|
||||
import { ReferralsButton } from 'web/components/referrals-button'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
|
||||
export function UserLink(props: {
|
||||
name: string
|
||||
|
@ -64,12 +66,8 @@ export function UserLink(props: {
|
|||
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
|
||||
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
|
||||
|
||||
export function UserPage(props: {
|
||||
user: User
|
||||
currentUser?: User
|
||||
defaultTabTitle?: string | undefined
|
||||
}) {
|
||||
const { user, currentUser, defaultTabTitle } = props
|
||||
export function UserPage(props: { user: User; currentUser?: User }) {
|
||||
const { user, currentUser } = props
|
||||
const router = useRouter()
|
||||
const isCurrentUser = user.id === currentUser?.id
|
||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||
|
@ -216,9 +214,10 @@ export function UserPage(props: {
|
|||
<Row className="gap-4">
|
||||
<FollowingButton user={user} />
|
||||
<FollowersButton user={user} />
|
||||
{currentUser?.username === 'ian' && (
|
||||
<ReferralsButton user={user} currentUser={currentUser} />
|
||||
)}
|
||||
{currentUser &&
|
||||
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
||||
currentUser.username
|
||||
) && <ReferralsButton user={user} />}
|
||||
<GroupsButton user={user} />
|
||||
</Row>
|
||||
|
||||
|
@ -273,32 +272,39 @@ export function UserPage(props: {
|
|||
)}
|
||||
</Col>
|
||||
|
||||
<Spacer h={10} />
|
||||
<Spacer h={5} />
|
||||
{currentUser?.id === user.id && (
|
||||
<Row
|
||||
className={
|
||||
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
Refer a friend and earn {formatMoney(500)} when they sign up! You
|
||||
have <ReferralsButton user={user} currentUser={currentUser} />
|
||||
</span>
|
||||
<ShareIconButton
|
||||
copyPayload={`https://${ENV_CONFIG.domain}?referrer=${currentUser.username}`}
|
||||
toastClassName={'sm:-left-40 -left-40 min-w-[250%]'}
|
||||
buttonClassName={'h-10 w-10'}
|
||||
iconClassName={'h-8 w-8 text-indigo-700'}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Spacer h={5} />
|
||||
|
||||
{usersContracts !== 'loading' && contractsById && usersComments ? (
|
||||
<Tabs
|
||||
<QueryUncontrolledTabs
|
||||
currentPageForAnalytics={'profile'}
|
||||
labelClassName={'pb-2 pt-1 '}
|
||||
defaultIndex={
|
||||
defaultTabTitle ? TAB_IDS.indexOf(defaultTabTitle) : 0
|
||||
}
|
||||
onClick={(tabName) => {
|
||||
const tabId = tabName.toLowerCase()
|
||||
const subpath = tabId === 'markets' ? '' : '?tab=' + tabId
|
||||
// BUG: if you start on `/Bob/bets`, then click on Markets, use-query-and-sort-params
|
||||
// rewrites the url incorrectly to `/Bob/bets` instead of `/Bob`
|
||||
router.push(`/${user.username}${subpath}`, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Markets',
|
||||
content: <CreatorContractsList creator={user} />,
|
||||
tabIcon: (
|
||||
<div className="px-0.5 font-bold">
|
||||
<span className="px-0.5 font-bold">
|
||||
{usersContracts.length}
|
||||
</div>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -311,7 +317,9 @@ export function UserPage(props: {
|
|||
/>
|
||||
),
|
||||
tabIcon: (
|
||||
<div className="px-0.5 font-bold">{usersComments.length}</div>
|
||||
<span className="px-0.5 font-bold">
|
||||
{usersComments.length}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -329,7 +337,7 @@ export function UserPage(props: {
|
|||
/>
|
||||
</div>
|
||||
),
|
||||
tabIcon: <div className="px-0.5 font-bold">{betCount}</div>,
|
||||
tabIcon: <span className="px-0.5 font-bold">{betCount}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -129,7 +129,6 @@ export async function listContractsByGroupSlug(
|
|||
): Promise<Contract[]> {
|
||||
const q = query(contracts, where('groupSlugs', 'array-contains', slug))
|
||||
const snapshot = await getDocs(q)
|
||||
console.log(snapshot.docs.map((doc) => doc.data()))
|
||||
return snapshot.docs.map((doc) => doc.data())
|
||||
}
|
||||
|
||||
|
|
|
@ -129,56 +129,61 @@ export async function addContractToGroup(
|
|||
contract: Contract,
|
||||
userId: string
|
||||
) {
|
||||
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // already in that group
|
||||
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||
const newGroupLinks = [
|
||||
...(contract.groupLinks ?? []),
|
||||
{
|
||||
groupId: group.id,
|
||||
createdTime: Date.now(),
|
||||
slug: group.slug,
|
||||
userId,
|
||||
name: group.name,
|
||||
} as GroupLink,
|
||||
]
|
||||
|
||||
const newGroupLinks = [
|
||||
...(contract.groupLinks ?? []),
|
||||
{
|
||||
groupId: group.id,
|
||||
createdTime: Date.now(),
|
||||
slug: group.slug,
|
||||
userId,
|
||||
name: group.name,
|
||||
} as GroupLink,
|
||||
]
|
||||
|
||||
await updateContract(contract.id, {
|
||||
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
||||
groupLinks: newGroupLinks,
|
||||
})
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq([...group.contractIds, contract.id]),
|
||||
})
|
||||
.then(() => group)
|
||||
.catch((err) => {
|
||||
console.error('error adding contract to group', err)
|
||||
return err
|
||||
await updateContract(contract.id, {
|
||||
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
||||
groupLinks: newGroupLinks,
|
||||
})
|
||||
}
|
||||
if (!group.contractIds.includes(contract.id)) {
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq([...group.contractIds, contract.id]),
|
||||
})
|
||||
.then(() => group)
|
||||
.catch((err) => {
|
||||
console.error('error adding contract to group', err)
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeContractFromGroup(
|
||||
group: Group,
|
||||
contract: Contract
|
||||
) {
|
||||
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // not in that group
|
||||
|
||||
const newGroupLinks = contract.groupLinks?.filter(
|
||||
(link) => link.slug !== group.slug
|
||||
)
|
||||
await updateContract(contract.id, {
|
||||
groupSlugs:
|
||||
contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [],
|
||||
groupLinks: newGroupLinks ?? [],
|
||||
})
|
||||
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq(newContractIds),
|
||||
})
|
||||
.then(() => group)
|
||||
.catch((err) => {
|
||||
console.error('error removing contract from group', err)
|
||||
return err
|
||||
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||
const newGroupLinks = contract.groupLinks?.filter(
|
||||
(link) => link.slug !== group.slug
|
||||
)
|
||||
await updateContract(contract.id, {
|
||||
groupSlugs:
|
||||
contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [],
|
||||
groupLinks: newGroupLinks ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
if (group.contractIds.includes(contract.id)) {
|
||||
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
|
||||
return await updateGroup(group, {
|
||||
contractIds: uniq(newContractIds),
|
||||
})
|
||||
.then(() => group)
|
||||
.catch((err) => {
|
||||
console.error('error removing contract from group', err)
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function setContractGroupLinks(
|
||||
|
@ -187,6 +192,7 @@ export async function setContractGroupLinks(
|
|||
userId: string
|
||||
) {
|
||||
await updateContract(contractId, {
|
||||
groupSlugs: [group.slug],
|
||||
groupLinks: [
|
||||
{
|
||||
groupId: group.id,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { storage } from './init'
|
||||
|
||||
// TODO: compress large images
|
||||
export const uploadImage = async (
|
||||
username: string,
|
||||
file: File,
|
||||
|
@ -12,6 +12,18 @@ export const uploadImage = async (
|
|||
const [, ext] = file.name.split('.')
|
||||
const filename = `${nanoid(10)}.${ext}`
|
||||
const storageRef = ref(storage, `user-images/${username}/${filename}`)
|
||||
|
||||
if (file.size > 20 * 1024 ** 2) {
|
||||
return Promise.reject('File is over 20 MB.')
|
||||
}
|
||||
|
||||
if (file.size > 1024 ** 2) {
|
||||
file = await imageCompression(file, {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1920,
|
||||
})
|
||||
}
|
||||
|
||||
const uploadTask = uploadBytesResumable(storageRef, file)
|
||||
|
||||
let resolvePromise: (url: string) => void
|
||||
|
|
|
@ -3,12 +3,14 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"",
|
||||
"devdev": "cross-env NEXT_PUBLIC_FIREBASE_ENV=DEV concurrently -n NEXT,TS -c magenta,cyan \"cross-env FIREBASE_ENV=DEV next dev -p 3000\" \"cross-env FIREBASE_ENV=DEV yarn ts --watch\"",
|
||||
"serve": "next dev -p 3000",
|
||||
"ts-watch": "tsc --watch --noEmit --incremental --preserveWatchOutput --pretty",
|
||||
"dev": "concurrently -n NEXT,TS -c magenta,cyan \"yarn serve\" \"yarn ts-watch\"",
|
||||
"devdev": "cross-env NEXT_PUBLIC_FIREBASE_ENV=DEV yarn dev",
|
||||
"dev:dev": "yarn devdev",
|
||||
"dev:the": "cross-env NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE concurrently -n NEXT,TS -c magenta,cyan \"cross-env FIREBASE_ENV=THEOREMONE next dev -p 3000\" \"cross-env FIREBASE_ENV=THEOREMONE yarn ts --watch\"",
|
||||
"dev:the": "cross-env NEXT_PUBLIC_FIREBASE_ENV=THEOREMONE yarn dev",
|
||||
"dev:local": "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8080 yarn devdev",
|
||||
"dev:emulate": "cross-env NEXT_PUBLIC_FIREBASE_EMULATE=TRUE yarn devdev",
|
||||
"ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
@ -27,10 +29,12 @@
|
|||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/extension-placeholder": "2.0.0-beta.53",
|
||||
"@tiptap/react": "2.0.0-beta.114",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"algoliasearch": "4.13.0",
|
||||
"browser-image-compression": "2.0.0",
|
||||
"clsx": "1.1.1",
|
||||
"cors": "2.8.5",
|
||||
"daisyui": "1.16.4",
|
||||
|
@ -49,7 +53,8 @@
|
|||
"react-hot-toast": "2.2.0",
|
||||
"react-instantsearch-hooks-web": "6.24.1",
|
||||
"react-query": "3.39.0",
|
||||
"string-similarity": "^4.0.4"
|
||||
"string-similarity": "^4.0.4",
|
||||
"tippy.js": "6.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "0.4.0",
|
||||
|
@ -60,7 +65,6 @@
|
|||
"@types/react": "17.0.43",
|
||||
"@types/string-similarity": "^4.0.0",
|
||||
"autoprefixer": "10.2.6",
|
||||
"concurrently": "6.5.1",
|
||||
"critters": "0.0.16",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint-config-next": "12.1.6",
|
||||
|
|
|
@ -217,7 +217,8 @@ export function ContractPageContent(
|
|||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'FREE_RESPONSE' && (
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||
<>
|
||||
<Spacer h={4} />
|
||||
<AnswersPanel contract={contract} />
|
||||
|
|
|
@ -31,9 +31,8 @@ export default function UserProfile(props: { user: User | null }) {
|
|||
const { user } = props
|
||||
|
||||
const router = useRouter()
|
||||
const { username, tab } = router.query as {
|
||||
const { username } = router.query as {
|
||||
username: string
|
||||
tab?: string | undefined
|
||||
}
|
||||
const currentUser = useUser()
|
||||
|
||||
|
@ -42,11 +41,7 @@ export default function UserProfile(props: { user: User | null }) {
|
|||
if (user === undefined) return <div />
|
||||
|
||||
return user ? (
|
||||
<UserPage
|
||||
user={user}
|
||||
currentUser={currentUser || undefined}
|
||||
defaultTabTitle={tab}
|
||||
/>
|
||||
<UserPage user={user} currentUser={currentUser || undefined} />
|
||||
) : (
|
||||
<Custom404 />
|
||||
)
|
||||
|
|
27
web/pages/api/v0/bet/cancel/[betId].ts
Normal file
27
web/pages/api/v0/bet/cancel/[betId].ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
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 { betId } = req.query as { betId: string }
|
||||
|
||||
if (req.body) req.body.betId = betId
|
||||
try {
|
||||
const backendRes = await fetchBackend(req, 'cancelbet')
|
||||
await forwardResponse(res, backendRes)
|
||||
} catch (err) {
|
||||
console.error('Error talking to cloud function: ', err)
|
||||
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||
}
|
||||
}
|
28
web/pages/api/v0/market/[id]/sell.ts
Normal file
28
web/pages/api/v0/market/[id]/sell.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, 'sellshares')
|
||||
await forwardResponse(res, backendRes)
|
||||
} catch (err) {
|
||||
console.error('Error talking to cloud function: ', err)
|
||||
res.status(500).json({ message: 'Error communicating with backend.' })
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ import { Checkbox } from 'web/components/checkbox'
|
|||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||
import { Title } from 'web/components/title'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||
|
||||
|
@ -116,6 +117,8 @@ export function NewContract(props: {
|
|||
const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale)
|
||||
const [initialValueString, setInitialValueString] = useState(initValue)
|
||||
|
||||
const [answers, setAnswers] = useState<string[]>([]) // for multiple choice
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId && creator)
|
||||
getGroup(groupId).then((group) => {
|
||||
|
@ -160,6 +163,10 @@ export function NewContract(props: {
|
|||
// get days from today until the end of this year:
|
||||
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
|
||||
|
||||
const isValidMultipleChoice = answers.every(
|
||||
(answer) => answer.trim().length > 0
|
||||
)
|
||||
|
||||
const isValid =
|
||||
(outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) &&
|
||||
question.length > 0 &&
|
||||
|
@ -178,7 +185,13 @@ export function NewContract(props: {
|
|||
min < max &&
|
||||
max - min > 0.01 &&
|
||||
min < initialValue &&
|
||||
initialValue < max))
|
||||
initialValue < max)) &&
|
||||
(outcomeType !== 'MULTIPLE_CHOICE' || isValidMultipleChoice)
|
||||
|
||||
const [errorText, setErrorText] = useState<string>('')
|
||||
useEffect(() => {
|
||||
setErrorText('')
|
||||
}, [isValid])
|
||||
|
||||
const descriptionPlaceholder =
|
||||
outcomeType === 'BINARY'
|
||||
|
@ -216,6 +229,7 @@ export function NewContract(props: {
|
|||
max,
|
||||
initialValue,
|
||||
isLogScale,
|
||||
answers,
|
||||
groupId: selectedGroup?.id,
|
||||
})
|
||||
)
|
||||
|
@ -232,6 +246,9 @@ export function NewContract(props: {
|
|||
await router.push(contractPath(result as Contract))
|
||||
} catch (e) {
|
||||
console.error('error creating contract', e, (e as any).details)
|
||||
setErrorText(
|
||||
(e as any).details || (e as any).message || 'Error creating contract'
|
||||
)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
@ -251,10 +268,11 @@ export function NewContract(props: {
|
|||
'Users can submit their own answers to this market.'
|
||||
)
|
||||
else setMarketInfoText('')
|
||||
setOutcomeType(choice as 'BINARY' | 'FREE_RESPONSE')
|
||||
setOutcomeType(choice as outcomeType)
|
||||
}}
|
||||
choicesMap={{
|
||||
'Yes / No': 'BINARY',
|
||||
'Multiple choice': 'MULTIPLE_CHOICE',
|
||||
'Free response': 'FREE_RESPONSE',
|
||||
Numeric: 'PSEUDO_NUMERIC',
|
||||
}}
|
||||
|
@ -269,6 +287,10 @@ export function NewContract(props: {
|
|||
|
||||
<Spacer h={6} />
|
||||
|
||||
{outcomeType === 'MULTIPLE_CHOICE' && (
|
||||
<MultipleChoiceAnswers setAnswers={setAnswers} />
|
||||
)}
|
||||
|
||||
{outcomeType === 'PSEUDO_NUMERIC' && (
|
||||
<>
|
||||
<div className="form-control mb-2 items-start">
|
||||
|
@ -413,7 +435,7 @@ export function NewContract(props: {
|
|||
</div>
|
||||
|
||||
<Spacer h={6} />
|
||||
|
||||
<span className={'text-error'}>{errorText}</span>
|
||||
<Row className="items-end justify-between">
|
||||
<div className="form-control mb-1 items-start">
|
||||
<label className="label mb-1 gap-2">
|
||||
|
@ -455,6 +477,8 @@ export function NewContract(props: {
|
|||
{isSubmitting ? 'Creating...' : 'Create question'}
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
<Spacer h={6} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ import { useWindowSize } from 'web/hooks/use-window-size'
|
|||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { Button } from 'web/components/button'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||
|
@ -541,10 +542,26 @@ function GroupLeaderboards(props: {
|
|||
function AddContractButton(props: { group: Group; user: User }) {
|
||||
const { group, user } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [contracts, setContracts] = useState<Contract[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function addContractToCurrentGroup(contract: Contract) {
|
||||
await addContractToGroup(group, contract, user.id)
|
||||
setOpen(false)
|
||||
if (contracts.map((c) => c.id).includes(contract.id)) {
|
||||
setContracts(contracts.filter((c) => c.id !== contract.id))
|
||||
} else setContracts([...contracts, contract])
|
||||
}
|
||||
|
||||
async function doneAddingContracts() {
|
||||
Promise.all(
|
||||
contracts.map(async (contract) => {
|
||||
setLoading(true)
|
||||
await addContractToGroup(group, contract, user.id)
|
||||
})
|
||||
).then(() => {
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
setContracts([])
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -558,37 +575,66 @@ function AddContractButton(props: { group: Group; user: User }) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<Modal open={open} setOpen={setOpen} className={'sm:p-0'}>
|
||||
<Col
|
||||
className={
|
||||
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white'
|
||||
}
|
||||
>
|
||||
<Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
|
||||
<Col className={' w-full gap-4 rounded-md bg-white'}>
|
||||
<Col className="p-8 pb-0">
|
||||
<div className={'text-xl text-indigo-700'}>
|
||||
Add a question to your group
|
||||
</div>
|
||||
|
||||
<Col className="items-center">
|
||||
<CreateQuestionButton
|
||||
user={user}
|
||||
overrideText={'New question'}
|
||||
className={'w-48 flex-shrink-0 '}
|
||||
query={`?groupId=${group.id}`}
|
||||
/>
|
||||
{contracts.length === 0 ? (
|
||||
<Col className="items-center justify-center">
|
||||
<CreateQuestionButton
|
||||
user={user}
|
||||
overrideText={'New question'}
|
||||
className={'w-48 flex-shrink-0 '}
|
||||
query={`?groupId=${group.id}`}
|
||||
/>
|
||||
|
||||
<div className={'mt-2 text-lg text-indigo-700'}>or</div>
|
||||
</Col>
|
||||
<div className={'mt-1 text-lg text-gray-600'}>
|
||||
(or select old questions)
|
||||
</div>
|
||||
</Col>
|
||||
) : (
|
||||
<Col className={'w-full '}>
|
||||
{!loading ? (
|
||||
<Row className={'justify-end gap-4'}>
|
||||
<Button onClick={doneAddingContracts} color={'indigo'}>
|
||||
Add {contracts.length} question
|
||||
{contracts.length > 1 && 's'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setContracts([])
|
||||
}}
|
||||
color={'gray'}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Row>
|
||||
) : (
|
||||
<Row className={'justify-center'}>
|
||||
<LoadingIndicator />
|
||||
</Row>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<div className={'overflow-y-scroll sm:px-8'}>
|
||||
<ContractSearch
|
||||
hideOrderSelector={true}
|
||||
onContractClick={addContractToCurrentGroup}
|
||||
overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'}
|
||||
overrideGridClassName={
|
||||
'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1'
|
||||
}
|
||||
showPlaceHolder={true}
|
||||
hideQuickBet={true}
|
||||
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
|
||||
additionalFilter={{ excludeContractIds: group.contractIds }}
|
||||
highlightOptions={{
|
||||
contractIds: contracts.map((c) => c.id),
|
||||
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { Title } from 'web/components/title'
|
||||
import { claimManalink } from 'web/lib/firebase/api'
|
||||
import { useManalink } from 'web/lib/firebase/manalinks'
|
||||
import { ManalinkCard } from 'web/components/manalink-card'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { firebaseLogin, getUser } from 'web/lib/firebase/users'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Button } from 'web/components/button'
|
||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||
import { User } from 'common/user'
|
||||
import { Manalink } from 'common/manalink'
|
||||
|
||||
export default function ClaimPage() {
|
||||
const user = useUser()
|
||||
|
@ -18,6 +21,8 @@ export default function ClaimPage() {
|
|||
const [claiming, setClaiming] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
|
||||
useReferral(user, manalink)
|
||||
|
||||
if (!manalink) {
|
||||
return <></>
|
||||
}
|
||||
|
@ -76,3 +81,13 @@ export default function ClaimPage() {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useReferral = (user: User | undefined | null, manalink?: Manalink) => {
|
||||
const [creator, setCreator] = useState<User | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (manalink?.fromId) getUser(manalink.fromId).then(setCreator)
|
||||
}, [manalink])
|
||||
|
||||
useSaveReferral(user, { defaultReferrer: creator?.username })
|
||||
}
|
||||
|
|
|
@ -726,18 +726,14 @@ function NotificationItem(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export const setNotificationsAsSeen = (notifications: Notification[]) => {
|
||||
notifications.forEach((notification) => {
|
||||
if (!notification.isSeen)
|
||||
updateDoc(
|
||||
doc(db, `users/${notification.userId}/notifications/`, notification.id),
|
||||
{
|
||||
isSeen: true,
|
||||
viewTime: new Date(),
|
||||
}
|
||||
)
|
||||
})
|
||||
return notifications
|
||||
export const setNotificationsAsSeen = async (notifications: Notification[]) => {
|
||||
const unseenNotifications = notifications.filter((n) => !n.isSeen)
|
||||
return await Promise.all(
|
||||
unseenNotifications.map((n) => {
|
||||
const notificationDoc = doc(db, `users/${n.userId}/notifications/`, n.id)
|
||||
return updateDoc(notificationDoc, { isSeen: true, viewTime: new Date() })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionOrGroupLink(props: {
|
||||
|
@ -957,7 +953,7 @@ function getReasonForShowingNotification(
|
|||
reasonText = 'followed you'
|
||||
break
|
||||
case 'liquidity':
|
||||
reasonText = 'added liquidity to your question'
|
||||
reasonText = 'added a subsidy to your question'
|
||||
break
|
||||
case 'group':
|
||||
reasonText = 'added you to the group'
|
||||
|
|
|
@ -53,7 +53,7 @@ export default function ReferralsPage() {
|
|||
<InfoBox
|
||||
title="FYI"
|
||||
className="mt-4 max-w-md"
|
||||
text="You can also earn the referral bonus from sharing the link to any market or group you've created!"
|
||||
text="You can also earn the referral bonus using the share link to any market or group!"
|
||||
/>
|
||||
</Col>
|
||||
</Col>
|
||||
|
|
|
@ -59,6 +59,9 @@ function putIntoMapAndFetch(data) {
|
|||
document.getElementById('guess-type').innerText = 'Counterspell Guesser'
|
||||
} else if (whichGuesser === 'burn') {
|
||||
document.getElementById('guess-type').innerText = 'Match With Hot Singles'
|
||||
} else if (whichGuesser === 'beast') {
|
||||
document.getElementById('guess-type').innerText =
|
||||
'Finding Fantastic Beasts'
|
||||
}
|
||||
setUpNewGame()
|
||||
}
|
||||
|
|
|
@ -149,6 +149,16 @@
|
|||
<h3>Match With Hot Singles</h3></label
|
||||
><br />
|
||||
|
||||
<input type="radio" id="beast" name="whichguesser" value="beast" />
|
||||
<label class="radio-label" for="beast">
|
||||
<img
|
||||
class="thumbnail"
|
||||
src="https://c1.scryfall.com/file/scryfall-cards/art_crop/front/3/3/33f7e788-8fc7-49f3-804b-2d7f96852d4b.jpg?1562905469"
|
||||
/>
|
||||
<h3>Finding Fantastic Beasts</h3></label
|
||||
>
|
||||
<br />
|
||||
|
||||
<details id="addl-options">
|
||||
<summary>
|
||||
<img
|
||||
|
|
1
web/public/mtg/jsons/beast1.json
Normal file
1
web/public/mtg/jsons/beast1.json
Normal file
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/beast2.json
Normal file
1
web/public/mtg/jsons/beast2.json
Normal file
File diff suppressed because one or more lines are too long
1
web/public/mtg/jsons/beast3.json
Normal file
1
web/public/mtg/jsons/beast3.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
175
yarn.lock
175
yarn.lock
|
@ -1353,6 +1353,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@corex/deepmerge/-/deepmerge-2.6.148.tgz#8fa825d53ffd1cbcafce1b6a830eefd3dcc09dd5"
|
||||
integrity sha512-6QMz0/2h5C3ua51iAnXMPWFbb1QOU1UvSM4bKBw5mzdT+WtLgjbETBBIQZ+Sh9WvEcGwlAt/DEdRpIC3XlDBMA==
|
||||
|
||||
"@cspotcode/source-map-support@^0.8.0":
|
||||
version "0.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
|
||||
integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "0.3.9"
|
||||
|
||||
"@docsearch/css@3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.1.0.tgz#6781cad43fc2e034d012ee44beddf8f93ba21f19"
|
||||
|
@ -2337,6 +2344,14 @@
|
|||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c"
|
||||
integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==
|
||||
|
||||
"@jridgewell/trace-mapping@0.3.9":
|
||||
version "0.3.9"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9"
|
||||
integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.0.3"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.9":
|
||||
version "0.3.13"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz#dcfe3e95f224c8fe97a87a5235defec999aa92ea"
|
||||
|
@ -3010,6 +3025,15 @@
|
|||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.0.0-beta.23.tgz#6d1ac7235462b0bcee196f42bb1871669480b843"
|
||||
integrity sha512-AkzvdELz3ZnrlZM0r9+ritBDOnAjXHR/8zCZhW0ZlWx4zyKPMsNG5ygivY+xr4QT65NEGRT8P8b2zOhXrMjjMQ==
|
||||
|
||||
"@tiptap/extension-mention@2.0.0-beta.102":
|
||||
version "2.0.0-beta.102"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.0.0-beta.102.tgz#a80036b0a4481efc4f69b788af3f5c76428624cc"
|
||||
integrity sha512-QTBBpWnRnoV7/ZW31HwhPvZL3HiwnlehlHSLeMioVxAQPF5WrRtlOpxK/SRu7+KuwdCb7ZA1eWW/yjbXI3oktg==
|
||||
dependencies:
|
||||
"@tiptap/suggestion" "^2.0.0-beta.97"
|
||||
prosemirror-model "1.18.1"
|
||||
prosemirror-state "1.4.1"
|
||||
|
||||
"@tiptap/extension-ordered-list@^2.0.0-beta.30":
|
||||
version "2.0.0-beta.30"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.0.0-beta.30.tgz#1f656b664302d90272c244b2e478d7056203f2a8"
|
||||
|
@ -3073,6 +3097,15 @@
|
|||
"@tiptap/extension-strike" "^2.0.0-beta.29"
|
||||
"@tiptap/extension-text" "^2.0.0-beta.17"
|
||||
|
||||
"@tiptap/suggestion@^2.0.0-beta.97":
|
||||
version "2.0.0-beta.97"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.0.0-beta.97.tgz#2e3dc20deebc2c37c5d39c848e61e9c837e7188a"
|
||||
integrity sha512-3NWG+HE7v2w97Ek6z1tUosoZKpCDH+oAtIG9XoNkK1PmlaVV/H4d6HT9uPX+Y6SeN7fSAqlcXFUGLXcDi9d+Zw==
|
||||
dependencies:
|
||||
prosemirror-model "1.18.1"
|
||||
prosemirror-state "1.4.1"
|
||||
prosemirror-view "1.26.2"
|
||||
|
||||
"@tootallnate/once@2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
||||
|
@ -3088,6 +3121,26 @@
|
|||
resolved "https://registry.yarnpkg.com/@tsconfig/docusaurus/-/docusaurus-1.0.5.tgz#5298c5b0333c6263f06c3149b38ebccc9f169a4e"
|
||||
integrity sha512-KM/TuJa9fugo67dTGx+ktIqf3fVc077J6jwHu845Hex4EQf7LABlNonP/mohDKT0cmncdtlYVHHF74xR/YpThg==
|
||||
|
||||
"@tsconfig/node10@^1.0.7":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2"
|
||||
integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==
|
||||
|
||||
"@tsconfig/node12@^1.0.7":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d"
|
||||
integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
|
||||
|
||||
"@tsconfig/node14@^1.0.0":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1"
|
||||
integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
|
||||
|
||||
"@tsconfig/node16@^1.0.2":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e"
|
||||
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
|
||||
|
||||
"@types/body-parser@*":
|
||||
version "1.19.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
|
||||
|
@ -3652,7 +3705,7 @@ acorn-walk@^7.0.0:
|
|||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
|
||||
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
|
||||
|
||||
acorn-walk@^8.0.0:
|
||||
acorn-walk@^8.0.0, acorn-walk@^8.1.1:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
|
||||
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
|
||||
|
@ -3836,6 +3889,11 @@ anymatch@~3.1.2:
|
|||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
arg@^4.1.0:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
|
||||
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
|
||||
|
||||
arg@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb"
|
||||
|
@ -4212,6 +4270,13 @@ broadcast-channel@^3.4.1:
|
|||
rimraf "3.0.2"
|
||||
unload "2.2.0"
|
||||
|
||||
browser-image-compression@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/browser-image-compression/-/browser-image-compression-2.0.0.tgz#f421381a76d474d4da7dcd82810daf595b09bef6"
|
||||
integrity sha512-kBlkZo13yOOfcmrPW0M0K/UdZPogIQj2gRvXIM3FktAnfW6VRq9aY2RI+F6O0x6DMj1Xm+WLGgWcFK8Fu/ddnw==
|
||||
dependencies:
|
||||
uzip "0.20201231.0"
|
||||
|
||||
browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.18.1, browserslist@^4.20.2, browserslist@^4.20.3:
|
||||
version "4.20.3"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.3.tgz#eb7572f49ec430e054f56d52ff0ebe9be915f8bf"
|
||||
|
@ -4392,7 +4457,7 @@ cheerio@^1.0.0-rc.10:
|
|||
parse5-htmlparser2-tree-adapter "^7.0.0"
|
||||
tslib "^2.4.0"
|
||||
|
||||
chokidar@^3.4.2, chokidar@^3.5.3:
|
||||
chokidar@^3.4.2, chokidar@^3.5.2, chokidar@^3.5.3:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
|
||||
|
@ -4754,6 +4819,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1:
|
|||
path-type "^4.0.0"
|
||||
yaml "^1.10.0"
|
||||
|
||||
create-require@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
critters@0.0.16:
|
||||
version "0.0.16"
|
||||
resolved "https://registry.yarnpkg.com/critters/-/critters-0.0.16.tgz#ffa2c5561a65b43c53b940036237ce72dcebfe93"
|
||||
|
@ -5256,6 +5326,11 @@ didyoumean@^1.2.2:
|
|||
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
|
||||
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
||||
|
||||
diff@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
|
||||
|
||||
dir-glob@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
|
||||
|
@ -5909,7 +5984,7 @@ execa@^5.0.0:
|
|||
signal-exit "^3.0.3"
|
||||
strip-final-newline "^2.0.0"
|
||||
|
||||
express@^4.16.4, express@^4.17.1, express@^4.17.3:
|
||||
express@4.18.1, express@^4.16.4, express@^4.17.1, express@^4.17.3:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf"
|
||||
integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==
|
||||
|
@ -7041,6 +7116,11 @@ idb@3.0.2:
|
|||
resolved "https://registry.yarnpkg.com/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384"
|
||||
integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw==
|
||||
|
||||
ignore-by-default@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09"
|
||||
integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==
|
||||
|
||||
ignore@^5.1.9, ignore@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
|
||||
|
@ -8065,6 +8145,11 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
|
|||
dependencies:
|
||||
semver "^6.0.0"
|
||||
|
||||
make-error@^1.1.1:
|
||||
version "1.3.6"
|
||||
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
|
||||
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
|
||||
|
||||
markdown-escapes@^1.0.0:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535"
|
||||
|
@ -8416,7 +8501,23 @@ node-releases@^2.0.3:
|
|||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.4.tgz#f38252370c43854dc48aa431c766c6c398f40476"
|
||||
integrity sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==
|
||||
|
||||
nopt@1.0.10:
|
||||
nodemon@2.0.19:
|
||||
version "2.0.19"
|
||||
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.19.tgz#cac175f74b9cb8b57e770d47841995eebe4488bd"
|
||||
integrity sha512-4pv1f2bMDj0Eeg/MhGqxrtveeQ5/G/UVe9iO6uTZzjnRluSA4PVWf8CW99LUPwGB3eNIA7zUFoP77YuI7hOc0A==
|
||||
dependencies:
|
||||
chokidar "^3.5.2"
|
||||
debug "^3.2.7"
|
||||
ignore-by-default "^1.0.1"
|
||||
minimatch "^3.0.4"
|
||||
pstree.remy "^1.1.8"
|
||||
semver "^5.7.1"
|
||||
simple-update-notifier "^1.0.7"
|
||||
supports-color "^5.5.0"
|
||||
touch "^3.1.0"
|
||||
undefsafe "^2.0.5"
|
||||
|
||||
nopt@1.0.10, nopt@~1.0.10:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
|
||||
integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=
|
||||
|
@ -9534,6 +9635,11 @@ pseudomap@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
|
||||
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
|
||||
|
||||
pstree.remy@^1.1.8:
|
||||
version "1.1.8"
|
||||
resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a"
|
||||
integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==
|
||||
|
||||
pump@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
|
||||
|
@ -10404,12 +10510,12 @@ semver-diff@^3.1.1:
|
|||
dependencies:
|
||||
semver "^6.3.0"
|
||||
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0:
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0, semver@^5.7.1:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
|
||||
|
||||
semver@7.0.0:
|
||||
semver@7.0.0, semver@~7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
|
||||
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
|
||||
|
@ -10556,6 +10662,13 @@ signal-exit@^3.0.2, signal-exit@^3.0.3:
|
|||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
simple-update-notifier@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz#7edf75c5bdd04f88828d632f762b2bc32996a9cc"
|
||||
integrity sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==
|
||||
dependencies:
|
||||
semver "~7.0.0"
|
||||
|
||||
sirv@^1.0.7:
|
||||
version "1.0.19"
|
||||
resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49"
|
||||
|
@ -10919,7 +11032,7 @@ stylehacks@^5.1.0:
|
|||
browserslist "^4.16.6"
|
||||
postcss-selector-parser "^6.0.4"
|
||||
|
||||
supports-color@^5.3.0:
|
||||
supports-color@^5.3.0, supports-color@^5.5.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
|
||||
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
|
||||
|
@ -11058,7 +11171,7 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
|
|||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
|
||||
|
||||
tippy.js@^6.3.7:
|
||||
tippy.js@6.3.7, tippy.js@^6.3.7:
|
||||
version "6.3.7"
|
||||
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
|
||||
integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
|
||||
|
@ -11099,6 +11212,13 @@ totalist@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
|
||||
integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==
|
||||
|
||||
touch@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
|
||||
integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==
|
||||
dependencies:
|
||||
nopt "~1.0.10"
|
||||
|
||||
tr46@~0.0.3:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
|
||||
|
@ -11124,6 +11244,25 @@ trough@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"
|
||||
integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==
|
||||
|
||||
ts-node@10.9.1:
|
||||
version "10.9.1"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b"
|
||||
integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==
|
||||
dependencies:
|
||||
"@cspotcode/source-map-support" "^0.8.0"
|
||||
"@tsconfig/node10" "^1.0.7"
|
||||
"@tsconfig/node12" "^1.0.7"
|
||||
"@tsconfig/node14" "^1.0.0"
|
||||
"@tsconfig/node16" "^1.0.2"
|
||||
acorn "^8.4.1"
|
||||
acorn-walk "^8.1.1"
|
||||
arg "^4.1.0"
|
||||
create-require "^1.1.0"
|
||||
diff "^4.0.1"
|
||||
make-error "^1.1.1"
|
||||
v8-compile-cache-lib "^3.0.1"
|
||||
yn "3.1.1"
|
||||
|
||||
tsc-files@1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/tsc-files/-/tsc-files-1.1.3.tgz#ef4cfcb7affc9b90577d707a879dc53bb105be83"
|
||||
|
@ -11235,6 +11374,11 @@ unbox-primitive@^1.0.2:
|
|||
has-symbols "^1.0.3"
|
||||
which-boxed-primitive "^1.0.2"
|
||||
|
||||
undefsafe@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c"
|
||||
integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==
|
||||
|
||||
unherit@^1.0.4:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22"
|
||||
|
@ -11493,6 +11637,16 @@ uuid@^8.0.0, uuid@^8.3.2:
|
|||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
uzip@0.20201231.0:
|
||||
version "0.20201231.0"
|
||||
resolved "https://registry.yarnpkg.com/uzip/-/uzip-0.20201231.0.tgz#9e64b065b9a8ebf26eb7583fe8e77e1d9a15ed14"
|
||||
integrity sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==
|
||||
|
||||
v8-compile-cache-lib@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
|
||||
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
|
||||
|
||||
v8-compile-cache@^2.0.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||
|
@ -11912,6 +12066,11 @@ yargs@^16.2.0:
|
|||
y18n "^5.0.5"
|
||||
yargs-parser "^20.2.2"
|
||||
|
||||
yn@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
|
||||
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
|
|
Loading…
Reference in New Issue
Block a user