Merge branch 'main' into search

This commit is contained in:
James Grugett 2022-07-29 17:01:18 -07:00
commit 85fa76200f
91 changed files with 1847 additions and 663 deletions

View File

@ -5,12 +5,14 @@ import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees' import { noFees } from './fees'
import { ENV_CONFIG } from './envs/constants' import { ENV_CONFIG } from './envs/constants'
import { Answer } from './answer'
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
@ -111,6 +113,50 @@ export function getFreeAnswerAnte(
return anteBet 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( export function getNumericAnte(
anteBettorId: string, anteBettorId: string,
contract: NumericContract, contract: NumericContract,

View File

@ -12,7 +12,9 @@ export class APIError extends Error {
} }
export function getFunctionUrl(name: string) { 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 const { projectId, region } = ENV_CONFIG.firebaseConfig
return `http://localhost:5001/${projectId}/${region}/${name}` return `http://localhost:5001/${projectId}/${region}/${name}`
} else { } else {

View File

@ -23,6 +23,7 @@ import {
BinaryContract, BinaryContract,
FreeResponseContract, FreeResponseContract,
PseudoNumericContract, PseudoNumericContract,
MultipleChoiceContract,
} from './contract' } from './contract'
import { floatingEqual } from './util/math' 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 { answers } = contract
const top = maxBy( const top = maxBy(
answers?.map((answer) => ({ answers?.map((answer) => ({

View File

@ -169,7 +169,7 @@ export const charities: Charity[] = [
{ {
name: "Founder's Pledge Climate Change Fund", name: "Founder's Pledge Climate Change Fund",
website: 'https://founderspledge.com/funds/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: preview:
'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.', '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. 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", name: "Founder's Pledge Patient Philanthropy Fund",
website: 'https://founderspledge.com/funds/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: preview:
'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity', '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. 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 dont 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.`, The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that dont 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 worlds richest often overlooks the worlds 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) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -4,13 +4,19 @@ import { JSONContent } from '@tiptap/core'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
export type AnyMechanism = DPM | CPMM export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyOutcomeType =
| Binary
| MultipleChoice
| PseudoNumeric
| FreeResponse
| Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)
| (DPM & Binary) | (DPM & Binary)
| (DPM & FreeResponse) | (DPM & FreeResponse)
| (DPM & Numeric) | (DPM & Numeric)
| (DPM & MultipleChoice)
export type Contract<T extends AnyContractType = AnyContractType> = { export type Contract<T extends AnyContractType = AnyContractType> = {
id: string id: string
@ -57,6 +63,7 @@ export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse export type FreeResponseContract = Contract & FreeResponse
export type MultipleChoiceContract = Contract & MultipleChoice
export type DPMContract = Contract & DPM export type DPMContract = Contract & DPM
export type CPMMContract = Contract & CPMM export type CPMMContract = Contract & CPMM
export type DPMBinaryContract = BinaryContract & DPM export type DPMBinaryContract = BinaryContract & DPM
@ -104,6 +111,13 @@ export type FreeResponse = {
resolutions?: { [outcome: string]: number } // Used for MKT resolution. 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 = { export type Numeric = {
outcomeType: 'NUMERIC' outcomeType: 'NUMERIC'
bucketCount: number bucketCount: number
@ -118,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = [ export const OUTCOME_TYPES = [
'BINARY', 'BINARY',
'MULTIPLE_CHOICE',
'FREE_RESPONSE', 'FREE_RESPONSE',
'PSEUDO_NUMERIC', 'PSEUDO_NUMERIC',
'NUMERIC', 'NUMERIC',

View File

@ -18,6 +18,7 @@ import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -322,7 +323,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
loanAmount: number loanAmount: number
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -5,6 +5,7 @@ import {
CPMM, CPMM,
DPM, DPM,
FreeResponse, FreeResponse,
MultipleChoice,
Numeric, Numeric,
outcomeType, outcomeType,
PseudoNumeric, PseudoNumeric,
@ -30,7 +31,10 @@ export function getNewContract(
bucketCount: number, bucketCount: number,
min: number, min: number,
max: number, max: number,
isLogScale: boolean isLogScale: boolean,
// for multiple choice
answers: string[]
) { ) {
const tags = parseTags( const tags = parseTags(
[ [
@ -48,6 +52,8 @@ export function getNewContract(
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale)
: outcomeType === 'NUMERIC' : outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max) ? getNumericProps(ante, bucketCount, min, max)
: outcomeType === 'MULTIPLE_CHOICE'
? getMultipleChoiceProps(ante, answers)
: getFreeAnswerProps(ante) : getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({ const contract: Contract = removeUndefinedProps({
@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => {
return system 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 = ( const getNumericProps = (
ante: number, ante: number,
bucketCount: number, bucketCount: number,

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.190",
"lodash": "4.17.21" "lodash": "4.17.21"
}, },

View File

@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet' import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm' 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 { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees'
import { addObjects } from './util/object' import { addObjects } from './util/object'
@ -180,7 +184,7 @@ export const getDpmMktPayouts = (
export const getPayoutsMultiOutcome = ( export const getPayoutsMultiOutcome = (
resolutions: { [outcome: string]: number }, resolutions: { [outcome: string]: number },
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const poolTotal = sum(Object.values(contract.pool)) const poolTotal = sum(Object.values(contract.pool))

View File

@ -117,6 +117,7 @@ export const getDpmPayouts = (
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
const { outcomeType } = contract
switch (outcome) { switch (outcome) {
case 'YES': case 'YES':
@ -124,7 +125,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets) return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT': 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) ? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability) : getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL': case 'CANCEL':
@ -132,7 +134,7 @@ export const getDpmPayouts = (
return getDpmCancelPayouts(contract, openBets) return getDpmCancelPayouts(contract, openBets)
default: default:
if (contract.outcomeType === 'NUMERIC') if (outcomeType === 'NUMERIC')
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[]) return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
// Outcome is a free response answer id. // Outcome is a free response answer id.

View File

@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text'
// other tiptap extensions // other tiptap extensions
import { Image } from '@tiptap/extension-image' import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link' import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
export function parseTags(text: string) { export function parseTags(text: string) {
@ -81,9 +82,9 @@ export const exhibitExts = [
Image, Image,
Link, Link,
Mention,
Iframe, Iframe,
] ]
// export const exhibitExts = [StarterKit as unknown as Extension, Image]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {
return !text ? '' : generateText(text, exhibitExts) return !text ? '' : generateText(text, exhibitExts)

43
dev.sh Executable file
View 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

View File

@ -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` ### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending. Gets a list of bets, ordered by creation date descending.

View File

@ -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 - [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) - [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 - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
## Bots ## Bots

View File

@ -12,6 +12,8 @@
"start": "yarn shell", "start": "yarn shell",
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "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", "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: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", "db:backup-local": "firebase emulators:export --force ./firestore_export",
@ -27,7 +29,10 @@
"@tiptap/core": "2.0.0-beta.181", "@tiptap/core": "2.0.0-beta.181",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.190",
"cors": "2.8.5",
"express": "4.18.1",
"firebase-admin": "10.0.0", "firebase-admin": "10.0.0",
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "lodash": "4.17.21",

View File

@ -1,6 +1,7 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { logger } from 'firebase-functions/v2' import { Request, RequestHandler, Response } from 'express'
import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https' import { error } from 'firebase-functions/logger'
import { HttpsOptions } from 'firebase-functions/v2/https'
import { log } from './utils' import { log } from './utils'
import { z } from 'zod' import { z } from 'zod'
import { APIError } from '../../common/api' 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) } return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
} catch (err) { } catch (err) {
// This is somewhat suspicious, so get it into the firebase console // 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.') throw new APIError(403, 'Error validating token.')
} }
case 'Key': case 'Key':
@ -83,6 +84,11 @@ export const zTimestamp = () => {
}, z.date()) }, z.date())
} }
export type EndpointDefinition = {
opts: EndpointOptions & { method: string }
handler: RequestHandler
}
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => { export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val) const result = schema.safeParse(val)
if (!result.success) { if (!result.success) {
@ -99,12 +105,12 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
} }
} }
interface EndpointOptions extends HttpsOptions { export interface EndpointOptions extends HttpsOptions {
methods?: string[] method?: string
} }
const DEFAULT_OPTS = { const DEFAULT_OPTS = {
methods: ['POST'], method: 'POST',
minInstances: 1, minInstances: 1,
concurrency: 100, concurrency: 100,
memory: '2GiB', memory: '2GiB',
@ -113,28 +119,29 @@ const DEFAULT_OPTS = {
} }
export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
const opts = Object.assign(endpointOpts, DEFAULT_OPTS) const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return onRequest(opts, async (req, res) => { return {
log('Request processing started.') opts,
try { handler: async (req: Request, res: Response) => {
if (!opts.methods.includes(req.method)) { log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
const allowed = opts.methods.join(', ') try {
throw new APIError(405, `This endpoint supports only ${allowed}.`) if (opts.method !== req.method) {
} throw new APIError(405, `This endpoint supports only ${opts.method}.`)
const authedUser = await lookupUser(await parseCredentials(req)) }
log('User credentials processed.') const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser)) res.status(200).json(await fn(req, authedUser))
} catch (e) { } catch (e) {
if (e instanceof APIError) { if (e instanceof APIError) {
const output: { [k: string]: unknown } = { message: e.message } const output: { [k: string]: unknown } = { message: e.message }
if (e.details != null) { if (e.details != null) {
output.details = e.details 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
} }

View File

@ -10,7 +10,7 @@ const bodySchema = z.object({
export const cancelbet = newEndpoint({}, async (req, auth) => { export const cancelbet = newEndpoint({}, async (req, auth) => {
const { betId } = validate(bodySchema, req.body) const { betId } = validate(bodySchema, req.body)
const result = await firestore.runTransaction(async (trans) => { return await firestore.runTransaction(async (trans) => {
const snap = await trans.get( const snap = await trans.get(
firestore.collectionGroup('bets').where('id', '==', betId) firestore.collectionGroup('bets').where('id', '==', betId)
) )
@ -28,8 +28,6 @@ export const cancelbet = newEndpoint({}, async (req, auth) => {
return { ...bet, isCancelled: true } return { ...bet, isCancelled: true }
}) })
return result
}) })
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -2,11 +2,12 @@ import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { import {
CPMMBinaryContract,
Contract, Contract,
CPMMBinaryContract,
FreeResponseContract, FreeResponseContract,
MAX_QUESTION_LENGTH, MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH, MAX_TAG_LENGTH,
MultipleChoiceContract,
NumericContract, NumericContract,
OUTCOME_TYPES, OUTCOME_TYPES,
} from '../../common/contract' } from '../../common/contract'
@ -20,15 +21,18 @@ import {
FIXED_ANTE, FIXED_ANTE,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getMultipleChoiceAntes,
getNumericAnte, getNumericAnte,
} from '../../common/antes' } from '../../common/antes'
import { getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group' import { Group, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric' import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { zip } from 'lodash'
import { Bet } from 'common/bet'
const descScehma: z.ZodType<JSONContent> = z.lazy(() => const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -79,11 +83,15 @@ const numericSchema = z.object({
isLogScale: z.boolean().optional(), isLogScale: z.boolean().optional(),
}) })
const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2),
})
export const createmarket = newEndpoint({}, async (req, auth) => { export const createmarket = newEndpoint({}, async (req, auth) => {
const { question, description, tags, closeTime, outcomeType, groupId } = const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body) validate(bodySchema, req.body)
let min, max, initialProb, isLogScale let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
@ -97,12 +105,22 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100
if (initialProb < 1 || initialProb > 99) 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') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body)) ;({ initialProb } = validate(binarySchema, req.body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, req.body))
}
const userDoc = await firestore.collection('users').doc(auth.uid).get() const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) { if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.') 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 let group = null
if (groupId) { if (groupId) {
const groupDocRef = await firestore.collection('groups').doc(groupId) const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get() const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) { if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.') 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, NUMERIC_BUCKET_COUNT,
min ?? 0, min ?? 0,
max ?? 0, max ?? 0,
isLogScale ?? false isLogScale ?? false,
answers ?? []
) )
if (ante) await chargeUser(user.id, ante, true) if (ante) await chargeUser(user.id, ante, true)
@ -184,6 +203,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
) )
await liquidityDoc.set(lp) 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') { } else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`) .collection(`contracts/${contract.id}/answers`)

View File

@ -63,10 +63,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
const deviceUsedBefore = const deviceUsedBefore =
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0 const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE
const balance =
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
const user: User = { const user: User = {
id: auth.uid, id: auth.uid,
@ -113,7 +110,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
return !snap.empty return !snap.empty
} }
const numberUsersWithIp = async (ipAddress: string) => { export const numberUsersWithIp = async (ipAddress: string) => {
const snap = await firestore const snap = await firestore
.collection('private-users') .collection('private-users')
.where('initialIpAddress', '==', ipAddress) .where('initialIpAddress', '==', ipAddress)

View File

@ -1,6 +1,6 @@
import { newEndpoint } from './api' import { newEndpoint } from './api'
export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => { export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => {
return { return {
message: 'Server is working.', message: 'Server is working.',
uid: auth.uid, uid: auth.uid,

View File

@ -1,4 +1,6 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onRequest } from 'firebase-functions/v2/https'
import { EndpointDefinition } from './api'
admin.initializeApp() admin.initializeApp()
@ -25,20 +27,63 @@ export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
// v2 // v2
export * from './health' import { health } from './health'
export * from './transact' import { transact } from './transact'
export * from './change-user-info' import { changeuserinfo } from './change-user-info'
export * from './create-user' import { createuser } from './create-user'
export * from './create-answer' import { createanswer } from './create-answer'
export * from './place-bet' import { placebet } from './place-bet'
export * from './cancel-bet' import { cancelbet } from './cancel-bet'
export * from './sell-bet' import { sellbet } from './sell-bet'
export * from './sell-shares' import { sellshares } from './sell-shares'
export * from './claim-manalink' import { claimmanalink } from './claim-manalink'
export * from './create-contract' import { createmarket } from './create-contract'
export * from './add-liquidity' import { addliquidity } from './add-liquidity'
export * from './withdraw-liquidity' import { withdrawliquidity } from './withdraw-liquidity'
export * from './create-group' import { creategroup } from './create-group'
export * from './resolve-market' import { resolvemarket } from './resolve-market'
export * from './unsubscribe' import { unsubscribe } from './unsubscribe'
export * from './stripe' 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,
}

View File

@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore
contractId: string contractId: string
} }
const { eventId } = context 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 const answer = change.data() as Answer
// Ignore ante answer. // Ignore ante answer.
if (answer.number === 0) return 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) const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator') if (!answerCreator) throw new Error('Could not find answer creator')

View File

@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore
.onCreate(async (change, context) => { .onCreate(async (change, context) => {
const liquidity = change.data() as LiquidityProvision const liquidity = change.data() as LiquidityProvision
const { eventId } = context 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 // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision
if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return 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) const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider') if (!liquidityProvider) throw new Error('Could not find liquidity provider')

View File

@ -103,8 +103,8 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`, description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`,
} }
const txnDoc = await firestore.collection(`txns/`).doc(txn.id) const txnDoc = firestore.collection(`txns/`).doc(txn.id)
await transaction.set(txnDoc, txn) transaction.set(txnDoc, txn)
console.log('created referral with txn id:', txn.id) 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. // We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes.
transaction.update(referredByUserDoc, { transaction.update(referredByUserDoc, {

View File

@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb, limitProb,
unfilledBets 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 { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome) const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc) const answerSnap = await trans.get(answerDoc)

View File

@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { import {
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
@ -245,7 +246,10 @@ function getResolutionParams(contract: Contract, body: string) {
...validate(pseudoNumericSchema, body), ...validate(pseudoNumericSchema, body),
resolutions: undefined, resolutions: undefined,
} }
} else if (outcomeType === 'FREE_RESPONSE') { } else if (
outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE'
) {
const freeResponseParams = validate(freeResponseSchema, body) const freeResponseParams = validate(freeResponseSchema, body)
const { outcome } = freeResponseParams const { outcome } = freeResponseParams
switch (outcome) { switch (outcome) {
@ -292,7 +296,10 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`) 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) const validIds = contract.answers.map((a) => a.id)
if (!validIds.includes(answer.toString())) { if (!validIds.includes(answer.toString())) {
throw new APIError(400, `${answer} is not a valid answer ID`) throw new APIError(400, `${answer} is not a valid answer ID`)

View 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.`)
})
}

View File

@ -66,10 +66,18 @@ export const getServiceAccountCredentials = (env?: string) => {
} }
export const initAdmin = (env?: string) => { export const initAdmin = (env?: string) => {
const serviceAccount = getServiceAccountCredentials(env) try {
console.log(`Initializing connection to ${serviceAccount.project_id}...`) const serviceAccount = getServiceAccountCredentials(env)
return admin.initializeApp({ console.log(
projectId: serviceAccount.project_id, `Initializing connection to ${serviceAccount.project_id} Firebase...`
credential: admin.credential.cert(serviceAccount), )
}) 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()
}
} }

View File

@ -16,7 +16,7 @@ import { redeemShares } from './redeem-shares'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
shares: z.number(), shares: z.number().optional(), // leave it out to sell all shares
outcome: z.enum(['YES', 'NO']), 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 outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares) 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.`) 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( const unfilledBetsSnap = await transaction.get(
getUnfilledBetsQuery(contractDoc) getUnfilledBetsQuery(contractDoc)

68
functions/src/serve.ts Normal file
View 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}.`)

View File

@ -1,7 +1,7 @@
import { onRequest } from 'firebase-functions/v2/https'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import Stripe from 'stripe' import Stripe from 'stripe'
import { EndpointDefinition } from './api'
import { getPrivateUser, getUser, isProd, payUser } from './utils' import { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails' import { sendThankYouEmail } from './emails'
import { track } from './analytics' import { track } from './analytics'
@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd()
10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE', 10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE',
} }
export const createcheckoutsession = onRequest( export const createcheckoutsession: EndpointDefinition = {
{ minInstances: 1, secrets: ['STRIPE_APIKEY'] }, opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] },
async (req, res) => { handler: async (req, res) => {
const userId = req.query.userId?.toString() const userId = req.query.userId?.toString()
const manticDollarQuantity = req.query.manticDollarQuantity?.toString() const manticDollarQuantity = req.query.manticDollarQuantity?.toString()
@ -86,21 +86,24 @@ export const createcheckoutsession = onRequest(
}) })
res.redirect(303, session.url || '') res.redirect(303, session.url || '')
} },
) }
export const stripewebhook = onRequest( export const stripewebhook: EndpointDefinition = {
{ opts: {
method: 'POST',
minInstances: 1, minInstances: 1,
secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'],
}, },
async (req, res) => { handler: async (req, res) => {
const stripe = initStripe() const stripe = initStripe()
let event let event
try { try {
// Cloud Functions jam the raw body into a special `rawBody` property
const rawBody = (req as any).rawBody ?? req.body
event = stripe.webhooks.constructEvent( event = stripe.webhooks.constructEvent(
req.rawBody, rawBody,
req.headers['stripe-signature'] as string, req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOKSECRET as string process.env.STRIPE_WEBHOOKSECRET as string
) )
@ -116,8 +119,8 @@ export const stripewebhook = onRequest(
} }
res.status(200).send('success') res.status(200).send('success')
} },
) }
const issueMoneys = async (session: StripeSession) => { const issueMoneys = async (session: StripeSession) => {
const { id: sessionId } = session const { id: sessionId } = session

View File

@ -1,66 +1,72 @@
import { onRequest } from 'firebase-functions/v2/https'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { EndpointDefinition } from './api'
import { getUser } from './utils' import { getUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => { export const unsubscribe: EndpointDefinition = {
const id = req.query.id as string opts: { method: 'GET', minInstances: 1 },
let type = req.query.type as string handler: async (req, res) => {
if (!id || !type) { const id = req.query.id as string
res.status(400).send('Empty id or type parameter.') let type = req.query.type as string
return 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 ( if (
!['market-resolve', 'market-comment', 'market-answer', 'generic'].includes( ![
type 'market-resolve',
) 'market-comment',
) { 'market-answer',
res.status(400).send('Invalid type parameter.') 'generic',
return ].includes(type)
} ) {
res.status(400).send('Invalid type parameter.')
return
}
const user = await getUser(id) const user = await getUser(id)
if (!user) { if (!user) {
res.send('This user is not currently subscribed or does not exist.') res.send('This user is not currently subscribed or does not exist.')
return return
} }
const { name } = user const { name } = user
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
...(type === 'market-resolve' && { ...(type === 'market-resolve' && {
unsubscribedFromResolutionEmails: true, unsubscribedFromResolutionEmails: true,
}), }),
...(type === 'market-comment' && { ...(type === 'market-comment' && {
unsubscribedFromCommentEmails: true, unsubscribedFromCommentEmails: true,
}), }),
...(type === 'market-answer' && { ...(type === 'market-answer' && {
unsubscribedFromAnswerEmails: true, unsubscribedFromAnswerEmails: true,
}), }),
...(type === 'generic' && { ...(type === 'generic' && {
unsubscribedFromGenericEmails: true, unsubscribedFromGenericEmails: true,
}), }),
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
if (type === 'market-resolve') if (type === 'market-resolve')
res.send( res.send(
`${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.`
) )
else if (type === 'market-comment') else if (type === 'market-comment')
res.send( res.send(
`${name}, you have been unsubscribed from market comment emails on Manifold Markets.` `${name}, you have been unsubscribed from market comment emails on Manifold Markets.`
) )
else if (type === 'market-answer') else if (type === 'market-answer')
res.send( res.send(
`${name}, you have been unsubscribed from market answer emails on Manifold Markets.` `${name}, you have been unsubscribed from market answer emails on Manifold Markets.`
) )
else res.send(`${name}, you have been unsubscribed.`) else res.send(`${name}, you have been unsubscribed.`)
}) },
}
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -11,8 +11,6 @@ import { last } from 'lodash'
const firestore = admin.firestore() const firestore = admin.firestore()
const oneDay = 1000 * 60 * 60 * 24
const computeInvestmentValue = ( const computeInvestmentValue = (
bets: Bet[], bets: Bet[],
contractsDict: { [k: string]: Contract } contractsDict: { [k: string]: Contract }
@ -59,8 +57,8 @@ export const updateMetricsCore = async () => {
return { return {
doc: firestore.collection('contracts').doc(contract.id), doc: firestore.collection('contracts').doc(contract.id),
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - oneDay), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - oneDay * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
}, },
} }
}) })

View File

@ -14,10 +14,13 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0", "@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0", "@typescript-eslint/parser": "5.25.0",
"concurrently": "6.5.1",
"eslint": "8.15.0", "eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"prettier": "2.5.0", "prettier": "2.5.0",
"typescript": "4.6.4" "typescript": "4.6.4",
"ts-node": "10.9.1",
"nodemon": "2.0.19"
}, },
"resolutions": { "resolutions": {
"@types/react": "17.0.43" "@types/react": "17.0.43"

View File

@ -1,24 +1,26 @@
import { ExclamationIcon } from '@heroicons/react/solid' import { ExclamationIcon } from '@heroicons/react/solid'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { Linkify } from './linkify' import { Linkify } from './linkify'
export function AlertBox(props: { title: string; text: string }) { export function AlertBox(props: { title: string; text: string }) {
const { title, text } = props const { title, text } = props
return ( return (
<div className="rounded-md bg-yellow-50 p-4"> <Col className="rounded-md bg-yellow-50 p-4">
<div className="flex"> <Row className="mb-2 flex-shrink-0">
<div className="flex-shrink-0"> <ExclamationIcon
<ExclamationIcon className="h-5 w-5 text-yellow-400"
className="h-5 w-5 text-yellow-400" aria-hidden="true"
aria-hidden="true" />
/>
</div>
<div className="ml-3"> <div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">{title}</h3> <h3 className="text-sm font-medium text-yellow-800">{title}</h3>
<div className="mt-2 text-sm text-yellow-700">
<Linkify text={text} />
</div>
</div> </div>
</Row>
<div className="mt-2 whitespace-pre-line text-sm text-yellow-700">
<Linkify text={text} />
</div> </div>
</div> </Col>
) )
} }

View File

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input' import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, placeBet } from 'web/lib/firebase/api' import { APIError, placeBet } from 'web/lib/firebase/api'
@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device'
export function AnswerBetPanel(props: { export function AnswerBetPanel(props: {
answer: Answer answer: Answer
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
closePanel: () => void closePanel: () => void
className?: string className?: string
isModal?: boolean isModal?: boolean

View File

@ -1,7 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
showChoice: 'radio' | 'checkbox' | undefined showChoice: 'radio' | 'checkbox' | undefined
chosenProb: number | undefined chosenProb: number | undefined
totalChosenProb?: number totalChosenProb?: number

View File

@ -2,7 +2,7 @@ import clsx from 'clsx'
import { sum } from 'lodash' import { sum } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { Contract, FreeResponse } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, resolveMarket } from 'web/lib/firebase/api' import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
contract: Contract & FreeResponse contract: FreeResponseContract | MultipleChoiceContract
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: ( setResolveOption: (
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined

View File

@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react' import { memo } from 'react'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
const NUM_LINES = 6 const NUM_LINES = 6
export const AnswersGraph = memo(function AnswersGraph(props: { export const AnswersGraph = memo(function AnswersGraph(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[] bets: Bet[]
height?: number height?: number
}) { }) {
@ -178,15 +178,22 @@ function formatTime(
return d.format(format) return d.format(format)
} }
const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => { const computeProbsByOutcome = (
const { totalBets } = contract bets: Bet[],
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { totalBets, outcomeType } = contract
const betsByOutcome = groupBy(bets, (bet) => bet.outcome) const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max( const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter) ...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( const trackedOutcomes = sortBy(

View File

@ -1,7 +1,7 @@
import { sortBy, partition, sum, uniq } from 'lodash' import { sortBy, partition, sum, uniq } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FreeResponseContract } from 'common/contract' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector' 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 { contract } = props
const { creatorId, resolution, resolutions, totalBets } = contract const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract
const answers = useAnswers(contract.id) ?? contract.answers const answers = useAnswers(contract.id) ?? contract.answers
const [winningAnswers, losingAnswers] = partition( const [winningAnswers, losingAnswers] = partition(
answers.filter( 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) =>
answer.id === resolution || (resolutions && resolutions[answer.id]) 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> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{tradingAllowed(contract) && {outcomeType === 'FREE_RESPONSE' &&
tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && ( (!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
)} )}
@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
} }
function getAnswerItems( function getAnswerItems(
contract: FreeResponseContract, contract: FreeResponseContract | MultipleChoiceContract,
answers: Answer[], answers: Answer[],
user: User | undefined | null user: User | undefined | null
) { ) {
@ -178,7 +184,7 @@ function getAnswerItems(
} }
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
answer: Answer answer: Answer
items: ActivityItem[] items: ActivityItem[]
type: string type: string

View 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
}

View File

@ -42,6 +42,8 @@ import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets' import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
import { AlertBox } from './alert-box'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -72,6 +74,7 @@ export function BetPanel(props: {
<QuickOrLimitBet <QuickOrLimitBet
isLimitOrder={isLimitOrder} isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder} setIsLimitOrder={setIsLimitOrder}
hideToggle={!user}
/> />
<BuyPanel <BuyPanel
hidden={isLimitOrder} hidden={isLimitOrder}
@ -85,9 +88,13 @@ export function BetPanel(props: {
user={user} user={user}
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
/> />
<SignUpPrompt /> <SignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col> </Col>
{unfilledBets.length > 0 && (
{user && unfilledBets.length > 0 && (
<LimitBets className="mt-4" contract={contract} bets={unfilledBets} /> <LimitBets className="mt-4" contract={contract} bets={unfilledBets} />
)} )}
</Col> </Col>
@ -124,6 +131,7 @@ export function SimpleBetPanel(props: {
<QuickOrLimitBet <QuickOrLimitBet
isLimitOrder={isLimitOrder} isLimitOrder={isLimitOrder}
setIsLimitOrder={setIsLimitOrder} setIsLimitOrder={setIsLimitOrder}
hideToggle={!user}
/> />
<BuyPanel <BuyPanel
hidden={isLimitOrder} hidden={isLimitOrder}
@ -140,7 +148,10 @@ export function SimpleBetPanel(props: {
unfilledBets={unfilledBets} unfilledBets={unfilledBets}
onBuySuccess={onBetSuccess} onBuySuccess={onBetSuccess}
/> />
<SignUpPrompt /> <SignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col> </Col>
{unfilledBets.length > 0 && ( {unfilledBets.length > 0 && (
@ -254,6 +265,8 @@ function BuyPanel(props: {
const format = getFormattedMappedValue(contract) const format = getFormattedMappedValue(contract)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500"> <div className="my-3 text-left text-sm text-gray-500">
@ -277,6 +290,22 @@ function BuyPanel(props: {
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} 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"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
<div className="text-gray-500"> <div className="text-gray-500">
@ -688,32 +717,35 @@ function LimitOrderPanel(props: {
function QuickOrLimitBet(props: { function QuickOrLimitBet(props: {
isLimitOrder: boolean isLimitOrder: boolean
setIsLimitOrder: (isLimitOrder: boolean) => void setIsLimitOrder: (isLimitOrder: boolean) => void
hideToggle?: boolean
}) { }) {
const { isLimitOrder, setIsLimitOrder } = props const { isLimitOrder, setIsLimitOrder, hideToggle } = props
return ( return (
<Row className="align-center mb-4 justify-between"> <Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div> <div className="text-4xl">Bet</div>
<Row className="mt-1 items-center gap-2"> {!hideToggle && (
<PillButton <Row className="mt-1 items-center gap-2">
selected={!isLimitOrder} <PillButton
onSelect={() => { selected={!isLimitOrder}
setIsLimitOrder(false) onSelect={() => {
track('select quick order') setIsLimitOrder(false)
}} track('select quick order')
> }}
Quick >
</PillButton> Quick
<PillButton </PillButton>
selected={isLimitOrder} <PillButton
onSelect={() => { selected={isLimitOrder}
setIsLimitOrder(true) onSelect={() => {
track('select limit order') setIsLimitOrder(true)
}} track('select limit order')
> }}
Limit >
</PillButton> Limit
</Row> </PillButton>
</Row>
)}
</Row> </Row>
) )
} }
@ -739,7 +771,9 @@ export function SellPanel(props: {
const betDisabled = isSubmitting || !amount || error const betDisabled = isSubmitting || !amount || error
// Sell all shares if remaining shares would be < 1 // 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() { async function submitSell() {
if (!user || !amount) return if (!user || !amount) return
@ -748,7 +782,7 @@ export function SellPanel(props: {
setIsSubmitting(true) setIsSubmitting(true)
await sellShares({ await sellShares({
shares: sellQuantity, shares: isSellingAllShares ? undefined : amount,
outcome: sharesOutcome, outcome: sharesOutcome,
contractId: contract.id, contractId: contract.id,
}) })

View File

@ -3,6 +3,7 @@ import { groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets' import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
@ -277,13 +278,7 @@ function ContractBets(props: {
bets bets
) )
return ( return (
<div <div tabIndex={0} className="relative bg-white p-4 pr-6">
tabIndex={0}
className={clsx(
'collapse collapse-arrow relative bg-white p-4 pr-6',
collapsed ? 'collapse-close' : 'collapse-open pb-2'
)}
>
<Row <Row
className="cursor-pointer flex-wrap gap-2" className="cursor-pointer flex-wrap gap-2"
onClick={() => setCollapsed((collapsed) => !collapsed)} onClick={() => setCollapsed((collapsed) => !collapsed)}
@ -300,10 +295,11 @@ function ContractBets(props: {
</Link> </Link>
{/* Show carrot for collapsing. Hack the positioning. */} {/* Show carrot for collapsing. Hack the positioning. */}
<div {collapsed ? (
className="collapse-title absolute h-0 min-h-0 w-0 p-0" <ChevronDownIcon className="absolute top-5 right-4 h-6 w-6" />
style={{ top: -10, right: 0 }} ) : (
/> <ChevronUpIcon className="absolute top-5 right-4 h-6 w-6" />
)}
</Row> </Row>
<Row className="flex-1 items-center gap-2 text-sm text-gray-500"> <Row className="flex-1 items-center gap-2 text-sm text-gray-500">
@ -335,55 +331,42 @@ function ContractBets(props: {
</Row> </Row>
</Col> </Col>
<Row className="mr-5 justify-end sm:mr-8"> <Col className="mr-5 sm:mr-8">
<Col> <div className="whitespace-nowrap text-right text-lg">
<div className="whitespace-nowrap text-right text-lg"> {formatMoney(metric === 'profit' ? profit : payout)}
{formatMoney(metric === 'profit' ? profit : payout)} </div>
</div> <ProfitBadge className="text-right" profitPercent={profitPercent} />
<div className="text-right"> </Col>
<ProfitBadge profitPercent={profitPercent} />
</div>
</Col>
</Row>
</Row> </Row>
<div {!collapsed && (
className="collapse-content !px-0" <div className="bg-white">
style={{ backgroundColor: 'white' }} <BetsSummary
> className="mt-8 mr-5 flex-1 sm:mr-8"
<Spacer h={8} /> contract={contract}
bets={bets}
isYourBets={isYourBets}
/>
<BetsSummary {contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
className="mr-5 flex-1 sm:mr-8"
contract={contract}
bets={bets}
isYourBets={isYourBets}
/>
<Spacer h={4} />
{contract.mechanism === 'cpmm-1' && limitBets.length > 0 && (
<>
<div className="max-w-md"> <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 <LimitOrderTable
contract={contract} contract={contract}
limitBets={limitBets} limitBets={limitBets}
isYou={true} isYou={true}
/> />
</div> </div>
</> )}
)}
<Spacer h={4} /> <div className="mt-4 bg-gray-50 px-4 py-2">Bets</div>
<ContractBetsTable
<div className="bg-gray-50 px-4 py-2">Bets</div> contract={contract}
<ContractBetsTable bets={bets}
contract={contract} isYourBets={isYourBets}
bets={bets} />
isYourBets={isYourBets} </div>
/> )}
</div>
</div> </div>
) )
} }
@ -427,107 +410,92 @@ export function BetsSummary(props: {
return ( return (
<Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}> <Row className={clsx('flex-wrap gap-4 sm:flex-nowrap sm:gap-6', className)}>
<Row className="flex-wrap gap-4 sm:gap-6"> {!isCpmm && (
{!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>
)}
</>
)}
<Col> <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"> <div className="whitespace-nowrap">
{formatMoney(profit)} <ProfitBadge profitPercent={profitPercent} /> {formatMoney(payout)} <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> </div>
</Col> </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> </Row>
) )
} }
@ -689,7 +657,13 @@ function BetRow(props: {
!isClosed && !isClosed &&
!isSold && !isSold &&
!isAnte && !isAnte &&
!isNumeric && <SellButton contract={contract} bet={bet} />} !isNumeric && (
<SellButton
contract={contract}
bet={bet}
unfilledBets={unfilledBets}
/>
)}
</td> </td>
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>} {isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
<td> <td>
@ -729,8 +703,12 @@ function BetRow(props: {
) )
} }
function SellButton(props: { contract: Contract; bet: Bet }) { function SellButton(props: {
const { contract, bet } = props contract: Contract
bet: Bet
unfilledBets: LimitBet[]
}) {
const { contract, bet, unfilledBets } = props
const { outcome, shares, loanAmount } = bet const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -740,8 +718,6 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
outcome === 'NO' ? 'YES' : outcome outcome === 'NO' ? 'YES' : outcome
) )
const unfilledBets = useUnfilledBets(contract.id) ?? []
const outcomeProb = getProbabilityAfterSale( const outcomeProb = getProbabilityAfterSale(
contract, contract,
outcome, outcome,
@ -787,8 +763,8 @@ function SellButton(props: { contract: Contract; bet: Bet }) {
) )
} }
function ProfitBadge(props: { profitPercent: number }) { function ProfitBadge(props: { profitPercent: number; className?: string }) {
const { profitPercent } = props const { profitPercent, className } = props
if (!profitPercent) return null if (!profitPercent) return null
const colors = const colors =
profitPercent > 0 profitPercent > 0
@ -799,7 +775,8 @@ function ProfitBadge(props: { profitPercent: number }) {
<span <span
className={clsx( className={clsx(
'ml-1 inline-flex items-center rounded-full px-3 py-0.5 text-sm font-medium', '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) + '%'} {(profitPercent > 0 ? '+' : '') + profitPercent.toFixed(1) + '%'}

View File

@ -17,58 +17,55 @@ export function UserCommentsList(props: {
contractsById: { [id: string]: Contract } contractsById: { [id: string]: Contract }
}) { }) {
const { comments, contractsById } = props const { comments, contractsById } = props
const commentsByContract = groupBy(comments, 'contractId')
const contractCommentPairs = Object.entries(commentsByContract) // we don't show comments in groups here atm, just comments on contracts
.map( const contractComments = comments.filter((c) => c.contractId)
([contractId, comments]) => [contractsById[contractId], comments] as const const commentsByContract = groupBy(contractComments, 'contractId')
)
.filter(([contract]) => contract)
return ( return (
<Col className={'bg-white'}> <Col className={'bg-white'}>
{contractCommentPairs.map(([contract, comments]) => ( {Object.entries(commentsByContract).map(([contractId, comments]) => {
<div key={contract.id} className={'border-width-1 border-b p-5'}> const contract = contractsById[contractId]
<div className={'mb-2 text-sm text-indigo-700'}> return (
<SiteLink href={contractPath(contract)}> <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} {contract.question}
</SiteLink> </SiteLink>
{comments.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3 pb-6"
/>
))}
</div> </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> </Col>
) )
} }
function ProfileComment(props: { comment: Comment }) { function ProfileComment(props: { comment: Comment; className?: string }) {
const { comment } = props const { comment, className } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
// TODO: find and attach relevant bets by comment betId at some point // TODO: find and attach relevant bets by comment betId at some point
return ( return (
<div> <Row className={className}>
<Row className={'gap-4'}> <Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<Avatar username={userUsername} avatarUrl={userAvatarUrl} /> <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <p className="mt-0.5 text-sm text-gray-500">
<div> <UserLink
<p className="mt-0.5 text-sm text-gray-500"> className="text-gray-500"
<UserLink username={userUsername}
className="text-gray-500" name={userName}
username={userUsername} />{' '}
name={userName} <RelativeTimestamp time={createdTime} />
/>{' '} </p>
<RelativeTimestamp time={createdTime} /> <Linkify text={text} />
</p> </div>
</div> </Row>
<Linkify text={text} />
</div>
</Row>
</div>
) )
} }

View File

@ -3,7 +3,10 @@ import algoliasearch from 'algoliasearch/lite'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' 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 { Row } from './layout/row'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
@ -52,11 +55,15 @@ export function ContractSearch(props: {
excludeContractIds?: string[] excludeContractIds?: string[]
groupSlug?: string groupSlug?: string
} }
highlightOptions?: ContractHighlightOptions
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
showPlaceHolder?: boolean showPlaceHolder?: boolean
hideOrderSelector?: boolean hideOrderSelector?: boolean
overrideGridClassName?: string overrideGridClassName?: string
hideQuickBet?: boolean cardHideOptions?: {
hideGroupLink?: boolean
hideQuickBet?: boolean
}
}) { }) {
const { const {
querySortOptions, querySortOptions,
@ -65,7 +72,8 @@ export function ContractSearch(props: {
overrideGridClassName, overrideGridClassName,
hideOrderSelector, hideOrderSelector,
showPlaceHolder, showPlaceHolder,
hideQuickBet, cardHideOptions,
highlightOptions,
} = props } = props
const user = useUser() const user = useUser()
@ -327,7 +335,8 @@ export function ContractSearch(props: {
showTime={showTime} showTime={showTime}
onContractClick={onContractClick} onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName} overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet} highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/> />
)} )}
</Col> </Col>

View File

@ -5,9 +5,10 @@ import { formatLargeNumber, formatPercent } from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import {
Contract,
BinaryContract, BinaryContract,
Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from 'common/contract' } from 'common/contract'
@ -24,7 +25,7 @@ import {
} from 'common/calculate' } from 'common/calculate'
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' 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 { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
@ -38,8 +39,16 @@ export function ContractCard(props: {
className?: string className?: string
onClick?: () => void onClick?: () => void
hideQuickBet?: boolean 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 contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
const { resolution } = contract const { resolution } = contract
@ -106,7 +115,8 @@ export function ContractCard(props: {
{question} {question}
</p> </p>
{outcomeType === 'FREE_RESPONSE' && {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
(resolution ? ( (resolution ? (
<FreeResponseOutcomeLabel <FreeResponseOutcomeLabel
contract={contract} contract={contract}
@ -121,6 +131,7 @@ export function ContractCard(props: {
contract={contract} contract={contract}
showHotVolume={showHotVolume} showHotVolume={showHotVolume}
showTime={showTime} showTime={showTime}
hideGroupLink={hideGroupLink}
/> />
</Col> </Col>
{showQuickBet ? ( {showQuickBet ? (
@ -148,7 +159,8 @@ export function ContractCard(props: {
/> />
)} )}
{outcomeType === 'FREE_RESPONSE' && ( {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance <FreeResponseResolutionOrChance
className="self-end text-gray-600" className="self-end text-gray-600"
contract={contract} contract={contract}
@ -200,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
} }
function FreeResponseTopAnswer(props: { function FreeResponseTopAnswer(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
className?: string className?: string
}) { }) {
@ -218,7 +230,7 @@ function FreeResponseTopAnswer(props: {
} }
export function FreeResponseResolutionOrChance(props: { export function FreeResponseResolutionOrChance(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
className?: string className?: string
}) { }) {

View File

@ -42,8 +42,9 @@ export function MiscDetails(props: {
contract: Contract contract: Contract
showHotVolume?: boolean showHotVolume?: boolean
showTime?: ShowTime showTime?: ShowTime
hideGroupLink?: boolean
}) { }) {
const { contract, showHotVolume, showTime } = props const { contract, showHotVolume, showTime, hideGroupLink } = props
const { const {
volume, volume,
volume24Hours, volume24Hours,
@ -80,7 +81,7 @@ export function MiscDetails(props: {
<NewContractBadge /> <NewContractBadge />
)} )}
{groupLinks && groupLinks.length > 0 && ( {!hideGroupLink && groupLinks && groupLinks.length > 0 && (
<SiteLink <SiteLink
href={groupPath(groupLinks[0].slug)} href={groupPath(groupLinks[0].slug)}
className="text-sm text-gray-400" className="text-sm text-gray-400"

View File

@ -41,6 +41,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'YES / NO' ? 'YES / NO'
: outcomeType === 'FREE_RESPONSE' : outcomeType === 'FREE_RESPONSE'
? 'Free response' ? 'Free response'
: outcomeType === 'MULTIPLE_CHOICE'
? 'Multiple choice'
: 'Numeric' : 'Numeric'
return ( return (

View File

@ -85,7 +85,8 @@ export const ContractOverview = (props: {
{tradingAllowed(contract) && <BetRow contract={contract} />} {tradingAllowed(contract) && <BetRow contract={contract} />}
</Row> </Row>
) : ( ) : (
outcomeType === 'FREE_RESPONSE' && (outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
resolution && ( resolution && (
<FreeResponseResolutionOrChance <FreeResponseResolutionOrChance
contract={contract} contract={contract}
@ -110,7 +111,8 @@ export const ContractOverview = (props: {
{(isBinary || isPseudoNumeric) && ( {(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={bets} /> <ContractProbGraph contract={contract} bets={bets} />
)}{' '} )}{' '}
{outcomeType === 'FREE_RESPONSE' && ( {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<AnswersGraph contract={contract} bets={bets} /> <AnswersGraph contract={contract} bets={bets} />
)} )}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />} {outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}

View File

@ -9,6 +9,7 @@ import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from 'common/liquidity-provision'
import { useComments } from 'web/hooks/use-comments'
export function ContractTabs(props: { export function ContractTabs(props: {
contract: Contract contract: Contract
@ -18,11 +19,15 @@ export function ContractTabs(props: {
comments: Comment[] comments: Comment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, user, bets, comments, tips, liquidityProvisions } = props const { contract, user, bets, tips, liquidityProvisions } = props
const { outcomeType } = contract const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id) 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 = ( const betActivity = (
<ContractActivity <ContractActivity
contract={contract} contract={contract}
@ -89,8 +94,12 @@ export function ContractTabs(props: {
<Tabs <Tabs
currentPageForAnalytics={'contract'} currentPageForAnalytics={'contract'}
tabs={[ 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 ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your bets', content: yourTrades }]), : [{ title: 'Your bets', content: yourTrades }]),

View File

@ -1,5 +1,5 @@
import { Contract } from '../../lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import { User } from '../../lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card' import { ContractCard } from './contract-card'
@ -9,6 +9,11 @@ import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
export type ContractHighlightOptions = {
contractIds?: string[]
highlightClassName?: string
}
export function ContractsGrid(props: { export function ContractsGrid(props: {
contracts: Contract[] contracts: Contract[]
loadMore: () => void loadMore: () => void
@ -16,7 +21,11 @@ export function ContractsGrid(props: {
showTime?: ShowTime showTime?: ShowTime
onContractClick?: (contract: Contract) => void onContractClick?: (contract: Contract) => void
overrideGridClassName?: string overrideGridClassName?: string
hideQuickBet?: boolean cardHideOptions?: {
hideQuickBet?: boolean
hideGroupLink?: boolean
}
highlightOptions?: ContractHighlightOptions
}) { }) {
const { const {
contracts, contracts,
@ -25,9 +34,12 @@ export function ContractsGrid(props: {
loadMore, loadMore,
onContractClick, onContractClick,
overrideGridClassName, overrideGridClassName,
hideQuickBet, cardHideOptions,
highlightOptions,
} = props } = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
const [elem, setElem] = useState<HTMLElement | null>(null) const [elem, setElem] = useState<HTMLElement | null>(null)
const isBottomVisible = useIsVisible(elem) const isBottomVisible = useIsVisible(elem)
@ -66,6 +78,12 @@ export function ContractsGrid(props: {
onContractClick ? () => onContractClick(contract) : undefined onContractClick ? () => onContractClick(contract) : undefined
} }
hideQuickBet={hideQuickBet} hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
className={
contractIds?.includes(contract.id)
? highlightClassName
: undefined
}
/> />
))} ))}
</ul> </ul>

View File

@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
params.initValue = getMappedValue(contract)(contract.initialProbability) params.initValue = getMappedValue(contract)(contract.initialProbability)
} }
if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId
}
return ( return (
`/create?` + `/create?` +
Object.entries(params) Object.entries(params)

View File

@ -11,6 +11,7 @@ import {
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image' import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link' import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Linkify } from './linkify' import { Linkify } from './linkify'
@ -19,6 +20,9 @@ import { useMutation } from 'react-query'
import { exhibitExts } from 'common/util/parse' import { exhibitExts } from 'common/util/parse'
import { FileUploadButton } from './file-upload-button' import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' 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 Iframe from 'common/util/tiptap-iframe'
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid' import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
@ -40,33 +44,41 @@ export function useTextEditor(props: {
}) { }) {
const { placeholder, max, defaultValue = '', disabled } = props const { placeholder, max, defaultValue = '', disabled } = props
const users = useUsers()
const editorClass = clsx( const editorClass = clsx(
proseClass, proseClass,
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0' 'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
) )
const editor = useEditor({ const editor = useEditor(
editorProps: { attributes: { class: editorClass } }, {
extensions: [ editorProps: { attributes: { class: editorClass } },
StarterKit.configure({ extensions: [
heading: { levels: [1, 2, 3] }, StarterKit.configure({
}), heading: { levels: [1, 2, 3] },
Placeholder.configure({ }),
placeholder, Placeholder.configure({
emptyEditorClass: placeholder,
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0', emptyEditorClass:
}), 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0',
CharacterCount.configure({ limit: max }), }),
Image, CharacterCount.configure({ limit: max }),
Link.configure({ Image,
HTMLAttributes: { Link.configure({
class: clsx('no-underline !text-indigo-700', linkClass), HTMLAttributes: {
}, class: clsx('no-underline !text-indigo-700', linkClass),
}), },
Iframe, }),
], DisplayMention.configure({
content: defaultValue, 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) const upload = useUploadMutation(editor)
@ -261,7 +273,11 @@ function RichContent(props: { content: JSONContent | string }) {
const { content } = props const { content } = props
const editor = useEditor({ const editor = useEditor({
editorProps: { attributes: { class: proseClass } }, 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, content,
editable: false, editable: false,
}) })

View 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>
)
})

View 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()
},
}
},
})

View 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),
})

View File

@ -2,7 +2,6 @@ import { Contract } from 'web/lib/firebase/contracts'
import { Comment } from 'web/lib/firebase/comments' import { Comment } from 'web/lib/firebase/comments'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { useBets } from 'web/hooks/use-bets' import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments'
import { getSpecificContractActivityItems } from './activity-items' import { getSpecificContractActivityItems } from './activity-items'
import { FeedItems } from './feed-items' import { FeedItems } from './feed-items'
import { User } from 'common/user' import { User } from 'common/user'
@ -26,10 +25,7 @@ export function ContractActivity(props: {
props props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const comments = props.comments
const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments
const updatedBets = useBets(contract.id) const updatedBets = useBets(contract.id)
const bets = (updatedBets ?? props.bets).filter( const bets = (updatedBets ?? props.bets).filter(
(bet) => !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isRedemption && bet.amount !== 0
@ -50,6 +46,7 @@ export function ContractActivity(props: {
items={items} items={items}
className={className} className={className}
betRowClassName={betRowClassName} betRowClassName={betRowClassName}
user={user}
/> />
) )
} }

View File

@ -36,14 +36,18 @@ import {
import { FeedBet } from 'web/components/feed/feed-bets' import { FeedBet } from 'web/components/feed/feed-bets'
import { CPMMBinaryContract, NumericContract } from 'common/contract' import { CPMMBinaryContract, NumericContract } from 'common/contract'
import { FeedLiquidity } from './feed-liquidity' 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: { export function FeedItems(props: {
contract: Contract contract: Contract
items: ActivityItem[] items: ActivityItem[]
className?: string className?: string
betRowClassName?: string betRowClassName?: string
user: User | null | undefined
}) { }) {
const { contract, items, className, betRowClassName } = props const { contract, items, className, betRowClassName, user } = props
const { outcomeType } = contract const { outcomeType } = contract
const [elem, setElem] = useState<HTMLElement | null>(null) const [elem, setElem] = useState<HTMLElement | null>(null)
@ -67,11 +71,20 @@ export function FeedItems(props: {
</div> </div>
))} ))}
</div> </div>
{outcomeType === 'BINARY' && tradingAllowed(contract) && (
<BetRow {!user ? (
contract={contract as CPMMBinaryContract} <Col className="mt-4 max-w-sm items-center xl:hidden">
className={clsx('mb-2', betRowClassName)} <SignUpPrompt />
/> <PlayMoneyDisclaimer />
</Col>
) : (
outcomeType === 'BINARY' &&
tradingAllowed(contract) && (
<BetRow
contract={contract as CPMMBinaryContract}
className={clsx('mb-2', betRowClassName)}
/>
)
)} )}
</div> </div>
) )

View File

@ -77,8 +77,7 @@ export function LiquidityStatusText(props: {
) : ( ) : (
<span>{isSelf ? 'You' : 'A trader'}</span> <span>{isSelf ? 'You' : 'A trader'}</span>
)}{' '} )}{' '}
{bought} {money} {bought} a subsidy of {money}
{' of liquidity'}
<RelativeTimestamp time={createdTime} /> <RelativeTimestamp time={createdTime} />
</div> </div>
) )

View File

@ -46,7 +46,7 @@ export function CreateGroupButton(props: {
const newGroup = { const newGroup = {
name: groupName, name: groupName,
memberIds: memberUsers.map((user) => user.id), memberIds: memberUsers.map((user) => user.id),
anyoneCanJoin: false, anyoneCanJoin: true,
} }
const result = await createGroup(newGroup).catch((e) => { const result = await createGroup(newGroup).catch((e) => {
const errorDetails = e.details[0] const errorDetails = e.details[0]

View File

@ -20,7 +20,7 @@ export function InfoBox(props: {
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h3 className="text-sm font-medium text-black">{title}</h3> <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} /> <Linkify text={text} />
</div> </div>
</div> </div>

View File

@ -26,7 +26,7 @@ export function Modal(props: {
className="fixed inset-0 z-50 overflow-y-auto" className="fixed inset-0 z-50 overflow-y-auto"
onClose={setOpen} 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 <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -57,7 +57,7 @@ export function Modal(props: {
> >
<div <div
className={clsx( 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, sizeClass,
className className
)} )}

View File

@ -1,77 +1,121 @@
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import { useRouter, NextRouter } from 'next/router'
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { Row } from './row'
import { track } from '@amplitude/analytics-browser' import { track } from '@amplitude/analytics-browser'
type Tab = { type Tab = {
title: string title: string
tabIcon?: ReactNode tabIcon?: ReactNode
content: ReactNode content: ReactNode
// If set, change the url to this href when the tab is selected // If set, show a badge with this content
href?: string badge?: string
} }
export function Tabs(props: { type TabProps = {
tabs: Tab[] tabs: Tab[]
defaultIndex?: number
labelClassName?: string labelClassName?: string
onClick?: (tabTitle: string, index: number) => void onClick?: (tabTitle: string, index: number) => void
className?: string className?: string
currentPageForAnalytics?: string currentPageForAnalytics?: string
}) { }
export function ControlledTabs(props: TabProps & { activeIndex: number }) {
const { const {
tabs, tabs,
defaultIndex, activeIndex,
labelClassName, labelClassName,
onClick, onClick,
className, className,
currentPageForAnalytics, currentPageForAnalytics,
} = props } = props
const [activeIndex, setActiveIndex] = useState(defaultIndex ?? 0)
const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case const activeTab = tabs[activeIndex] as Tab | undefined // can be undefined in weird case
return ( return (
<> <>
<div className={clsx('mb-4 border-b border-gray-200', className)}> <nav
<nav className="-mb-px flex space-x-8" aria-label="Tabs"> className={clsx('mb-4 space-x-8 border-b border-gray-200', className)}
{tabs.map((tab, i) => ( aria-label="Tabs"
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}> >
<a {tabs.map((tab, i) => (
id={`tab-${i}`} <a
key={tab.title} href="#"
onClick={(e) => { key={tab.title}
track('Clicked Tab', { onClick={(e) => {
title: tab.title, e.preventDefault()
href: tab.href, track('Clicked Tab', {
currentPage: currentPageForAnalytics, title: tab.title,
}) currentPage: currentPageForAnalytics,
if (!tab.href) { })
e.preventDefault() onClick?.(tab.title, i)
} }}
setActiveIndex(i) className={clsx(
onClick?.(tab.title, i) activeIndex === i
}} ? 'border-indigo-500 text-indigo-600'
className={clsx( : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
activeIndex === i 'inline-flex cursor-pointer flex-row gap-1 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
? 'border-indigo-500 text-indigo-600' labelClassName
: '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', aria-current={activeIndex === i ? 'page' : undefined}
labelClassName >
)} {tab.tabIcon && <span>{tab.tabIcon}</span>}
aria-current={activeIndex === i ? 'page' : undefined} {tab.badge ? (
> <span className="px-0.5 font-bold">{tab.badge}</span>
<Row className={'items-center justify-center gap-1'}> ) : null}
{tab.tabIcon && <span> {tab.tabIcon}</span>} {tab.title}
{tab.title} </a>
</Row> ))}
</a> </nav>
</Link>
))}
</nav>
</div>
{activeTab?.content} {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

View File

@ -10,6 +10,7 @@ import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog' import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user' import { useUserById } from 'web/hooks/use-user'
import getManalinkUrl from 'web/get-manalink-url' import getManalinkUrl from 'web/get-manalink-url'
import { QrcodeIcon } from '@heroicons/react/outline'
export type ManalinkInfo = { export type ManalinkInfo = {
expiresTime: number | null expiresTime: number | null
maxUses: number | null maxUses: number | null
@ -78,7 +79,9 @@ export function ManalinkCardFromView(props: {
const { className, link, highlightedSlug } = props const { className, link, highlightedSlug } = props
const { message, amount, expiresTime, maxUses, claims } = link const { message, amount, expiresTime, maxUses, claims } = link
const [showDetails, setShowDetails] = useState(false) const [showDetails, setShowDetails] = useState(false)
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl(
link.slug
)}`
return ( return (
<Col> <Col>
<Col <Col
@ -127,6 +130,19 @@ export function ManalinkCardFromView(props: {
> >
{formatMoney(amount)} {formatMoney(amount)}
</div> </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 <ShareIconButton
toastClassName={'-left-48 min-w-[250%]'} toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'} buttonClassName={'transition-colors'}

View File

@ -12,6 +12,7 @@ import dayjs from 'dayjs'
import { Button } from '../button' import { Button } from '../button'
import { getManalinkUrl } from 'web/pages/links' import { getManalinkUrl } from 'web/pages/links'
import { DuplicateIcon } from '@heroicons/react/outline' import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code'
export function CreateLinksButton(props: { export function CreateLinksButton(props: {
user: User user: User
@ -98,6 +99,8 @@ function CreateManalinkForm(props: {
}) })
} }
const url = getManalinkUrl(highlightedSlug)
return ( return (
<> <>
{!finishedCreating && ( {!finishedCreating && (
@ -199,17 +202,17 @@ function CreateManalinkForm(props: {
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : '' copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
)} )}
> >
<div className="w-full select-text truncate"> <div className="w-full select-text truncate">{url}</div>
{getManalinkUrl(highlightedSlug)}
</div>
<DuplicateIcon <DuplicateIcon
onClick={() => { onClick={() => {
navigator.clipboard.writeText(getManalinkUrl(highlightedSlug)) navigator.clipboard.writeText(url)
setCopyPressed(true) setCopyPressed(true)
}} }}
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50" className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
/> />
</Row> </Row>
<QRCode url={url} className="self-center" />
</> </>
)} )}
</> </>

View File

@ -7,6 +7,7 @@ import {
BinaryContract, BinaryContract,
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract,
resolution, resolution,
} from 'common/contract' } from 'common/contract'
import { formatLargeNumber, formatPercent } from 'common/util/format' import { formatLargeNumber, formatPercent } from 'common/util/format'
@ -77,7 +78,7 @@ export function BinaryContractOutcomeLabel(props: {
} }
export function FreeResponseOutcomeLabel(props: { export function FreeResponseOutcomeLabel(props: {
contract: FreeResponseContract contract: FreeResponseContract | MultipleChoiceContract
resolution: string | 'CANCEL' | 'MKT' resolution: string | 'CANCEL' | 'MKT'
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
answerClassName?: string answerClassName?: string

View 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!"
/>
)

View 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} />
}

View File

@ -5,13 +5,13 @@ import { prefetchUsers, useUserById } from 'web/hooks/use-user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs' import { Tabs } from './layout/tabs'
import { TextButton } from './text-button'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { useReferrals } from 'web/hooks/use-referrals' import { useReferrals } from 'web/hooks/use-referrals'
import { FilterSelectUsers } from 'web/components/filter-select-users' import { FilterSelectUsers } from 'web/components/filter-select-users'
import { getUser, updateUser } from 'web/lib/firebase/users' import { getUser, updateUser } from 'web/lib/firebase/users'
import { TextButton } from 'web/components/text-button'
export function ReferralsButton(props: { user: User; currentUser?: User }) { export function ReferralsButton(props: { user: User; currentUser?: User }) {
const { user, currentUser } = props const { user, currentUser } = props
@ -24,7 +24,6 @@ export function ReferralsButton(props: { user: User; currentUser?: User }) {
<span className="font-semibold">{referralIds?.length ?? ''}</span>{' '} <span className="font-semibold">{referralIds?.length ?? ''}</span>{' '}
Referrals Referrals
</TextButton> </TextButton>
<ReferralsDialog <ReferralsDialog
user={user} user={user}
referralIds={referralIds ?? []} referralIds={referralIds ?? []}

View File

@ -8,7 +8,7 @@ export function TextButton(props: {
const { onClick, children, className } = props const { onClick, children, className } = props
return ( return (
<div <span
className={clsx( className={clsx(
className, className,
'cursor-pointer gap-2 hover:underline hover:decoration-indigo-400 hover:decoration-2 focus:underline focus:decoration-indigo-400 focus:decoration-2' '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} onClick={onClick}
> >
{children} {children}
</div> </span>
) )
} }

View File

@ -8,9 +8,9 @@ import Confetti from 'react-confetti'
import { import {
follow, follow,
getPortfolioHistory,
unfollow, unfollow,
User, User,
getPortfolioHistory,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-list' import { CreatorContractsList } from './contract/contracts-list'
import { SEO } from './SEO' import { SEO } from './SEO'
@ -22,7 +22,7 @@ import { Linkify } from './linkify'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { Row } from './layout/row' import { Row } from './layout/row'
import { genHash } from 'common/util/random' import { genHash } from 'common/util/random'
import { Tabs } from './layout/tabs' import { QueryUncontrolledTabs } from './layout/tabs'
import { UserCommentsList } from './comments-list' import { UserCommentsList } from './comments-list'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { Comment, getUsersComments } from 'web/lib/firebase/comments' 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 { useUserBets } from 'web/hooks/use-user-bets'
import { ReferralsButton } from 'web/components/referrals-button' import { ReferralsButton } from 'web/components/referrals-button'
import { formatMoney } from 'common/util/format' 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: { export function UserLink(props: {
name: string name: string
@ -64,12 +66,8 @@ export function UserLink(props: {
export const TAB_IDS = ['markets', 'comments', 'bets', 'groups'] export const TAB_IDS = ['markets', 'comments', 'bets', 'groups']
const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf() const JUNE_1_2022 = new Date('2022-06-01T00:00:00.000Z').valueOf()
export function UserPage(props: { export function UserPage(props: { user: User; currentUser?: User }) {
user: User const { user, currentUser } = props
currentUser?: User
defaultTabTitle?: string | undefined
}) {
const { user, currentUser, defaultTabTitle } = props
const router = useRouter() const router = useRouter()
const isCurrentUser = user.id === currentUser?.id const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id) const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
@ -216,9 +214,10 @@ export function UserPage(props: {
<Row className="gap-4"> <Row className="gap-4">
<FollowingButton user={user} /> <FollowingButton user={user} />
<FollowersButton user={user} /> <FollowersButton user={user} />
{currentUser?.username === 'ian' && ( {currentUser &&
<ReferralsButton user={user} currentUser={currentUser} /> ['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
)} currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} /> <GroupsButton user={user} />
</Row> </Row>
@ -273,32 +272,39 @@ export function UserPage(props: {
)} )}
</Col> </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 ? ( {usersContracts !== 'loading' && contractsById && usersComments ? (
<Tabs <QueryUncontrolledTabs
currentPageForAnalytics={'profile'} currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '} 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={[ tabs={[
{ {
title: 'Markets', title: 'Markets',
content: <CreatorContractsList creator={user} />, content: <CreatorContractsList creator={user} />,
tabIcon: ( tabIcon: (
<div className="px-0.5 font-bold"> <span className="px-0.5 font-bold">
{usersContracts.length} {usersContracts.length}
</div> </span>
), ),
}, },
{ {
@ -311,7 +317,9 @@ export function UserPage(props: {
/> />
), ),
tabIcon: ( 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> </div>
), ),
tabIcon: <div className="px-0.5 font-bold">{betCount}</div>, tabIcon: <span className="px-0.5 font-bold">{betCount}</span>,
}, },
]} ]}
/> />

View File

@ -129,7 +129,6 @@ export async function listContractsByGroupSlug(
): Promise<Contract[]> { ): Promise<Contract[]> {
const q = query(contracts, where('groupSlugs', 'array-contains', slug)) const q = query(contracts, where('groupSlugs', 'array-contains', slug))
const snapshot = await getDocs(q) const snapshot = await getDocs(q)
console.log(snapshot.docs.map((doc) => doc.data()))
return snapshot.docs.map((doc) => doc.data()) return snapshot.docs.map((doc) => doc.data())
} }

View File

@ -129,56 +129,61 @@ export async function addContractToGroup(
contract: Contract, contract: Contract,
userId: string 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 = [ await updateContract(contract.id, {
...(contract.groupLinks ?? []), groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
{ groupLinks: newGroupLinks,
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
}) })
}
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( export async function removeContractFromGroup(
group: Group, group: Group,
contract: Contract contract: Contract
) { ) {
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) return // not in that group if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
const newGroupLinks = contract.groupLinks?.filter(
const newGroupLinks = contract.groupLinks?.filter( (link) => link.slug !== group.slug
(link) => link.slug !== group.slug )
) await updateContract(contract.id, {
await updateContract(contract.id, { groupSlugs:
groupSlugs: contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [],
contract.groupSlugs?.filter((slug) => slug !== group.slug) ?? [], groupLinks: newGroupLinks ?? [],
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 (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( export async function setContractGroupLinks(
@ -187,6 +192,7 @@ export async function setContractGroupLinks(
userId: string userId: string
) { ) {
await updateContract(contractId, { await updateContract(contractId, {
groupSlugs: [group.slug],
groupLinks: [ groupLinks: [
{ {
groupId: group.id, groupId: group.id,

View File

@ -1,8 +1,8 @@
import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage' import { ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage'
import imageCompression from 'browser-image-compression'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { storage } from './init' import { storage } from './init'
// TODO: compress large images
export const uploadImage = async ( export const uploadImage = async (
username: string, username: string,
file: File, file: File,
@ -12,6 +12,18 @@ export const uploadImage = async (
const [, ext] = file.name.split('.') const [, ext] = file.name.split('.')
const filename = `${nanoid(10)}.${ext}` const filename = `${nanoid(10)}.${ext}`
const storageRef = ref(storage, `user-images/${username}/${filename}`) 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) const uploadTask = uploadBytesResumable(storageRef, file)
let resolvePromise: (url: string) => void let resolvePromise: (url: string) => void

View File

@ -3,12 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently -n NEXT,TS -c magenta,cyan \"next dev -p 3000\" \"yarn ts --watch\"", "serve": "next dev -p 3000",
"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\"", "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: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", "dev:emulate": "cross-env NEXT_PUBLIC_FIREBASE_EMULATE=TRUE yarn devdev",
"ts": "tsc --noEmit --incremental --preserveWatchOutput --pretty",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -27,10 +29,12 @@
"@tiptap/extension-character-count": "2.0.0-beta.31", "@tiptap/extension-character-count": "2.0.0-beta.31",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@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/extension-placeholder": "2.0.0-beta.53",
"@tiptap/react": "2.0.0-beta.114", "@tiptap/react": "2.0.0-beta.114",
"@tiptap/starter-kit": "2.0.0-beta.190", "@tiptap/starter-kit": "2.0.0-beta.190",
"algoliasearch": "4.13.0", "algoliasearch": "4.13.0",
"browser-image-compression": "2.0.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"cors": "2.8.5", "cors": "2.8.5",
"daisyui": "1.16.4", "daisyui": "1.16.4",
@ -49,7 +53,8 @@
"react-hot-toast": "2.2.0", "react-hot-toast": "2.2.0",
"react-instantsearch-hooks-web": "6.24.1", "react-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0", "react-query": "3.39.0",
"string-similarity": "^4.0.4" "string-similarity": "^4.0.4",
"tippy.js": "6.3.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "0.4.0", "@tailwindcss/forms": "0.4.0",
@ -60,7 +65,6 @@
"@types/react": "17.0.43", "@types/react": "17.0.43",
"@types/string-similarity": "^4.0.0", "@types/string-similarity": "^4.0.0",
"autoprefixer": "10.2.6", "autoprefixer": "10.2.6",
"concurrently": "6.5.1",
"critters": "0.0.16", "critters": "0.0.16",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint-config-next": "12.1.6", "eslint-config-next": "12.1.6",

View File

@ -217,7 +217,8 @@ export function ContractPageContent(
/> />
)} )}
{outcomeType === 'FREE_RESPONSE' && ( {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<> <>
<Spacer h={4} /> <Spacer h={4} />
<AnswersPanel contract={contract} /> <AnswersPanel contract={contract} />

View File

@ -31,9 +31,8 @@ export default function UserProfile(props: { user: User | null }) {
const { user } = props const { user } = props
const router = useRouter() const router = useRouter()
const { username, tab } = router.query as { const { username } = router.query as {
username: string username: string
tab?: string | undefined
} }
const currentUser = useUser() const currentUser = useUser()
@ -42,11 +41,7 @@ export default function UserProfile(props: { user: User | null }) {
if (user === undefined) return <div /> if (user === undefined) return <div />
return user ? ( return user ? (
<UserPage <UserPage user={user} currentUser={currentUser || undefined} />
user={user}
currentUser={currentUser || undefined}
defaultTabTitle={tab}
/>
) : ( ) : (
<Custom404 /> <Custom404 />
) )

View 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.' })
}
}

View 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.' })
}
}

View File

@ -31,6 +31,7 @@ import { Checkbox } from 'web/components/checkbox'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
export const getServerSideProps = redirectIfLoggedOut('/') export const getServerSideProps = redirectIfLoggedOut('/')
@ -116,6 +117,8 @@ export function NewContract(props: {
const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale) const [isLogScale, setIsLogScale] = useState<boolean>(!!params?.isLogScale)
const [initialValueString, setInitialValueString] = useState(initValue) const [initialValueString, setInitialValueString] = useState(initValue)
const [answers, setAnswers] = useState<string[]>([]) // for multiple choice
useEffect(() => { useEffect(() => {
if (groupId && creator) if (groupId && creator)
getGroup(groupId).then((group) => { getGroup(groupId).then((group) => {
@ -160,6 +163,10 @@ export function NewContract(props: {
// get days from today until the end of this year: // get days from today until the end of this year:
const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day')
const isValidMultipleChoice = answers.every(
(answer) => answer.trim().length > 0
)
const isValid = const isValid =
(outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) && (outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) &&
question.length > 0 && question.length > 0 &&
@ -178,7 +185,13 @@ export function NewContract(props: {
min < max && min < max &&
max - min > 0.01 && max - min > 0.01 &&
min < initialValue && min < initialValue &&
initialValue < max)) initialValue < max)) &&
(outcomeType !== 'MULTIPLE_CHOICE' || isValidMultipleChoice)
const [errorText, setErrorText] = useState<string>('')
useEffect(() => {
setErrorText('')
}, [isValid])
const descriptionPlaceholder = const descriptionPlaceholder =
outcomeType === 'BINARY' outcomeType === 'BINARY'
@ -216,6 +229,7 @@ export function NewContract(props: {
max, max,
initialValue, initialValue,
isLogScale, isLogScale,
answers,
groupId: selectedGroup?.id, groupId: selectedGroup?.id,
}) })
) )
@ -232,6 +246,9 @@ export function NewContract(props: {
await router.push(contractPath(result as Contract)) await router.push(contractPath(result as Contract))
} catch (e) { } catch (e) {
console.error('error creating contract', e, (e as any).details) console.error('error creating contract', e, (e as any).details)
setErrorText(
(e as any).details || (e as any).message || 'Error creating contract'
)
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
@ -251,10 +268,11 @@ export function NewContract(props: {
'Users can submit their own answers to this market.' 'Users can submit their own answers to this market.'
) )
else setMarketInfoText('') else setMarketInfoText('')
setOutcomeType(choice as 'BINARY' | 'FREE_RESPONSE') setOutcomeType(choice as outcomeType)
}} }}
choicesMap={{ choicesMap={{
'Yes / No': 'BINARY', 'Yes / No': 'BINARY',
'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE', 'Free response': 'FREE_RESPONSE',
Numeric: 'PSEUDO_NUMERIC', Numeric: 'PSEUDO_NUMERIC',
}} }}
@ -269,6 +287,10 @@ export function NewContract(props: {
<Spacer h={6} /> <Spacer h={6} />
{outcomeType === 'MULTIPLE_CHOICE' && (
<MultipleChoiceAnswers setAnswers={setAnswers} />
)}
{outcomeType === 'PSEUDO_NUMERIC' && ( {outcomeType === 'PSEUDO_NUMERIC' && (
<> <>
<div className="form-control mb-2 items-start"> <div className="form-control mb-2 items-start">
@ -413,7 +435,7 @@ export function NewContract(props: {
</div> </div>
<Spacer h={6} /> <Spacer h={6} />
<span className={'text-error'}>{errorText}</span>
<Row className="items-end justify-between"> <Row className="items-end justify-between">
<div className="form-control mb-1 items-start"> <div className="form-control mb-1 items-start">
<label className="label mb-1 gap-2"> <label className="label mb-1 gap-2">
@ -455,6 +477,8 @@ export function NewContract(props: {
{isSubmitting ? 'Creating...' : 'Create question'} {isSubmitting ? 'Creating...' : 'Create question'}
</button> </button>
</Row> </Row>
<Spacer h={6} />
</div> </div>
) )
} }

View File

@ -49,6 +49,7 @@ import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button' import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -541,10 +542,26 @@ function GroupLeaderboards(props: {
function AddContractButton(props: { group: Group; user: User }) { function AddContractButton(props: { group: Group; user: User }) {
const { group, user } = props const { group, user } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
async function addContractToCurrentGroup(contract: Contract) { async function addContractToCurrentGroup(contract: Contract) {
await addContractToGroup(group, contract, user.id) if (contracts.map((c) => c.id).includes(contract.id)) {
setOpen(false) 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 ( return (
@ -558,37 +575,66 @@ function AddContractButton(props: { group: Group; user: User }) {
</button> </button>
</div> </div>
<Modal open={open} setOpen={setOpen} className={'sm:p-0'}> <Modal open={open} setOpen={setOpen} className={'sm:p-0'} size={'lg'}>
<Col <Col className={' w-full gap-4 rounded-md bg-white'}>
className={
'max-h-[60vh] min-h-[60vh] w-full gap-4 rounded-md bg-white'
}
>
<Col className="p-8 pb-0"> <Col className="p-8 pb-0">
<div className={'text-xl text-indigo-700'}> <div className={'text-xl text-indigo-700'}>
Add a question to your group Add a question to your group
</div> </div>
<Col className="items-center"> {contracts.length === 0 ? (
<CreateQuestionButton <Col className="items-center justify-center">
user={user} <CreateQuestionButton
overrideText={'New question'} user={user}
className={'w-48 flex-shrink-0 '} overrideText={'New question'}
query={`?groupId=${group.id}`} className={'w-48 flex-shrink-0 '}
/> query={`?groupId=${group.id}`}
/>
<div className={'mt-2 text-lg text-indigo-700'}>or</div> <div className={'mt-1 text-lg text-gray-600'}>
</Col> (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> </Col>
<div className={'overflow-y-scroll sm:px-8'}> <div className={'overflow-y-scroll sm:px-8'}>
<ContractSearch <ContractSearch
hideOrderSelector={true} hideOrderSelector={true}
onContractClick={addContractToCurrentGroup} 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} showPlaceHolder={true}
hideQuickBet={true} cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{ excludeContractIds: group.contractIds }} additionalFilter={{ excludeContractIds: group.contractIds }}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
}}
/> />
</div> </div>
</Col> </Col>

View File

@ -1,14 +1,17 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { claimManalink } from 'web/lib/firebase/api' import { claimManalink } from 'web/lib/firebase/api'
import { useManalink } from 'web/lib/firebase/manalinks' import { useManalink } from 'web/lib/firebase/manalinks'
import { ManalinkCard } from 'web/components/manalink-card' import { ManalinkCard } from 'web/components/manalink-card'
import { useUser } from 'web/hooks/use-user' 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 { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button' 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() { export default function ClaimPage() {
const user = useUser() const user = useUser()
@ -18,6 +21,8 @@ export default function ClaimPage() {
const [claiming, setClaiming] = useState(false) const [claiming, setClaiming] = useState(false)
const [error, setError] = useState<string | undefined>(undefined) const [error, setError] = useState<string | undefined>(undefined)
useReferral(user, manalink)
if (!manalink) { if (!manalink) {
return <></> 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 })
}

View File

@ -726,18 +726,14 @@ function NotificationItem(props: {
) )
} }
export const setNotificationsAsSeen = (notifications: Notification[]) => { export const setNotificationsAsSeen = async (notifications: Notification[]) => {
notifications.forEach((notification) => { const unseenNotifications = notifications.filter((n) => !n.isSeen)
if (!notification.isSeen) return await Promise.all(
updateDoc( unseenNotifications.map((n) => {
doc(db, `users/${notification.userId}/notifications/`, notification.id), const notificationDoc = doc(db, `users/${n.userId}/notifications/`, n.id)
{ return updateDoc(notificationDoc, { isSeen: true, viewTime: new Date() })
isSeen: true, })
viewTime: new Date(), )
}
)
})
return notifications
} }
function QuestionOrGroupLink(props: { function QuestionOrGroupLink(props: {
@ -957,7 +953,7 @@ function getReasonForShowingNotification(
reasonText = 'followed you' reasonText = 'followed you'
break break
case 'liquidity': case 'liquidity':
reasonText = 'added liquidity to your question' reasonText = 'added a subsidy to your question'
break break
case 'group': case 'group':
reasonText = 'added you to the group' reasonText = 'added you to the group'

View File

@ -53,7 +53,7 @@ export default function ReferralsPage() {
<InfoBox <InfoBox
title="FYI" title="FYI"
className="mt-4 max-w-md" 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>
</Col> </Col>

View File

@ -59,6 +59,9 @@ function putIntoMapAndFetch(data) {
document.getElementById('guess-type').innerText = 'Counterspell Guesser' document.getElementById('guess-type').innerText = 'Counterspell Guesser'
} else if (whichGuesser === 'burn') { } else if (whichGuesser === 'burn') {
document.getElementById('guess-type').innerText = 'Match With Hot Singles' document.getElementById('guess-type').innerText = 'Match With Hot Singles'
} else if (whichGuesser === 'beast') {
document.getElementById('guess-type').innerText =
'Finding Fantastic Beasts'
} }
setUpNewGame() setUpNewGame()
} }

View File

@ -149,6 +149,16 @@
<h3>Match With Hot Singles</h3></label <h3>Match With Hot Singles</h3></label
><br /> ><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"> <details id="addl-options">
<summary> <summary>
<img <img

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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
View File

@ -1353,6 +1353,13 @@
resolved "https://registry.yarnpkg.com/@corex/deepmerge/-/deepmerge-2.6.148.tgz#8fa825d53ffd1cbcafce1b6a830eefd3dcc09dd5" resolved "https://registry.yarnpkg.com/@corex/deepmerge/-/deepmerge-2.6.148.tgz#8fa825d53ffd1cbcafce1b6a830eefd3dcc09dd5"
integrity sha512-6QMz0/2h5C3ua51iAnXMPWFbb1QOU1UvSM4bKBw5mzdT+WtLgjbETBBIQZ+Sh9WvEcGwlAt/DEdRpIC3XlDBMA== 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": "@docsearch/css@3.1.0":
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.1.0.tgz#6781cad43fc2e034d012ee44beddf8f93ba21f19" 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" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c"
integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w== 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": "@jridgewell/trace-mapping@^0.3.9":
version "0.3.13" version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz#dcfe3e95f224c8fe97a87a5235defec999aa92ea" 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" 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== 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": "@tiptap/extension-ordered-list@^2.0.0-beta.30":
version "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" 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-strike" "^2.0.0-beta.29"
"@tiptap/extension-text" "^2.0.0-beta.17" "@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": "@tootallnate/once@2":
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" 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" resolved "https://registry.yarnpkg.com/@tsconfig/docusaurus/-/docusaurus-1.0.5.tgz#5298c5b0333c6263f06c3149b38ebccc9f169a4e"
integrity sha512-KM/TuJa9fugo67dTGx+ktIqf3fVc077J6jwHu845Hex4EQf7LABlNonP/mohDKT0cmncdtlYVHHF74xR/YpThg== 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@*": "@types/body-parser@*":
version "1.19.2" version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" 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" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== 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" version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
@ -3836,6 +3889,11 @@ anymatch@~3.1.2:
normalize-path "^3.0.0" normalize-path "^3.0.0"
picomatch "^2.0.4" 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: arg@^5.0.0:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.1.tgz#eb0c9a8f77786cad2af8ff2b862899842d7b6adb" 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" rimraf "3.0.2"
unload "2.2.0" 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: 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" version "4.20.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.3.tgz#eb7572f49ec430e054f56d52ff0ebe9be915f8bf" 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" parse5-htmlparser2-tree-adapter "^7.0.0"
tslib "^2.4.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" version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@ -4754,6 +4819,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1:
path-type "^4.0.0" path-type "^4.0.0"
yaml "^1.10.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: critters@0.0.16:
version "0.0.16" version "0.0.16"
resolved "https://registry.yarnpkg.com/critters/-/critters-0.0.16.tgz#ffa2c5561a65b43c53b940036237ce72dcebfe93" 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" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== 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: dir-glob@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" 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" signal-exit "^3.0.3"
strip-final-newline "^2.0.0" 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" version "4.18.1"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf"
integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== 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" resolved "https://registry.yarnpkg.com/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384"
integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw== 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: ignore@^5.1.9, ignore@^5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" 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: dependencies:
semver "^6.0.0" 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: markdown-escapes@^1.0.0:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" 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" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.4.tgz#f38252370c43854dc48aa431c766c6c398f40476"
integrity sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ== 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" version "1.0.10"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4= integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=
@ -9534,6 +9635,11 @@ pseudomap@^1.0.1:
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= 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: pump@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
@ -10404,12 +10510,12 @@ semver-diff@^3.1.1:
dependencies: dependencies:
semver "^6.3.0" 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" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
semver@7.0.0: semver@7.0.0, semver@~7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== 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" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== 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: sirv@^1.0.7:
version "1.0.19" version "1.0.19"
resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49" resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49"
@ -10919,7 +11032,7 @@ stylehacks@^5.1.0:
browserslist "^4.16.6" browserslist "^4.16.6"
postcss-selector-parser "^6.0.4" 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" version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 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" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== 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" version "6.3.7"
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c" resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ== 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" resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== 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: tr46@~0.0.3:
version "0.0.3" version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 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" resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"
integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== 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: tsc-files@1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/tsc-files/-/tsc-files-1.1.3.tgz#ef4cfcb7affc9b90577d707a879dc53bb105be83" 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" has-symbols "^1.0.3"
which-boxed-primitive "^1.0.2" 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: unherit@^1.0.4:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" 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" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== 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: v8-compile-cache@^2.0.3:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" 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" y18n "^5.0.5"
yargs-parser "^20.2.2" 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: yocto-queue@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"